Skip to Content
EngineeringConversion and swap

The swap and the conversion circle after ITM

The rule everything else is built for

An ITM expiry must leave the opposite asset. The arithmetic only works out exactly if the entire balance is converted.

OutcomeBeforeMechanicsAfter
Call, strike K, settlement S > Kq ETHthe exchange debits (S−K)·q USDC that is not there → debt. We sell the whole collateral at spot: S·q USDCK·q USDC — “the ETH was sold at the strike”
Put, strike K, settlement S < KK·q USDCthe (K−S)·q debit comes from that same USDC, debt never arises. We buy ETH with the remaining S·qq ETH — “the ETH was bought at the strike”
Either, OTMown collateralnothingthe same collateral plus the premium

Debt is a call-only story. A put’s collateral is K·q USDC and the (K−S)·q debit cannot mathematically exceed it. The USDC balance on Derive is a single signed number; there is no separate debt account.

Debt is cleared neither by the conversion nor by platform money, but by a USDC depositprivate/deposit reduces the negative by construction (C5). There is no separate repayment call and none is needed: the circle’s last stage, depositing, is the repayment. On the put side there is nothing to repay, and the stage does not change by a single line because of it.

What debt looks like to the user: there is no dedicated field, it is simply a negative balance. In GET /portfolio/summary it arrives as a negative amount in onExchange; in the release endpoint it is broken out as debt together with the way to clear it — deposit USDC (P11). Borrow interest accrues even on a closed cycle until the debt is cleared, which is why the interest collector walks every funded subaccount and not only those with an open position. See Ledger and balances.

OTM starts nothing

if (!itm) return

The option expired worthless, the side did not change, the collateral is still the user’s own, and the premium is in their pocket. Converting here would mean taking a trading decision the market did not demand, and paying bridge hops and slippage for it every cycle.

The second case of no row on an ITM expiry: there is no positive balance left — the exchange debited everything at settlement. A legitimate outcome, not a failure.

The stage ladder

requested → releasing → bridging_out → [parked] → [swapping] → bridging_in → depositing → done ↘ failed
StageWhat it does
releasingReleaseService moves funds off the subaccount to the SCW; ReleaseWatcher follows up. No network work of its own (C2)
bridging_outinserts a withdrawals row with the swap wallet as recipient and a conversion_id; the existing Withdrawer drives it without a single edit
parkedonly for leg='pre': waits for the 08:00 outcome
swappinginserts a swaps row with idempotencyKey = conversion:{id}
bridging_inswap wallet → bridge → SCW on Derive Chain
depositingSCW → subaccount; this is also where debt is cleared

Both bracketed stages are conditional, and that is the main economy: the return path after a wrong prediction is the same ladder minus one stage. The last two stages do not know what they are carrying — the asset is a parameter of the row, not a branch in the code. There is no separate rollback path, because it would run rarely, and a rarely-run money path is where a bug lives unnoticed until the day it costs real money.

Exactly one stage per tick. Unlike routeSwapRow, where both handlers work on sending (two independent operations on one transaction), the stages here run in a chain: a second stage in the same tick would receive a row that went stale in memory the moment the first one transitioned, and would decide on a status the row no longer has.

The router is an exhaustive switch: a new status without a handler must break the type build rather than silently fall past every stage.

Supervision, not duplication: the row stores four foreign keys and reads its children’s outcomes rather than copying their statuses. “Where is the user’s money right now” is answered by one row, not by correlating four tables on timestamps.

The scheduler

Two ticks, one lock (728_035_957_004), so they cannot overlap:

  • the ordinary EVERY_MINUTE one;
  • a fast EVERY_5_SECONDS one, active only in the 07:45–08:15 UTC window. It exists for C9: a minute tick would eat up to 60 seconds exactly between “the settlement price is published” and “the swap is sent”.

In the same tick, each in its own try/catch: BridgeInRunner.tick() (deliberately without its own @Cron — a third advisory lock for a table that lives exactly as long as a circle would be a third point of failure) and PreWithdrawRunner.run() (which looks for work by time in cycles, not in a conversion queue that does not exist for them yet).

alertIfStuck measures age from updated_at, not created_at: a circle lives for minutes and normally passes through six stages.

Pre-withdrawal before expiry

