Technical Audit Documentation

Kairos Protocol - Physically Settled Options Vaults on Base

Live Alpha: https://kairos.markets

Introduction

Kairos is a subdao of Thetanuts that provides physically settled vaults at fixed prices that automatically transition between calls and puts. The protocol enables users to earn yield on their assets while waiting for target prices, transforming traditional limit orders into yield-generating positions.

Core Value Proposition

  • Yield-Generating Limit Orders: Users deposit assets at a target price and earn continuous premium yield
  • Automatic Position Flipping: When price crosses strike, position automatically flips from ETH to USDC (or vice versa)
  • 100% Collateralization: No liquidation risk, no margin calls
  • Physical Settlement: Actual asset delivery rather than cash settlement

Design Philosophy

Non-Upgradeable Contracts: All contracts are immutable once deployed. This eliminates admin key risks and upgrade governance attacks. Bug fixes require deploying new versions rather than upgrading existing contracts.

Architecture Overview

User Deposit | v +------------------+ | FixedStrikeVault | <-- Automatically determines Call vs Put based on price +------------------+ | | Creates RFQ via OptionFactory v +----------------+ | OptionFactory | <-- RFQ System (Request for Quotation) +----------------+ | | Market Makers submit offers v +----------------------------+ | PhysicallySettledCallOption | or PhysicallySettledPutOption +----------------------------+ | | At expiry (1-hour exercise window) v Physical Settlement: Asset delivery between buyer and seller

Contract Hierarchy

BaseVault (abstract)
    └── PhysicallySettledVault (dual-asset management, recovery mode)
           └── FixedStrikeVault (fixed strike strategy)

BaseOption (abstract)
    └── PhysicallySettledOption (abstract, exercise window logic)
           ├── PhysicallySettledCallOption
           └── PhysicallySettledPutOption

OptionFactory (RFQ system, option creation)

Key Design Decisions

Decision Tradeoff Rationale
Non-upgradeable Can't fix bugs in deployed contracts Eliminates admin key risks, upgrade attacks
100% Collateralization Capital inefficient vs lending No liquidation cascades, no oracle manipulation risks
Physical Settlement Requires exercise action from buyer Direct asset delivery, simpler security model
Fixed Strike Less flexibility than dynamic strikes Predictable execution, enhanced liquidity

Audit Scope

In-Scope Contracts with Test Coverage

Contract Lines Branches Description
OptionFactory.sol 98.35% 97.55% RFQ system, option creation
BaseOption.sol 98.80% 95.12% Abstract base for all options
PhysicallySettledOption.sol 94.79% 95.00% Physical settlement logic
PhysicallySettledCallOption.sol 97.14% 100.00% Call option implementation
PhysicallySettledPutOption.sol 97.14% 100.00% Put option implementation
BaseVault.sol 97.10% 94.44% Abstract vault base
PhysicallySettledVault.sol 98.37% 94.87% Physical settlement vault base
FixedStrikeVault.sol 92.00% 100.00% Fixed strike strategy

Coverage measured via forge coverage. All in-scope contracts have >90% line coverage.

OptionFactory

Overview

The OptionFactory implements a Request for Quotation (RFQ) system using a commit-reveal scheme to prevent front-running and MEV attacks.

RFQ Lifecycle

1. REQUEST PHASE Requester calls requestForQuotation() - Deposits collateral (if selling) or premium budget (if buying) - Publishes ephemeral public key for encrypted offers 2. OFFER PHASE (until offerEndTimestamp) Market makers call makeOfferForQuotation() - Submit EIP-712 signed offer (commitment) - Encrypt offer details for requester using ECDH 3. REVEAL PHASE (REVEAL_WINDOW after offer end) Anyone can call revealOffer() - Reveal offer amount, nonce, offeror - Contract verifies signature - Best offer wins (lowest for buyers, highest for sellers) OR Requester calls settleQuotationEarly() - Decrypt and accept an offer before reveal ends 4. SETTLEMENT settleQuotation() creates the option contract - Transfers collateral to option contract - Transfers premium to appropriate party - Distributes fees 5. LIMIT ORDER FALLBACK (if convertToLimitOrder=true) If no offers received after reveal window: - Anyone can call settleQuotation() and provide funds at reserve price - Caller becomes counterparty (NOT automatic - requires active participation)

Key Functions

