Technical Audit Documentation

CLVEX - Clear Convexity | Strategy Vaults with Yield Generation on Base

A subdao of Thetanuts providing rebasing token yield strategies via options

Introduction

CLVEX provides no-loss strategy vaults that use rebasing token yields to create options positions. Users deposit rebasing tokens (e.g., aBasUSDC), and only the accrued yield is used for options strategies - original deposits are always preserved.

Core Value Proposition

  • No-Loss Guarantee: Original deposits (baseDeposit) are never used for options
  • Yield-Only Strategy: Only rebased yield above baseDeposit funds option positions
  • Two Strategies: Directional (buys options) and Condor (sells iron condors)
  • 100% Collateralization: No liquidation risk, no margin calls

Available Strategies

Strategy Position Vault Compounding
Directional LONG options (buys calls/puts) DirectionalStrategyVault No (earnings distributed)
Condor SHORT iron condors (sells spreads) MeanRevertingCondorStrategyVault Yes (auto-reinvest)
Important: DirectionalStrategyVault buys options (isLongOptionStrategy=true). MeanRevertingCondorStrategyVault sells iron condors (isLongOptionStrategy=false).

Scope & Reading Order

This HTML is meant to orient auditors to the Solidity code under thetanuts_v4/src/. It focuses on the CLVEX/Klyra strategy vault stack, plus the RFQ/options infrastructure that the vaults rely on.

Line numbers: References like YieldStrategyVault.sol L314 are accurate for this repository snapshot. If the code moves, prefer searching by file + function name.

High-Level Components in thetanuts_v4/src/

LayerDirectoryWhat it contains
Vaults src/vaults/ ERC20 share vaults, RFQ orchestration, principal-protection logic, withdrawals + option splitting
Options src/options/ RFQ factory (including limit-mode), cash-settled option primitives + implementations
Event Routing src/vaults/utils/ + src/libraries/ Delegatecall-based event router (EventHandler) and event constants (OptionEvents)
Oracles src/oracles/ Chainlink round helpers + TWAP computation used at option settlement
Rebasing Adapters src/adapters/ Wrappers that present yield-bearing assets as rebasing ERC20s (ERC4626 wrapper, Sky rsUSDS adapter)
Wrappers src/clvex/ + src/wrappers/ User-facing wrappers (USDC↔aBasUSDC convenience, strategy wrappers that forward option positions)

Recommended Audit Reading Order

  1. src/vaults/utils/VaultStorage.sol (storage layout shared with EventHandler)
  2. src/vaults/BaseVault.sol (queues, RFQs, withdrawals, option splitting, callbacks)
  3. src/vaults/utils/EventHandler.sol (delegatecall event routing and tracking mutation)
  4. src/vaults/YieldStrategyVault.sol (principal protection, earnings accounting, recovery mode)
  5. src/vaults/DirectionalStrategyVault.sol + src/vaults/MeanRevertingCondorStrategyVault.sol (strategy-specific sizing/strikes)
  6. src/options/BaseOption.sol + src/options/CashSettledOption.sol (roles, events, splitting, payout path)
  7. src/options/OptionFactory.sol (RFQ lifecycle, fees, settlement, limit-mode)
  8. src/oracles/ and src/adapters/ (external dependency surfaces)

Quick Start Integration

Depositing (ClvexWrapper: USDC entry)

// 1) Approve USDC to the wrapper
await usdc.approve(clvexWrapperAddress, amount);

// 2) Deposit (wrapper supplies to Aave → deposits aBasUSDC on your behalf)
await clvexWrapper.depositUSDC(vaultAddress, amount);

// Note: after the first vault deposit, shares are typically minted on RFQ settlement.
// See "Pending Deposits" for lifecycle details.

Depositing (StrategyVaultWrapperABasUSDC: aBasUSDC entry)

// 1) Approve aBasUSDC to the wrapper
await aBasUSDC.approve(strategyWrapperAddress, amount);

// 2) Deposit into a directional vault (isCall=true for call vault, false for put vault)
await strategyWrapper.depositDirectional(amount, true);

// Or deposit into the condor vault
await strategyWrapper.depositCondor(amount);

Depositing (StrategyVaultWrapper: sUSDS entry + rsUSDS adapter)

// 1) Approve sUSDS to the wrapper
await sUSDS.approve(strategyWrapperAddress, amount);

// 2) Deposit directional (wrapper converts sUSDS → rsUSDS internally)
await strategyWrapper.depositDirectional(amount, true);
Audit Note (Wrappers): StrategyVaultWrapper* contracts call vault deposit() (not depositOnBehalf). Since BaseVault queues deposits and mints shares later, deposit attribution + pending-deposit reclaim semantics should be reviewed closely.

Claiming Earnings (Non-Compounding Vaults)

// Check claimable
const claimable = await vault.calculateClaimableEarnings(userAddress);

// Claim (transfers aBasUSDC to user)
await vault.claimEarnings();

Withdrawing

// Direct (receive aBasUSDC; withdrawals are payable for potential option split fees)
await vault.withdraw(shares, { value: ethers.parseEther("0.01") });

// ClvexWrapper: withdraw to USDC (Aave withdraw)
await clvexWrapper.withdrawToUSDC(vaultAddress, shares, { value: ethers.parseEther("0.01") });

// StrategyVaultWrapper*: withdraw via wrapper
// 1) Approve vault shares to the wrapper
await vault.approve(strategyWrapperAddress, shares);

// 2) Withdraw from a directional vault (isCall=true/false) or condor
await strategyWrapper.withdrawDirectional(shares, true, { value: ethers.parseEther("0.01") });
await strategyWrapper.withdrawCondor(shares, { value: ethers.parseEther("0.01") });

Note on assetIndex Parameter

assetIndex is always 0 for CLVEX vaults.

The assetIndex parameter appears in deposit functions due to inheritance from multi-asset BaseVault. For CLVEX vaults (DirectionalStrategyVault and MeanRevertingCondorStrategyVault), which use a single rebasing token (aBasUSDC), this parameter is always 0.

  • deposit(amount) - Preferred for simplicity (assetIndex hardcoded to 0)
  • deposit(amount, assetIndex) - Second param is ignored, use 0
  • depositOnBehalf(beneficiary, amount, assetIndex) - Use assetIndex = 0
  • withdraw(shares) - No assetIndex parameter

Architecture Overview

