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.
| Outcome | Before | Mechanics | After |
|---|---|---|---|
Call, strike K, settlement S > K | q ETH | the exchange debits (S−K)·q USDC that is not there → debt. We sell the whole collateral at spot: S·q USDC | K·q USDC — “the ETH was sold at the strike” |
Put, strike K, settlement S < K | K·q USDC | the (K−S)·q debit comes from that same USDC, debt never arises. We buy ETH with the remaining S·q | q ETH — “the ETH was bought at the strike” |
| Either, OTM | own collateral | nothing | the 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 deposit —
private/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) returnThe 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| Stage | What it does |
|---|---|
releasing | ReleaseService moves funds off the subaccount to the SCW; ReleaseWatcher follows up. No network work of its own (C2) |
bridging_out | inserts a withdrawals row with the swap wallet as recipient and a conversion_id; the existing Withdrawer drives it without a single edit |
parked | only for leg='pre': waits for the 08:00 outcome |
swapping | inserts a swaps row with idempotencyKey = conversion:{id} |
bridging_in | swap wallet → bridge → SCW on Derive Chain |
depositing | SCW → 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_MINUTEone; - a fast
EVERY_5_SECONDSone, 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:50 | Fact at 08:00 | What happens |
|---|---|---|
| OTM | — | nothing, no row is created at all |
| ITM | ITM | release → bridge → swap → bridge → subaccount |
| ITM | OTM | release → bridge → |
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
parseUnitsis wrapped inSWAP_BAD_AMOUNT:numeric(38,18)allows 18 digits while USDC has 6, and without an explicit terminal prefix such a row would retry forever.- Gas first:
Approver.ensurebelow can send a transaction itself and has no gas threshold of its own. - 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 withtransfer amount exceeds allowance— an unrelated cause, control never reachedensure, and swaps did not work at all. approver.ensure(...)— an infinite approve, once per token × spender pair. The comparison threshold is half ofINFINITE, not equality: USDT-like tokens decrease the allowance on every transfer.- Ladder → sanity guard →
minOut. - Under lock
728_035_957_005:getNonce('pending')→ sign →hash = keccak256(signed)→attachTxto the database →broadcast.
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.
| Stage | Failure |
|---|---|
releasing | pre — terminal (pre-withdrawal skipped, normal degradation); tail — needs_review |
bridging_out | bridge did not deliver → terminal: the money is safe on the SCW |
bridging_out | delivered partially → the circle continues with what arrived, plus an alert: stopping would strand it on Base |
swapping | revert → needs_review plus an alert, money still in the original asset |
bridging_in | never terminal (C17): resetForRetry on the same row, plus an alert |
depositing | the 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:
| swaps | conversions | |
|---|---|---|
filter in claimActive | needs_review_at IS NULL — the row freezes | no 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 money | What closes it |
|---|---|
| Withdrawal to a wallet | the reserve in freeByCurrency (C8) |
| Release from the subaccount | 409 CONVERSION_ACTIVE (C10) |
| Opening a new position | 409 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.
| Stage | Event |
|---|---|
releasing | subaccount_released (sub → transit) |
bridging_out | withdrawal_sent + gas_spent |
parked | silent |
swapping | swap_executed / swap_reverted (slot 0) + gas_spent (slot 1) |
bridging_in | deliberately silent |
depositing | subaccount_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_reverted → no 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
depositToAppChaincalldata 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.