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
baseDepositfunds 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) |
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.
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/
| Layer | Directory | What 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
src/vaults/utils/VaultStorage.sol(storage layout shared withEventHandler)src/vaults/BaseVault.sol(queues, RFQs, withdrawals, option splitting, callbacks)src/vaults/utils/EventHandler.sol(delegatecall event routing and tracking mutation)src/vaults/YieldStrategyVault.sol(principal protection, earnings accounting, recovery mode)src/vaults/DirectionalStrategyVault.sol+src/vaults/MeanRevertingCondorStrategyVault.sol(strategy-specific sizing/strikes)src/options/BaseOption.sol+src/options/CashSettledOption.sol(roles, events, splitting, payout path)src/options/OptionFactory.sol(RFQ lifecycle, fees, settlement, limit-mode)src/oracles/andsrc/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);
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
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 0depositOnBehalf(beneficiary, amount, assetIndex)- Use assetIndex = 0withdraw(shares)- No assetIndex parameter
Architecture Overview
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.
| Area | Files | What to look for |
|---|---|---|
| Vault core |
src/vaults/BaseVault.solsrc/vaults/CashSettledVault.solsrc/vaults/YieldStrategyVault.sol
|
Deposit queueing, RFQ lifecycle, withdrawals + option splitting, principal protection, earnings hooks |
| Vault utils |
src/vaults/utils/VaultStorage.solsrc/vaults/utils/EventHandler.solsrc/vaults/utils/VaultUtils.solsrc/vaults/utils/DateUtils.sol
|
VaultStorage layout coupling with EventHandler; delegatecall event routing; TVL/estimates; expiry calc |
| Strategies |
src/vaults/DirectionalStrategyVault.solsrc/vaults/MeanRevertingCondorStrategyVault.sol
|
Strike selection + sizing math; strategy flags (isLongOptionStrategy, isCompounding) |
| RFQ + options |
src/options/OptionFactory.solsrc/options/BaseOption.solsrc/options/CashSettledOption.sol
|
Commit-reveal RFQs, limit-mode, fee/referral logic, option roles/events, payout/split/reclaim paths |
| Option impls (Klyra) |
src/options/CallSpreadOption.solsrc/options/PutSpreadOption.solsrc/options/IronCondorOption.sol
|
Collateral requirement formulas and payout curves (all in PRICE_DECIMALS = 1e8) |
| Oracles |
src/oracles/HistoricalPriceConsumerV3.solsrc/oracles/HistoricalPriceConsumerV3_TWAP.sol
|
Chainlink round navigation + TWAP computation at option expiry |
| Rebasing adapters |
src/adapters/ERC4626RebasingAdapter.solsrc/adapters/SkyRebasingAdapter.sol
|
External dependency surfaces (ERC4626 vault logic, Sky oracle conversion rate) |
| Wrappers |
src/clvex/ClvexWrapper.solsrc/wrappers/StrategyVaultWrapper.solsrc/wrappers/StrategyVaultWrapperABasUSDC.sol
|
UX integration patterns; option forwarding; deposit attribution differences |
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)
BaseVault._handleRfqSettlement() (L775) → _mintSharesForPendingDeposits() (L840). This is where premium/intrinsic adjustments occur.
RFQ Lifecycle (Vault Side)
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)
| Function | Lines | Why it matters |
|---|---|---|
_handleRfqSettlement() | L775 | Mints queued deposits into shares; records option; clears RFQ state |
_mintSharesForPendingDeposits() | L840 | Share pricing logic; long vs short paths; premium bonus/intrinsic deductions; ITM penalty |
_handleOptionWithdrawal() | L957 | Splits/transfers option positions; fee + rounding edge cases |
executeExternalCall() | L1189 | Owner-only integration hook with strict target restrictions |
handleOptionEvent() | L1250 | Entry 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.
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
| Event | Handler | Lines | Effect |
|---|---|---|---|
EVENT_SPLIT | _handleSplitNotification | EventHandler.sol L61 | Adjusts tracked option amounts; adds new option; tracks splitGeneration |
EVENT_SETTLEMENT | _handleSettlementNotification | EventHandler.sol L114 | Removes settled option from tracking |
EVENT_TRANSFER | _handleTransferNotification | EventHandler.sol L149 | Removes option from tracking on outgoing transfer |
EVENT_CLOSE | _handleCloseNotification | EventHandler.sol L183 | Removes closed option from tracking |
EVENT_RECLAIM | _handleReclaimNotification | EventHandler.sol L228 | Swaps 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)
Key Entry Points (OptionFactory.sol)
| Function | Lines | Notes |
|---|---|---|
requestForQuotation() | L455 | Creates quotation, posts deposit/collateral, sets reserve ask |
settleQuotation() | L1271 | Final settlement; deploys option clone; clears quotation state |
cancelQuotation() | L1415 | Requester cancellation; makers must monitor until settlement |
PRICE_DECIMALS = 1e8. The factory enforces 8-decimal Chainlink feeds in fee calculations for quote-collateral paths (InvalidPriceFeedDecimals).
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
Key Entry Points
| Function | Lines | Notes |
|---|---|---|
_notifyParty() | BaseOption.sol L252 | Non-blocking event delivery (try/catch) |
transfer(isBuyer, target) | BaseOption.sol L371 | Transfers buyer or seller role to target |
split(splitCollateralAmount) | BaseOption.sol L475 | Splits collateral into a new option; fee is paid via msg.value |
payout() | CashSettledOption.sol L29 | TWAP settlement and final transfers |
Oracles
Strike selection and settlement rely on Chainlink AggregatorProxy price feeds and a TWAP computation that iterates historical rounds.
| File | Key Function | Notes |
|---|---|---|
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 |
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.
| Adapter | Files | Key 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.
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.
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 |
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
}
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;
}
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:
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
- User deposits 1000 aBasUSDC →
baseDeposit += 1000 - Rebasing yields 10 aBasUSDC →
totalBalance = 1010 - Available for options = 1010 - 0 - 0 - 1000 = 10 aBasUSDC
- 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
| Operation | Normal Mode | Recovery Mode |
|---|---|---|
deposit() | Allowed | Blocked (reverts) |
withdraw() | Allowed | Requires recoveryRatio to be set first |
createOption() | Allowed | Blocked (reverts) |
claimEarnings() | Full amount | Scaled by recoveryRatio |
Security Warnings
_beforeTokenTransfer and _afterTokenTransfer) are
critical security code that prevents multiple attack vectors. See detailed section below.
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
| Property | Description | Violation 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
}
Historical Vulnerabilities Fixed
| Issue | Attack | Fix 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 |
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
| Limitation | Severity | Impact |
|---|---|---|
| Fee-on-transfer tokens | CRITICAL | Not supported - accounting breaks |
| Negative rebasing tokens | HIGH | Triggers recovery mode |
| Non-8-decimal price feeds | CRITICAL | Strike calculations incorrect |
| Direct token donations | MEDIUM | Dilutes depositor claims |
| DeFi composability | INTENTIONAL | Vault 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
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.
- 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
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();
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.
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
- 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
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);
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
| Operation | Gas Used | Notes |
|---|---|---|
depositUSDC() | ~368,000 | USDC transfer + Aave supply + vault deposit |
depositABasUSDC() | ~297,000 | aBasUSDC transfer + vault deposit |
withdrawToABasUSDC() | ~173,000 | Vault withdraw + aBasUSDC transfer |
withdrawToUSDC() | ~196,000 | Vault withdraw + Aave withdraw to USDC |
migrate() | ~354,000 | Withdraw from source + deposit to destination |
msg.value).
Error Reference
| Error | Trigger | Resolution |
|---|---|---|
| CannotInitiateRFQInRecoveryMode | Vault in recovery mode | Withdraw funds, deploy new vault |
| AlreadyInRecoveryMode | Recovery already triggered | No action needed |
| NotInRecoveryMode | determineRecoveryRatio() when not in recovery | Check vault state first |
| ActiveOptionsExist | Recovery ratio with unsettled options | Wait for options to expire |
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
| File | Function/Lines | Risk 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.