User Funds | | (optional) convert to rebasing collateral v +--------------------------------------+ | Wrappers (deployment / UX choice) | | - ClvexWrapper (USDC ↔ aBasUSDC) | | - StrategyVaultWrapper* (sUSDS/...) | +--------------------------------------+ | v +--------------------------------------+ | Strategy Vault (ERC20 shares) | | BaseVault → CashSettledVault → | | YieldStrategyVault → Strategy impls | +--------------------------------------+ | | availableForOptions = totalBalance - pendingDeposits - totalEarnings - baseDeposit | createOption() opens RFQ v +---------------------+ | OptionFactory (RFQ) | +---------------------+ | | creates option clone on settlement v +--------------------------------------+ | Cash-Settled Options (BaseOption) | | CallSpread / PutSpread / IronCondor | +--------------------------------------+ | | settlement price via TWAP at expiry v Chainlink AggregatorProxy + HistoricalPriceConsumerV3_TWAP Option Events: - Options notify buyer/seller via OptionEvents (creation/settlement/transfer/split/reclaim) - Vault receives events in BaseVault.handleOptionEvent → delegatecall EventHandler

Contract Hierarchy

BaseVault (abstract)
    └── CashSettledVault (abstract)
           └── YieldStrategyVault (abstract)
                  ├── DirectionalStrategyVault (isLongOptionStrategy=true, isCompounding=false)
                  └── MeanRevertingCondorStrategyVault (isLongOptionStrategy=false, isCompounding=true)

EventHandler (delegatecall)
    └── Mutates BaseVault state via VaultStorage layout

ClvexWrapper / StrategyVaultWrapper*
    └── UX wrappers for deposits/withdrawals + option forwarding

Key State Variables (YieldStrategyVault)

// YieldStrategyVault.sol lines 72-78
uint256 public totalEarnings;           // Accumulated earnings from options
uint256 public baseDeposit;             // Total original deposits (pool-level, not per-user)
uint256 public recoveryRatio = 0;       // Set during recovery mode
uint256 public accEarningsPerShare;     // Earnings accumulator (1e18 precision)
mapping(address => uint256) public userEarningsDebt;  // Per-user debt for earnings calc
bool public immutable isCompounding;    // Set at deployment, cannot change

Codebase Map

This section is a quick index of the major Solidity components in thetanuts_v4/src/ and how they relate.

AreaFilesWhat to look for
Vault core src/vaults/BaseVault.sol
src/vaults/CashSettledVault.sol
src/vaults/YieldStrategyVault.sol
Deposit queueing, RFQ lifecycle, withdrawals + option splitting, principal protection, earnings hooks
Vault utils src/vaults/utils/VaultStorage.sol
src/vaults/utils/EventHandler.sol
src/vaults/utils/VaultUtils.sol
src/vaults/utils/DateUtils.sol
VaultStorage layout coupling with EventHandler; delegatecall event routing; TVL/estimates; expiry calc
Strategies src/vaults/DirectionalStrategyVault.sol
src/vaults/MeanRevertingCondorStrategyVault.sol
Strike selection + sizing math; strategy flags (isLongOptionStrategy, isCompounding)
RFQ + options src/options/OptionFactory.sol
src/options/BaseOption.sol
src/options/CashSettledOption.sol
Commit-reveal RFQs, limit-mode, fee/referral logic, option roles/events, payout/split/reclaim paths
Option impls (Klyra) src/options/CallSpreadOption.sol
src/options/PutSpreadOption.sol
src/options/IronCondorOption.sol
Collateral requirement formulas and payout curves (all in PRICE_DECIMALS = 1e8)
Oracles src/oracles/HistoricalPriceConsumerV3.sol
src/oracles/HistoricalPriceConsumerV3_TWAP.sol
Chainlink round navigation + TWAP computation at option expiry
Rebasing adapters src/adapters/ERC4626RebasingAdapter.sol
src/adapters/SkyRebasingAdapter.sol
External dependency surfaces (ERC4626 vault logic, Sky oracle conversion rate)
Wrappers src/clvex/ClvexWrapper.sol
src/wrappers/StrategyVaultWrapper.sol
src/wrappers/StrategyVaultWrapperABasUSDC.sol
UX integration patterns; option forwarding; deposit attribution differences
Also present in src/: additional vaults (FixedStrikeVault, RollingCashSettledVault, PhysicallySettledVault, etc.), many option implementations (butterflies/condors/vanillas), and src/beacon/MarketMakerBeacon.sol used by the option stack. This doc focuses on the cash-settled strategy vault + RFQ path used by CLVEX/Klyra.

BaseVault (Queues, RFQs, Withdrawals)

BaseVault is the operational core: it queues deposits, opens RFQs via OptionFactory, tracks active options, and handles withdrawals that may split option positions.

Deposit Queueing (First vs Subsequent Deposits)

First deposit (totalSupply == 0) → mints initial shares immediately (fixed 1e18) All later deposits → stored in pendingDeposits[] → converted to shares only when an RFQ settles
Audit Focus: Deposits are not priced at deposit time. Share minting happens during RFQ settlement via BaseVault._handleRfqSettlement() (L775) → _mintSharesForPendingDeposits() (L840). This is where premium/intrinsic adjustments occur.

RFQ Lifecycle (Vault Side)

createOption() → _handleActiveRfq() (settle/cancel if reveal window passed) → _handleExpiredOptions() (settle matured options) → _issueNewRfq() (calls OptionFactory.requestForQuotation) → snapshots deposit queue via lastProcessedDepositIndex Option clone created (OptionEvents.EVENT_CREATION) → BaseVault.handleOptionEvent() (L1250) triggers _handleRfqSettlement() (L775) → mints shares for pending deposits using winning premium + intrinsic value
Limit-mode (no winner): Vault RFQs are created with convertToLimitOrder = true. After offerEndTimestamp + REVEAL_WINDOW with no winner, the RFQ becomes “limit-mode” and can be settled at the reserve price by any counterparty via OptionFactory.settleQuotation(). During this state, BaseVault allows withdrawals (_requireWithdrawalAllowed()) and may cancel/reissue the RFQ if collateral needs to be freed (e.g., pending-deposit withdrawals).

Withdrawal + Option Splitting

Withdrawals are payable because users may need to supply ETH for option split fees. When options are active, BaseVault can transfer or split option roles proportionally via _handleOptionWithdrawal() (L957).

Critical Functions (BaseVault.sol)