Function Access Description
requestForQuotation() Public Create new RFQ with option parameters
makeOfferForQuotation() Public Submit signed offer commitment
revealOffer() Public Reveal offer during reveal window
settleQuotationEarly() Requester Accept offer before reveal ends
settleQuotation() Public Finalize RFQ after reveal window
cancelQuotation() Requester Cancel active RFQ

Fee Structure

  • Base Rate: 0.06% per contract (e.g., 0.0006 ETH for 1 ETH contract)
  • Cap: 12.5% of premium maximum
  • Charged To: Premium receiver (not split between parties)
  • Referral Split: 50/50 between owner and referrer (if referral used)

Security Properties

ReentrancyGuard: Applied to most state-modifying functions. Exception: makeOfferForQuotation() is intentionally unguarded as it performs only one storage write and no external calls. Note that isActive = false is set BEFORE external calls during settlement to support integration callbacks.
Agnostic Design: No built-in whitelists for implementations or tokens. Security relies on user verification and trusted frontends. Malicious implementations could be deployed.

BaseOption

Overview

Abstract base contract providing common functionality for all option types including transfers, splits, and the event notification system.

State Variables

address public buyer;           // Option holder (right to exercise)
address public seller;          // Collateral provider (obligation)
address public collateralToken; // ERC20 used as collateral
uint256[] public strikes;       // Strike price(s)
uint256 public expiryTimestamp; // When option expires
uint256 public numContracts;    // Number of contracts
uint256 public collateralAmount;// Total collateral locked
uint256 public splitGeneration; // For exponential fee calculation
bytes32 public paramsHash;      // Hash of immutable parameters

Split Mechanism

Options can be split into two child options, preserving the total contract count.

split(splitCollateralAmount) | v Calculate splitNumContracts from collateral | v Verify: splitNumContracts > 0 AND parentNumContracts > 0 | v Create child option via clone() | v Transfer childCollateralAmount to new option | v Increment splitGeneration (fee doubles each generation) | v Forward split fee to counterparty (or rescueAddress if transfer fails)
Split Fee Attack Prevention: The split fee doubles exponentially with each generation (baseFee * 2^generation). This prevents DoS attacks where an attacker splits positions into millions of dust amounts, making settlement prohibitively expensive.

Transfer System

Role transfers (buyer/seller) require explicit approval or caller authorization:

function transfer(bool isBuyer, address target) external {
    // Authorized if:
    // 1. msg.sender is current role holder
    // 2. msg.sender has allowance from current role holder
    // 3. msg.sender is the factory

    // Allowance auto-revokes after use
}

Event Notification System

Options notify buyer and seller contracts about lifecycle events:

Event Type Code Data
Creation 0 empty
Settlement 1 empty
Transfer 2 (isIncoming, otherParty)
Close 3 (collateralReturned)
Split 4 (originalAmount, splitAmount, newOption)
Reclaim 5 (newOption, reclaimer)
Non-Blocking Notifications: All callbacks are wrapped in try/catch. Receivers cannot prevent operations by reverting.

PhysicallySettledOption

Overview

Abstract contract for options involving actual asset delivery. Manages exercise windows, oracle failure handling, and physical settlement flows.

Key Constants

uint256 public constant EXERCISE_WINDOW = 1 hours;
// Minimum time after expiry before anyone can force-settle ITM options

Settlement Flow

After expiryTimestamp: ITM Option (In-The-Money): +---------------------------+ | Immediately after expiry | +---------------------------+ | | exercise() callable by ANYONE (permissionless) | But pulls tokens from buyer - requires buyer's approval v +--------------------------------+ | Buyer delivers deliveryAsset | | Buyer receives collateralAsset | +--------------------------------+ 1 hour passes... | v +-------------------+ | withdraw() called | <-- Anyone can call after 1-hour window +-------------------+ | | First attempts exercise (if buyer has approvals set) v Success? --> Done (exercise executed) Failure? --> Treated as doNotExercise(), collateral returns to seller OTM Option (Out-of-The-Money): +-------------------------------+ | withdraw() callable immediately| <-- No 1-hour wait for OTM +-------------------------------+ | v Collateral returned to seller

Exercise Functions

Function Access Description
exercise() Public Exercise ITM option (requires buyer approval for delivery tokens)
swapAndExercise() Buyer only Swap collateral to delivery asset and exercise in one tx
doNotExercise() Buyer only Explicitly forfeit exercise rights
withdraw() Public Force-settle after exercise window (attempts exercise first)
exerciseOnOracleFailure() Buyer only Exercise when oracle is unhealthy. No ITM/OTM price check occurs - buyer can exercise regardless of whether option would have been ITM. Sellers are forced to settle at strike price without oracle validation. Last-resort mechanism granting buyer full discretion during oracle failures.

