Core Concepts
The reference pages document what each function does. This page covers the mechanics that span multiple functions and aren't obvious from any single one: how the utilization fee is priced, the shape of the fee curve, how leverage sizes collateral, how the floating rate is derived per oracle convention, how open swaps are cohorted into buckets, and how index snapshots drive settlement.
Source: SwapCore.sol, lib/SwapFormulas.sol, lib/Utils.sol, lib/RateIndexLib.sol, Admin.sol.
Seasoned utilization fee
The utilization fee a buyer pays at buySwap is not priced against the pool's live collateral. It's priced against a seasoned exponential moving average (EMA) of pool.totalCollateral, tracked per market in utilAcc[marketId] (Types.UtilAccumulator = { avgTotalCollateral, lastUpdate }) over a fixed UTIL_AVG_WINDOW = 1 hour.
The EMA is advanced at every point pool.totalCollateral changes, and on every buy: LP supplyCollateral and withdrawCollateral, each swap settled inside makePayment, liquidateSwap, and buySwap itself. Each advancement moves the average toward the pre-mutation spot collateral, weighted by how much of the window has elapsed:
newAvg = avg + (spot − avg) × elapsed / window // spot ≥ avg
newAvg = avg − (avg − spot) × elapsed / window // spot < avgThe fee is then charged against min(EMA, spot) — never more than the live balance, but lagging recent increases.
Why it works this way — the JIT-liquidity defense. The accumulator is advanced with the pre-deposit collateral, so a just-in-time LP deposit made in the same block earns elapsed = 0 → zero weight in the EMA that block. That means a JIT deposit immediately expands capacity but cannot suppress the util fee, so an LP can't deposit → cheapen a buyer's fee → withdraw after the 1-block lock. Capacity and pricing are deliberately decoupled:
Capacity gate (validateSwapParams, E508)
Spot liquidity (totalLPAvailableCollateral)
Fresh deposits enable volume immediately
Utilization fee
Seasoned min(EMA, spot)
Fresh deposits can't front-run fee compression
Withdrawals are asymmetric on purpose: after a withdrawal the stored EMA is hard-clamped down to the post-withdrawal pool (never raised). Withdrawn liquidity is recognized instantly, and a withdraw → redeposit cycle can't re-enter fresh liquidity as "already seasoned" — the read-time min(EMA, spot) alone wouldn't catch that, because the redeposit restores spot back up to the stale average.
The EMA seeds to spot on a market's first deposit and snaps back to spot once a full window elapses with no activity, so a quiet market doesn't ramp from a stale value.
For quoting: a buyer's util fee reflects the pool's liquidity over the last ~hour, not the instantaneous balance. Right after a large LP deposit the fee stays elevated until the EMA catches up; right after a large withdrawal it's charged against the lower balance immediately (the stored EMA is clamped down, not just capped at read time).
The utilization fee curve
The fee is a kinked curve in utilization u, defined by three market params: utilFeeSlopeWad (linear slope), kinkUtilization (where the curve steepens), and maxKinkFeeWad (the extra fee added between the kink and 100%). The marginal rate f(u) is:
so it rises linearly to slope × kink at the kink, then adds a quadratic term reaching f(100%) = slope × kink + maxKinkFee at full utilization. The marginal rate is continuous at the kink, but not smooth: its derivative drops from slope to zero there (the quadratic term starts flat). The total fee paid — the integral of the curve — is continuously differentiable, so there's no fee cliff crossing the kink.
Path-independence. A swap consuming capacity from uPre to uPost is charged the definite integral of the curve over [uPre, uPost], averaged over the trade size — not the endpoint rate. The direct consequence: splitting one trade into N smaller trades produces the exact same total fee as doing it in one shot. There's no gaming the fee by slicing orders.
A trade that exceeds the seasoned in-band capacity pays a notional-weighted blend: the in-capacity slice at its integrated band rate, and the excess at the f(100%) ceiling rate.
Leverage
leverageMultiplier is a per-market constant (WAD; 1e18 = 1×, capped at 12e18 = 12×, immutable after creation). Higher leverage lets the same collateral support proportionally more notional: at 5× leverage, backing a given notional takes one-fifth the collateral it would at 1× — for both the buyer and the LP side. Equivalently, a pool of a given size can back 5× the notional.
Concretely, the two sides each post:
where r = max(|baseRate|, MIN_RATE) is this market's own base-rate magnitude and rc is the same floor applied to the pair-level collateral base rate (below). A pool's capacity is the inverse relationship, on the market's own rate:
Things to note:
Buyer collateral includes the fees; LP backing doesn't. The buyer posts enough to cover their maximum payment (base rate + util fee + risk premium); the LP side backs only the base-rate exposure. Both are sized off the magnitude of the rate, which is why a favorable rate can still inflate the collateral pulled — see
maxTotalInonbuySwap.Buyer collateral is sized off the pair, not just this market. The two sides of a pair are priced off separate base-rate oracles, whose quotes can differ. Buyer collateral uses the larger-magnitude of this market's and its twin's base quotes (
collateralBaseRate): the losing side's collateral is the winning side's payout cap in a paired liquidation, so sizing off the lower own-rate quote would let an equal-notional hedge across both sides pocket the quote spread risk-free once both legs saturate their caps. LP backing and the capacity gate stay on the market's own rate, so LP capital efficiency is untouched.Every rate in these formulas is floored at
MIN_RATE = 1e14(0.01%). A near-zero base rate would otherwise collapse required collateral toward zero and blow available liquidity toward infinity; the floor keeps both bounded and the divisions well-defined.Higher leverage → thinner buffer → faster liquidation. A smaller collateral cushion per notional means a position crosses its liquidation threshold sooner. Leverage is fixed at creation and shared by both sides of the pair.
Rate conventions
Every reference-rate oracle reports under one of three conventions, chosen at the market that first uses it and immutable per oracle thereafter (a later market trying to register a different convention on the same oracle reverts E607). The convention determines how the on-chain index is maintained and how a BUY_FLOATING swap's realized floating rate is derived at settlement from the entry and expiry index.
Convention
Oracle reports
Index maintenance
Realized rate from entryIdx → expiryIdx
Cumulative
a monotone index (Aave/Morpho/Compound style)
stored as-is
(expiryIdx / entryIdx − 1) × YEAR / elapsed
SpotRate
an instantaneous rate
accumulates Σ rate × Δt; index = INITIAL × (1 + crt/(WAD·YEAR)) (simple interest)
growth rate, corrected by × entryIdx / INITIAL
SpotCompoundRate
an instantaneous rate
accumulates Σ rate × Δt; index = INITIAL × exp(crt/YEAR) (continuous compounding)
ln(expiryIdx / entryIdx) × YEAR / elapsed
Cumulative clamps downward moves. A Cumulative index is held to a monotonic non-decreasing floor: if the source reports a value below the last recorded index, it's raised back to the floor and an IndexClamped event is emitted. A genuinely decreasing rate therefore reads as flat under this convention, and the realized floating leg can never go negative — which is why markets on a Cumulative oracle reject a negative base rate at swap entry (E512). The gate is keyed on the oracle convention and applies to both sides of the pair, before the rate-type branch: on BUY_FIXED a negative fixed leg would hand the buyer a one-sided claim on LP backing (floating ≥ 0 > fixed at every settlement), and on BUY_FLOATING it would open a position every remaining-tenor consumer (mark-to-market share pricing, early exit, NAV views) immediately rejects with the same error — unpriceable and un-exitable until maturity. SpotRate / SpotCompoundRate are not clamped and can represent negative rates (e.g. perp funding), subject to the MIN_INDEX floor.
Buckets and rate bands
Open swaps aren't valued one by one for pool accounting — each is folded into a bucket of weighted aggregates, and share pricing / pool NAV are computed over buckets. A swap's bucket is keyed by two coordinates: its entry-time window and its entry-rate band:
with NUM_RATE_BANDS = 9. The bands ladder the LP-leg base rate geometrically: seven interior bands covering (1.5%, 28%] whose width grows in proportion to the rate, plus two open end caps (≤ 1.5% including negatives, and > 28%). The banding key is the swap's baseRate for both rate types, and the ladder is identical across all three oracle conventions.
The reason for the second coordinate: within one time window, the entry rate is what determines whether positions win or lose together, and the per-bucket collateral clamp used in conservative pricing is only exact for cohorts that do. Sub-bucketing by rate band means opposite-rate positions never share a clamp, and the proportional band widths keep the clamp's behavior uniform across rate regimes. An occupancy bitmap tracks which of the numBuckets × 9 sub-buckets are live, so valuation sweeps only occupied cohorts.
The settlement / snapshot model
SwapCore keeps an append-only history of index snapshots per oracle, written whenever a state-changing function runs or when anyone calls the permissionless updateMarketRateIndex. Two writes within MIN_SNAPSHOT_GAP (12s) collapse into one slot.
makePayment snapshots the index itself. On the normal-expiry path, while inside the settlement grace window (below), makePayment makes a best-effort updateMarketRateIndex call before resolving the expiry index. Because a swap can only be settled once block.timestamp ≥ entryTimestamp + swapTerm, that self-update writes a snapshot at or after maturity — so in the normal case (a healthy oracle) settlement satisfies its own precondition and just proceeds. Integrators do not need to pre-snapshot before calling makePayment.
Inside the grace window, settlement fails closed — E450. After the best-effort update, settlement requires a stored snapshot at or after entryTimestamp + swapTerm; if none exists it reverts E450 rather than projecting past the last known data. Given that makePayment just tried to write that snapshot, E450 only surfaces when the internal update couldn't write one — i.e. the reference oracle is unhealthy at settlement time (the update reverts and is swallowed by the surrounding try/catch) and no earlier at-or-after-expiry snapshot was captured while the oracle was still healthy. This fail-closed behavior holds for a bounded window: settlementGracePeriodSec past maturity — an admin parameter (default 14 days, hard bounds [7 days, 90 days], changes timelocked).
Past the grace window, settlement always completes. Once block.timestamp ≥ expiry + settlementGracePeriodSec, the oracle gets one last chance and then loses its veto:
The uncapped best-effort self-update is deliberately skipped — a gas-burning oracle dependency could otherwise starve the transaction before the fallback could run.
Instead, the real index update is retried once under a fixed gas budget. If the transaction can't fund that minimum budget, it reverts
E451(retry with more gas). If the retry succeeds — the earlier failures were transient — settlement proceeds on the fresh, real snapshot: no extrapolation, no termination.If the retry fails, settlement proceeds anyway at a deterministic extrapolation of the frozen snapshot history: the slope is anchored 1 day behind the final snapshot (through the final snapshot's value), pricing the missing interval at the oracle's trailing average rate. Younger histories anchor at their oldest snapshot; a single-snapshot history extrapolates flat. The settlement emits
ExpiryIndexExtrapolated.Engaging the fallback permanently terminates both markets in the pair for new swaps — the twin prices against the same reference oracle and the same extrapolation-scarred history, so the dead-oracle verdict applies to it equally. Existing swaps still settle normally as they mature.
The consequence: a dead reference oracle cannot brick a market forever. Every open swap becomes settleable within swapTerm + grace of the oracle's death, so the expired-unsettled block on LP deposits and withdrawals is bounded, and the market winds itself down on a known schedule.
Where a keeper snapshot actually helps. Because a post-expiry snapshot captured during a healthy period satisfies the guard even if the oracle later fails, permissionlessly calling updateMarketRateIndex shortly after a swap matures is insurance against a subsequent oracle outage — it lets settlement complete on real data during the grace window instead of being blocked on E450, and avoids the extrapolated fallback (and pair termination) after it. It's a resilience measure, not a step every settlement requires.
Views project for active swaps; expired swaps are different. For an active swap, getSwapNetAmount and friends project a virtual index forward (so an off-chain reader sees the value settlement would realize, and a permissionless makePayment can't move a number they already cached in-transaction), while settlement itself only ever reads real stored snapshots. For an expired swap with no maturity-bracketing snapshot, the views revert E450 — and they keep doing so even past the grace window, where makePayment itself would fallback-settle. The views never guess at the extrapolated outcome; settle the position to realize its value in that state.
Last updated