FunctionLinesWhy it matters
_handleRfqSettlement()L775Mints queued deposits into shares; records option; clears RFQ state
_mintSharesForPendingDeposits()L840Share pricing logic; long vs short paths; premium bonus/intrinsic deductions; ITM penalty
_handleOptionWithdrawal()L957Splits/transfers option positions; fee + rounding edge cases
executeExternalCall()L1189Owner-only integration hook with strict target restrictions
handleOptionEvent()L1250Entry point for option lifecycle events; delegates to EventHandler

EventHandler (Delegatecall Option Event Router)

EventHandler is a separate contract created during BaseVault construction and invoked via delegatecall to mutate vault state in response to option events.

Audit Focus: EventHandler inherits VaultStorage to share the exact same storage layout as BaseVault. Any storage layout change in VaultStorage.sol must be append-only.

Event Routing

EventHandlerLinesEffect
EVENT_SPLIT_handleSplitNotificationEventHandler.sol L61Adjusts tracked option amounts; adds new option; tracks splitGeneration
EVENT_SETTLEMENT_handleSettlementNotificationEventHandler.sol L114Removes settled option from tracking
EVENT_TRANSFER_handleTransferNotificationEventHandler.sol L149Removes option from tracking on outgoing transfer
EVENT_CLOSE_handleCloseNotificationEventHandler.sol L183Removes closed option from tracking
EVENT_RECLAIM_handleReclaimNotificationEventHandler.sol L228Swaps tracking from reclaimed option → new option (with identity checks)

OptionFactory (RFQ System)

OptionFactory implements a commit-reveal RFQ mechanism and deploys options as EIP-1167 clones. Vaults use it to source pricing and counterparties for each option cycle.

RFQ Flow (Factory Side)

Vault calls requestForQuotation() (OptionFactory.sol L455) → offers committed + revealed (commit-reveal) → if winner exists: settleQuotation() (L1271) deploys option clone → option emits EVENT_CREATION to buyer/seller via BaseOption._notifyParty() → vault mints pending deposits + starts tracking the option If no winner and convertToLimitOrder == true (limit-mode): → quotation remains active after reveal period → anyone can call settleQuotation() to accept the reserve price (msg.sender becomes counterparty) → requester may still cancelQuotation() before it is filled (e.g., to free reserved collateral)

Key Entry Points (OptionFactory.sol)

FunctionLinesNotes
requestForQuotation()L455Creates quotation, posts deposit/collateral, sets reserve ask
settleQuotation()L1271Final settlement; deploys option clone; clears quotation state
cancelQuotation()L1415Requester cancellation; makers must monitor until settlement
Oracle decimals: Option math uses PRICE_DECIMALS = 1e8. The factory enforces 8-decimal Chainlink feeds in fee calculations for quote-collateral paths (InvalidPriceFeedDecimals).
What “limit-mode” means in code: OptionFactory.settleQuotation() explicitly supports “no winner + convertToLimitOrder” by calling _handleBestOfferAcceptance(..., msg.sender, reservePrice) and settling at state.currentBestPriceOrReserve (the reserve). This is an OptionFactory-only mechanism.

BaseOption & CashSettledOption (Option Primitives)

Options are role-based contracts (buyer/seller). They notify both parties (vaults or wrappers) using IOptionEventReceiver callbacks; these callbacks are explicitly non-blocking.

Lifecycle Events

BaseOption._initialize() → notifies buyer + seller (EVENT_CREATION) via _notifyParty() (BaseOption.sol L252) At expiry (cash-settled): → CashSettledOption.payout() (CashSettledOption.sol L29) → computes TWAP, pays buyer, sends remainder to seller → notifies buyer + seller (EVENT_SETTLEMENT) Role transfers and splits: → BaseOption.transfer() (L371) emits EVENT_TRANSFER → BaseOption.split() (L475) emits EVENT_SPLIT and creates a new option clone

Key Entry Points

FunctionLinesNotes
_notifyParty()BaseOption.sol L252Non-blocking event delivery (try/catch)
transfer(isBuyer, target)BaseOption.sol L371Transfers buyer or seller role to target
split(splitCollateralAmount)BaseOption.sol L475Splits collateral into a new option; fee is paid via msg.value
payout()CashSettledOption.sol L29TWAP settlement and final transfers

Oracles

Strike selection and settlement rely on Chainlink AggregatorProxy price feeds and a TWAP computation that iterates historical rounds.

FileKey FunctionNotes
HistoricalPriceConsumerV3_TWAP.sol calculateTWAP() (L19) Computes TWAP over Chainlink rounds; can revert on phase boundary crossings
HistoricalPriceConsumerV3.sol Round search helpers Binary-search helpers used by TWAP and historical price lookups
TWAP Period: Settlement uses a 30-minute Time-Weighted Average Price (TWAP) calculated from Chainlink oracle rounds, matching industry standard (Deribit).
Audit Focus: All option math assumes PRICE_DECIMALS = 1e8. Any mismatch between the configured price feeds and this convention can break strike sizing or fees.

Rebasing Adapters

Vaults are designed to hold rebasing/yield-bearing ERC20s. Adapters present yield accrual as increasing balances and expand the external dependency surface.

AdapterFilesKey Risks
ERC4626RebasingAdapter src/adapters/ERC4626RebasingAdapter.sol Underlying ERC4626 vault correctness; index sync + rounding; donation behavior impacts “yield”
SkyRebasingAdapter src/adapters/SkyRebasingAdapter.sol Oracle dependency for conversion rate (getValidatedRate() L189); operational trust assumptions

DirectionalStrategyVault

Overview

A LONG option buyer vault that uses rebased yield to purchase directional options. Can be configured for calls (bullish) or puts (bearish) via constructor parameters.

Key Insight: This vault buys options (isLongOptionStrategy = true), paying premium from rebased yield. It does NOT sell options.

Configuration Parameters

// DirectionalStrategyVault.sol lines 67-69
int256 public immutable STRIKE_PERCENTAGE_DIFFERENCE;  // Can be negative for puts
uint256 public immutable STRIKE_ROUNDING;              // Strike price increment
uint256 public immutable CALL_SPREAD_RATIO_BPS;        // Optional spread ratio (0 = vanilla)

Strike Calculation (Actual Implementation)