Oracle Failure Handling

Oracle Failure Scenarios: During oracle failures, normal exercise() and withdraw() paths are blocked as they depend on TWAP. The option remains frozen until recovery.

Recovery Sequence (Buyer-Controlled)

  1. Oracle recovers - Normal paths resume automatically
  2. exerciseOnOracleFailure() - Buyer exercises at strike price without TWAP verification
  3. doNotExercise() - Buyer forfeits rights, collateral returns to seller
  4. rescueERC20() - Admin fallback available 24 hours after expiry
function oracleUnhealthy() public view returns (bool unhealthy, uint8 reason) {
    // reason: 0=healthy, 1=TWAP failed, 2=Price feed stale, 3=Invalid data
    // Staleness threshold: max(2 * twapPeriod, 1 day)
}

Security Properties

Permissionless Exercise Design

exercise() is permissionless by design. Security relies on token approvals, not access control. Anyone can call exercise for ITM options, but only the buyer's tokens can be spent (requires buyer's approval to the option contract).

Withdraw Attempts Exercise First

When withdraw() is called on an ITM option after the 1-hour window, it first attempts to call exercise(). If the buyer has pre-approved the delivery tokens, exercise will succeed. This ensures buyers who set approvals in advance don't lose their ITM position even if they miss the exercise window.

// Simplified withdraw logic for ITM options:
function withdraw() external {
    // After 1-hour window for ITM...
    try this.exercise() {
        return; // Success - buyer got their payout
    } catch {
        // Buyer didn't have approvals - treat as forfeit
        // Return collateral to seller
    }
}

PhysicallySettledCallOption & PhysicallySettledPutOption

Call Option (Base Collateral)

Option Type: 0x0010

  • Collateral: Base asset (e.g., WETH)
  • Delivery: Quote asset (e.g., USDC)
  • ITM Condition: price > strike
  • Delivery Amount: numContracts * strike * deliveryDecimals / PRICE_DECIMALS / collateralDecimals
  • Collateral Required: numContracts (1:1 with base asset)

Example: 1 ETH call at $3000 strike. If ETH > $3000, buyer pays $3000 USDC, receives 1 ETH.

Put Option (Quote Collateral)

Option Type: 0x0111

  • Collateral: Quote asset (e.g., USDC)
  • Delivery: Base asset (e.g., WETH)
  • ITM Condition: price < strike
  • Delivery Amount: numContracts * deliveryDecimals / collateralDecimals
  • Collateral Required: strike * numContracts / PRICE_DECIMALS

Example: 1 ETH put at $3000 strike. If ETH < $3000, buyer pays 1 ETH, receives $3000 USDC.

Option Type Encoding

// Option type bit layout: 0xABCD
// A (bits 12-15): Structure (0=Single, 1=Spread, 2=Butterfly, 3=Condor)
// B (bits 8-11):  Collateral (0=Base, 1=Quote)
// C (bits 4-7):   Settlement (0=Cash, 1=Physical)
// D (bits 0-3):   Style (0=Call, 1=Put, 2=Iron Condor)

BaseVault

Overview

Abstract ERC20 vault managing deposits, withdrawals, option creation via RFQ, and share accounting.

Core State

// Immutables
OptionFactory public immutable factory;
bool public immutable isLongOptionStrategy;
uint256 public immutable activeOptionsStrategies;

// RFQ State
uint256 public activeRfqId = type(uint256).max;  // No active RFQ
uint256 public lastRFQTimestamp;
uint256 public activeExpiryTimestamp;

// Deposit Queue
PendingDeposit[] public pendingDeposits;
uint256 public lastProcessedDepositIndex;
uint256[] public lastRecordedValuePerShare;

Deposit Flow

deposit(amount, assetIndex) | v First deposit (totalSupply == 0)? | +-- YES --> Mint initial shares, set lastRecordedValuePerShare | +-- NO --> Queue deposit in pendingDeposits[] (processed when next option is created)

Withdrawal Flow

