Organization
- @steakhouse
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
High Risk
1 findings
1 fixed
0 acknowledged
Low Risk
13 findings
5 fixed
8 acknowledged
Informational
14 findings
8 fixed
6 acknowledged
Gas Optimizations
7 findings
7 fixed
0 acknowledged
High Risk1 finding
Share price manipulation attack in Box.flash() callbacks
Description
At the beginning of the
Box.flash()function, the NAV is cached, sototalAssets()remains the same throughout the entire flash operation. However, theBoxallows deposits (restricted to feeders) and withdrawals (by anyone holding the vault share), which changestotalSupply().This would cause an inconsistency where
totalAssets()remains the same, whiletotalSupply()is updated to the latest value. As a result, the share price may change during the flash operation.It is possible to exploit such an inconsistency to extract value from the vault. Consider the following example:
- Initial state: 6000 USDC deposited into the vault, with 6000 shares minted.
- The attacker (an allocator or anyone during winddown) holds 3000 shares.
- The attacker calls
flash()with a flash amount of 3000 USDC. - The vault caches the NAV, so
totalAssetsis 6000 USDC. - During the callback, the attacker redeems 1500 shares. They get 1500 / 6000 * 6000 = 1500 USDC.
- During the callback as well, the attacker redeems the remaining 1500 shares. They get 1500 / 4500 * 6000 = 2000 USDC, as
totalSupply()is decreased to 4500 shares. - After the callback, the attacker gets back their original 3000 USDC.
As a result, the attacker redeemed 3000 shares but received 1500 + 2000 = 3500 USDC, more than they would have received from a direct redemption.
Recommendation
Consider disabling deposits or withdrawals during the flash callback by reverting with an error in the
deposit(),mint(),withdraw(), andredeem()functions so to avoid changes tototalSupply().Steakhouse: Fixed in PR 6. We resolved this by reverting on mint and redeem when
_cachedNavDepth > 0.Cantina Managed: Verified.
Low Risk13 findings
Stale adapter NAV mints free shares
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
BoxAdapterCached.realAssets()blindly returns the cachedtotalAssetswhenever the adapter’s allocation is non-zero and never recomputes the Box balance on read, so NAV queries keep using whatever value was last written duringallocate/deallocateorupdateTotalAssetseven if hours of yield have accrued in theBox. The implementation is literally just returning the cached number:function realAssets() external view returns (uint256) { return allocation() != 0 ? totalAssets : 0;}updateTotalAssets()is the only place that refreshes the cache, yet it can only be called by allocators or sentinels unless a full day has passed, which makes long stale periods expected whenever allocators are inactive:function updateTotalAssets() external { require( IVaultV2(parentVault).isAllocator(msg.sender) || IVaultV2(parentVault).isSentinel(msg.sender) || totalAssetsTimestamp + 1 days < block.timestamp, NotAuthorized() ); _updateTotalAssets();}VaultV2’s deposit/mint path trusts adapters to report live balances insideaccrueInterestView:for (uint256 i = 0; i < adapters.length; i++) { realAssets += IAdapter(adapters[i]).realAssets();}uint256 newTotalAssets = MathLib.min(realAssets, maxTotalAssets);return assets.mulDivDown(newTotalSupply + virtualShares, newTotalAssets + 1);If
Boxaccrues yield while the cache stays stale,realAssets()keeps returning the old lower value,newTotalAssetsremains depressed andpreviewDeposit/previewMintmint too many vault shares. Attackers can monitor theBoxcontract off-chain, wait for NAV to drift, then deposit intoVaultV2to receive underpriced shares and later redeem them for the higher, refreshed NAV, stealing the accumulated yield from honest depositors. Impact: recurring economic loss proportional to the NAV drift while the cache is stale and repeatable whenever allocators neglect to refresh the adapter.Recommendation
Consider removing the
BoxAdapterCachedversion and always use theBoxAdapterimplementation.Steakhouse: Acknowledged.
VaultV2introduces a maximum interest-rate parameter that ensures value accrues linearly at the vault level, regardless of what happens in the underlying strategy. For complex vaults (particularly those built onBox), we intend to set this cap with a small buffer so the vault can safely absorb scenarios where a PT trades down. If the allocator fails to callupdateTotalAssetsappropriately, the vault should be considered unsafe to use. In the extreme case, a malicious curator could even set the max rate to 0%, effectively freezing accrual. We plan to rely on the non-cached version of asset accounting whenever possible. However, we want to preserve the ability to use the cached version, as it reduces gas costs and may provide protection against downside oracle manipulation.Cantina Managed: Acknowledged.
Invisible Aave isolation mode
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
FundingAave.pledgeunconditionally supplies a collateral token and then callspool.setUserUseReserveAsCollateral(token, true). If that token is marked as an isolated collateral on Aave, enabling it blocks every other collateral and restricts future borrows to governance-approved stablecoins, yet theBoxstack never tracks or validates those constraints. An unsuspecting allocator can enable an isolated asset first and accidentally place the wholeBoxposition into isolation mode. Later attempts to pledge additional tokens or to borrow any non-approved asset will revert with opaque Aave errors, leaving automation stuck mid-flight. There is also no awareness of isolation debt ceilings: once Aave’s cap is reached,borrowsimply reverts and the strategy has no hint why. Impact: governance and allocators can brick investment workflows or strand collateral with no diagnostics, exposing the vault to outages precisely when new assets are onboarded.Recommendation
Tag collateral tokens that Aave deploys in isolation mode (or query the Aave pool configuration) and enforce the rules locally: forbid enabling multiple collaterals when one is isolated, restrict
debtTokenchoices to the allowed stablecoins, and surface meaningful errors when the global isolation debt limit would be exceeded. Alternatively, block isolated assets entirely unless governance explicitly whitelists them with the corresponding safety rails.Steakhouse: Acknowledged.
Cantina Managed: Acknowledged.
CREATE2 salt front-running blocks governance deployments
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
BoxFactory.createBoxexposes a permissionlessCREATE2deployment that takes the caller-supplied salt verbatim. The resultingBoxaddress depends only on the factory, the salt and the constructor bytecode/parameters. If governance pre-announces a proposal that will callcreateBoxwith a known salt so the deterministic address can be referenced in the same bundle, an adversary can front-run the execution window and callcreateBoxfirst with the exact same salt and arguments. BecauseCREATE2cannot reuse a salt once the address exists, the scheduled deployment reverts, aborting the governance transaction. The attacker gains no control over theBoxinstance (they had to replicate the parameters), but they do acquire an inexpensive denial-of-service lever that forces governance to re-propose the launch with a fresh salt.Recommendation
Derived salts from trusted context (e.g. hash the user-provided salt with
msg.senderor a governance nonce) or restrictcreateBoxto vetted callers, preventing outsiders from front-running the deterministic address before the scheduled deployment executes.Steakhouse: Acknowledged.
Cantina Managed: Acknowledged.
Donation attack can partially absorb the first deposit
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
When the base asset already uses 18 decimals, the
Boxconstructor setsvirtualShares = 10 ** 0 = 1, so the ERC4626 conversion relies almost entirely on real supply. The conversion function shows the problem:function convertToShares(uint256 assets) public view returns (uint256) { uint256 supply = totalSupply(); return assets.mulDiv(supply + virtualShares, totalAssets() + 1, Math.Rounding.Floor);}Before any feeder seeds the vault, an attacker can donate
Dunits of the asset directly to the contract (no role check blocks transfers). This leavestotalSupply() == 0buttotalAssets() == D. When the legitimate feeder depositsA <= D, the formula becomesfloor(A * 1 / (D + 1)), which evaluates to zero becausevirtualSharesis only one wei._depositMintstill pullsAtokens from the feeder and emits the event even thoughshares == 0, so the entire bootstrap deposit is burned and remains stranded in the vault. The attacker cannot withdraw the victim’s funds, but they can grief by forcing operators either to bridge in additional capital greater thanDbefore seeding or to redeploy/clean the vault. This is purely a denial-of-service vector, no attacker profit is possible.Proof of Concept
test/BoxDonationFork.t.sol demonstrates the attack on a fork: the test donates USDC to an unseeded Box, invokes
depositfor less than or equal to the donated amount and asserts that the feeder’s transaction pulls assets while minting zero shares, leaving their capital irrecoverably stuck.Recommendation
Consider reverting whenever
totalSupply() == 0buttotalAssets() > 0, preventing deposits until operators drain the donation.Steakhouse: Acknowledged. As for Morpho, we plan to use a dead deposit to avoid this issue.
Cantina Managed: Acknowledged.
Winddown allows unvetted swappers to skim the slippage budget
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
When
shutdownTimehas elapsed and theBoxenters winddown,allocatedeliberately relaxes its caller check so “anyone” can source the tokens needed to repay outstanding debt, provided_debtBalance(token) > 0. The function still accepts an arbitraryISwapperimplementation supplied by the caller and only enforces that the swap delivers at leastminTokens = expectedTokens * (1 - slippageTolerance). Because winddown widens the tolerance up to ~1% over time, a malicious helper can register itself as the swapper, keep the base asset it receives, and simply hand back the minimum acceptable number of debt tokens. The transaction succeeds because the numeric slippage check passes, and the attacker immediately repays the debt with theBox’s own tokens to make space for another call. Repeating this loop drains roughly the entire slippage allowance (≈1% of every unwind trade) into the attacker’s contract until the shutdown completes. This does not allow draining principal beyond that budget, but it converts every winddown swap into a guaranteed loss equal to the configured tolerance.Recommendation
Document that winddown swaps are fully trusted and the tolerance represents the maximum economic loss. If that is unacceptable, either (1) keep
allocategated to known allocators even during winddown, (2) hardcode/whitelist trusted swappers for the unwind phase, or (3) perform the unwind through a guardian-controlled swap path instead of arbitraryISwappercontracts.Steakhouse: Acknowledged. Other options were considered, but we believe this approach offers the best overall trade-off. The allowed slippage can increase up to 1%, which reflects the potential fee required by a third party executing the wind-down. In the intended (“happy-path”) scenario, the allocator will have already placed the Box in a fully liquid position before the wind-down procedure begins.
Cantina Managed: Acknowledged.
Dust transfers blocks token delisting
State
- Acknowledged
Severity
- Severity: Low
Submitted by
r0bert
Description
Removing an investment token requires a clean slate:
removeTokenchecks both that the caller is the curator and that the Box holds zero balance for the token. The only helper that can sweep balances,skim, explicitly rejects whitelisted tokens and the base asset. This means any outsider can front-run a planned delisting by transferring a single wei of the token to the Box; from that point onremoveTokenreverts withErrorsLib.TokenBalanceMustBeZero, and governance must first clear the dust (for example via a manual deallocation) before it can proceed.Impact: The grief lasts only until the curator runs a tiny
deallocate/reallocatethrough a trusted swapper to zero the balance, but it does force governance to spend extra transactions and gas to keep the whitelist clean.Recommendation
Provide a governance-controlled escape hatch, for example, allow the owner/guardian to sweep dust balances of whitelisted tokens before removal, or bundle deallocation and removal into a single transaction that ignores residual dust below a configurable threshold, so an adversary cannot lock tokens on the whitelist with a trivial transfer.
Steakhouse: Acknowledged. Token delisting are not daily operations and we feel adding complexity at this stage is not the right tradeoff. This is something we can take care at a higher level process.
Cantina Managed: Acknowledged.
Include boundary checks on duration-related parameters
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Eric Wang
Description
When creating a new
Boxcontract, the_slippageEpochDurationand_shutdownSlippageDurationparameters are checked to be non-zero, while there can be upper bound checks with a reasonable value to prevent configuration errors in deployment scripts, etc.Similarly, there can be a lower bound of the
shutdownWarmupparameter to give the guardian sufficient time to notice the shutdown andrecover()if needed, in case the curator is compromised and initiates an unnecessary shutdown.Recommendation
Consider adding lower or upper bound checks for the parameters mentioned above.
Steakhouse: Acknowledged. Those parameters are set at the creation of a Box and are immutable, therefore users can verify before using the product or veto when such a Box is added to the vault. Should a Box be badly configured, it shouldn't pass our Q&A processes neither and we would just recreate a new one.
Cantina Managed: Acknowledged.
Incorrect rounding directions
Description
As best practice, integer divisions should be rounded with a direction that benefits the protocol or enforces stricter checks.
Take
Box.sol#L416as an example. When calculating the expected tokens from a swap, it is recommended to round up to avoid underestimating the expected amount. Note that theminTokenscalculation at L417 rounds up, which follows best practice.Below are the divisions that need to be rounded up:
- Box.sol#L407-L408
- Box.sol#L416
- Box.sol#L424
- Box.sol#L464
- Box.sol#L515-L516
- Box.sol#L525
- Box.sol#L1360: The raw
/division.
Recommendation
Consider rounding up the divisions as mentioned.
Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Compromised curator can abdicate timelock functions immediately
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Eric Wang
Description
The
Box.abdicateTimelock()function does not require a timelock. Assume the curator is compromised, they can abdicate the timelock functions immediately. The functions cannot be executed anymore, even after the owner removes the compromised curator since abdicating the functions is irreversible.The timelock functions that could be affected include:
setGuardian()decreaseTimelock()setIsFeeder()setMaxSlippage()addToken()changeTokenOracle()addFunding()addFundingFacility()addFundingCollateral()addFundingDebt()
Recommendation
Consider adding a timelock to the
abdicateTimelock()function so the guardian can revokeabdicateTimelock()calls if the curator is compromised.Steakhouse: Acknowledged. Our rationale is to have a timelock only for action that are increase risk for users. In such case, the solution would be to create a new Box under the VaultV2 and migrate. While being an inconvenience, it would be minor compared to the fact of having the curator msig compromised.
Cantina Managed: Acknowledged.
Risks of unset or insufficiently long timelock periods
Description
Several critical functions of the
Boxcontract require a timelock to prevent a compromised curator from immediately changing settings and putting users at risk without them or the guardian noticing in time.Note that the functions' timelock period is 0 by default. Users and the box owner should ensure that a sufficiently long timelock period is set for all critical functions, especially for the
setGuardian()function, since the guardian can protect users if the curator is compromised.Recommendation
Consider adding a note in the docs or comments to alert users to verify that the timelock periods for critical functions are enough. Also, if applicable, consider adding a check in the
submit()function at the contract level to revert if thedelayvalue is 0, which likely indicates the timelock is unset.Steakhouse: Fixed in PR 6. Timelocks are expected to be set to a minimum of three days, whereas the guardian decision-making process (via the AragonDAO) is designed to operate on a one-day turnaround. Users can verify it before using the product and then veto any change in the timelocks.
Cantina Managed: Verified. A comment is added to the
Box.submit()function as recommended.Revert if an oracle is unset when calculating NAV
Description
To calculate the current NAV, the
Box._nav()function sums up the current values of all the tokens based on the oracle price. The function skips the case ifaddress(oracle) == address(0)(see L1408). However, we suggest removing theifcondition because:- Given the current checks in the
addToken()andchangeTokenOracle()functions, it is not possible for a token's oracle to beaddress(0). - Even if the oracle address is zero, it is considered better to raise an error instead of skipping it, since it is likely a programming error and could result in an incorrect NAV.
The same logic applies to the
nav()function inFundingAaveandFundingMorpho. It applies to theltv()function inFundingMorphoas well, as it is unlikely that a Morpho market has an oracle set toaddress(0).Recommendation
Consider removing the
if (address(oracle) == address(0))condition and replacing it with an explicit check to revert with an error if the oracle isaddress(0).Steakhouse: Fixed in PR 6.
Cantina Managed: Verified. Note that while there are no explicit zero-address checks on the oracle address when calculating the NAV, this does not pose a security issue, since calling the zero address returns an empty byte, which causes the ABI decoder to revert when decoding an
uint256value from it.- Given the current checks in the
Possible duplicate markets in FundingMorpho
Description
The
FundingMorphocontract assumes thatfacilityDatainput can be decoded into theMarketParamsstruct. Note that appending arbitrary bytes to a properly encodedfacilityDatawould decode into the sameMarketParamssinceabi.decode()ignores the appended bytes.Since
isFacilitycompares the entirefacilityDatabytes, it is possible to add the same market toFundingMorphomultiple times, which could result in an incorrect NAV.Recommendation
Consider adding a check
require(facilityData.length == FACILITY_DATA_LENGTH, ...)in thedecodeFacilityData()function withFACILITY_DATA_LENGTH = 160to ensure a Morpho market can only be added once.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Risks of allowing anyone to execute Box.changeTokenOracle()
Description
The
Box.changeTokenOracle()function can be executed by anyone after the timelock has passed, and if the box is not in winddown mode. Changing the token's oracle would instantly update its asset value, changing the NAV and potentially creating an arbitrage opportunity. Arbitrageurs can try to sandwich the oracle update atomically by depositing and withdrawing (or vice versa) to profit.This is a general concern that could also occur with oracle price updates, especially when the price difference is large enough to make arbitrage profitable. This issue highlights the specific
changeTokenOracle()case, since anyone in the regular case can call the function.Recommendation
Curators should be cautious about whether changing token oracles could create profitable arbitrage opportunities. The waiting timelock period needs to be considered as well.
On the other hand, besides having a timelock, the
changeTokenOracle()could also be restricted to the curator. The curator could then submit the transactions to services with MEV protection to reduce the risks.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified. The
changeTokenOracle()function is now restricted to the curator (if the Box is not in winddown mode).
Informational14 findings
Factory mapping can be overwritten
Description
BoxAdapterFactory.createBoxAdapteris public and stores the new adapter in theboxAdapter[parentVault][box]mapping. There’s no guard against redeploying for an existing pair, so a third party can overwrite the mapping with another adapter. The on-chain vault never consults this mapping for authorization, privileges are bound to whatever address governance added viavault.addAdapter. Consequently, overwriting the mapping doesn’t interfere with live operations, but off-chain tooling that treats the factory’s mapping as canonical could pick up the wrong adapter address and lose track of the active deployment.Recommendation
Document that the factory mapping is informational only. If external systems rely on it, add caller checks or refuse to overwrite non-empty entries to prevent accidental pointer churn.
Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Winddown slippage ramp can block exits
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
During winddown both
allocateanddeallocateinsist the trade output meets a minimum derived from_winddownSlippageTolerance(). The allocator check at the top ofallocatefalls back to that helper, then enforcestokensReceived >= minTokens, causing any shortfall to revert:uint256 slippageTolerance = winddown ? _winddownSlippageTolerance() : maxSlippage;...uint256 minTokens = _calculateMinAmount(expectedTokens, slippageTolerance);require(tokensReceived >= minTokens, ErrorsLib.AllocationTooExpensive());The sell path mirrors that logic and reverts on
ErrorsLib.TokenSaleNotGeneratingEnoughAssetswhen the base asset received violates the same tolerance._winddownSlippageTolerance()then starts the ramp at zero and only climbs linearly toMAX_SLIPPAGE_LIMIT, while the constant itself hard caps at 1% inConstants.sol. Because the deployment scripts passshutdownSlippageDuration = 10 days, the system refuses to tolerate any slippage for an entire day and stays below 1% for the remainder of the ramp. Realistic distressed unwinds often need more room, so therequirechecks above keep reverting through most of the period, blocking debt repayments and exits until the ramp finishes.Importantly, chopping the trade into smaller clips does not help: the
requirecompares per-trade execution price (tokensReceived/expectedTokens) against the same zero-to-1% tolerance, so any market that clears 0.5% below oracle will cause every sub-trade to revert until the ramp exceeds 0.5%.Impact: In some cases, emergency shutdown cannot unwind positions when the market demands >0-1% slippage, leaving allocators unable to repay debt or exit during the crisis window. We can’t assume price convergence between the oracle and the pool price in the time window that matters. During a shutdown you are trying to unwind under duress: liquidity dries up, spreads blow out, and the best executable price can sit several percent away from the oracle for hours or days. The oracle might refresh quickly but still reflect a TWAP, price feed bias, or governance delay, while the on-chain pool you’re actually hitting remains skewed because LPs fled or routing is one-sided. Even if both eventually realign, the protocol has to service redemptions before 10 days passes (
shutdownSlippageDuration); being forced to wait until markets “settle” defeats the purpose of an emergency exit. That’s why a fixed 0->1% ramp is too tight, there are realistic windows where the oracle shows the “correct” price yet the only available exit path is 3–5% off and the system still bricks until tolerance finally catches up (if it ever does).Proof of Concept
test/WinddownSlippageFork.t.sol forks Base mainnet, deploys a fresh Box, allocates stUSD through the live swapper, then forces shutdown while its oracle is intentionally 20% stale. The first unwind (immediately after shutdown while tolerance ≈0 bps) reverts, and even after fast-forwarding 11 days so the ramp caps at 1% the second unwind still reverts, showing that the
Boxcannot exit as long as the market/oracle gap exceeds the ramp ceiling.Recommendation
Allow winddown operators to raise the slippage ceiling during emergencies for example by letting guardians set a larger cap for a specific shutdown, bypass
_winddownSlippageTolerance()for privileged callers, or introduce a higher configurable limit once the vault enters winddown, so that necessary trades can execute even when markets move more than 1%.Steakhouse: Acknowledged. In extreme situations, the guardian can change the oracle. We thought about it since the start of the project, and we don't want to increase slippage beyond 1%.
Cantina Managed: Acknowledged.
No oracle scale normalization
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
ORACLE_PRECISIONis hard-coded to 1e36 and every valuation,allocate/deallocateslippage math,_nav()and both funding modules' NAV/ltv calculations, blindly multiplies balances byoracle.price()and divides by1e36. The code never factors in token/base decimals, so the conversion only works if each oracle already emits a price scaled by10^(36 - tokenDecimals + baseDecimals). With USDC (6 decimals) and an 18-decimal Pendle token, a 24-decimal feed happens to satisfy this, but nothing enforces it at smart contract level. Supplying a true 36-decimal feed for that token would makeneededTokens * price / 1e36return 18-decimal units (1e12 higher than USDC), while a 6-decimal token would be off by 1e30. Because share pricing, deposit previews, slippage limits, and funding NAV reuse the same assumption, any mis-scaled feed immediately corrupts the vault’s accounting, allowing underpriced share mints or blocking swaps entirely.Recommendation
Normalize prices explicitly. Consider enforcing that each oracle to expose its own decimals and rescale on-chain (e.g., divide by
10^(oracleDecimals - (36 - tokenDecimals + baseDecimals))).Steakhouse: Acknowledged. This is a non-issue, because this is how Morpho oracles work. https://github.com/morpho-org/morpho-blue/blob/cf3f0ce68db99421bcd808d505cfe49d61f4eaa0/src/interfaces/IOracle.sol#L10-L14. We're using the same oracles we would be using in Morpho markets.
Cantina Managed: Acknowledged.
Predicted funding address risk
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
r0bert
Description
The factory for
FundingMorphois permissionless: anyone can deploy a module and pick both theowner_and themorpho_backend.Box.addFundinglater checks only that the module lists theBoxas owner and that the module is empty. If governance ever queuesaddFundingwith a precomputed factory address, rather than deploying the module first, an outsider can front-run the deployment, instantiate the module themselves, setowner = Boxand pointmorphoto arbitrary logic. When the timelock executes, the malicious module becomes whitelisted. While the documented flow deploys before queuing (so the risk is mitigated), relying on predicted addresses reopens the hijack window. This is largely an operational concern: the code is permissive by design, so governance needs to stick to “deploy first, queue later” or introduce additional verification.Recommendation
Document clearly that funding modules must be deployed before they are scheduled for
addFunding, or add light-weight safeguards (e.g. enforcing a registry or verifying a code hash) so the timelock cannot be pointed at a front-run deployment.Steakhouse: Acknowledged.
Cantina Managed: Acknowledged.
Skim ETH transfer uses 2300 gas
Description
Box.skimroutes native balances toskimRecipientwith Solidity’stransfer, which enforces the historical 2300 gas stipend. A recipient implemented as a Gnosis Safe or any contract that needs more than 2300 gas in its fallback will revert, so the entire skim call fails and the vault cannot recover ETH that was meant to be swept. Because only the owner may updateskimRecipient, an attacker cannot steal funds, but the vault’s administrators lose the ability to claim native fees whenever the configured recipient is a Safe that executes complex logic (the default Gnosis Safe setup does). In practice this strands the ETH inBoxuntil governance changes the recipient to an EOA or redeploys the Safe.Recommendation
Send native assets with a low-level
callthat forwards all remaining gas and checks the return flag, e.g.(bool ok, ) = skimRecipient.call{value: balance}(""); require(ok, ErrorsLib.TransferFailed());, so Safe-based recipients can accept the sweep without reverts.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Unused code
Description
Several artifacts are included but never exercised, adding bytecode or maintenance overhead without providing functionality.
BoxAdapterimportsMathLiband declaresusing MathLib for uint256;despite not calling anyMathLibhelpers in the adapter; removing the directive has no effect.FundingAavedefinesRAYbut never reads it, so the constant can be dropped. Finally,ErrorsLibexposes custom errorsInvalidOwner,CannotDepositZero,CannotMintZero,NoOracleForToken,CannotSkimAssetandAlreadySetthat have no throw sites in the repo, meaning they should be deleted to keep the error catalog lean. Keeping unused code around increases bytecode size for no benefit.Recommendation
Delete the unused
usingdirective, constant and error definitions (or wire them up if future work needs them) so the deployed contracts only contain reachable code paths and the error list reflects real behaviors.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Oracles must return prices in asset denomination
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Jonatas Martins
Description
The oracle set in the
Boxcontract is used innavcalculations. The return value is expected to be in asset price (token/asset). This isn't guaranteed when adding oracles and should be verified before. Using an oracle that doesn't return asset-denominated prices will mix asset units, resulting in incorrect values, inflated or deflated depending on the oracle.Recommendation
Verify that every oracle returns the price in token/asset format, by checking it before adding or checking the oracle returns in asset price.
Steakhouse: Acknowledged.
Cantina Managed: Acknowledged.
More detailed custom errors
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Eric Wang
Description
In the
Box.allocate()function, if the contract is in winddown mode, but the debt balance of thetokenis 0, an errorOnlyAllocatorsOrWinddown()is raised. However, the error name does not reflect that the debt balance is 0, which is the actual cause of the failure. Raising a separate custom error, e.g.,NoDebtBalance(), would be more accurate in this case.The check can be updated to:
if (winddown) { require(_debtBalance(token) > 0, ErrorsLib.NoDebtBalance());} else { _onlyAllocatorNotWinddown();}Similar changes can be applied to the check in the
Box.deallocate()function as well.Recommendation
Consider applying the above suggested changes.
Steakhouse: Acknowledged.
Cantina Managed: Acknowledged.
Explicitly check whether the target function is abdicated in Box.submit()
Description
If the curator abdicates a function, its corresponding timelock value is set to
TIMELOCK_DISABLED, which istype(uint256).max. Subsequentsubmit()calls withdatatargeting the abdicated function will revert with an arithmetic overflow error when calculatingexecutableAt[data]at L817.Reverting is expected behavior. On the other hand, for better clarity, consider adding an explicit check
if (delay == TIMELOCK_DISABLED), and revert with a custom error, e.g.,FunctionDisabled().Recommendation
Consider implementing the above suggestions.
Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Custom error name could be more accurate
Description
In the
Box.increaseTimelock()function, aTimelockDecrease()error is raised if the new timelock period is not larger than the current one. The error could be renamed toTimelockDecreaseOrSame()orTimelockNotIncreasing()to be more accurate. Similar for theTimelockIncrease()error at L876.Recommendation
Consider renaming the errors for clarity.
Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Suggestions on the IBox interface
Description
- Consider making the
IBoxinterface inherit fromIOracleCallback. It is to ensure theBoxcontract implements the correct interface for theIFunding.nav(IOracleCallback oraclesProvider)function at the programming level. - Consider adding the
skimFunding()andmulticall()functions in theIBox.solinterface.
Recommendation
Consider implementing the above suggestions.
Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
- Consider making the
Reduce code duplication in Funding modules
Description
FundingAaveandFundingMorphohave several identical function implementations. Code duplication could be reduced by creating, e.g., aFundingBasecontract that implements common functions, and by havingFundingAaveandFundingMorphooverride module-specific functions, such as thenav()calculation.Recommendation
Consider implementing a base contract for funding modules and refactoring the codebase.
Steakhouse: Fixed in PR 6. Wrote a
FundingBaseto inherit from.Cantina Managed: Verified.
Suggestions on code comments and docs
Description
-
Box.sol#L647: Update the comment "@dev Caller must implement IBoxFlashCallback and return tokens within same transaction" since the
callerdoes not return the tokens, but theBoxcontract does. -
Box.sol#L1296: The comments can be updated to "Checks if token is whitelisted or is the base asset" to be more accurate.
-
FundingAave.sol#L78: The
ownervariable is misleading, usually theownercan be changed through functionalities and never be set as immutable. For better consistency of the naming, it could be changed tobox.
Recommendation
Consider implementing the above suggestions.
Steakhouse: Fixed in PR 6.
Cantina Managed: Verified fix for items 1 and 2, acknowledged item 3.
-
Non-existent Morpho markets can be added as facilities
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Eric Wang
Description
The
FundingMorpho.addFacility()function does not check whether the provided market exists on Morpho, so it is possible to add a non-existent market as a facility. Note that it is also possible to callremoveFacility()to remove the market.Recommendation
If adding a non-existent market as a facility is not an expected use case, consider adding
morpho.market(market_id)and checkinglastUpdate != 0in theaddFacility()function.Steakhouse: Acknowledged.
Cantina Managed: Acknowledged.
Gas Optimizations7 findings
Cache asset address in FundingAave.nav
Description
FundingAave.nav()compares each collateral and debt token against the base asset by callingoraclesProvider.asset()inside the loops. Because the oracle provider is external to the funding module, every iteration makes another external call even though the asset address remains constant throughout the function. When many collateral/debt tokens are registered this wastes gas: each token incurs an extraSTATICCALLsolely to re-fetch the same address. Caching the result once before the loops would eliminate two external calls per token without changing semantics.Recommendation
Read
address asset = oraclesProvider.asset();once at the top ofnav()and compare against that local when iterating overcollateralTokensanddebtTokens.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Cache facilityHash in FundingAave loops
Description
FundingAave.isFacilityand_findFacilityIndexrecomputekeccak256(facilityData)inside each loop iteration when walkingfacilities. The calldata argument never changes through the loop, so hashing it once before iterating and comparing to the cached value would avoid re-running keccak256 for every entry. Gas impact grows linearly with the number of facilities; caching removes one Keccak per element in both functions.Recommendation
Store
bytes32 facilityHash = keccak256(facilityData);before the loop in both functions and comparefacilityHashagainstkeccak256(facilities[i]).Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Skip oracle call when LTV has no collateral
Description
FundingMorpho.ltv()fetches each facility’s collateral amount, and even when that amount is zero it still queries the oracle address to pull a price before concluding the LTV is zero. The function already documents “returns 0 if there is no collateral,” so it can short-circuit earlier: after readingcollateralAmount, check for zero and return immediately instead of making a needless external call andmulDivDown. With multiple collateral tokens registered, the current code burns gas calling oracles even though the branch ends up returning zero.Recommendation
After computing
collateralAmount, insertif (collateralAmount == 0) return 0;so the function exits before touching oracles in the no-collateral case.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Reuse debtToken in FundingMorpho.repay
Description
FundingMorpho.repayverifies that thedebtTokenargument matchesmarket.loanToken, yet it later redeclaresIERC20(market.loanToken)solely to callforceApprove. Casting again is redundant once the equality check passes. Using the originaldebtTokenparameter would save a stack slot and keep the code clearer.Recommendation
Replace
IERC20(market.loanToken).forceApprove(...)withdebtToken.forceApprove(...), reusing the validated argument instead of re-wrapping the same address.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Return cached totalAssets after update
Description
BoxAdapterCached._updateTotalAssets()storesbox.previewRedeem()intototalAssetsand updates the timestamp, but it does not return the freshly computed value. Callers likeallocate,deallocateand evenupdateTotalAssetsitself immediately SLOADtotalAssetsafter invoking_updateTotalAssets()just to use the value they already calculated. Forwarding the updated amount as a return value would let callers use the in-memory result instead of wasting gas re-reading storage.Recommendation
Have
_updateTotalAssets()return the updated total (e.g.,return totalAssets = ...;) and update call sites to consume that return value instead of performing an extra SLOAD right after the update.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.
Use EnumerableSet for constant-time lookups and removals
Description
In the
FundingAaveandFundingMorphocontracts, the data structures forfacilities,collateralTokens, anddebtTokensare maintained as arrays, with a time complexity of O(n) for lookups and removals.OpenZeppelin has a EnumerableSet library that supports O(1) lookups and removals, and therefore is more gas efficient on average than the array implementation due to fewer SLOAD/SSTORE. On the other hand, insertion requires an additional SSTORE. The benefit of using the library, in terms of gas savings, would be more significant as the number of elements in the array grows.
Recommendation
Consider using the
EnumerableSetlibrary to maintain the data structures in the mentioned contracts for gas optimization or code simplification purposes.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified. The
FundingBase.facilitiesArray(),collateralTokensArray(), anddebtTokensArray()functions could be further simplified withfacilitiesSet.values(), etc.function facilitiesArray() external view returns (bytes32[] memory) { return facilitiesSet.values();}Avoid this.nav() external calls
Description
The
skim()function ofFundingAaveandFundingMorphocallsthis.nav()before and after the skim operation. Thenav()function could be declaredpublic, allowing theskim()function to reduce external calls and therefore save gas.Recommendation
Consider making the
nav()functionpublicrather thanexternal, and replacethis.nav()withnav()in theskim()function.Steakhouse: Fixed in PR 6.
Cantina Managed: Verified.