// DirectionalStrategyVault.sol lines 153-166
function calculateStrikes() public view override returns (uint256[] memory strikes) {
    uint256 currentPrice = getCurrentPrice(0);
    strikes = new uint256[]((CALL_SPREAD_RATIO_BPS > 0) ? 2 : 1);

    uint256 mainStrike = currentPrice * uint256(100 + STRIKE_PERCENTAGE_DIFFERENCE) / 100;

    // Directional rounding: Ceiling for calls (rounds up), Floor for puts (rounds down)
    strikes[0] = ((mainStrike + ((STRIKE_PERCENTAGE_DIFFERENCE >= 0) ? (STRIKE_ROUNDING - 1) : 0))
            / STRIKE_ROUNDING) * STRIKE_ROUNDING;

    if (CALL_SPREAD_RATIO_BPS > 0) {
        strikes[1] = strikes[0] * CALL_SPREAD_RATIO_BPS / 10_000;
    }
}

Strategy Direction

STRIKE_PERCENTAGE_DIFFERENCE Strategy Option Type
>= 0 (e.g., +5) Bullish Buys calls (or call spreads if CALL_SPREAD_RATIO_BPS > 0)
< 0 (e.g., -5) Bearish Buys puts (or put spreads if CALL_SPREAD_RATIO_BPS > 0)

Non-Compounding Behavior

isCompounding = false means option returns are tracked separately in totalEarnings and distributed via the earnings system, not reinvested into new positions.

MeanRevertingCondorStrategyVault

Overview

A SHORT iron condor seller vault that profits when price stays within a range. Sells OTM put spreads and call spreads centered around current price.

Key Insight: This vault sells iron condors (isLongOptionStrategy = false), collecting premium that compounds into new positions.

Configuration Parameters

// MeanRevertingCondorStrategyVault.sol lines 49-51
uint256 public immutable STRIKE_GAP;        // Distance from spot to inner strikes
uint256 public immutable STRIKE_INCREMENT;  // Width of each spread wing
uint256 public immutable STRIKE_ROUNDING;   // Strike price rounding increment

Strike Calculation (4 strikes)

// MeanRevertingCondorStrategyVault.sol lines 105-116
function calculateStrikes() public view override returns (uint256[] memory strikes) {
    uint256 currentPrice = getCurrentPrice(0);
    strikes = new uint256[](4);

    // Round current price to nearest STRIKE_ROUNDING
    uint256 roundedPrice = ((currentPrice + STRIKE_ROUNDING / 2) / STRIKE_ROUNDING) * STRIKE_ROUNDING;

    strikes[0] = roundedPrice - STRIKE_GAP - STRIKE_INCREMENT;  // Long put (lower)
    strikes[1] = roundedPrice - STRIKE_GAP;                      // Short put
    strikes[2] = roundedPrice + STRIKE_GAP;                      // Short call
    strikes[3] = roundedPrice + STRIKE_GAP + STRIKE_INCREMENT;  // Long call (upper)
}

Compounding Behavior

isCompounding = true means premium collected is automatically reinvested into new condor positions. No separate earnings tracking.

Compounding Design Rationale

The different compounding modes between Directional and Condor vaults are not arbitrary—they reflect the fundamental economic asymmetry between buying options and selling options.

Aspect Condor (Seller) Directional (Buyer)
Cash Flow Receives premium upfront Pays premium from yield
Return Profile Many small wins, occasional large losses Binary: expires worthless (0) or large payout
Reinvestment Logic Premium = same asset = reinvest naturally Payout = realized gain = user decides
User Expectation Passive "set and forget" yield accumulation Capture asymmetric upside, control over profits
Key Insight: Compounding is natural for premium sellers because every successful trade generates more capital for the next trade. For option buyers, returns are too irregular for meaningful compounding—users typically want control over realized profits rather than automatically doubling down on directional bets.

Why Condor = Compounding

Iron condor sellers receive premium (USDC) when positions are opened. This premium is the same asset used to collateralize new positions. Automatic reinvestment creates a yield-compounding effect typical of premium-selling strategies. The expected return profile (many small wins) aligns well with snowball accumulation.

Why Directional = Non-Compounding

Option buyers pay premium upfront and receive binary payouts—either the option expires worthless (0 return) or pays out significantly if ITM. These returns are irregular and speculative. Distributing earnings via totalEarnings gives users control over realized gains rather than automatically reinvesting into more directional bets. Users may want to take profits, rebalance, or wait for better entry points.

YieldStrategyVault (Base)

Core Formula

The fundamental no-loss calculation that determines how much yield is available for options:

// YieldStrategyVault.sol lines 508-521
function calculateRfqAmount() public view override returns (uint256) {
    if (isInRecoveryMode) revert CannotInitiateRFQInRecoveryMode();
    uint256 totalBalance = _getAssetBalance(0);
    uint256 pendingDepositsTotal = _calculatePendingDepositsTotal();
    uint256 requiredBalance = pendingDepositsTotal + totalEarnings + baseDeposit;

    if (totalBalance <= requiredBalance) {
        return 0;  // No yield available
    }

    uint256 rfqAmount = totalBalance - requiredBalance;
    return rfqAmount;  // Only the rebased yield
}
No-Loss Guarantee: Only totalBalance - pendingDeposits - totalEarnings - baseDeposit is used for options. Original deposits are never at risk.

Earnings System (Non-Compounding Vaults)

How Earnings Accrue

For non-compounding vaults (DirectionalStrategyVault), option payouts are tracked via an accumulator pattern:

// YieldStrategyVault.sol lines 266-294
function _handleExpiredOptions() internal override {
    // Early return guard: only proceed if options have actually expired
    if ((block.timestamp < activeExpiryTimestamp) || (activeExpiryTimestamp == 0)) {
        return;
    }

    if (!isCompounding) {
        uint256 newEarnings = 0;

        // Optimization: Cache TWAP to avoid redundant oracle calls (L274-278)
        // Only cache if options exist, otherwise skip the oracle call
        if (activeOptions[0].length > 0) {
            CashSettledOption firstOption = CashSettledOption(activeOptions[0][0].optionContract);
            uint256 cachedTWAP = firstOption.getTWAP();

            for (uint256 i = 0; i < activeOptions[0].length; i++) {
                CashSettledOption option = CashSettledOption(activeOptions[0][i].optionContract);
                newEarnings += option.calculatePayout(cachedTWAP);
            }
        }

        super._handleExpiredOptions();

        if (newEarnings > 0 && totalSupply() > 0) {
            accEarningsPerShare += (newEarnings * 1e18) / totalSupply();
            totalEarnings += newEarnings;
        }
    } else {
        // Compounding vaults also call super to handle option cleanup
        super._handleExpiredOptions();
    }
}

