Organization
- @concretefinance
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Low Risk
5 findings
3 fixed
2 acknowledged
Informational
7 findings
3 fixed
4 acknowledged
Gas Optimizations
2 findings
2 fixed
0 acknowledged
Low Risk5 findings
Unhandled insufficient liquidity problem during withdrawals
State
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Kankodu
Description
In a standard vault, when a user initiates a withdrawal, multiple checks are performed to ensure the contract holds sufficient funds to support it. When users deposit funds, these are allocated to various strategies. Upon withdrawal, the vault attempts to retrieve the required assets from these strategies one by one, following the deallocation order. However, the maximum amount that can be withdrawn instantly across all strategies may be less than the assets the user needs. This scenario is not currently handled in the code. Instead, it simply fails with an "Insufficient balance" error from the ERC20 contract.
This issue can occur, for example, if a strategy deposits funds into a lending protocol with utilization near 100%. In such cases, even if the strategy supports instant withdrawals, the lending protocol may not be able to provide the liquidity.
Recommendation
Instead of relying on underflow errors that result in hard to read failures, fail with a helpful custom error. After the loop in
_executeWithdrawiftotalWithdrawableAmountis still less thanassets, fail with a helpful error before_withdrawgets called.+ if (totalWithdrawableAmount < assets) {+ revert ERC4626InsufficientWithdrawableAssets();+ }ERC4626 non compliance
Severity
- Severity: Low
≈
Likelihood: High×
Impact: Low Submitted by
Kankodu
Description
The ERC4626 standard requires
maxDepositto account for global and user-specific limits, returning 0 if deposits are disabled. However, in the standard vault,maxDepositandmaxMintare not overridden and always returntype(uint256).max, ignoring deposit limits set by the vault manager. Similarly,maxWithdrawandmaxRedeemfail to consider withdrawal limits, violating the standard.Recommendation
Override
maxDepositandmaxMintto return values based on the configured deposit limits, factoring in global and per-user constraints. Likewise, overridemaxWithdrawandmaxRedeemto incorporate withdrawal limits for full ERC4626 compliance.Upgrade handler receives the wrong old version
State
Severity
- Severity: Low
Submitted by
phaze
Description
UpgradeableVault.upgrade()wraps_upgrade(oldVersion, newVersion, data)insidereinitializer(newVersion). The modifier updates_initializedtonewVersionbefore the function body executes, so_getInitializedVersion()already returns the new version. As a result_upgrade()receives identical values for both theoldVersionandnewVersionparameters and cannot know which implementation it is migrating away from. This prevents any version-specific migration logic or sanity checks that depend on the previous version.Recommendation
Cache the current version before applying the
reinitializer()guard or update the reinitialization flow so_upgrade()receives both versions accurately, for example by performing the version assignment inside the function after_upgrade()succeeds or by storing the previous version locally before changing_initialized.Strategy withdrawal return value is trusted without verification
State
- Acknowledged
Severity
- Severity: Low
Submitted by
phaze
Description
ConcreteStandardVaultImpl._deallocateFromStrategies()updates accounting based solely on the value returned byIStrategyTemplate.onWithdraw(). Because strategies are external contracts, a faulty or compromised strategy could return an inflated amount while transferring fewer tokens back to the vault, causing share redemptions to be paid with unbacked accounting entries. The current logic never checks the actual vault asset balance delta to confirm the claimed transfer, so accounting drift would go unnoticed until losses accumulate.Recommendation
Consider measuring the vault’s asset balance before and after each
onWithdraw()call and cap the credited amount to the observed delta. This ensures share accounting only reflects funds that actually arrived from the strategy, even if a strategy misreports its withdrawal results.Vault address prediction drifts when metadata hash changes
State
- Acknowledged
Severity
- Severity: Low
Submitted by
phaze
Description
ConcreteFactory._computeBytecode()andpredictVaultAddress()calculate deterministic vault addresses by hashingtype(VaultProxy).creationCodetogether with the constructor arguments. The Solidity compiler appends a metadata hash to that creation code, so any repository change that alters the metadata, such as editing comments or changing unrelated files, causes the compiled value to differ even when the proxy logic is identical. If the factory implementation is later upgraded or redeployed with a newly compiled artifact, the metadata hash in the bytecode constant will change, so the same version/owner parameters will hash to different CREATE2 addresses than those previously communicated. This breaks deterministic address calculation and may invalidate integrations that rely on stable precomputed vault addresses.Recommendation
Consider building the proxy artifact without embedding the metadata hash (e.g.,
bytecode_hash = "none") so the creation code remains stable across recompilations, or store a fixed canonical proxy creation bytecode in the factory contract instead of referencingtype(VaultProxy).creationCodedynamically.
Informational7 findings
Inconsistencies in deposit/withdrawal limits definition
State
Severity
- Severity: Informational
Submitted by
Kankodu
Description
The system imposes deposit and withdrawal limits on the minimum and maximum amounts allowed. There is inconsistency in how these limits are defined. The
maxDepositlimit applies globally, meaning the total deposits in the vault cannot exceed this amount. In contrast, theminDeposit,minWithdraw, andmaxWithdrawlimits are user-specific. For example, ifminDepositis set to a certain value, an individual user cannot deposit less than that amount, regardless of the total deposits in the vault.Recommendation
To enhance clarity and consistency, standardize the terminology used for these limits. Consider renaming them to clearly distinguish between global and user-specific constraints (e.g.,
globalMaxDeposit,userMinDeposit,userMinWithdraw,userMaxWithdraw).Incorrect docs
State
Severity
- Severity: Informational
Submitted by
Kankodu
Description
-
UserDepositCapHook.sol#L106: The NatSpec comment describing the parameters expected for the
preMintfunction says thesenderis the vault address, which is incorrect. It is the user that is paying to mint the vault shares. -
ConcreteStandardVaultImpl.sol#L673: When describing what
_executeWithdrawdoes, as a last step, the comment says it delegates to the parent contract for final ERC4626 withdrawal execution. This is not correct. The parent contract's_withdrawfunction never gets called. There is an overridden_withdrawmethod in the same contract that gets called. -
Conversion.sol#L34-39 - When converting from shares to assets, because the decimals offset is 1, both totalSupply and totalAssets are increased by 1. That is correct, but the comment says totalAssets is increased by
10 ** decimalsOffset, which is not correct.10 ** decimalsOffsetshould be added to the totalSupply instead.
Recommendation
Correct the mistakes as recommended.
User deposit limits can be bypassed
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Medium×
Impact: Low Submitted by
Kankodu
Description
The
UserDepositCapHookis a pre-deposit hook that enforces per-user deposit limits across multiple vaults by checking the user's current balance plus the intended deposit amount against the owner set limit. However, without share transfer restrictions, users can bypass this by transferring shares to a new address, resetting their balance to zero before depositing more.Recommendation
Implement transfer restrictions on vault shares for this limit to be meaningful
Strategy withdrawal reverts block vault deallocation
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Medium Submitted by
phaze
Summary
A single reverting strategy halts the entire loop, leaving the vault unable to serve withdrawal requests that require other strategies even though sufficient liquidity may be available elsewhere.
Description
When redeeming user shares the vault iterates over
deallocationOrderand queries each strategy for its available liquidity viamaxWithdraw()before callingonWithdraw()to pull funds. Both calls are unguarded external interactions, so any revert propagates and aborts the wholewithdraw()/redeem()transaction. Strategies are external contracts that may downgrade, be paused, or temporarily revert, so one misbehaving strategy can continue to block the vault even if remaining strategies could satisfy the withdrawal queue. This introduces a single point of failure that defeats the diversification intent of multiple strategies.Impact Explanation
A reverting strategy prevents all pending withdrawals from completing until the issue is manually resolved, degrading availability but not directly losing funds.
Likelihood Explanation
Although unlikely, strategies can include their own guards and may revert under operator misconfiguration or downstream protocol issues, making this scenario plausible whenever any strategy experiences problems.
Recommendation
Consider wrapping the
maxWithdraw()andonWithdraw()calls intry/catchblocks (or equivalent low-level calls) so the loop can skip failing strategies, optionally logging the failure and continuing with the remaining entries.Cross-chain migration controls are manually coordinated
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description and Recommendations
-
Claims can begin while deposits remain open
BothclaimOnTargetChain()andbatchClaimOnTargetChain()check that deposit and withdrawal limits are zero when the call executes, but the guards do not permanently lock those limits once migration starts. A manager can therefore re-enable deposits after some users have already burned their shares for bridge claims, producing new supply that will never be exported to the destination chain. The resulting mix of locked and unlocked shares complicates accounting and creates opportunities for dilution. Consider binding deposit and withdrawal locking directly to the migration flag (for example, flipping an irreversible switch whenselfClaimsEnabledbecomes true) or refusing new deposits whenever the system tracks outstandinglockedShares. -
No automated reconciliation between burned and minted shares
The source vault records how many shares each user burned vialockedShares, yet the bridged vault and distributor blindly trust the amount of inventory that operators mint viaunbackedMintand transfer manually. Because those steps happen off-chain and in separate transactions, there is no deterministic link between the burned supply and the destination balances, so an operator could mint too many shares, keep a portion of them, or underfund the distributor. Consider orchestrating the migration through a single cross-chain message (or tightly scripted transaction bundle) that carries the canonical total supply and asset amounts taken directly from the source chain, allowing the destination contracts to mint and seed inventory using data that cannot be falsified locally. -
Destination vault accepts deposits before it is backed
ConcreteBridgedVaultImplinherits the default deposit/mint entry points and starts withmaxDepositset totype(uint256).max. Immediately afterunbackedMint, the vault hastotalSupply > 0buttotalAssets == 0, so the share pricing math lets any early depositor exchange a dust amount of assets for nearly the entire share supply. This can happen before migration operators notice, effectively bricking theunbackedMintflow or diluting legitimate bridged holders. Consider disabling deposits, mints, and arbitrary transfers until backing assets are recorded and an explicit “ready” flag is set as part of the migration script. -
Migration sequencing depends on manual operator handling
Executing the migration requires a long checklist: configure the OApp, freeze deposits/withdrawals, ensure management/performance fees are zero, callunbackedMint, invokeadjustTotalAssets, runaccrueYield(), transfer shares to the distributor, and only then enable claims on the source chain. Each step occurs in separate transactions across chains, so a missed or misordered action can corrupt accounting or expose the bridged vault. Consider introducing an automated migration entry point (potentially initiated via a cross-chain message) that sequences the critical operations atomically, and bake in sanity checks such as gatingadjustTotalAssetsbehind zero-fee configurations to prevent accidental fee minting during the handover.
Withdraw simulation ignores locked asset reserve
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
phaze
Description
ConcreteStandardVaultImpl._simulateWithdraw()treats the entire idle vault balance as withdrawable liquidity by queryingIERC20(asset()).balanceOf(address(this)). The contract also supports strategy-specific locks via_lockedAssets(), but that value is never subtracted from the idle balance when previewing withdrawals. As a result,_maxWithdraw()and downstream ERC4626 previews may report more liquidity than can actually be sent because some assets are reserved for pending withdrawals, cross-chain claims, or other lockups enforced by_lockedAssets().Recommendation
Consider adjusting
_simulateWithdraw()to subtract_lockedAssets()from the local balance before reporting available liquidity so that previews and max-withdraw calculations reflect the funds that are genuinely transferable.Implementation blocking relies on manual version lookup
State
Severity
- Severity: Informational
Submitted by
phaze
Description
ConcreteFactory.blockImplementation()andsetMigratable()operate strictly on version numbers instead of the actual implementation addresses. Operators must first query each version → implementation mapping, confirm it matches the intended contract, and then call the management functions. When multiple upgrades are deployed or the mapping changes, this workflow becomes error-prone and increases the chance of blocking the wrong implementation or forgetting to mark a valid migration path.Recommendation
Consider adding variants of these controls that accept implementation addresses directly (and internally resolve them to versions), or store the migration structure per implementation address instead of versions. This keeps the management interface aligned with how upgrades are referenced elsewhere and reduces manual bookkeeping during operations.
Gas Optimizations2 findings
Inefficient storage array assignment
State
Severity
- Severity: Gas optimization
Submitted by
Kankodu
Description
When setting the deallocation order, it is built by pushing each strategy individually into the
$.deallocationOrderstorage array within a loop, leading to unnecessary repeated storage operations.Recommendation
Assign the entire user provided order array to
$.deallocationOrderat the end of the loopfunction setDeallocationOrder(address[] calldata order) external { SVLib.ConcreteStandardVaultImplStorage storage $ = SVLib.fetch(); delete $.deallocationOrder; uint256 orderLength = order.length; for (uint256 i = 0; i < orderLength; i++) { address strategy = order[i]; require($.strategies.contains(strategy), IConcreteStandardVaultImpl.StrategyDoesNotExist()); require( $.strategyData[strategy].status == IConcreteStandardVaultImpl.StrategyStatus.Active, IConcreteStandardVaultImpl.StrategyIsHalted() );-- $.deallocationOrder.push(strategy);+ }++ $.deallocationOrder = order; }Optimization opportunities
State
Severity
- Severity: Gas optimization
Submitted by
phaze
Description and Recommendations
-
Avoid copying the entire deallocation order for small withdrawals
_simulateWithdraw()pulls the full$.deallocationOrderarray into memory before it knows how many strategies it will touch. For small withdrawals the first strategy often satisfies the request, so iterating directly over storage (or copying chunks lazily) would avoid unnecessary memory allocations and lower gas costs on every call. -
Redundant strategy membership guard
Both_simulateWithdraw()and_executeWithdraw()check$.strategies.contains(deallocationOrder[i])even thoughremoveStrategy()prevents unregistered strategies from remaining indeallocationOrder. Since toggling a strategy toHaltednever removes it from the set, the containment test can never fail during normal operations and only wastes gas. Consider deleting the extra membership check in both functions. -
Management-fee share math can reuse cached ratios
previewManagementFee()derives fee shares by recomputing the asset-denominated fee and then converting it to shares using the adjusted total assets. Because the share mint is effectivelyfeeShares = f * totalSupply / (1 - f)(wherefis the time-delta fee fraction), the calculation could be expressed directly in terms of the fee rate and supply, saving reading asset values. The current logic is correct, so this is as a low-priority gas reduction. -
Process-epoch flow can hardcode its precision scale
ConcreteAsyncVaultImpl.processEpoch()derives a share price by callingdecimals()to determine how many assets correspond to one share-sized unit. The value only serves as a precision scaling factor, so it can be hardcoded to10**18(or another fixed precision) without depending on the vault’s actual share decimals. Using a constant removes the repeateddecimals()lookups, saves a storage read, and avoids passing the same number back throughAsyncVaultHelperLib.processEpoch().