Skip to main content
A swap walks through exactly three states — UNINITIALIZED → ACTIVE → SETTLED (the Status enum) — recorded in a single SwapRecord struct. This page traces that journey end to end: the one-time market setup, the shared read prologue every create path runs, the three create entry points (each obeying strict Checks-Effects- Interactions), the ACTIVE custody phase, and the settlement core that is identical regardless of which rail opened the swap. The shape of the lifecycle is the protocol’s whole safety argument in miniature: the price is pinned on-chain at both ends (entry P0 and settlement round-at-T), the position’s liquidity L is frozen at creation and never re-read, and the payout is capped at MaxIL — so payout ≤ collateral holds by construction.
One core, two rails. The three create entry points (createSwap / createSwapPathA / createSwapRouted) differ only in who underwrites and how the price is discovered — strictly upstream of settlement. The settlement core (settle, the min(IL, MaxIL) cap, the MaxIL formula, and invariants I1–I9) does not branch on the rail. See the two rails for the upstream split; this page is the timeline.

The lifecycle at a glance

The SwapRecord that threads through all of this carries the fields settlement will need, written once at creation:
Rail inference. The shipped struct keeps the counterparty field named mm and has no path field — settle infers the rail from s.mm == address(convexityVault). The SwapRouted / SwapPriced events carry the path for the data moat.

Phase 0 — registerMarket (the prerequisite)

Before any swap can be created, the owner registers each market once via registerMarket. A market is a (pair, fee, duration) triple, identified by: marketId=keccak256(abi.encodePacked(token0, token1, fee, durationSeconds))\text{marketId} = \mathrm{keccak256}\bigl(\mathrm{abi.encodePacked}(\text{token0},\,\text{token1},\,\text{fee},\,\text{durationSeconds})\bigr) For ETH/USDC that is 3 fee tiers × 3 durations = 9 marketIds — all underwritten by one ConvexityVault with fungible capital. Registration is the place three pieces of hardening get baked in, each closing a way a mis-configured market could later mis-price or be re-priced:
1

Oracle token must be one of the pair

The Chainlink oracle token must be token0 or token1, so the on-chain price always refers to the actual traded asset.
2

Decimals are read on-chain, never trusted from calldata

Token decimals are read from the token contracts at registration, not accepted from the caller. One wrong digit would mis-scale every price by ~10×; reading on-chain removes the trust.
3

Price-config immutability

The oracle orientation is frozen once set, so re-registering can never re-price an active swap. A market’s pricing geometry is write-once.
Path A markets must additionally be opted in via setCvammEnabled before the cvAMM will underwrite them — see Path A.

Phase 1 — CREATE

Creation is the only phase that differs by rail, and even there the first two steps are shared. All three entry points run the same read prologue and compute the same fair value before any rail-specific pricing or locking happens.

1a. The shared read prologue (_prepareSwap)

_prepareSwap is identical for createSwap, createSwapPathA, and createSwapRouted. It is a view function — it touches no state (the only non-view step in creation, the VolOracle poke, lives in _fairPremium below). Its job is to read the truth about the position from the chain and reject anything that is not a valid in-range entry.
1

Ownership

ownerOf(tokenId) == msg.sender, else NotPositionOwner. Only the LP who owns the NFT can hedge it.
2

Position read + marketId cross-check

Read positions(tokenId); derive a marketId from the live token0 / token1 / fee plus the market’s durationSeconds, and require it equals the requested marketId, else MarketMismatch. The LP cannot route a position into the wrong market.
3

Pa / Pb from ticks

Derive the range bounds from the position’s ticks via TickMath.getSqrtRatioAtTick. The range is read from the NFT — the LP cannot lie about it.
4

P0 pinned to Chainlink, on-chain

oracle.getPrice(oracleToken) → _oracleSqrtPriceX96; the entry price is never caller-supplied. The same read (g.livePrice) is reused for the Path-B band check at zero extra oracle cost.
5

In-range gate — entry must be in range

require Pa ≤ P0 ≤ Pb, else PositionOutOfRange. This is the rule that makes this an in-range convexity hedge, not insurance — out-of-range entries are rejected at creation.
6

MaxIL

maxIL = ilMath.computeMaxIL(...) — the pure-geometry cap and unit of risk, MaxIL=max⁡(IL(Pa), IL(Pb))\text{MaxIL} = \max(IL(P_a),\,IL(P_b)).
7

Entry-amount snapshot

(a0, a1) = SwapMath.entryAmounts(...) — the token0 / token1 amounts the position holds at P0.
8

V0 in the numeraire