Earnings Debt System

Prevents new depositors from claiming historical earnings:

// On deposit - YieldStrategyVault.sol lines 171-188
function _mint(address account, uint256 shareAmount, uint256 assetAmount, uint256 assetIndex) internal override {
    uint256 newSharesDebt = 0;
    if (!isCompounding) {
        newSharesDebt = (shareAmount * accEarningsPerShare) / 1e18;
    }

    baseDeposit += assetAmount;
    super._mint(account, shareAmount, assetAmount, assetIndex);

    if (!isCompounding) {
        userEarningsDebt[account] += newSharesDebt;  // Prevents claiming old earnings
    }
}

Claiming Earnings

// YieldStrategyVault.sol lines 314-324
function calculateClaimableEarnings(address user) public view returns (uint256) {
    if (isCompounding) return 0;
    if (isInRecoveryMode && recoveryRatio == 0) revert RecoveryRatioNotSet();

    uint256 accrued = (balanceOf(user) * accEarningsPerShare) / 1e18;
    uint256 pending = accrued > userEarningsDebt[user] ? accrued - userEarningsDebt[user] : 0;
    if (recoveryRatio > 0) {
        pending = (pending * recoveryRatio) / 1e18;  // Scale down in recovery
    }
    return pending;
}
Auto-Claim: Earnings are automatically claimed before deposits (depositOnBehalf L149) and during withdrawals via _burn (L201-214) to prevent accounting issues.

No-Loss Guarantee

Pool-Level Tracking

The no-loss guarantee is implemented at the pool level, not per-user:

Important: There is NO userBaseDeposit mapping. Only a global baseDeposit tracks total original deposits across all users.
// State variable - YieldStrategyVault.sol line 73
uint256 public baseDeposit;  // Total original deposits (pool-level)

// Updated on mint - YieldStrategyVault.sol line 179
baseDeposit += assetAmount;

// Updated on withdrawal - YieldStrategyVault.sol lines 410-416
if (shares == totalSupply()) {
    baseDeposit = 0;
} else if (baseDeposit > 0) {
    // Proportional reduction
    baseDeposit -= (amounts[0] * baseDeposit) / (baseDeposit + excessBalance);
}

How It Works

  1. User deposits 1000 aBasUSDC → baseDeposit += 1000
  2. Rebasing yields 10 aBasUSDC → totalBalance = 1010
  3. Available for options = 1010 - 0 - 0 - 1000 = 10 aBasUSDC
  4. Original 1000 is protected, only the 10 yield is at risk

Recovery Mode

Trigger Condition

Recovery mode activates when any asset shortfall occurs (negative rebase, withdrawal, transfer, or other cause) that causes the invariant to fail:

// YieldStrategyVault.sol lines 570-577
function initiateRecoveryMode() external override {
    if (isInRecoveryMode) revert AlreadyInRecoveryMode();
    if (_getAssetBalance(0) < baseDeposit + _calculatePendingDepositsTotal() + totalEarnings) {
        isInRecoveryMode = true;
        emit RecoveryModeInitiated();
    }
}

Recovery Ratio Calculation

// YieldStrategyVault.sol lines 462-487
function determineRecoveryRatio() public {
    if (!isInRecoveryMode) revert NotInRecoveryMode();
    // Must have no active options
    for (uint256 i = 0; i < activeOptionsStrategies; i++) {
        if (activeOptions[i].length != 0) revert ActiveOptionsExist();
    }

    uint256 totalBalance = _getAssetBalance(0);
    uint256 pendingDepositsTotal = _calculatePendingDepositsTotal();

    // Calculate ratio for fair distribution
    recoveryRatio = (totalBalance * 1e18) / (pendingDepositsTotal + baseDeposit + totalEarnings);

    // Scale down all claims proportionally
    for (uint256 i = 0; i < pendingDeposits.length; i++) {
        pendingDeposits[i].amount = (pendingDeposits[i].amount * recoveryRatio) / 1e18;
    }
    baseDeposit = (baseDeposit * recoveryRatio) / 1e18;
    totalEarnings = (totalEarnings * recoveryRatio) / 1e18;
}

Effects of Recovery Mode

OperationNormal ModeRecovery Mode
deposit()AllowedBlocked (reverts)
withdraw()AllowedRequires recoveryRatio to be set first
createOption()AllowedBlocked (reverts)
claimEarnings()Full amountScaled by recoveryRatio
Recovery Mode is PERMANENT: Once activated, the vault cannot return to normal operation. Users must withdraw; protocol should deploy a new vault.

Security Warnings

ERC20 Share Transfer Hooks - AUDIT FOCUS AREA: Vault shares are standard ERC20 tokens. The transfer hooks (_beforeTokenTransfer and _afterTokenTransfer) are critical security code that prevents multiple attack vectors. See detailed section below.
Recovery Mode is Permanent: Once triggered, recovery mode cannot be exited. The vault stops accepting new deposits forever. Users must withdraw and migrate to a new vault deployment.
Recovery Ratio is Intentionally Re-callable: determineRecoveryRatio() can be called multiple times by design. The math self-corrects: at equilibrium, ratio = 1.0 (no-op). If the underlying asset recovers value, ratio > 1.0 scales obligations back UP. This enables fair distribution if a depegged asset re-pegs. DO NOT add guards to prevent repeat calls.

Transfer Hooks Security (Auditor Deep-Dive Required)

The _beforeTokenTransfer and _afterTokenTransfer hooks in YieldStrategyVault.sol implement earnings claim and debt management during share transfers. This is a complex attack surface that has required multiple fixes.

Security Properties That Must Hold

PropertyDescriptionViolation Impact
Sender Earnings Protection Sender must claim pending earnings before balance decreases Sender loses unclaimed earnings forever
Dust Griefing Prevention Receiver's pending earnings must be claimed before debt update Attacker sends 1 wei to victim → victim loses all pending
Historical Earnings Theft Prevention Fresh addresses (balance=0) cannot claim historical earnings Attacker drains totalEarnings by transferring to fresh wallets
Reentrancy Protection External safeTransfer in _claimEarnings cannot be exploited via callbacks Malicious token re-enters during transfer, corrupts state or double-claims

Current Implementation (Post-Fix)