withdraw(shares) payable | v Check: Not during active RFQ (unless in limit mode) | v Handle active RFQ settlement Handle expired options | v _executeWithdrawal(shares) | +-- Transfer proportional liquid assets | +-- Split/transfer proportional option positions | (requires ETH for split fees) | v Burn shares, emit events, update valuePerShare

Option Creation Flow

createOption() [permissionless, but timing restricted] | v Check timing constraints: - Not too soon after last RFQ - Not too close to expiry | v _handleActiveRfq() - settle/cancel pending RFQ _handleExpiredOptions() - settle expired options | v _issueNewRfq() - Calculate numContracts, strikes - Cap at MAX_RFQ_VALUE - Approve factory for collateral - Call factory.requestForQuotation() | v Update lastProcessedDepositIndex, lastRFQTimestamp

Share Minting for Pending Deposits

When an RFQ settles, pending deposits are minted shares based on:

// For SHORT strategies (selling options):
effectiveDeposit = deposit + premiumBonus - intrinsicDeduction - itmPenalty

// Where:
// - premiumBonus = deposit * premium / collateralAmount
// - intrinsicDeduction = deposit * intrinsicValue / collateralAmount
// - itmPenalty = 1% if option is ITM (prevents ITM deposit manipulation)

// For LONG strategies (buying options):
effectiveDeposit = deposit - (deposit * totalLongOptionValuation / totalAssetsUSD)

// Where:
// - priceOfOption = premium / collateralAmount (per-contract option price)
// - totalLongOptionValuation = priceOfOption * totalOptionStrategyCollateral
// Long deposits are haircut by the proportion of vault value locked in options
ITM Penalty: A 1% penalty is applied to deposits that create ITM scenarios. This prevents manipulation where users deposit right before ITM options are sold to dilute losses.

Reserve Ask Calculation

function calculateReserveAsk(...) public view returns (uint256) {
    uint256 timeToExpiry = getNextExpiryTimestamp() - block.timestamp;
    uint256 aprBasedReserve = collateralAmount * MIN_APR_BPS * timeToExpiry / (365 days * 10000);

    if (isLongOptionStrategy) {
        // Buying: APR-based maximum (prevent overpaying)
        return aprBasedReserve;
    } else {
        // Selling: max(APR-based, intrinsicValue) - prevent selling ITM at loss
        uint256 intrinsicValue = calculatePayout(currentPrice);
        return max(aprBasedReserve, intrinsicValue);
    }
}

PhysicallySettledVault

Overview

Extends BaseVault for dual-asset management (base + quote) and physical settlement. Includes comprehensive recovery mode for fault tolerance.

Dual Asset Management

The vault manages two assets:

Value Calculation

function calculateValueInUSD(uint256 amount, uint256 assetIndex) {
    if (assetIndex == 1) {
        // Quote asset: adjust decimals only (assume ~$1)
        return amount * PRICE_DECIMALS / 10^quoteDecimals;
    } else {
        // Base asset: multiply by oracle price
        return amount * getCurrentPrice(0) / 10^baseDecimals;
    }
}

Invariant Check

Value Per Share Invariant: Value per share must not decrease by more than MAX_VALUE_DROP_BPS in both base AND quote terms simultaneously. A drop in one denomination alone is allowed (e.g., ETH price change affects base value but not quote value).
function assertInvariant() internal view {
    if (totalSupply() == 0) return;
    if (isInRecoveryMode && block.timestamp >= recoveryInitiatedAt + RECOVERY_DELAY) return;

    uint256[] memory currentVPS = _calculateValuePerShare();
    bool baseDecreased = currentVPS[0] * 10000 < lastRecordedVPS[0] * (10000 - MAX_VALUE_DROP_BPS);
    bool quoteDecreased = currentVPS[1] * 10000 < lastRecordedVPS[1] * (10000 - MAX_VALUE_DROP_BPS);

    if (baseDecreased && quoteDecreased) revert InvariantViolation();
}

Recovery Mode

Recovery mode can be triggered by anyone when:

  1. Invariant Violation: Value drops in both base AND quote terms beyond MAX_VALUE_DROP_BPS threshold
  2. Oracle Failure: Invalid data (roundId=0, price<=0, updatedAt=0) or stale data exceeding 86400 seconds (24 hours) since last update
initiateRecoveryMode() [permissionless] | v Check: Invariant broken OR oracle failure? | +-- NO --> revert InvariantNotBroken() | +-- YES --> Set isInRecoveryMode = true Set recoveryInitiatedAt = block.timestamp | v Active RFQ exists? | +-- try factory.cancelQuotation() | | | +-- SUCCESS: Clear activeRfqId | +-- FAILURE: RFQ settled, needs manual recovery | v emit RecoveryModeTriggered()