V0 = amount0InToken1(a0) + a1, the position value in token1 units (typically dUSDC, 6 decimals).
The position’s liquidity L is captured here as g.liquidity and stored at execution — this is the I6 freeze that settlement will depend on.
Entry-snapshot semantics. Every entry quantity — P0, (a0, a1), L — is snapshotted at swap creation, not at the LP’s original mint. The hedge covers IL accruing from creation onward; any IL the LP already bore before hedging stays theirs. This is why P0 is the oracle price at createSwap, not the position’s historical entry.

1b. Fair value (_fairPremium)

With geometry in hand, every create path computes the same on-chain published fair value via _fairPremium:
1

Geometry → ratios

Convert to normalized ratios a=Pa/P0a = P_a/P_0 and b=Pb/P0b = P_b/P_0 (WAD) from the sqrt prices. The Phi-sum runs in normalized units, so it depends only on a, b, σ_ref, and T.
2

Poke the VolOracle

Refresh σ_ref permissionlessly — a no-op if too soon via VolOracle.poke. Folding the poke into create keeps the vol estimate fresh without a dedicated keeper. This is the only non-view step in creation’s read phase.
3

Read the FairValueOracle

FairValueOracle.fairPremium(oracleToken, a, b, durationSeconds, maxIL) returns (premium, fairRateWad, sigmaRefWad). This is the single source of fair value for both rails — the router pokes once and prices both rails off the same FairPremium.
FairPremium=fairRate⋅MaxIL,fairRate=EQ ⁣[min⁡(IL, MaxIL)]MaxIL\text{FairPremium} = \text{fairRate}\cdot\text{MaxIL}, \qquad \text{fairRate} = \frac{\mathbb{E}_{\mathbb{Q}}\!\left[\min(IL,\,\text{MaxIL})\right]}{\text{MaxIL}} fairRate is an exact closed-form Phi-sum, evaluated live per quote by the Stylus FairValueOracle — never a fitted surface and never reimplemented off-chain. See the Phi-sum and the pricing stack.

1c. The three entry points (strict CEI)

All three enforce the dust floors MIN_POSITION_V0 = $100 and MIN_PREMIUM = $1 — blocking dust swaps that grief capacity and closing the integer-division free-coverage edge — plus a final maxPremium LP slippage guard. From there each rail differs only in pricing and locking, and each obeys strict Checks-Effects-Interactions: all reverting checks first, then state effects, then external calls.
createSwapPathA is the signature-free cvAMM rail.
  • Checks: the cvAMM must be wired (convexityVault != 0) and the market must be active + cvammEnabled.
  • Price: _pricePathA = the on-chain FairPremium plus the I10-clamped load stack, then capped at MaxIL.
  • Effects + Interactions (_executePathA), in order:
    1. convexityVault.lockCollateral(marketId, maxIL) — checks free capital and senior protection.
    2. nextSwapId++.
    3. Write SwapRecord{ mm: address(convexityVault), collateral: maxIL, liquidity: L, ... }.
    4. Pull premium USDC from the LP.
    5. Take the NFT into the ILVault.
    6. Premium split: poolCut = 99% accrued into the vault via accruePremium, treasuryCut = 1% to the treasury.
No keeper, no signed quote, no validity clock, no relayer. Pure on-chain.
A bad MM quote on createSwapRouted falls back to the pool — it never reverts.
  • Limit: an MM quote can be absent, expired, stale, over-band, over-load, zero-priced, undercollateralized, or simply not cheaper than the pool.
  • Why it cannot harm the LP: the router evaluates the quote through the read-only _quoteUsableAndPremiumB mirror, so any of those conditions just sets usableB = false.
  • Mitigation (by construction): the swap silently routes to the always-on pool. Only protocol-level failures (market inactive, not the owner, out of range, dust, slippage, pool unwired) revert. An LP using createSwapRouted is never worse off than going straight to Path A.

Phase 2 — ACTIVE

Once created, the swap is ACTIVE and the NFT sits in custody until expiry. This phase has no on-chain action in FULL mode — by design.
1

NFT in custody, fees still the LP's

The position NFT is held in the ILVault; the LP keeps fee accrual and may claimFees(tokenId) at any time. There is no rehypothecation — locked collateral is never lent out.
2

The position is frozen

While in custody the LP cannot re-range, add/remove liquidity, or exit early. This is a real opportunity cost — and a key reason short durations (7d) exist.
3