// _claimEarnings: Defense-in-depth early return (L335-338)
function _claimEarnings(address user) internal {
    if (isCompounding) return;
    // Fresh addresses have nothing to claim - prevents griefing via dust transfers
    if (balanceOf(user) == 0 && userEarningsDebt[user] == 0) return;
    // ... proceed with earnings calculation and transfer
}

// _beforeTokenTransfer: Claims for BOTH parties at PRE-TRANSFER balances
if (from != address(0) && to != address(0) && !isCompounding) {
    _claimEarnings(from);  // Sender claims at current balance
    _claimEarnings(to);    // Receiver claims at current balance
                           // Fresh address: balance=0, debt=0 → early return, claims 0
}

// _afterTokenTransfer: Only debt updates (NO claiming)
if (from != address(0) && to != address(0) && !isCompounding) {
    userEarningsDebt[to] = (balanceOf(to) * accEarningsPerShare) / 1e18;
    userEarningsDebt[from] = (balanceOf(from) * accEarningsPerShare) / 1e18;
}

// Reentrancy protection on public ERC20 functions (L362, L383, L392)
// Required because _claimEarnings does external safeTransfer
function transfer(address to, uint256 amount) public override nonReentrant returns (bool);
function transferFrom(address from, address to, uint256 amount) public override nonReentrant returns (bool);
function claimEarnings() external nonReentrant;

Earnings Calculation Formula (Full Logic)

The simplified formula pending = (balance * accEarningsPerShare / 1e18) - debt has additional protective mechanisms in the actual implementation:

// calculateClaimableEarnings() - L314-323
uint256 accrued = (balanceOf(user) * accEarningsPerShare) / 1e18;
uint256 pending = accrued > userEarningsDebt[user]
    ? accrued - userEarningsDebt[user]
    : 0;  // ← Clamped to prevent negative (underflow protection)

if (recoveryRatio > 0) {
    pending = (pending * recoveryRatio) / 1e18;  // ← Scaled during recovery mode
}

// _claimEarnings() - L332-356
// Defense-in-depth: Fresh addresses exit early (L335-338)
if (balanceOf(user) == 0 && userEarningsDebt[user] == 0) return;
// ...
if (pending > totalEarnings) {
    pending = totalEarnings;  // ← Capped to prevent over-distribution
}
Note: Users relying on the simplified formula may miscalculate expected payouts during recovery mode or when totalEarnings pool is depleted.

Historical Vulnerabilities Fixed

IssueAttackFix Applied
Full Transfer Debt Desync Transfer all shares to fresh address → claim historical earnings Added sender claim in _beforeTokenTransfer
Dust Transfer Griefing Send 1 wei to victim → victim's debt overwritten, loses pending Claim receiver before debt update
Partial Transfer Leak Transfer PART of shares to fresh address → receiver claims historical (post-transfer balance > 0, debt = 0) Moved receiver claim to _beforeTokenTransfer (balance=0 for fresh → claims 0)
Reentrancy via Callbacks Malicious token callbacks during safeTransfer could re-enter transfer hooks Added nonReentrant to transfer(), transferFrom(), claimEarnings()
Redundant Claim Window withdraw() called _claimEarnings before super.withdraw(), then _burn() claimed again Removed redundant claim from withdraw() - _burn() handles it inside nonReentrant context
Audit Focus: The ordering of operations in these hooks is critical. Claiming must happen on pre-transfer balances for both parties. Debt updates must happen on post-transfer balances. Any reordering can reintroduce vulnerabilities.

Reentrancy Model: _claimEarnings updates debt (effects) BEFORE safeTransfer (interactions), following CEI pattern. The nonReentrant modifier on public entry points provides defense-in-depth.

Known Limitations

LimitationSeverityImpact
Fee-on-transfer tokensCRITICALNot supported - accounting breaks
Negative rebasing tokensHIGHTriggers recovery mode
Non-8-decimal price feedsCRITICALStrike calculations incorrect
Direct token donationsMEDIUMDilutes depositor claims
DeFi composabilityINTENTIONALVault shares override transfer()/transferFrom() with nonReentrant because earnings claiming performs external token transfers inside ERC20 transfer hooks. Integrations that rely on re-entering the vault during a share transfer (e.g., callback-driven flows that call back into the vault) may revert.

ClvexWrapper

Purpose

Converts between USDC and aBasUSDC, providing a simpler UX where users interact with USDC but vaults use yield-bearing aBasUSDC.

Actual Function Signatures

// ClvexWrapper.sol - Deposit functions
function depositUSDC(IStrategyVault vault, uint256 usdcAmount) external;
function depositABasUSDC(IStrategyVault vault, uint256 aBasUSDCAmount) external;

// ClvexWrapper.sol - Withdraw functions
function withdrawToABasUSDC(IStrategyVault vault, uint256 shares) external;
function withdrawToUSDC(IStrategyVault vault, uint256 shares) external;

Deposit Flow

depositUSDC(vault, 1000 USDC) | v 1. Transfer USDC from user to wrapper 2. Approve Aave Pool for USDC 3. Supply USDC to Aave -> receive aBasUSDC 4. Approve vault for aBasUSDC 5. Call vault.depositOnBehalf(user, aBasUSDC, 0) | v User receives vault shares (or added to pending queue)

Pending Deposits

When you deposit into a CLVEX vault (except the very first deposit), your tokens are not immediately converted to shares. Instead, they enter a pending queue and are processed when the next option cycle settles.

Design Rationale: The vault cannot fairly price new shares until an RFQ (Request for Quotation) auction completes. Your share allocation depends on:
  • Option premium received (you get a bonus from premium)
  • Intrinsic value if the option is ITM (you bear proportional losses)
  • Current vault NAV at settlement time

Deposit Lifecycle

1. deposit(amount) | v [Token transferred to vault] | v [Added to pendingDeposits queue] | v 2. createOption() called (by anyone) | v [RFQ auction starts, queue snapshotted] | v 3. RFQ settles (winner found or timeout) | v [Shares minted to depositor]

Checking Queue Status

// Check how many deposits are pending
const queueLength = await vault.getPendingDepositsLength();

// Check individual pending deposit
const [user, amount, assetIndex] = await vault.pendingDeposits(index);

Cancelling a Pending Deposit

You can cancel your pending deposit at any time before shares are minted by calling withdrawPendingDeposits():

// Cancel all your pending deposits and get tokens back
await vault.withdrawPendingDeposits();
Timing: Once createOption() has been called and your deposit is included in the snapshot (lastProcessedDepositIndex), you can still withdraw, but this may cancel the active RFQ if your collateral is needed.