The window is [expiry − PRE_CONVERT_LEAD_MINUTES, expiry). The default is 10 minutes, not 5: the L2 ↔ Derive Chain bridge takes 2–5 minutes, and that is after an asynchronous private/withdraw; at five minutes the money arrives at 07:59–08:02, i.e. after expiry in half the cases, and the hop was paid for nothing.

Predicting ITM

The spot feed over the 07:30–08:00 TWAP window at a 60 s period (15 and 1 are rejected by the method) → a projection over “every point in the window except the last”. A check on 2026-08-14 against the actual price of 1873.486377 gave 1873.429562−0.30 bp; the “whole segment” and “without the first” variants gave −0.41 and −0.85.

An empty feed → null → pre-withdrawal is skipped: a guess on an empty feed is worse than skipping.

The projector answers “will the result be above the strike”; the inversion for a put lives in the caller, not in the pure function, which has no notion of option type.

requiredRemainingAverage: F* = (N·K − m·A)/(N − m). By 07:50 two thirds of the average are already fixed, and spot being above the strike does not by itself mean ITM — hence the rule “decide on the projection, not on spot”.

There is deliberately no confidence threshold: a false positive costs two extra bridge hops — about a cent — and paying in complexity to save a cent is a bad trade.

The withdrawal fraction is a call, not a constant (C7)

It steps from 1 − STEP downwards (the first that passes is the maximum; the reverse order would find the minimum), in 2% steps, against the criterion initialMargin ≥ MARGIN_BUFFER_USD ($100).

The simulator is the public, free public/get_margin with an empty simulated_positions: [] — i.e. the portfolio after expiry. It is not getMargin(subaccountId), which rejects subaccount_id with code −32602.

post_initial_margin is a buffer in USD, not a requirement: comparing it the wrong way round would be the last mistake before a liquidation.

Measured: 64–76% is withdrawable, not the 50% in the spec; haircut 0.7701/0.8121. The hypothesis “margin is cheaper closer to expiry” was not confirmed: 17, 41 and 65 hours give practically identical numbers — the PM2 model is shock-based, not time-based.

null (the simulator is unavailable, or no step passed) → pre-withdrawal is skipped rather than guessed.

Why the swap cannot happen before settlement

At 07:50 the predictor gives a forecast, not an observation: 20 of the 30-minute TWAP window have elapsed and the remaining 10 can flip the outcome for a position near the strike. Being wrong with an early swap means we sold the user’s ETH when it did not need selling, and nothing can undo it: a bridge hop can be reversed, a swap cannot.

Parking

ParkStage asks the journal for the outcome (option_settled.itm) rather than recomputing it: recomputing would create a second version of the truth.

Prediction at 07:50Fact at 08:00What happens
OTMnothing, no row is created at all
ITMITMrelease → bridge → swap → bridge → subaccount
ITMOTMrelease → bridge → swap → bridge → subaccount. About a cent lost on gas

null means the settlement price is not published yet (normal) — wait. A tail leg never parks: it is created after settlement.

Swaps

Statuses: quoting → sending → sent → confirmed | reverted | failed.

The slippage ladder

Steps: 3 → 6 → 12 → 25 → 50 → 100 bp, stopping at the first that passes.

LI.FI bakes minReturn inside the calldata, where it cannot be edited separately from the amount, so each step requests fresh calldata and simulates it through eth_call.

The default is “the cause was something else”. A step is only escalated if the revert is recognised as slippage by string markers. Mistaking an unrelated failure for slippage means widening the price tolerance in response to a breakage that has nothing to do with price — i.e. weakening the only protection the money has. The opposite mistake costs one refused swap, which will be retried.

The 100 bp ceiling equals the price-sanity threshold — not a coincidence. Any higher and the ladder would reach a tolerance wider than the guard is willing to pass, and would always hit PRICE_SANITY instead of an honest SLIPPAGE_UNREACHABLE — two different diagnoses of one wall.

The deadline is checked BEFORE each step, not after: there is no point starting a simulation that is already expired, because the price-gap window has closed. LADDER_DEADLINE_MS = 30_000.

Evidence of a revert is only a CALL_EXCEPTION with err.data or a non-empty err.reason. ethers@6.17 packs rate-limit -32005 and header not found into CALL_EXCEPTION too, returning reason: null, shortMessage: 'missing revert data' — so a plain rate limit would bury the swap.

The price-sanity guard

It compares the execution price against Derive’s index price — the same one options settle against. eth_call confirms the trade is executable; it says nothing about whether the price is reasonable. Under an oracle failure or an attack, the simulation passes happily at a bad price.