Recovery Mode Effects

Operation Normal Mode Recovery Mode
deposit() Allowed Blocked
withdraw() Allowed Allowed (after 7-day delay)
createOption() Allowed Blocked
rescueERC20() Non-vault tokens only Any token (including assets)
Recovery Mode is PERMANENT: Once triggered, the vault never accepts deposits again. This is intentional - recovery mode indicates fundamental issues that require manual intervention and potential redeployment.

Stuck RFQ Recovery

If an RFQ settles but the settlement callback fails (e.g., due to invariant violation), the owner must manually recover:

function recoverSettledRFQOption() external onlyOwner {
    require(isInRecoveryMode);
    require(activeRfqId != type(uint256).max);
    require(pendingDeposits.length > 0);

    // Verify RFQ is truly stuck
    try this.callHandleActiveRfq() {
        revert RFQNotStuck();
    } catch {
        // Transfer option to owner for manual distribution
        BaseOption(optionContract).transfer(isLongOptionStrategy, owner());
    }
}

FixedStrikeVault

Overview

Specialized vault creating options at a fixed strike price. The vault sells covered calls when holding ETH and cash-secured puts when holding USDC.

Key Properties

uint256 public immutable FIXED_STRIKE;

// Always a SHORT strategy (selling covered calls or secured puts)
isLongOptionStrategy = false;

// Both assets must share the SAME underlying price feed (e.g., ETH/USD)
require(assets[0].priceFeed == assets[1].priceFeed);

Automatic Asset Selection (Deposits)

The convenience deposit(uint256 amount) function selects asset based on price vs strike:

function deposit(uint256 amount) external {
    uint256 currentPrice = getCurrentPrice(0);
    uint256 assetIndex;

    if (currentPrice < FIXED_STRIKE) {
        // Below strike: deposit base asset (ETH) for covered calls
        assetIndex = 0;
    } else {
        // Above strike: deposit quote asset (USDC) for secured puts
        assetIndex = 1;
    }

    super.deposit(amount, assetIndex);
}

RFQ Asset Selection (Option Creation)

Important Distinction: RFQ creation does NOT use price vs strike. Instead, it uses whichever asset the vault currently holds more of (by USD value). This is inherited from PhysicallySettledVault.calculateRfqAmount().
// PhysicallySettledVault.calculateRfqAmount()
function calculateRfqAmount() public view returns (uint256 collateralAmount, uint256 assetIndex) {
    (uint256[] memory assetValues,,,) = getTotalAssets();
    assetIndex = assetValues[0] > assetValues[1] ? 0 : 1;  // Larger holding wins
    collateralAmount = assets[assetIndex].token.balanceOf(address(this));
}

The position flip happens indirectly: settlement exchanges assets, changing which asset dominates holdings.

Contract Calculation

function calculateNumContracts(uint256 amount, uint256 assetIndex) {
    if (assetIndex == 0) {
        // Calls: 1:1 with base asset
        return amount;
    } else {
        // Puts: amount * PRICE_DECIMALS / FIXED_STRIKE
        return amount * PRICE_DECIMALS / FIXED_STRIKE;
    }
}

Example Workflow

Fixed Strike at $3000 ETH

  1. ETH at $2800: User deposits ETH. Vault sells covered calls at $3000 strike.
  2. ETH rises to $3200: Calls exercised. User now holds USDC (sold ETH at $3000).
  3. Next cycle: Vault sells cash-secured puts at $3000 strike.
  4. ETH drops to $2900: Puts exercised. User now holds ETH (bought at $3000).
  5. Cycle continues...

Throughout this process, the user earns premium yield from selling options.

Key Invariants

1. Collateral Conservation

IERC20(collateralToken).balanceOf(option) >= option.collateralAmount()

Enforced by ERC20 transfers. Options always hold at least their stated collateral.

2. Contract Conservation on Split

parentContracts + childContracts == originalContracts

Total contracts preserved when splitting. No value extraction through splits.

3. Vault Asset Accounting

totalAssets = liquidAssets + pendingDeposits + optionCollateral

All vault value is accounted for in liquid tokens, queued deposits, or option positions.

4. Value Per Share (PhysicallySettledVault)