Exit Option Tracking

When users withdraw from vaults with active option positions, the vault splits the option and sends the user's proportional share. The wrapper tracks these "exit options" to distinguish them from options the user bought directly.

ClvexWrapper-only: This tracking exists in src/clvex/ClvexWrapper.sol. The StrategyVaultWrapper* wrappers forward option roles on withdrawal but do not maintain an on-chain exit-option index.

Problem Solved

After migration/withdrawal, exit options (split from vault) are indistinguishable from options acquired directly elsewhere (e.g., created via OptionFactory). The tracking system provides on-chain differentiation.

Data Structure

struct ExitOptionRecord {
    address option;        // Option contract address
    bool isBuyerRole;      // Whether user received buyer (true) or seller (false) role
    uint256 timestamp;     // Block timestamp when forwarded
    address fromVault;     // Source vault the option came from
}

mapping(address => ExitOptionRecord[]) public userExitOptions;
mapping(address => mapping(address => uint256)) public exitOptionIndex;

View Functions

// Get all exit options for a user
function getUserExitOptions(address user) external view returns (ExitOptionRecord[] memory);

// Check if an option is an exit option
function isExitOption(address user, address option) external view returns (bool);

// Get full details
function getExitOptionDetails(address user, address option) external view returns (
    bool exists,
    bool isBuyerRole,
    uint256 timestamp,
    address fromVault
);

Events

event ExitOptionTracked(
    address indexed user,
    address indexed option,
    address indexed fromVault,
    bool isBuyerRole,
    uint256 timestamp
);

Frontend Usage

Differentiating Options:
  • Call isExitOption(user, option) to check if option came from vault withdrawal
  • Use getUserExitOptions(user) to display "Options from vault exit" separately
  • Use getExitOptionDetails() to show source vault (e.g., "Exit from Directional Call Vault")

StrategyVaultWrapperABasUSDC

A strategy wrapper in src/wrappers/StrategyVaultWrapperABasUSDC.sol that accepts aBasUSDC directly and routes deposits/withdrawals to the strategy vaults. It also forwards any option positions received during withdrawals to the end user.

Key Functions (as implemented)

// StrategyVaultWrapperABasUSDC.sol
function depositDirectional(uint256 amount, bool isCall) external;
function depositCondor(uint256 amount) external;
function processQueuedDeposits(bool isDirectional, uint256 batchSize) external returns (uint256 processed);

function withdrawDirectional(uint256 shareAmount, bool isCall) external payable returns (uint256 returnedAssets);
function withdrawCondor(uint256 shareAmount) external payable returns (uint256 returnedAssets);

Withdrawal Option Forwarding

wrapper.withdrawDirectional(shares, isCall) → wrapper pulls vault shares from user (transferFrom) → wrapper calls vault.withdraw{value: msg.value}(shares) → during withdraw, option transfers may occur to the wrapper → wrapper.handleOptionEvent captures EVENT_TRANSFER (incoming only) → wrapper forwards option roles to user via BaseOption.transfer(isBuyerRole, user)
Audit Focus (Deposit Attribution): This wrapper calls vault.deposit(amount, 0) (not depositOnBehalf(user, ...)). Since BaseVault queues deposits to pendingDeposits under the beneficiary address, wrapper-mediated deposits can be attributed to the wrapper address unless explicitly handled. Review depositDirectional() / depositCondor() and any intended “queued deposit” workflow carefully.

StrategyVaultWrapper (sUSDS ↔ rsUSDS)

A strategy wrapper in src/wrappers/StrategyVaultWrapper.sol that accepts a “stableToken” (e.g., sUSDS) and converts to a rebasing token (rsUSDS) via SkyRebasingAdapter before depositing to vaults.

Key Functions (as implemented)

// StrategyVaultWrapper.sol
function depositDirectional(uint256 amount, bool isCall) external;
function depositCondor(uint256 amount) external;
function processQueuedDeposits(bool isDirectional, uint256 batchSize) external returns (uint256 processed);

function withdrawDirectional(uint256 shareAmount, bool isCall) external payable returns (uint256 returnedAssets);
function withdrawCondor(uint256 shareAmount) external payable returns (uint256 returnedAssets);
Additional dependency: This wrapper introduces an oracle dependency through SkyRebasingAdapter conversion rates. Auditors should treat this as a separate trust surface from the vault core.

Key Invariants

Primary Invariant (YieldStrategyVault)

_getAssetBalance(0) >= baseDeposit + pendingDeposits + totalEarnings

Enforced by assertInvariant() (L528-538). Violation triggers recovery mode eligibility.

Earnings Debt Consistency

userEarningsDebt[user] <= balanceOf(user) * accEarningsPerShare / 1e18

Maintained by _mint() adding debt and _burn() recalculating debt.

No-Loss: Yield-Only Strategy

rfqAmount = totalBalance - pendingDeposits - totalEarnings - baseDeposit

Only rebased yield is used for options. Original deposits protected.

Intentional Design Patterns (Not Bugs)

1. Auto-Claim Before Deposit/Withdraw

Location: depositOnBehalf() L149, _burn() L201-214

Why: Must claim earnings before share balance changes, otherwise debt calculation becomes invalid.

2. Debt Recalculation on Burn

Location: _burn() L201-214

Why: After burning shares, debt must be recalculated based on new balance. Without this, remaining shares couldn't claim future earnings (old debt > new entitlement).

3. Proportional baseDeposit Reduction

Location: _executeWithdrawal() L403-417

Why: On withdrawal, baseDeposit is reduced proportionally: baseDeposit -= (withdrawn * baseDeposit) / (baseDeposit + excessBalance)

4. Recovery Mode Pending Deposit Adjustment

Location: determineRecoveryRatio() L476-479

Why: Existing pending deposits are scaled down by recoveryRatio to ensure fair distribution of remaining assets.

5. totalEarnings Reserved from Withdrawals

Location: _reservedCollateral() L424-427

Why: Ensures user earnings remain untouched during pro-rata withdrawals.

6. isCompounding is Immutable

Location: L78

Why: Cannot be toggled after deployment. Directional vaults are always non-compounding; Condor vaults are always compounding.

7. Recovery Ratio Intentionally Re-callable

Location: determineRecoveryRatio() L462-487

