Yield.xyz: Allocatorvaultv3_2.sol
Cantina Security Report
Organization
- @yieldxyz
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Low Risk
7 findings
5 fixed
2 acknowledged
Informational
7 findings
3 fixed
4 acknowledged
Gas Optimizations
4 findings
1 fixed
3 acknowledged
Low Risk7 findings
Ineffective Restriction on Harvest Function
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Kankodu
Description
The
harvestfunction is restricted to theHARVESTER_ROLE, but_harvestis called internally indeposit,mint,withdraw, andredeem. Anyone can trigger a harvest by performing a dust amount deposit or withdrawal (e.g., 1 wei, assuming no minimum limits in the strategy), bypassing the restriction at minimal cost. This makes the access control ineffective, as harvests can still be forced without role permission.Recommendation
Make
harvestpublic by removing theonlyRole(HARVESTER_ROLE)modifier or add minimum deposit/withdraw limits in the vault for this check to be meaningful.Incorrect Return Value in Redeem Function
Description
In the
redeemfunction,_underlyingis calculated usingstrategy.convertToAssets(assets), which provides a theoretical conversion rate. However, per ERC4626 standard,convertToAssetsmay differ from the actual redemption amount due to slippage or other conditions. The function callsstrategy.redeem(assets, receiver, address(this))but ignores its return value (the actual underlying redeemed), returning and emitting the pre calculated_underlyinginstead.We know that pre calculation is required because support for withdraw with cooldown is required. When using the return value from actual redeem is not feasible,
strategy.previewRedeem(assets)should be used for accurate return value.Proof of Concept
Update
test/allocator-vaults/utils/Strategy.solto have withdrawal fees. This could be the reasonpreviewRedeemandconvertToAssetsmight not return the same amount.// SPDX-License-Identifier: MITpragma solidity ^0.8.25; import "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";import "forge-std/Test.sol";import { MockToken } from "test/allocator-vaults/utils/Token.sol"; contract MockStrategy is Test, ERC4626 { MockToken token; uint256 public fees = 10;//10% withdrawal fee constructor( IERC20 _underlying, string memory name, string memory symbol ) ERC4626(_underlying) ERC20(name, symbol) { token = MockToken(address(_underlying)); } function previewRedeem(uint256 shares) public view override returns (uint256) { uint256 assets = super.previewRedeem(shares); uint256 fee = (assets * fees) / 100; return assets - fee; } function previewWithdraw(uint256 assets) public view override returns (uint256) { uint256 fee = (assets * fees) / 100; assets = assets - fee; return super.previewWithdraw(assets); } function _withdraw( address caller, address receiver, address owner, uint256 assets, uint256 shares ) internal override { uint256 fee = (assets * fees) / 100; assets = assets - fee; super._withdraw(caller, receiver, owner, assets, shares); } function generateYield(uint256 yieldAmount) external { token.mint(address(this), yieldAmount); } function generateLoss(uint256 lossAmount) external { token.burn(address(this), lossAmount); } function mintForUser(address user, uint256 amount) external { _mint(user, amount); }}Update the
test_redeemtest intest/allocator-vaults/unit/Baseline.t.sol. Notice that the actual underlying amount received and the return amount are not equal:function test_redeem() public { vm.startPrank(finneas); vm.expectRevert("Exceeded max redeem"); vaultProxy.redeem(100, finneas, finneas); vm.stopPrank(); vm.startPrank(owner); underlying.mint(finneas, 1000); vm.stopPrank(); vm.startPrank(finneas); underlying.approve(address(vaultProxy), 1000); vaultProxy.deposit(100, finneas); uint256 finneasBalance = vaultProxy.balanceOf(finneas); uint256 finneasBalanceBefore = underlying.balanceOf(finneas); vm.expectEmit(true, true, false, true, address(vaultProxy)); emit Withdraw(finneas, finneas, finneas, 99, 99 * VIRTUAL_SHARES); uint256 assetsReturned = vaultProxy.redeem(finneasBalance, finneas, finneas); vm.stopPrank(); uint256 finneasBalanceAfter = underlying.balanceOf(finneas); uint256 actualUnderlyingReceived = finneasBalanceAfter - finneasBalanceBefore; //Notice that the actual underlying received is not equal to assetsReturned due to previewRedeem not being used. assertNotEq(assetsReturned, actualUnderlyingReceived); }Recommendation
Capture and use the actual return value from
strategy.redeemwhen possible. If not, usepreviewRedeeminstead:... uint256 _underlying;if (config.hasCooldown) { IERC20(address(strategy)).safeTransfer(receiver, assets); _underlying = strategy.previewRedeem(assets); // Approximation for cooldown mode} else { _underlying = strategy.redeem(assets, receiver, address(this));} ... emit Withdraw(msg.sender, receiver, owner, _underlying, shares);return _underlying;Incorrect Asset Definition Breaks ERC4626
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Kankodu
Description
asset for this ERC4626 vault (AllocatorVault) being the strategy contract breaks the ERC4626 in most places. In ERC4626
assetis referred to as the underlying token managed by the vault. In this case,assetis the strategyFor example, Here's the requirement for deposit function:
MUST support EIP-20 approve / transferFrom on asset as a deposit flow
This is not true for this vault. The deposit function doesn't pull the
asset(strategy tokens) from the user.Mints shares Vault shares to receiver by depositing exactly assets of underlying tokens
Deposit doesn't deposit exactly assets of underlying tokens because the underlying token for the this vault are strategy tokens.
Similar issues affect
mint,withdraw,redeem, and previews. OnlyconvertToShares,convertToAssets, andtotalAssetsalign correctly with the current setupRecommendation
Change
assetto the true underlying token via initialization swap, and adjust related functions for accurate conversions (e.g., updatetotalAssetsto reflect underlying equivalents via strategy conversions):function initialize( address _underlying, IERC4626 _strategy, string memory vaultTokenName, string memory vaultTokenSymbol) public initializer {- __ERC4626_init(IERC20(address(_strategy)));+ __ERC4626_init(IERC20(_underlying)); ... require(_underlying == _strategy.asset(), "Underlying token mismatch");- require(address(_strategy) == asset(), "Strategy token mismatch");+ require(_underlying == asset(), "Underlying token mismatch"); ...}If you fix this and change the underlying asset for the vault, you would need to change the public facing
convertToShares,convertToAssetsandtotalAssetsto return amounts in underlying assets instead of strategy shares.Incorrect Gain Calculation in Fee Computation
Description
In
computeHarvestFee,currentTotalUnderlyingis computed viastrategy.convertToAssets(totalAssets()), which per ERC4626 excludes fees charged against assets. This overestimatesgainby including unrealized fees (e.g., protocol fees on interest in a lending strategy) that users won't receive, leading to inflated performance fees.previewRedeemshould be used, as it includes withdrawal fees and simulates ideal redemption without limits.convertToAssets definition from ERC4626
The amount of assets that the Vault would exchange for the amount of shares provided, in an ideal scenario where all the conditions are met.
MUST NOT be inclusive of any fees that are charged against assets in the Vault
previewRedeem definition from ERC4626
MUST NOT account for redemption limits like those returned from maxRedeem and should always act as though the redemption would be accepted, regardless if the user has enough shares, etc. MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees.
Recommendation
Replace
convertToAssetswithpreviewRedeemfor accurate redeemable assets:uint256 currentTotalUnderlying = strategy.previewRedeem(totalAssets());Update
totalUnderlyingStampassignments (e.g., indeposit,redeem) similarly for consistency.maxDeposit/maxMint Ignores Paused State
Description
The
maxDepositandmaxMintfunctions do not account for thepausedstate, returning non zero values even when deposits are disabled.From ERC4626 standard for both
maxDepositandmaxMint:MUST factor in both global and user-specific limits, like if deposits are entirely disabled (even temporarily) it MUST return 0
Recommendation
Override maxDeposit and maxWithdraw check for paused state:
function maxDeposit(address receiver) public view override returns (uint256) { if (paused) { return 0; } return super.maxDeposit(receiver);} function maxMint(address receiver) public view override returns (uint256) { if (paused) { return 0; } return super.maxMint(receiver);}Insufficient Whitelist Restrictions Allows Admin to Pull Funds
Description
In
addToWhitelist,require(target != address(strategy), "Cannot whitelist strategy");prevents whitelisting the strategy but allows the underlying asset. If the underlying is whitelisted withtransferFromselector, admins could exploit viaclaimAdditionalRewardsby callingunderlying.transferFromfrom the vault context, potentially draining the hanging approvals to the vault. While admins control vault funds, they shouldn't be able to pull from user wallets in any condition.Recommendation
Add check in
addToWhitelist:require(target != underlying, "Cannot whitelist underlying");Incorrect previewRedeem Calculation
Description
In
previewRedeem, the underlying amount is computed viastrategy.convertToAssets(strategyShares), which per ERC4626 excludes fees charged against assets. This overestimates redeemable assets if the strategy has withdrawal fees, leading to inaccurate simulations.previewRedeemshould be used, as it includes fees and simulates actual redemption.Recommendation
Use
previewRedeem:function previewRedeem(uint256 shares) public view override returns (uint256) { uint256 newTotalShares = previewHarvest(); if (newTotalShares == 0) { return 0; } uint256 strategyShares = shares * (totalAssets() + VIRTUAL_ASSETS) / (newTotalShares + VIRTUAL_SHARES);- return strategy.convertToAssets(strategyShares);+ return strategy.previewRedeem(strategyShares); }
Informational7 findings
Incomplete Pause Mechanism Allowing Withdrawals During Emergency States
Description
The
AllocatorVaultV3_2contract implements an asymmetric pause mechanism where thenotPausedmodifier only restricts thedepositandmintfunctions, but explicitly allowswithdrawandredeemoperations to continue unrestricted whenpausedis set totrue. While the design intention is to prevent new capital from entering the vault during uncertain periods while allowing users to exit, this approach creates several security and operational risks. During a pause scenario triggered by suspected vulnerabilities, oracle manipulation, or strategy contract issues, allowing withdrawals can still expose the vault to exploitation through redemption-based attacks. Additionally, mass withdrawals during a paused state could deplete vault liquidity, preventing administrators from implementing proper remediation measures or emergency asset recovery procedures before users drain available funds.Recommendation
Implement a more granular pause mechanism that provides administrators with flexible control over vault operations during emergency scenarios. Consider introducing separate pause flags such as
depositPausedandwithdrawPausedthat can be independently toggled, allowing theDEFAULT_ADMIN_ROLEto pause withdrawals when necessary to prevent exploitation during suspected attacks or strategy failures, while maintaining the option to allow exits during less critical situations like temporary operational pauses.Inaccurate Annual Time Calculation Excluding Leap Years
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
AllocatorVaultV3_2contract definesSECONDS_IN_YEARas a constant value of365 * 24 * 60 * 60, which equals 31,536,000 seconds and assumes every year contains exactly 365 days. This calculation ignores the existence of leap years that occur approximately every four years and contain 366 days. ThecomputeHarvestFeefunction utilizes this constant when calculating both management fees based on time elapsed and performance fees annualized over the year constant.Recommendation
Consider using a more precise annual time representation of 365.25 days to account for the average occurrence of leap years.
ERC-4626 Compliance Violation During Cooldown Mode
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Cryptara
Description
The
AllocatorVaultV3_2contract implements a cooldown mechanism in thewithdrawandredeemfunctions that fundamentally violates ERC-4626 standard compliance whenconfig.hasCooldownis enabled. In thewithdrawfunction, when the cooldown is active, the function transfers strategy shares directly to the receiver viaIERC20(address(strategy)).safeTransfer(receiver, assets), rather than the underlying tokens as specified by the ERC-4626 standard. Similarly, theredeemfunction exhibits the same behavior, transferring strategy shares instead of underlying tokens when cooldown is enabled. The ERC-4626 standard explicitly requires thatwithdrawmust return the exact amount of underlying asset tokens specified in the_underlyingparameter, andredeemmust convert shares to the underlying asset. By returning strategy shares (which are themselves ERC-4626 vault tokens representing a claim on the underlying), the contract breaks this fundamental expectation. Users callingwithdraw(1000, receiver, owner)expecting 1000 underlying tokens will instead receive an equivalent value in strategy shares, which they must then separately redeem from the strategy contract. This creates a multi-step withdrawal process that violates the standard's interface guarantees.Recommendation
Consider implementing a compliant cooldown mechanism that maintains ERC-4626 standard adherence while still achieving the desired time-delay security feature. One approach is to implement a two-phase withdrawal system where users first call a
requestWithdrawalfunction that burns their vault shares and records a pending withdrawal request with a timestamp, then after the cooldown period expires, users call a separateclaimWithdrawalfunction that completes the withdrawal by transferring underlying tokens from the strategy. Alternatively, maintain separate compliantwithdrawandredeemfunctions that always return underlying tokens, and introduce new non-standard functions likewithdrawWithCooldownandredeemWithCooldownthat explicitly return strategy shares, allowing integrators to choose the appropriate function based on their requirements.Vault Decimals Do Not Account for VIRTUAL_SHARES
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Kankodu
Description
The vault's decimals match the strategy's decimals, ignoring
VIRTUAL_SHARES(10^6), which provides precision to avoid rounding errors. This leads to inflated share minting; e.g., depositing 0.01 EURC mints ~10,000 shares (as seen in this transaction). The decimals should be underlying asset decimals + 6 for accuracy.This is not an accounting error, but just a UX issue.
Recommendation
Override
_decimalsOffset()from ERC4626Upgradeable to return 6, ensuringdecimals()computes correctly (underlying decimals + 6). No storage changes needed for upgrade:function _decimalsOffset() internal view virtual override returns (uint8) { return 6;}Alternatively, override
decimals()directly:function decimals() public view virtual override returns (uint8) { return IERC20Metadata(underlying).decimals() + 6;}Unnecessary Restriction on Zero Function Selector
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Kankodu
Description
In
addToWhitelist,require(functionSelector != bytes4(0), "Invalid function selector");blocks whitelisting selectors equal to0x00000000. While the chances of it happening by chance is low, some projects might deliberately mine function selector to be bytes4(0) for important functions to save gas.Here's a list of real function selectors with
bytes4(0)function selectors.Recommendation
Remove the unnecessary restriction line to allow valid zero selectors.
Unnecessary Override of _spendAllowance
Description
The
_spendAllowancefunction is overridden but replicates the exact logic from ERC20Upgradeable (inherited via ERC4626Upgradeable), adding unnecessary code duplication without functional changes. This increases maintenance overhead.Recommendation
Remove the entire
_spendAllowanceoverride to rely on the parent implementation.Use assert Instead of require for Internal Sanity Check
Description
In
initialize,require(address(_strategy) == asset(), "Strategy token mismatch");checks an internal invariant post__ERC4626_init. This should always hold unless an internal error occurs (not from external inputs like params or interactions). Per Solidity best practices, useassertfor invariants (causing panic) andrequirefor input validation (reverting with error).Recommendation
Replace
requirewithassert:assert(address(_strategy) == asset());
Gas Optimizations4 findings
Deposit Share Calculation Logic Duplication
Description
The
AllocatorVaultV3_2contract contains duplicated share calculation logic between thedepositfunction andpreviewDepositfunction, which creates unnecessary gas overhead and increases the risk of implementation inconsistencies.Recommendation
Extract the core deposit share calculation logic into a dedicated internal function such as
_calculateDepositShares(uint256 assets, uint256 totalAssetsBefore, bool applyFee)that can be reused by bothdepositandpreviewDepositfunctions.Inefficient Harvest State Management with Duplicate Storage Writes
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Cryptara
Description
The
AllocatorVaultV3_2contract implements an inefficient pattern for managing harvest-related state updates across multiple functions. The_harvestinternal function updatestotalUnderlyingStampandlastStampafter minting fee shares, but every calling function (deposit,mint,withdraw, andredeem) subsequently performs identical storage writes to these same variables after executing their core logic. This results in double storage writes within a single transaction, where_harvestsets these stamps first, and then the calling function immediately overwrites them with potentially the same or only marginally different values. This pattern does create unnecessary gas overhead through redundantSSTOREoperations and increasing the cognitive burden on developers who must remember to manually update these stamps in every function that calls_harvest.Recommendation
Consider refactoring the harvest mechanism into a modifier pattern that encapsulates the pre-harvest fee calculation and post-operation state updates, eliminating the need for manual stamp management in each calling function.
Repeated Approvals to Underlying Vault Can be Avoided
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Kankodu
Description
In
depositandmint, the contract approves the exact deposit amount to the strategy viaIERC20(underlying).forceApprove(address(strategy), _underlying)before eachstrategy.depositcall. This repeated approval incurs unnecessary gas costs.The vault is not supposed to hold any underlying assets anyway. The balance returned to zero after each action. Making infinite approval is Safe
Recommendation
Add infinite approval in
initialize:IERC20(underlying).forceApprove(address(strategy), type(uint256).max);Remove approvals from
depositandmintRemove Redundant Storage of Strategy Address
State
- Acknowledged
Severity
- Severity: Gas optimization
Submitted by
Kankodu
Description
In
initialize,strategy = _strategy;stores the strategy address separately. However,ERC4626Upgradeablealready stores it internally via__ERC4626_init(IERC20(address(_strategy))), accessible viaasset(). This duplicates storage, increasing contract size and gas costs during initialization without any benefit.Recommendation
Remove the
strategyvariable declaration and all assignments. Replace allstrategyusages withIERC4626(asset())