increaseLiquidity cannot inflate the payout (F-#2 / I6)

Anyone can call increaseLiquidity on a v3 NFT (it is not owner-gated), but settlement uses the stored L captured at creation. Extra liquidity is simply returned with the NFT and can never inflate the payout above MaxIL.
4

No monitoring, no liquidation (FULL)

In FULL mode liquidation is mathematically impossible — collateral already equals MaxIL, the maximum the payout can ever be. No keeper monitors the position.
What is hedged (F-#14). The product hedges gross in-range IL, not net P&L. The LP also earns Uniswap fees, so its total outcome — fees − IL + payout − premium — can be positive. That is correct, not a leak. Invariant I4 (the LP never profits from the swap) is about the payout, which is still 0 whenever IL is 0.
The I6 freeze is the load-bearing detail of this phase. L is stored at creation and read only from storage at settlement — the NFT’s live liquidity is never the input to the payout. This is what makes the ungated increaseLiquidity harmless.

Phase 3 — SETTLE

At block.timestamp ≥ expiry, anyone can call settle(swapId, hintRoundId) — the untouched settlement core, with math identical regardless of which rail opened the swap.
1

Status + timing checks

Require status == ACTIVE and now ≥ expiry, else revert.
2

status = SETTLED FIRST (strict CEI)

Set status = SETTLED before any external call. A malicious oracle or ilMath re-entering settle on the same swapId would hit the ACTIVE check and revert. See CEI & reentrancy.
3

Pin the price at expiry (round-at-T)

oracle.getSettlementPrice(oracleToken, expiry, hintRoundId) pins the price to the Chainlink round active at expiry T. The settlement sqrt price is derived on-chain, never caller-supplied. The keeper supplies hintRoundId; the oracle verifies it — round-at-T pinning kills the settle-timing game. It reverts on sequencer-down / grace / staleness / lone-spike / wrong-round (see oracles and price pinning).
4

IL with the STORED L (I6)

ilMath.computeIL(sqrtP_T, sqrtPa, sqrtPb, s.liquidity, amount0Entry, amount1Entry) — using s.liquidity from storage, never a re-read of the NFT.
5

Apply the cap (I1 + I2)

payout = realisedIL > maxIL ? maxIL : realisedIL. This single line is invariants I1 (no bad debt: payout ≤ collateral == MaxIL) and I2 (cap correctness: payout == min(realized_IL, MaxIL)).
6

Vault dispatch by rail

Infer the rail from the stored counterparty: if s.mm == address(convexityVault) → ConvexityVault.releaseAndDistribute(marketId, lp, payout, collateral); else UnderwriterVault.releaseAndDistribute(mm, lp, payout, collateral). The LP receives payout; the counterparty keeps collateral − payout (the residual MaxIL).
7

Return the NFT

ilVault.returnNFT(...) hands the position (and any extra liquidity added during ACTIVE) back to the LP.
8

Emit SwapSettled

SwapSettled(id, realisedIL, payout, settlementPrice).
The settlement payout, written as the protocol’s core identity: payout=min⁡(realized_IL, MaxIL),realized_IL=(Vhold(PT)−Vlp(PT))+\text{payout} = \min\bigl(\text{realized\_IL},\ \text{MaxIL}\bigr), \qquad \text{realized\_IL} = \bigl(V_{\text{hold}}(P_T) - V_{\text{lp}}(P_T)\bigr)^{+} The non-negativity guard (\cdot)^{+} is invariant I3 — realized_IL = V_hold > V_lp ? V_hold − V_lp : 0, never an unchecked subtraction. See the IL formula for the three price regimes of VlpV_{\text{lp}}.
If the oracle is unhealthy, settle reverts and is retryable — neither party can exploit downtime, since the price can only be pinned to the verified round-at-T. The liveness backstop (Fork 1 / I8) guarantees eventual success: past LIVENESS_WINDOW, the Chainlink-at-T price is accepted unconditionally, so settle always succeeds within expiry + LIVENESS_WINDOW + MAX_STALENESS + GRACE_PERIOD. See the forks.
Preview without settling. settlePreview(swapId, sqrtPTX96) computes the hypothetical payout at any price with no state change — used by the invariant suite and by frontends to show “what would I be paid if price were here?”

Worked lifecycle — live on both rails

The 2026-06-05 redeploy ran a real create → settle on both rails against one 300-second market — the full lifecycle, end to end, on-chain: Both swaps walked the identical core: read prologue → fair value → lock collateral → ACTIVE → settle pins the round-at-T → payout = min(IL, MaxIL) → NFT returned. The only difference was the rail that opened them and the vault that paid — exactly as the “one core, two rails” design specifies.

The two separate claims

The lifecycle enforces two separate guarantees — never merge them. The LP-facing guarantee is enforced at settlement, identically for both rails (the cap at step 5, the FULL collateralization at lock): The capital-at-risk disclosure applies to whoever underwrote — the pool’s depositors on Path A, the market maker on Path B:

Where to go next

The two rails

The upstream split — Path A vs Path B, side by side.

Routing in depth

createSwapRouted and the non-reverting mirror, step by step.

The pricing stack

How FairPremium, the load stack, and the I10 cap compose into a premium.

The invariants

I1–I10 and exactly where each is enforced in the lifecycle.

Oracles

Entry P0, the settlement round-at-T pin, and the liveness backstop.

MaxIL — the unit of risk

Why the cap is also what makes positions fungible to an underwriter.