Why: Can be called multiple times by design. At equilibrium (assets = obligations), ratio = 1.0 (no-op). If underlying asset recovers value, ratio > 1.0 scales obligations back UP. This enables fair distribution if a depegged asset re-pegs. Note: When totalBalance is unchanged, the first call scales obligations to match assets, so subsequent calls converge to recoveryRatio ≈ 1.0 (no-op). If totalBalance changes (further depeg or recovery), re-calling recomputes and rescales obligations accordingly.

8. Auto-Claim on Transfer

Location: _beforeTokenTransfer() L229-242

Why: Transferring vault shares automatically claims earnings for both sender and receiver at pre-transfer balances. This prevents debt desynchronization and the partial transfer earnings leak. Claiming in _beforeTokenTransfer (not _afterTokenTransfer) is critical—see Transfer Hooks Security section.

9. TWAP Caching in _handleExpiredOptions

Location: _handleExpiredOptions() L274-278

Why: Cache TWAP value once for all options instead of calling oracle multiple times. Gas optimization: one oracle call instead of N calls for N options.

10. nonReentrant on ERC20 Transfer Functions

Location: transfer() L383, transferFrom() L392, claimEarnings() L362

Why: _claimEarnings does an external safeTransfer call. Without nonReentrant on public entry points, malicious tokens with callbacks could re-enter transfer hooks. The internal _claimEarnings follows CEI pattern (updates debt before transfer), but nonReentrant provides defense-in-depth.

11. Defense-in-Depth Early Return in _claimEarnings

Location: _claimEarnings() L335-338

Why: Fresh addresses (balance=0 && debt=0) have nothing to claim. Early return prevents: (1) unnecessary state updates, (2) griefing via dust transfers to fresh addresses, (3) any edge cases where zero-balance users might interact with earnings logic.

12. No Explicit _claimEarnings in withdraw()

Location: withdraw() L374-376

Why: Previously, withdraw() called _claimEarnings before super.withdraw(). This was removed because _burn() (called inside super.withdraw()) already handles earnings claiming. Removing the redundant call closes a reentrancy window where earnings could be claimed twice in the same transaction.

Gas Cost Reference

Measured gas costs from test suite (includes vault interactions):

ClvexWrapper Operations

OperationGas UsedNotes
depositUSDC()~368,000USDC transfer + Aave supply + vault deposit
depositABasUSDC()~297,000aBasUSDC transfer + vault deposit
withdrawToABasUSDC()~173,000Vault withdraw + aBasUSDC transfer
withdrawToUSDC()~196,000Vault withdraw + Aave withdraw to USDC
migrate()~354,000Withdraw from source + deposit to destination
Note: Gas costs vary based on vault state (queue length, active options). Withdrawals with option splits require additional ETH for split fees (passed via msg.value).

Error Reference

ErrorTriggerResolution
CannotInitiateRFQInRecoveryModeVault in recovery modeWithdraw funds, deploy new vault
AlreadyInRecoveryModeRecovery already triggeredNo action needed
NotInRecoveryModedetermineRecoveryRatio() when not in recoveryCheck vault state first
ActiveOptionsExistRecovery ratio with unsettled optionsWait for options to expire
Note on claimEarnings(): Calling claimEarnings() with 0 pending earnings does NOT revert - it silently succeeds. This is intentional to support auto-claim flows during deposits, withdrawals, and transfers.

Critical Code Paths for Auditors

FileFunction/LinesRisk Area
BaseVault.sol _mintSharesForPendingDeposits() L840 ⚠️ HIGH PRIORITY: Share pricing during RFQ settlement (premium/intrinsic/ITM penalty; long vs short paths)
BaseVault.sol _handleOptionWithdrawal() L957 ⚠️ HIGH PRIORITY: Option splitting/transfer during withdrawals; ETH fee + rounding invariants
BaseVault.sol handleOptionEvent() L1250 ⚠️ HIGH PRIORITY: Option event entry point; triggers RFQ settlement and delegates to EventHandler
BaseVault.sol _handleRfqSettlement() L775 Mint shares for queued deposits; record option; clear RFQ state
EventHandler.sol handleEvent() L33 Delegatecall router that mutates vault tracking; storage-layout coupling to VaultStorage
OptionFactory.sol requestForQuotation() L455, settleQuotation() L1271 ⚠️ HIGH PRIORITY: RFQ lifecycle + option deployment; callback timing assumptions for integrators
BaseOption.sol _notifyParty() L252, split() L475 Non-blocking callbacks; split fee mechanics; clone identity invariants
CashSettledOption.sol payout() L29 Settlement transfers (buyer payout + seller remainder) and settlement notifications
HistoricalPriceConsumerV3_TWAP.sol calculateTWAP() L19 TWAP computation over Chainlink rounds; phase-boundary edge cases
YieldStrategyVault.sol _beforeTokenTransfer() L229-242 ⚠️ HIGH PRIORITY: Transfer hooks - earnings claim ordering. See Transfer Hooks Security
YieldStrategyVault.sol _afterTokenTransfer() L249-259 ⚠️ HIGH PRIORITY: Debt update ordering - must NOT claim here
YieldStrategyVault.sol _mint() L171-188 Earnings debt assignment - prevents historical claim
YieldStrategyVault.sol _burn() L201-214 Debt recalculation - prevents future earnings lockout
YieldStrategyVault.sol _claimEarnings() L332-356 Defense-in-depth early return, double-claim prevention, totalEarnings underflow
YieldStrategyVault.sol _executeWithdrawal() L403-417 baseDeposit proportional reduction
YieldStrategyVault.sol determineRecoveryRatio() L462-487 Recovery ratio calculation - intentionally re-callable (see warning)
YieldStrategyVault.sol calculateRfqAmount() L508-521 No-loss formula - core guarantee
YieldStrategyVault.sol transfer(), transferFrom() L383, L392 ⚠️ HIGH PRIORITY: nonReentrant ERC20 overrides - prevents callback attacks
DirectionalStrategyVault.sol calculateStrikes() L153-166 Strike calculation, rounding logic
MeanRevertingCondorStrategyVault.sol calculateStrikes() L105-116 4-strike iron condor calculation
ClvexWrapper.sol depositUSDC() L196, withdrawToUSDC() L302 Aave integration, token conversions, deposit-on-behalf attribution
StrategyVaultWrapperABasUSDC.sol depositDirectional() L184, depositCondor() L224 Audit Focus: Wrapper-mediated deposits vs BaseVault pending-deposit attribution

Line references based on commit: 3ade888. Search by function name if code has moved.