AIMM spread and fee model
One swap pays exactly two charges, both on the output token and in this order: the convex coverage toll, then the fee at half the spread.
1. Scope and status
Formulas are stated in contract units and cited to the implementing function. The surrounding function name is the stable reference.
Path risk aggregation is shipped. minFee and confidence sum over legs, sigma composes in quadrature, and the staleness surcharge sums per leg (§3.2). Nothing caps the composed spread from above; the only bound is the saturating narrowing into the uint16 quote field, reasoning in §3.1.
The multi-anchor tree is shipped too. The interior leg prices under the same _legMid law as the terminal one and carries a manipulation fence in its floor (§10.2). Topology, depth bounds and what the anchor column configures: Anchor Path Pricing §1.
2. Units
| Symbol | Name | Base | Meaning |
|---|---|---|---|
PBPS | 1 unit = 0.0001% = 0.01 bp | ||
BPS | 1 unit = 0.01% = 1 bp | ||
| WAD | WAD | prices, coverage, |
Conversions used on the spread path: bps PBPS is . Volatility is PBPS-based (), capped at MAX_SIGMA_PBPS (PoolConstantsLib.sol).
Notation. is dispersion throughout this chapter (as in Liquidity Shaping and Parametrization §4.2). The coverage wall is (kappaCovBps). The feed’s confidence interval is (bps): it never shares a symbol with dispersion. On the interior leg, is the manipulation fence, the leg’s mid swing, its curve’s span, its dispRefPbps and the protocol swing cap (§10.2).
minFeePbps is uint16 in PBPS, and it is the only fee rate getAsset returns. It is bounded two-sidedly at every write path (initAsset and setAssetParams): floor MIN_FEE_PBPS = 1 (0.01 bp), ceiling ONE_PCT_PBPS = 10,000 PBPS (1%). minFeePbps is charged unconditionally on every swap, so ONE_PCT_PBPS is the whole guard against an owner grandfathering an extortionate floor. SwapQuote.spreadPbps is separately uint16, ceiling 65,535 6.55%: that is a field width, not a fee policy (§3.1).
3. The spread
3.1. Definition
Vocabulary, canonical: mark, mid, skew, price impact, spread, fee and coverage toll each name exactly one object, and only impact and the toll are direction-asymmetric. Slippage & Price Impact §1.1.
The spread is a round-trip width in PBPS. It is computed once per swap over the whole path and returned as SwapQuote.spreadPbps (uint16). A single swap is charged , once, on the output (§5).
Pricing._pathSpread and _staleTerm. Integer arithmetic, floor division at each /. On an interior leg is already raised to before it enters the sum (§10.2); on an endpoint leg it is the stored Asset.minFeePbps.
No clamp on either side. The floor needs none: is the additive base of , so by construction. The only bound above is the saturating narrowing into SwapQuote.spreadPbps at 65,535 PBPS, a uint16 field width and not a policy.
Any formula of the form , and any per-leg clamp composed into a path bound, is therefore wrong.
Why no cap. Written out in full in the _pathSpread doc comment; three claims:
- A cap is not trader protection.
minAmountOutis: exact, caller-set, per trade, and it bounds delivered output rather than one input to it. A protocol-wide fee cap is a worse instrument for the same job. - A cap manufactures its own pathologies. Composed per asset, , it makes a 2-leg quote cost more than the same two legs traded separately, paying a splitter to route around the hub: exactly the routing invariant path composition exists to hold. Repaired per leg, it silently under-quotes an unpinned path by about 10 bp unless the staleness term is also summed per leg. A bound that needs a second bound to stay sound is not a bound.
- A cap binds on the tape where the defense is needed. A stale or high-CI leg is precisely when the premium must be paid. Clamping there hands the picked-off pool a discount, and every such clamp is attacker-timeable: an adversary who can push
rawover the cap, by waiting out a keeper or by choosing a high-CI moment, chooses when it binds.
Scale. , so a single leg reporting a 500 bps CI adds exactly 50,000 PBPS (5%) on its own, on top of the floor. Integrators must derive worst-case cost from getSwapQuote rather than from any config field, and enforce it with minAmountOut.
What survives is the floor, which is a security quantity: with per leg (§9), plus the interior fence folded into it. Saturation at uint16 cannot eat that floor; the bound and its arithmetic are in §9.
3.2. The five inputs, and how they aggregate
All five compose over legs, never over endpoints, and none of them is a max.
| Input | Source | Aggregation (shipped) | Site |
|---|---|---|---|
Asset.minFeePbps, raised to on an interior leg | over legs | _walkLegs, floor at _priceInteriorLeg | |
FeedData.sigmaPbps | quadrature , one sqrt at the end | _walkLegs, rooted _pathSpread | |
Asset.vegaBps | over the endpoints only, a path constant | _quotePath | |
FeedData.confidenceBps | over legs | _walkLegs | |
| staleness excess (s) | of per-leg surcharges | _walkLegs |
Confidence and staleness share one accumulator (PathAccumulator.riskPath) because _pathSpread only ever consumed their sum, and the fusion is exact: , the scale being an integer constant.
Every quantity comes off the feed the leg already read (_legMarkAndFees): staleness and confidence are carried on the leg walk, not cached per endpoint, so a quote performs no second oracle read (worth 15.4k gas on a spoke-to-spoke quote). is the one endpoint-scoped input, deliberately: vega is the pool’s per-asset sigma-sensitivity dial, not a per-leg risk quantity, and the endpoints are the assets actually paid in and out. That scoping has a live deploy consequence: §11.2.
On a flat roster - every leg base-anchored (Anchor Path Pricing §1.1) - the only interior node is the base, which supplies no leg profile, so a path has at most two legs. At MAX_DEPTH = 4 it has up to eight (§10.1), and every one pays into all five aggregates.
is the NXR-signed Parkinson volatility stored directly on chain, floored at the realized on each push (FeedMathLib.markMovePbps). Which feed supplies it depends on the leg’s oracle mode; see Oracles.
3.3. Volatility band
Vega has base 10,000 = 1.0x. At and PBPS (1%), the volatility contribution is PBPS = 1 bp above the floor. There is no hardcoded base: the base is the configured floor.
3.4. Staleness surcharge
with (STALE_Z) and the grace cap s (STALE_GRACE_CAP_SECS), both file-private constants in Pricing.sol. is computed in _staleExcessOf off the feed the leg already read, the per-leg term in _staleTerm, which short-circuits to 0 when or (skips the sqrt), and the legs are summed at _walkLegs.
Per leg, not coupled. A coupled form, , charges one aggregate age against the path sigma and is wrong. One keeper feeds several spokes, so an outage staleses them together - precisely the case the term exists for - and the coupled form charges two equally stale legs of what they each owe (1414 against 2000 PBPS at , s: a 5.86 bp leak), while over-charging a fresh leg for its neighbor’s lag. Pinned by the staleness regression tests (per-leg summation; a fresh leg pays no staleness premium).
The clock is , computed by FeedMathLib.observedAt from the attested source time and the landing time: the attested source time, capped at the landing time, so a relay cannot stamp an old signed quote fresh.
The is a unit normalization, not a tuning knob. is PBPS-scaled, so without it the term is too large and pins the spread at its uint16 field ceiling the instant age crosses the grace, turning a ramp into a step. Worked: PBPS (1%), s PBPS = 10 bp. At s: PBPS = 42 bp.
The grace cap binds independently of : at ttl = 600 the grace is 30 s, not 300 s. Without the cap a long ttl buys a proportionally long unpriced window - exactly the window a delinquent keeper occupies.
Why the grace exists: the keeper snaps the mark whenever the deviation crosses an edge-relative push boundary, or on a heartbeat. That boundary is a configured percentage of the leg’s own live edge, edge_kappa_pct , with the static retained only as the fallback when the pool’s minFeePbps is unknown to the keeper (keepers/src/oracle/mod.rs, push_boundary_pbps / edge_pbps_with). Shipped on Arc at edge_kappa_pct = 50, so the keeper pushes when : the mark is snapped before the drift reaches the half-spread the swap is charged. Inside that contract an old but accurate mark (flat tape, no push owed) must not be penalized, or the pool quotes wide and loses flow for nothing. Past the grace the guarantee is void and the surcharge ramps with the unobserved drift up to the hard TTL revert. The confidence term is deliberately excluded from the keeper’s basis while confidenceBps on the wire is a freshness proxy rather than a dispersion measure: folding it in would invert the trigger, making a staling feed push less.
is a graceful-degradation layer below a hard revert, not instead of it. FeedMathLib.gate reverts:
StaleDataatage > ttlZeroValueatmark == 0ThresholdViolationatconfidence > MAX_CONFIDENCE_HALT_BPSFeatureDisabled(Err.Resource.FEED)on the guardian fast-freeze bitFEED_HALT_BIT
3.5. Confidence surcharge
is leg ’s feed-reported 1σ confidence interval in bps. There is no per-asset knob: 1 bp of reported CI buys 100 PBPS of spread, unconditionally. A path inherits every leg’s uncertainty, not the worst one: a quiet leg beside a noisy one is still charged its own CI (_walkLegs; pinned by regression tests). Past MAX_CONFIDENCE_HALT_BPS = 1000 bps (10%) the swap reverts rather than quoting (FeedMathLib.gate, PoolConstantsLib.sol). Fail-closed, like the depeg band.
This term dominates the live stable book. See §8.
4. Theorem 1: the spread is direction-invariant
Claim. For any listed pair , at the same block and the same pool state, for any trade size.
Proof. is a function of exactly five quantities: , , , , (_pathSpread). Each is built by one of two constructions:
- A fold over the leg multiset with a commutative, associative operator: over , and the per-leg staleness surcharges, and of squares for (
_walkLegs). - A
maxover the unordered paircIn,cOut: alone (_quotePath).
Reversing the swap reverses the hop order. The route between and is the unique tree path, so the reversed route is the same edge set traversed backwards, and the per-leg profile asset is a property of the edge (_executeLeg: profileAsset = isUpward ? from : to, which is the child endpoint of the edge either way). The leg multiset is therefore identical. A commutative fold over an identical multiset returns an identical value; a max over an unordered pair likewise. Hence all five inputs are identical and is identical.
The theorem is robust to how the aggregates are built. , and are all symmetric functions of the leg multiset, so any fold drawn from that set preserves direction invariance exactly. Any new term must be a symmetric fold.
Measured. Three stable crosses, both directions, one block: 997/997, 196/196, 885/885 PBPS. Zero divergence.
Consequence. No part of the bid/ask asymmetry can come from the spread. Everything asymmetric is in the mid (skew, §7) or in the toll (Theorem 2, §6.4).
5. The fee
5.1. Order of operations
Pricing._settleQuote (Pricing.sol) settles in exactly this order:
spreadPbps = _pathSpread(acc)
covToll = _covToll(cOut, currentAmount) // terminal asset only
currentAmount -= covToll
feeOut = currentAmount * spreadPbps / (2 * PBPS)
(protoFee, lpFee) = splitFee(feeOut, protoSharePct)
amountOut = currentAmount - feeOutSo, with the gross output from the leg walk:
Both charges are in output token units. The fee base is post-toll, not gross. The fee is charged once per swap, not once per leg: the spread is already a path aggregate.
The multiplication precedes the halving (* spreadPbps / (2 * PBPS), one division) so PBPS does not truncate to zero on any economically relevant size.
5.2. Why there is no input-side skim
The gross output is priced off the full amountIn: the spline contributes price impact only, no fee. An input-side half-spread would therefore be phantom revenue, crediting the trader the full input in the gross while withholding part of that input. That is an LP-reserve drain into the treasury, not a fee, so the charge is one-sided by construction.
An integrator deriving a two-sided quote from SwapQuote.spreadPbps must apply per side, not . The ABI comment at IPool.sol states it normatively: protoFee + lpFee is the whole fee, denominated in tokenOut, and an indexer must credit 100% of it to tokenOut and never to both legs.
5.3. Protocol split and settlement
Pricing.splitFee, protoSharePct a uint8 with base 100. Live: , so 20% protocol / 80% LP.
Settlement is endpoint-only (PoolIOLib.settle, PoolIOLib.sol):
aIn.reserves += amtIn
aOut.reserves -= amountOut + protoFee
protocolFees[tkOut] += protoFee
accrueLpFee(aOut, tkOut, lpFee)| Quantity | Reserve effect (out leg) | Liability effect (out leg) | Coverage effect |
|---|---|---|---|
| (protocol) | from reserves, moved to protocolFees | none | falls |
| (LP) | stays in reserves | (PoolLiquidity.accrueLpFee) | unchanged, LP claim rises via liquidityIndexWad |
| stays in reserves | none | rises: this is the surplus |
The LP fee is booked as an LP claim, not pool surplus: accrueLpFee raises liabilities by exactly lpFee and raises liquidityIndexWad in the same step (INDEX_REASON_FEE), leaving unchanged. The only charge that improves coverage is the coverage toll, withheld with no matching liability credit.
Consequence for the toll’s round-trip identity: telescopes exactly only at constant , and accrueLpFee moves mid-loop. The residual is pool-favorable (higher lower more toll), documented at Pricing.sol.
5.4. Worked example
1 ETH into a WETH/USDC pair, mark 2000 USDC/ETH, PBPS (0.5%), on USDC (illustration only; policy is everywhere), .
USDC leaves reserves for protocolFees; USDC stays in reserves and is credited to LP liabilities. The trader pays 25 bp, which is half the 50 bp round-trip width.
5.5. There is exactly one charge
The complete list of what one swap is charged:
- One charge, the half-spread, once per swap. A round trip therefore pays the full spread , which is what makes a round-trip width and not a per-swap fee.
- No half-spread is embedded in the executed price. At zero size the buy and the sell branch return the identical expression, so ; with centered presets exactly (Anchor Path Pricing §3.1). The spline contributes price impact only.
protoSharePctsplits that one pot, it does not add to it (§5.3).- There is no static fee. Every term of is live.
- The charge is output-side only. A swap that also skimmed the input would pay the half-spread twice, which is the failure mode §5.2 exists to rule out;
_settleQuotetouchesamountInnowhere.
5.6. What that costs a trader
All-in (spread plus impact) at par coverage, quiet tape and stressed tape:
| Pair | Legs | Quiet | Stressed |
|---|---|---|---|
| USDT USDC | 1 | 0.36 bp | 0.56 bp |
| USDT DAI | 2 | 1.29 bp | 1.57 bp |
| WETH USDC | 1 | 5.26 bp | 6.16 bp |
| WETH USDC at | 1 | - | 10.2 bp |
These carry no curve LVR: the quote tracks the external mark instead of being arbed onto it (Toxic Flow Mitigation).
6. The coverage toll
The coverage toll is its own object, distinct from the coverage ratio it reads: a convex, charge-only levy on the output leg, computed by Pricing._covToll and reported as SwapQuote.covToll. Search for it, and price against it, under that name.
6.1. Definition
Let and be the output asset’s reserves and liabilities, in WAD, and the gross output.
Pricing._covToll, _covQ (FixedPointMathLib.lnWad). Returns 0 immediately when , or .
is concave with a unique maximum , and as : a convex no-drain wall.
6.2. The three structural properties
Charge-only. . A coverage-restoring trade is never rebated, so no rebate ledger exists and a round trip strictly loses. This is what makes the toll safe to retain in reserves rather than express as a mark shift: a mark shift would be round-trip extractable.
The clamp restricts to its increasing branch, so the toll prices exactly the below-peg deficit and the over-peg portion is free. Without it a drain starting over-covered bypasses the wall entirely; the argument is in Inventory Management §6.2.
Terminal-only, never per leg. _covToll is called once, on cOut, in _settleQuote. Interior legs have by construction (settlement is endpoint-only, PoolIOLib.sol), so on them; a per-leg toll would invent a charge for a displacement that did not occur. Stays correct at MAX_DEPTH = 4.
Uncapped wall. As the toll saturates to , so amountOut and the drain halts gracefully rather than reverting. At the whole fill is tolled.
6.3. The marginal toll: a level shift, not a large-trade wall
Differentiate at . With and :
The marginal rate is strictly positive at zero size whenever the output leg is under-covered. Dust pays it at the same rate a block trade pays on its first wei. It is a level shift keyed on the state of the terminal asset, plus convexity on top.
Measured on a pre-Arc reference roster with RLUSD at and bps: bp, against 4.44 bp read from the quote. RLUSD is not listed on the Arc fleet, and the live there is 600 on a stable leg, so the same coverage costs bp - six times the figure above, and 111 bp on an equity leg at 2,500. The arithmetic is what the row demonstrates; the parameters are not today’s. That coverage is its own dated capture, not the snapshot tabulated at Inventory Management §3.3. Any description of the toll as a “large trade only” convex wall is wrong: convexity is the second-order behavior, the level shift is the first-order one.
6.4. Theorem 2: the toll is the only asymmetric charge
Old P3 / Thm 2 (“base never walled”, identically, sell-into-base toll-free) is void. It was a single-pool leftover. _covToll is output-only, so hub κ prices taking the hub out.
Claim. The coverage toll is charged once, on the terminal output. It is zero only when that asset’s , or when the drain does not worsen below-peg coverage. Same-pool spoke→spoke never outputs the hub; a hop that extracts the hub does.
Proof. _covToll short-circuits when cOut.kappaCovBps == 0 and is evaluated only on cOut (_settleQuote). requireWallOk does not reject on $.baseToken. On the terminal is , so hub κ does not fire on that same-pool cross. A swap whose output is the hub pays hub κ when .
Consequence, combined with Theorem 1. Take a same-pool cross at one block. The spread is identical in both directions. The toll is one way and the other. By §6.3 at small size that is
A trade whose output is the hub pays when hub .
Interior vs hub. A non-base interior can carry and still be tolled at zero, because it is interior (), not because it is unwalled. The hub is not interior on a hop that extracts it.
6.5. Coupled configuration
One coupling is enforced on chain, at every write:
-
haircutSuppressorBps = 0(Lemma B of the published coverage proofs), held by the single predicatePoolConfig.requireWallOk(PoolConfig.sol, revertsInvalidInput), called fromsetAssetParams,setRiskConfigandinitAsset(which zeroes theBPSdefault when a walled asset is listed). A suppressed haircut leaves a toll-exempt withdrawal bypass: an LP could exit the deficit the wall exists to price. Two further arms bind the wall: on every listed asset including the hub, enforced byrequireNeverDepletableat every writer, and cannot be stripped from an asset carrying aFLAG_REQUIRES_WALLpreset. Canonical statement: Invariants §I-9.
Nothing on the depth axis pushes back against the wall. The traverse denominator is the leg’s raw reserves with a zero-guard (depth = reserves == 0 ? 1 : reserves), at every coverage. A denominator that grew as coverage fell would shorten the traverse on exactly the leg the wall defends, which is how a two-block round trip becomes trader-positive (worth PBPS to the LP at ). Coverage reaches the quote through the skew anchor and this toll, and through nothing else.
7. Bid-ask asymmetry
7.1. Where it comes from
The book is centered not on the mark but on the skew-loaded mid , where is the int8 inventory-skew index and the price displacement it buys. The spread is symmetric about :
The skew is piecewise-linear in the coverage ratio, clamped at and returned as an int8 (Pricing.computeInventorySkew); it maps onto the spline’s depth axis in _skewToDepth, displacing off the curve’s stored density median at BPS/200 per skew unit, so the book is quoted from the skew anchor outward and zero skew quotes the mark. It is a fixed protocol law with no per-asset dial, and its two arms are deliberately asymmetric rather than one slope rounded twice. Formula, slopes and saturation points: Inventory Management §3.
Four asymmetry sources, ranked:
- Mid skew : shifts the center. Coverage-worsening flow gets a worse price at the same spread. Cancels exactly on a round trip: it is a level, not a cost.
- Spline traverse direction: the buy and sell legs integrate the same monotone curve over different intervals from the anchor. Size-dependent, endpoint legs only.
- Coverage toll : charge-only, terminal asset only, never rebated. Theorem 2.
- Spread: contributes nothing. Theorem 1.
7.2. Inventory skew as a Glosten-Milgrom soft fee
The skew doubles as a toxicity-targeting soft fee in the sense of Glosten & Milgrom (1985): flow that persistently moves the maker’s inventory one way is on average the informed flow, and should pay more than flow that round-trips. AIMM realizes this through the skew slope, not a flat spread.
with the dispersion. Because is a frozen external mark between pushes, drifts only through realized inventory .
- Inventory-moving flow walks up the skew and pays on top of . That slippage is retained in reserves, so the pool captures the adverse-selection cost it would otherwise leak.
- Round-trip flow returns coverage to where it found it, nets to zero, and it pays only the floor.
This is why a competitive base fee can coexist with charging toxic flow: tune the defensiveness with (how fast ramps), not with .
Guardrail. The construction is safe only because the drift is via realized inventory around a frozen mark. If the center drifted via a mutable internal accumulator that the swaps themselves push, the identical soft fee would be a flash-loan-manipulable surface. AIMM deletes that surface: the quote center is FeedMathLib.mark() = mark1e18, never a reserve- or trade-derived invariant, and the toll is charge-only so a round trip strictly loses.
8. What the number users call “spread” actually is
Composition of RLUSD DAI at PBPS (9.97 bp), captured on a pre-Arc reference-roster stable pool (neither symbol is listed on the Arc fleet; addresses: 2. Deployments). The decomposition is the point, not the roster:
RLUSD and DAI both anchor to the base, so the path is RLUSD -> USDC -> DAI: two legs, and the profile asset of each is the spoke. The floor is therefore PBPS. USDC’s own 50 never enters, because minFee is per leg’s profile asset and the base supplies no leg profile.
| Term | PBPS | Share | Basis |
|---|---|---|---|
| 252 | 25.3% | configured: , read off the roster | |
| ~2 | 0.2% | computed: at the quoted | |
| 743 | 74.5% | residual: less the two rows above, not an independent reading |
The last row is solved for, not measured. and the floor are both hard numbers (the floor is roster configuration and was read from the quote), so the surcharge total is what the identity leaves. It is reported as one figure for a second reason as well: the contract carries confidence and staleness in a single accumulator (PathAccumulator.riskPath holds , and _pathSpread only ever consumes the sum, §3.2), so no quote splits them either. Splitting the 743 requires per-feed confidenceBps and mark age off chain, at the same block. The block noted below could not be re-quoted, so that split is not restated here.
Three quarters of the quoted spread is an oracle-health readout, not a fee. The surcharges describe how much the pool distrusts its own mark; neither is compensation the LP asked for. A pool whose keeper is healthy and whose feed reports a tight CI quotes near , which on this pair is 2.52 bp round trip, 1.26 bp charged.
Track the surcharge share as an operational metric, not revenue.
Re-verification of these numbers at block 11,456,319 was not possible: every stable-pool feed was
StaleData(34600, 7200)(age 34,600 s against a 7,200 s TTL), sogetSwapQuotereverted rather than quoting. The pool was correctly fail-closed; the keeper was not pushing.
9. Why the path fee floor sums over legs
The invariant. Each leg ships , where is the keeper’s configured per-feed deviation threshold in bps. The keeper machine-checks it at startup and hard-fails with no escape hatch (keepers/src/oracle/startup.rs, minfee_covers_theta): the on-chain invariant is convention only, so the gate lives in the pusher. This is the static config value the boot gate reads; the runtime push boundary is edge-relative and derived from the same floor (§3.4). Derivation: Parametrization §11.
Why not . The pick-off edge an adversary can realize is the sum of the per-leg thetas: the adversary picks the joint worst realization, and every mark on the path is independently allowed to sit away from truth. A funds one leg and leaves the rest unfunded.
Measured on the reference roster (2. Deployments). 15 spokes, 105 crosses, at the shipped floors and thetas (2. Deployments):
| Crosses that a rule would under-fence | 72 of 105 |
| Worst pair | (U, USDTB) |
| Its floor under a rule | PBPS |
| Its requirement | PBPS |
| Deficit | 68 PBPS = 0.68 bp per pick-off, repeatable, size-unbounded |
| Crosses under-fenced under the shipped | 0 of 105 |
The sum rule clears every pair on the roster with no parameter change: . A rule measures +20,096 PBPS for a cross-spoke round trip with both marks walking against a per-leg fence. The full aggregation is at §3.2.
composes in quadrature rather than as a sum because independent leg innovations add in variance, not in level. NXR already composes bridged pairs this way. minFee and confidence add in level because both bound a worst-case displacement, not a variance.
The floor, the quadrature and the per-leg staleness sum are one mechanism. A path whose floor sums but whose staleness couples charges a mixed-freshness path about 10 bp too little; the reverse pairing over-charges a fresh leg for its neighbor’s lag. The sum/quadrature aggregation stands alone, with no clamp above it (§3.1).
Overflow discipline. The accumulators are uint256 in memory (Pricing.PathAccumulator), so the leg sums cannot wrap. There is exactly one narrowing on the path, in _pathSpread, into SwapQuote.spreadPbps (uint16, 6.55% ceiling), and it saturates explicitly. At eight legs a summed floor passes uint16 easily; an unchecked cast would turn a maximally fenced path into a near-zero fee. Pinned by a spread-composition regression test.
Saturation cannot eat the security floor. The raw spread is bounded below by the composed interior fence, which the swing cap bounds at PBPS, so what saturates away is the /CI/staleness surcharge. That bound holds by construction and needs no runtime check to carry it; it is pinned by value in test_the_interior_fence_ceiling_fits_the_uint16_spread, which is where a future MAX_DEPTH or swing-cap change fails.
10. Multi-hop
10.1. Routing
Shipped: the multi-anchor tree, bounded at 9 nodes and 8 legs. The route is the unique tree path, so a quote cannot depend on a routing choice. Topology, the depth arithmetic and what the anchor column configures are stated once at Anchor Path Pricing §1 and §1.1. Every summed budget on this page is sized against 8 legs.
Cycle safety at any depth follows from the tree structure: a closed trading cycle is a closed walk in a tree and crosses every edge equally often up and down, so per-edge safety gives cycle safety at any length. The three per-edge conditions are stated at Anchor Path Pricing §4.
10.2. Leg pricing
Endpoint legs carry full price impact: spline traversal plus reserve accounting (_priceEdgeHop, Pricing.sol).
Interior legs price at mid with inventory skew and carry no impact.
Skew belongs on an interior leg because it is pricing, not a charge for inventory change: an under-covered pivot is worth less to the pool, and pricing the hop at the undepressed mark hands the trader the pivot’s depeg risk for free.
It cancels on a round trip only against unchanged interior coverage. The two crossings read the same skew level only if nothing moved the interior node’s coverage in between; an interposed trade that shifts it leaves an extractable residue (+2.96 bp measured under max aggregation, +1.01 bp under sum).
Shipped fence. An interior leg’s floor is , not bare , with
computed per quote (it reads the leg’s live dispersion and its curve’s span ) and taken as a ratio over a constant low multiplier, because what the manipulation extracts is a price ratio. Pricing._interiorMidAndFence, _fenceOfSwingPbps, floor applied in _priceInteriorLeg; the constants are file-private to Pricing.sol. The swing is peak-to-peak, so is a 1%-of-mark total range and one-sided; reading it as a one-sided 1% doubles every fence figure.
The fence is a fee floor and nothing else: it folds into the leg’s floor and sums into minFeePath like any other leg floor, with no separate accumulator and nothing above it that could clamp it away. Its per-leg and composed ceilings, and why saturation cannot eat them, are in §9.
Two properties do the work, and neither reads a value an admin can write: the stored curve is centered at the write (NUQuartic.set, NUQuartic.sol), and a swing past the cap reverts rather than clamping, with the dispersion band bound to Pricing.dispersionCap at every write path so a live asset never reaches the revert. See Anchor Path Pricing §3 for the derivation, the rejected alternatives, and the five earlier fences that did not hold.
Impact must not be charged on an interior leg: under endpoint-only settlement, so an impact charge invents a cost for a displacement that did not occur and replaces an exact identity with a size coincidence. The revenue an interior leg owes is collected by the fee sum in §3.2, which is why the sum rule is required, not optional.
Every node on the path, interior included, is checked three ways:
- Halt bits.
HALT_MASK(the risk and guardian halt bits, set byhaltAsset(pool, token, src)) is tested per hop at_quotePath. - Feed-relative depeg band. Tested per hop by
PoolIOLib.priceBandGuardPath, which runspriceBandGuardon every interior node except the base, and byPoolIOLib.priceBandGuardon both endpoints. - Base parity halt. Tested wherever the path touches the base:
_readBasePriceOrHaltis gated onhop == $.baseToken(Pricing.sol) and revertsBaseDepeggedpastBASE_DEPEG_HALT_BPS= 500 bps.
It is the ref band, not the parity halt, that scales with depth. Parity tests the base mark against 1e18 and nothing else, at most once per path, which is exactly why priceBandGuardPath skips the base rather than double-charging a cold ref-feed round trip on the one node every path transits. Neither is a parent-depeg breaker: the ref band is a same-unit agreement check between two independent attestations of one pair and cannot express a peg test (Anchor Path Pricing §7.1).
10.3. Denomination
Shipped: the feed for asset must be attested in units of assets[X].anchor, and mark = FeedMathLib.mark(feed) with no correction. At depth a base-denominated rule is dimensionally wrong, not merely imprecise: stETH -> ETH -> USDC would compose .
OracleConfig.quoteUnit is a uint8. QUOTE_UNIT_UOA = 1 (PoolConstantsLib.sol) is the instruction “divide by the base mark”, not a unit, so PoolConfig rejects it on any asset that does not anchor directly to the base (PoolConfig.sol), and a bridged leg’s minFee must cover . There is no re-denomination helper on the path: the division is inline in _legMarkAndFees (Pricing.sol), reusing the gated, depeg-banded base read the path already performs.
11. Shipped vs decided
| Behavior | State | Reference |
|---|---|---|
| Fee once, output-side, , post-toll | shipped | Pricing._settleQuote |
| Toll charged before fee, terminal asset only | shipped | _covToll |
| on every listed asset, the base/hub included (old P3 “base never walled” is void) | shipped, enforced | PoolConfig.requireNeverDepletable rejects at every writer; requireWallOk only enforces |
| Direction-invariant spread (Theorem 1) | shipped | _pathSpread |
| clamp on the toll | shipped | _covToll |
| Sum / quadrature path aggregation | shipped | _walkLegs, _pathSpread, §9 |
| No cap of any kind on the composed spread | shipped, deliberate | §3.1; trader protection is minAmountOut |
| Per-leg staleness surcharge, summed | shipped | _walkLegs |
Anchor-unit marks consumed as attested, quoteUnit | shipped | §10.3 |
MAX_DEPTH = 4 multi-anchor tree (8 legs, 9 nodes) | shipped, activation gated | AnchorTreeLib.sol, Anchor Path Pricing §10 |
Interior leg priced by the same _legMid as the terminal leg | shipped | Pricing.sol |
| Interior-leg manipulation fence in the path floor | shipped | _interiorMidAndFence, _priceInteriorLeg, §10.2 |
| Dispersion band bound to the preset’s fence cap at the write | shipped | Pricing.dispersionCap, PoolConfig.sanitizeDispersion |
| Re-anchor timelocked at the base-migration tier, atomic with the oracle config | shipped | requestOp(..., UPDATE_ANCHOR, ...) / Admin.executeAnchorUpdate |
Parent-depeg breaker (absFeedId, or a split refFeedId) | not designed into any field | Anchor Path Pricing §7.1 |
11.1. Open items
- Full-drain toll yields a zero-output view quote (exec-side fixed).
_covTollreturnsgrossOutwhengrossOut >= R(Pricing.sol). The executing path is closed:Pricing.swaprevertsErr.ZeroValueonout == 0before settlement (Pricing.sol; see Slippage & Price Impact §4.1), so a fully-tolled wall drain reverts instead of consuming input for nothing. What remains is the view-quote shape:getSwapQuotereturns a fully populatedSwapQuotewithamountOut == 0rather than surfacing the failure, so a caller that skips an explicitamountOut > 0check sees an executable-looking quote that can never fill. - Re-anchoring is timelocked and atomic with the oracle config. It queues at the
CRITICALtier with the anchor and theOracleConfigin one inseparable payload (requestOp(..., UPDATE_ANCHOR, ...)→Admin.executeAnchorUpdate), because re-anchoring to while ’s feed is still attested in base units mis-prices the leg by the parent’s price. There is no untimelocked anchor writer; the only immediate path is the guardian’sAdmin.collapseAnchor, which may move a leg toward the root only and halts it in the same write. - The reserve clamp binds on the delivering leg only.
_legScaleOutcaps output at the destination’s reserves only when the leg is the one delivering (Pricing.sol). Applied to every leg it would clamp an interior hop against a hub balance the swap never spends, and a large cross would silently quote short. The decimal rescale runs per leg regardless.
11.2. Deploy-config constraint: vegaBps
Canonical statement of this hazard. Parametrization and Invariants I-11 carry the shipped default and a pointer, not a second derivation.
Live hazard, derived against HEAD. vegaBps enters the quote at exactly two places, and the two have different scopes:
| Site | Which vegaBps | Scope |
|---|---|---|
_pathSpread volatility band, | the two endpoints only, applied to the whole path’s | |
_calculateDispersion, per leg | the profile asset’s own Asset.vegaBps | that leg alone |
The per-leg dispersion is correctly scoped: each leg shapes its own curve off its own asset’s dial. The volatility band is not. It multiplies the endpoint maximum against , which includes the of legs whose own vega is lower. On a composite path, an interior or far-side leg’s volatility is priced at a stranger’s sensitivity dial.
With uniform vega the composite is never worse than the split, because quadrature sum. With heterogeneous vega that ordering can invert, and the inversion scales with across the assets on the path. The vegaHardMinBps / vegaHardMaxBps fences in RiskFences do not bound it in general: they run only in Admin.setAssetParamsBounded, the risk-steward lane, which itself refuses to execute on an unseeded fence (maxDeltaBps == 0 reverts NotConfigured), and the owner lane Admin.setAssetParams checks only the minFeeHardMinPbps floor. No deploy script calls setRiskFences, so on Arc the fences are unseeded and the only write-path bound on is PoolConfig.validateAssetParams: .
The constraint was: keep vegaBps uniform across every asset in a pool. The live fleet no longer does. Nothing in the contract enforces uniformity, and Admin._isDefensiveTighten treats a vega increase as defensive, so the untimelocked tighten path can introduce heterogeneity one asset at a time. Since 2026-09-04 Arc runs per class — 10,000 on stables and hubs, 4,500 metals, 4,000 crypto majors, 3,500 equities, 3,000 FX — because at a uniform 10,000 eight legs’ live put their book past the interior swing cap (Parametrization §5).
So the hazard above is exercised, and its size is the ratio: at most inside one pool today. It moves the premium term only — the fee floor sums per leg and dispersion reads each leg’s own — so what it can do is make a composite path quote wider than the same two legs traded separately, which a splitter can route around. It is a competitiveness cost taken to keep every leg quotable, not a safety hole, and it must be re-derived before any depth-2 listing, where the far-side leg stops being an endpoint.
12. Flash loan fee
Flash.flashLoan, flashFeePbps a per-pool FeeParams field. Ceiling MAX_FLASH_FEE_PBPS = 10,000 PBPS = 1% (PoolConstantsLib.sol). No code-level default: the raw struct zero-initialises, so an unconfigured pool charges nothing; per-deployment values live in 2. Deployments.
Flash loans are not tolled and not spread-charged: they do not change coverage over the transaction.
13. Comparison with other AMMs
| Protocol | Fee taken in | Charged | Distribution |
|---|---|---|---|
| Uniswap V2/V3 | 100% input | per hop | one-sided LP |
| Trader Joe V2 | 100% input | per hop | one-sided LP |
| Curve V1 | 100% output | per hop | one-sided LP |
| Wombat | input, redistributed | per hop | single-asset LP |
| AIMM | 100% output | once per swap, | LP claim via index; toll to coverage |
| Aspect | Uniswap V3 | AIMM |
|---|---|---|
| Fee structure | fixed tiers 0.01-1% | multi-factor, + CI + staleness |
| Direction dependence of the width | none | none (Theorem 1) |
| Inventory response | none | mid skew + terminal coverage toll |
| Quote center | pool reserves | external keeper mark, no write-on-swap |
| LVR mitigation | hooks only | fresh mark + floor + staleness surcharge |
14. Economics
LPs. The LP share of the fee is credited as a liability and paid through liquidityIndexWad, so it is a claim, not surplus. Genuine surplus comes from the coverage toll and from retained skew slippage, both of which raise against unchanged .
Surplus is not redeemable. At withdrawal pays face only (PoolLiquidity.applyHaircut early return); over-coverage never pays an LP bonus. Surplus dissipates through negative-skew discounts to coverage-restoring flow, and any residual after the last LP exit stays in the pool. No salvage path, by design: index accretion would reopen an extraction surface.
Traders. Quotes ride a fresh external mark, so there is no hidden stale-mark tax beyond the priced . Coverage-improving trades get a better mid and pay zero toll. Cost is bounded by the caller’s own minAmountOut (§3.1); the quote is not capped protocol-side. The one cost not obvious from a quote screen is the toll’s level shift on an under-covered destination (§6.3): it is in SwapQuote.covToll, and integrators should surface it separately from spreadPbps.
15. Implementation reference
| Function | File | Role |
|---|---|---|
_pathSpread | Pricing.sol | , saturating narrow into uint16 |
_staleTerm | Pricing.sol | , per leg |
_settleQuote | Pricing.sol | toll, then fee, then split |
_covToll / _covQ | Pricing.sol | convex coverage toll |
_legMid | Pricing.sol | the one offset law, interior and terminal alike |
_interiorMidAndFence | Pricing.sol | interior mid + its manipulation fence |
dispersionCap | Pricing.sol | write-path half of the fence: the band ceiling |
_priceInteriorLeg | Pricing.sol | raises the leg floor to |
splitFee | Pricing.sol | protocol / LP division |
_walkLegs | Pricing.sol | per-leg fold: sums, quadrature |
swap | Pricing.sol | the single state-mutating swap entry |
FeedMathLib.gate | FeedMathLib.sol | stale / dead / uncertain / paused halts |
PoolIOLib.settle | PoolIOLib.sol | endpoint-only settlement (there is no separate exec) |
PoolLiquidity.accrueLpFee | PoolLiquidity.sol | LP fee to liabilities + index |
16. Related documentation
- Inventory Management: coverage ratio, haircut
- Liquidity Shaping: dispersion law and the spline
- Feed Oracle: mark, , confidence, push API
- Toxic Flow Mitigation: LVR/OEV budget, the fence
- Parametrization: every parameter, its fence, its live value
- Invariants: settlement and liquidity-floor invariants
17. References
- Glosten, Milgrom, Bid, Ask and Transaction Prices in a Specialist Market with Heterogeneously Informed Traders, JFE 14(1), 1985. doi:10.1016/0304-405X(85)90044-3
- Avellaneda, Stoikov, High-frequency trading in a limit order book, Quantitative Finance 8(3), 2008. (Reservation price and the inventory term.)
- Relation between Bid-Ask Spread, Impact and Volatility in Double Auction Markets
- Volatility, Information Feedback and Market Microstructure Noise
- Inventory Risk and Market Making