!(baseDropped > threshold AND quoteDropped > threshold)

Value per share cannot decrease beyond threshold in both denominations simultaneously.

5. RFQ Deposit Coverage

requesterDeposit + winnerDeposit >= premium + collateral

During active RFQ, deposits cover all required amounts for settlement.

Protection Mechanisms

Split Attack Prevention

Attack Vector

Attacker splits victim's position into millions of dust positions, making settlement prohibitively expensive in gas.

Protection

  • Exponential Fees: Split fee = baseFee * 2^generation
  • Economic Deterrent: After ~20 generations, fees become astronomical
  • Pure Economic Defense: No minimum split value - the exponential fee alone makes dust attacks economically infeasible

Vault Share Rounding Attacks

Attack Vector

First depositor manipulates share price by depositing tiny amount then donating to vault.

Protection

  • MIN_VALUE Scaling: First deposit must meet full MIN_VALUE threshold; subsequent deposits require only MIN_VALUE / MAX_QUEUE (scaled to prevent queue spam while allowing smaller follow-on deposits)
  • Initial Shares: First deposit always mints exactly 1e18 shares
  • Value Per Share Tracking: Sudden drops trigger invariant violations

ITM Deposit Manipulation

Attack Vector

Depositor times deposit right before vault sells an ITM option, diluting losses across existing shareholders.

Protection

  • Intrinsic Deduction: New deposits lose proportional intrinsic value
  • ITM Penalty: Additional 1% penalty for ITM-causing deposits
  • Reserve Price Floor: Reserve ask is max(APR-based, intrinsicValue)

Oracle Manipulation

Attack Vector

Attacker manipulates oracle to exercise favorable options or cause incorrect settlements.

Protection

  • TWAP: 30-minute TWAP for settlement (matches Deribit)
  • Staleness Checks: Oracle data must be fresh
  • Oracle Failure Mode: Only buyer can resolve during failures
  • Recovery Mode: Vault enters recovery on oracle failure

MEV/Frontrunning

Attack Vector

MEV bots frontrun offers or settle transactions for profit.

Protection

  • Commit-Reveal: Offers are committed (signature only) then revealed
  • Encrypted Offers: Offer details encrypted for requester using ECDH
  • Early Settlement: Requester can accept before reveal window

Reentrancy

Protected Functions (with nonReentrant)

  • BaseVault: withdraw(), withdrawPendingDeposits(), createOption(), executeExternalCall()
  • OptionFactory: requestForQuotation(), settleQuotation(), cancelQuotation(), revealOffer()
  • BaseOption: transfer(), split(), reclaimCollateral()

Intentionally Unguarded Functions

  • BaseVault.deposit() - No external calls after state changes, uses CEI pattern
  • PhysicallySettledOption.exercise() - Terminal state (optionSettled = true) set before transfers prevents re-entry benefit
  • OptionFactory.makeOfferForQuotation() - Single storage write, no external calls

Note: Lists above show key functions, not exhaustive. Verify nonReentrant usage in any modified code.

Note: Some functions set state (isActive) before external calls intentionally to support integration callbacks. This is documented in the code and does not create exploitable reentrancy due to terminal state transitions.

Attack Vectors to Review

High Priority

  1. Exercise Window Race: Can withdraw() and exercise() create race conditions where both succeed/fail unexpectedly?
  2. Split Fee Bypass: Can an attacker avoid exponential split fees through creative contract interactions?
  3. Recovery Mode Entry: Can an attacker force recovery mode via transient oracle issues to permanently DoS deposits?
  4. RFQ Settlement Order: Can ordering of reveals be manipulated to extract value?
  5. Pending Deposit Manipulation: Can pending deposits be weaponized during RFQ settlement?
  6. First Depositor Donation Attack: After first deposit, can direct token transfer inflate share price to dilute later depositors? (MIN_VALUE provides partial mitigation)

Medium Priority

  1. Price Oracle Dependency: What happens with extreme price movements during settlement?
  2. Callback Griefing: Can malicious option holders grief vaults via callbacks?
  3. Allowance Persistence: Are approvals properly cleaned up after transfers?
  4. Dust Accumulation: Can dust amounts accumulate in ways that affect accounting?
  5. Split Generation Overflow: After many splits, does 2^generation overflow or create DoS?
  6. Share Minting Arithmetic: Edge cases when totalLongOptionValuation > totalAssetsUSD or division by zero scenarios