The 100 bp threshold is not a tuning parameter but a market-breakage detector: in normal operation it never fires, and firing must stop the swap and wake a human.

The index source deliberately has no try/catch: if the index is unavailable, the swap must stop rather than sail past the only sanity check it has.

It is checked twice: before signing (against the expectation) and after the receipt (against the fact). Between eth_call and inclusion in a block, pool state can change. Firing after the receipt rolls nothing back — the money is already exchanged; the journal event is written regardless, and the guard only decides whether to call a human.

All three rates are read before the check and catch SnapshotNotReadyError separately: otherwise an unavailable price source would look like “the guard fired” — a false alarm under a wrong diagnosis.

Send order

  1. parseUnits is wrapped in SWAP_BAD_AMOUNT: numeric(38,18) allows 18 digits while USDC has 6, and without an explicit terminal prefix such a row would retry forever.
  2. Gas first: Approver.ensure below can send a transaction itself and has no gas threshold of its own.
  3. A preliminary quote at the ladder’s ceiling, solely to learn the spender (Ruling 13). Before it, approve stood after the ladder, and the ladder reverted with transfer amount exceeds allowance — an unrelated cause, control never reached ensure, and swaps did not work at all.
  4. approver.ensure(...) — an infinite approve, once per token × spender pair. The comparison threshold is half of INFINITE, not equality: USDT-like tokens decrease the allowance on every transfer.
  5. Ladder → sanity guard → minOut.
  6. Under lock 728_035_957_005: getNonce('pending') → sign → hash = keccak256(signed)attachTx to the databasebroadcast.

The lock is held exactly from getNonce to broadcast: any wider and it would cover quoting and simulation, i.e. seconds of someone else’s waiting inside the price-gap window.

attachTx writes tx_hash, signed_tx, min_out, epsilon_bps in one call: on a crash between attachTx and markSent there would be nowhere to recover the trade’s terms from — the bytes are not reassembled.

resend sends the same bytes → the same hash. already known and nonce too low mean success and fall through to markSent; without that distinction a row whose transaction actually landed would never leave sending.

Failures: where terminal is acceptable

The swap cuts the circle in two. Before the swap the asset is the original one and the money is either on the subaccount or in our wallet in the same form — a terminal failure is safe. After the swap there is no way back: the ETH is sold, the money is on another chain in another asset, and the position is closed.

StageFailure
releasingpre — terminal (pre-withdrawal skipped, normal degradation); tailneeds_review
bridging_outbridge did not deliver → terminal: the money is safe on the SCW
bridging_outdelivered partially → the circle continues with what arrived, plus an alert: stopping would strand it on Base
swappingrevert → needs_review plus an alert, money still in the original asset
bridging_innever terminal (C17): resetForRetry on the same row, plus an alert
depositingthe receipt is cleared and it is sent again, plus an alert

markFailed on a post-swap stage would mean “abandon the user’s money on Base in an asset they did not order, and close the row”. That outcome must not exist in the code.

needs_review behaves differently in the two modules:

swapsconversions
filter in claimActiveneeds_review_at IS NULL — the row freezesno filter (C19)

In conversions the mark does not freeze the circle: if it was set after the swap, “stopping” means abandoning money on Base in the wrong asset. The mark answers “were we robbed”, not “should the money keep moving”, and the answer to the second is always “keep moving”.

Retrying a bridge hop resets THE SAME child row rather than creating a second one: the unique index bridge_ins_conversion allows one per conversion, and “detach and recreate” would loop forever. The reset clears the hash, nonce and bytes — a reverted transaction burned its nonce — but keeps error: it is the last known cause, and erasing it loses the history.

A bridge-in retry is not rate-limited by age. BridgeInStage.retry() alerts, sets markNeedsReview on the conversion and calls resetForRetry on every failure, starting with the first — there is no quiet-WARN grace period here.

The age bound that does exist belongs to a different stage: sending the swap (SwapSender.sendNew, SWAP_RETRY_STALE_AFTER_MS = 30 min). There, retryable errors before attachTx accumulate as quiet WARNs until row.createdAt passes the threshold, and only then become needs_review (Ruling 16) — “this is no longer network hiccups, it is an incident”.

Reserves and three prohibitions

The reserve is held during bridging_out, parked, swapping, bridging_in, depositing — from bridging_out and not earlier, because before that the money is still on the subaccount.