External Dependencies

Additional Security Considerations

Known Limitations & Unsupported Scenarios

Scenario Impact Mitigation
Fee-on-transfer tokens CRITICAL - Assets unrecoverable, accounting broken Not supported. Do not use.
Negatively rebasing tokens HIGH - Accounting mismatch, potential insolvency Not supported. Do not use.
Positively rebasing tokens MEDIUM - Works but yield accrues to seller Acceptable if understood. Buyer loses rebase yield.
Malicious option implementations CRITICAL - Factory is agnostic, accepts any impl Users must verify implementation address or use trusted frontend
Oracle manipulation MEDIUM - Incorrect ITM/OTM determination 30-min TWAP, staleness checks, recovery mode
Non-8-decimal price feeds CRITICAL - Reverts with InvalidPriceFeedDecimals() All Chainlink price feeds must be 8 decimals (USD convention). Note: only USD prices use 8 decimals; numContracts and collateralAmount use collateral token decimals (e.g., 18 for ETH, 6 for USDC).
First depositor attack MEDIUM - Share price manipulation via donation MIN_VALUE threshold provides partial mitigation
Split DoS at high generation LOW - Exponential fees make further splits expensive Intentional economic throttle, not a bug
Recovery mode permanent LOW - Vault never accepts deposits again Intentional. Deploy new vault if needed.

Emergency Procedures

Scenario Recovery Path
Oracle failure Recovery mode + buyer exercises via exerciseOnOracleFailure()
Stuck RFQ Owner calls recoverSettledRFQOption()
Invariant violation Recovery mode activated, 7-day delay for withdrawals
Token rescue (option) rescueERC20() after 24 hours post-expiry
Token rescue (vault) Recovery mode required for vault assets
Oracle failure blocking vault _handleExpiredOptions() calls option.withdraw() which requires oracle data via getTWAP(). If oracle is stale/invalid, vault operations (withdrawals, option creation) are blocked until buyer calls exerciseOnOracleFailure() or doNotExercise() on each blocked option.

Access Control Summary

Role Capabilities
Factory Owner Withdraw fees, set MAX_RFQ_VALUE, set baseSplitFee, authorize routers
Vault Owner Execute external calls via executeExternalCall() to any contract except vault itself, factory, vault assets, and active options (centralization risk: owner can interact with arbitrary external contracts); rescue tokens (recovery mode required for vault assets); set pause guardian; recover stuck RFQ
Pause Guardian Pause/unpause vault (blocks deposits/createOption, not withdrawals)
Option Buyer Exercise, doNotExercise, exerciseOnOracleFailure, swapAndExercise
Anyone withdraw() after exercise window, initiateRecoveryMode(), createOption()

Testing Recommendations

Intentional Design Patterns (Not Bugs)

The following patterns may appear problematic but are intentional design decisions:

1. Post-Expiry RFQ Settlement Allowed

Appears as: _settleQuotation allows RFQs to settle after option expiry

Why correct: Both parties escrowed funds before expiry; late settlement finalizes the deal (OTM sellers recover collateral, ITM buyers claim payout). New RFQs for expired options ARE blocked at creation.

Location: OptionFactory.sol:1293-1305

2. Reserve Price Can Be Exceeded via Top-Up

Appears as: _handleRequesterTopUp overwrites reserve when requester accepts higher offer

Why correct: Reserve is only the passive auto-settlement cap. Explicit settlement (settleEarly/swapAndCall) requires token transfer for the delta, which IS the consent signal.

Location: OptionFactory.sol:935-1004

3. isActive Set False Before External Calls

Appears as: CEI violation in _settleQuotation

Why correct: Intentional for integration callbacks (e.g., loan handlers check RFQ state). Settlement is terminal with no re-entry path; funds already escrowed.

Location: OptionFactory.sol:1311-1334

4. Anyone Can Cancel Some RFQs

Appears as: cancelQuotation allows external callers

Why correct: Only when (a) reveal window ended, (b) no winner, (c) not limit-order mode. Cleanup path for stuck zero-offer RFQs; funds return to requester.

Location: OptionFactory.sol:1388-1415

5. Permissionless withdraw() Can Force Non-Exercise

Appears as: Third parties can forfeit buyer's ITM position

Why correct: Only after 1-hour EXERCISE_WINDOW. First attempts exercise using buyer's approvals; only forfeits if buyer can't pay. Seller protection after reasonable exercise period.

Location: PhysicallySettledOption.sol:266-309

6. Recovery Mode Preserves Stuck activeRfqId

Appears as: Failed cancelQuotation leaves RFQ stuck forever

Why correct: Intentional loss socialization. Keeps pending deposits bound to stuck option so they can't exit without loss; owner uses recoverSettledRFQOption for manual distribution.

Location: PhysicallySettledVault.sol:516-559

7. Exponential Split Fees (2^generation)

Appears as: Bug or DoS vector on user funds

Why correct: Anti-griefing control against deep split trees. Paid in ETH, forwarded to counterparty. Economic throttle, not a protocol fee on option value.

Location: BaseOption.sol:474-547

8. Withdrawal Rounding Can Drop Option Portions

Appears as: Small withdrawals lose option value silently

Why correct: Explicit zero-check at lines 963, 972, 977 returns early rather than reverting. Alternative would lock user funds. Dust loss accepted over withdrawal DoS.

Location: BaseVault.sol:960-978

9. swapAndExercise Uses Seller's Collateral

Appears as: Buyer exercises "for free" using seller's posted collateral

Why correct: This is standard physically-settled option mechanics. The buyer PAID PREMIUM upfront when entering the position. At exercise, buyer is ENTITLED to the collateral. swapAndExercise is a convenience function that atomically: (1) swaps collateral to deliveryCollateral, (2) pays seller the strike from swap proceeds, (3) gives buyer the remainder. Economically identical to normal exercise() where buyer pays strike separately.

Comparison:

  • exercise(): Buyer pays $50k USDC → receives 1 ETH ($60k) → net +$10k
  • swapAndExercise(): Contract swaps 1 ETH → $60k → seller gets $50k → buyer gets $10k

Location: PhysicallySettledOption.sol:204-232

10. Vault Picks Lowest Split Generation

Appears as: Vault will DoS after ~17 withdrawals due to exponential split fees

Why correct: Vault withdrawal logic explicitly sorts options by splitGeneration and ALWAYS picks the option with the LOWEST generation (cheapest fee) to split. This prevents fee accumulation across withdrawals.

Code evidence:

// BaseVault.sol:955-956
// Split the lowest generation option (cheapest fee)
BaseOption optionToSplit = BaseOption(options[lowestGenerationIndex].optionContract);

Location: BaseVault.sol:930-956

11. Recovery Mode is Permanent (By Design)

Appears as: Transient oracle failure permanently kills vault

Why correct: Conservative safety design. Once triggered, vault blocks new deposits/RFQs but allows withdrawals. Rationale: better to "kill" a potentially compromised vault than risk repeated attacks or inconsistent state. Users can withdraw; protocol deploys new vault if needed.

No exit mechanism: isInRecoveryMode = true has no corresponding false setter. This is intentional.

Location: PhysicallySettledVault.sol:543

12. TWAP Phase Boundary Reverts

Appears as: Option settlement blocked if Chainlink transitions phases during TWAP window

Current behavior: If TWAP calculation crosses a Chainlink phase boundary (roundID % 2^64 == 0), the calculation reverts. This is rare but can temporarily block settlement.

Recovery paths:

  • Physical options: Use exerciseOnOracleFailure(), doNotExercise(), or wait for rescueERC20()
  • Cash options: Must wait for oracle to recover or rescueERC20() after 24h

Future improvement: Potential fix in HistoricalPriceConsumerV3_TWAP.sol - stop at current phase boundary instead of reverting, using partial TWAP.

Location: oracles/HistoricalPriceConsumerV3_TWAP.sol

Critical Code Paths for Auditors

Line references for intensive review:

File Lines Focus Area
OptionFactory.sol 1307-1386 _settleQuotation - fund flows, existing option buyback logic
OptionFactory.sol 779-793 _handleBestOfferAcceptance - collateral/premium transfers
BaseVault.sol 201-230 deposit - first depositor handling, MIN_VALUE checks
BaseVault.sol 802-899 _mintSharesForPendingDeposits - share calculation, ITM penalty
BaseOption.sol 415-547 split - exponential fees, conservation invariants
PhysicallySettledOption.sol 156-309 exercise/withdraw - settlement flows, oracle gating
PhysicallySettledVault.sol 516-559 initiateRecoveryMode - trigger conditions, RFQ cancellation
PhysicallySettledVault.sol 676-700 recoverSettledRFQOption - stuck RFQ handling