The currency follows the stage (C15): asset_in before the swap is confirmed, asset_out after. A reserve that always counted asset_in would, after the swap, hold an asset no longer in transit and release the one that just appeared — working exactly backwards.

coalesce(amount_out, amount_in): the reserve must be no smaller than reality; an understated one would release money to the user that does not exist.

Route to the moneyWhat closes it
Withdrawal to a walletthe reserve in freeByCurrency (C8)
Release from the subaccount409 CONVERSION_ACTIVE (C10)
Opening a new position409 CONVERSION_ACTIVE (C11)

One shared reason: the exchange knows nothing about money sitting on Base and will report collateral that is not there.

A fourth, separate check exists for the wheel — hasFailedForCycle, see Auto-wheel and signals.

There is no double counting: internal withdrawals rows are marked with conversion_id and excluded from the withdrawal reserve. Across the whole circle the reserve has exactly one owner — the conversion.

The circle’s gas

CIRCLE_GAS_FLOOR_WEI is a threshold for a whole circle, not for one operation. A wallet with enough for the swap but not for the hop stops the circle exactly in the middle — with the user’s money on another chain in another asset. That is the worst place to stop in the entire subsystem, and there is nothing to save here.

Conversion frequency is not measured (SQ5): the threshold is computed from a conservative estimate of “no more than one conversion per half hour at peak”. That is an estimate, not a measured rate.

Journal events

The conversions table itself writes no events — postings appear only through its child entities.

StageEvent
releasingsubaccount_released (sub → transit)
bridging_outwithdrawal_sent + gas_spent
parkedsilent
swappingswap_executed / swap_reverted (slot 0) + gas_spent (slot 1)
bridging_indeliberately silent
depositingsubaccount_funded (transit → sub)

Both bridge hops are deliberately silent: the SCW on Derive Chain and the swap wallet on Base are both our custody, so the system boundary is not crossed. A run on a wrong prediction leaves nothing in the journal except gas_spent.

swap_executed is three legs, not a pair: −amountIn and +amountOut on transit, with the third being the remainder of the first two in USD on expense:swap, so the event closes to zero by construction rather than by a coincidence of rounding.

The posting currency is assetIn/assetOut (ETH), not tokenInSymbol (WETH) — finding C-1. Money.of does not validate a currency, 'WETH' quietly reached ledger_entries.currency, failed to net against previously recorded +ETH, and usdRateFor('WETH') threw UnsupportedAssetError, which valuate() caught in a blanket catch — zeroing the user’s entire totalUsd.

swap_revertedno postings: no asset crossed the transit boundary. The burned gas carries its own gas_spent; guessing the amount here would mean inventing it.

Ordering is a class invariant: everything that can throw runs before append, and append/projectEvent run before the terminal write. claimActive does not pick up terminal rows, so an event lost between the write and the append is gone forever — “the swap happened in the wallet and there is not one posting about it in the journal”.

What has been verified live

There has never been a single live swap. swap-live.integration.spec.ts is written and fully working, but has never been run: the swap wallet does not physically exist, SWAP_WALLET_PRIVATE_KEY is unset, and there is neither gas ETH nor WETH on Base.

Verified for free, without a single send:

  • LI.FI’s shapes were captured by live measurement, not from documentation — “in this project the shapes of external APIs have already turned out to be different three times”;
  • Ruling 19 was found by a live run: LI.FI periodically picks RFQ solvers whose slippage revert is a custom error with no decodable string;
  • the bridge-in routes are live and depositToAppChain calldata is accepted by the network (eth_call);
  • bridge limits: 50,000 ETH / 100,000,000 USDC — three orders of magnitude above any conversion;
  • the inbound relay fee is 0.0000012 ETH, about a third of a cent.

The public Base node is not good enough (CQ5, measured 2026-09-01): called back-to-back, the same getMinFees succeeded 0 times out of 8; with a two-second pause between calls it succeeded 8 out of 8. The failure mode is missing revert data. That is survivable for the circle (a read failure is separated from a terminal failure) but fatal for the epsilon ladder, which makes 2–4 eth_calls back-to-back against a deadline measured in seconds and cannot space them out. A dedicated Base provider is mandatory before a live run.

The spot feed lives about a day: 30 points at 23.5 hours back, zero at 24. The projector can only be checked against past expiries for the most recent one, and only on the same day.