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

SymbolNameBaseMeaning
BPBPS1061 unit = 0.0001% = 0.01 bp
BPSBPS1041 unit = 0.01% = 1 bp
WADWAD1018prices, coverage, Q

Conversions used on the spread path: bps PBPS is ×B/BPS=×100. Volatility σ is PBPS-based (104=1%), capped at MAX_SIGMA_PBPS =108 (PoolConstantsLib.sol).

Notation. κ is dispersion throughout this chapter (as in Liquidity Shaping and Parametrization §4.2). The coverage wall is κcov (kappaCovBps). The feed’s confidence interval is u (bps): it never shares a symbol with dispersion. On the interior leg, Fi is the manipulation fence, wi the leg’s mid swing, Σi its curve’s span, ρi its dispRefPbps and w¯ 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 S 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 S/2, once, on the output (§5).

S=ifmin,i+σpνp100·BPSSvol+izσiτiBPSUstale+(iui)·BBPSUconf,σp=iσi2

Pricing._pathSpread and _staleTerm. Integer arithmetic, floor division at each /. On an interior leg fmin,i is already raised to max(fmin,i,Fi) 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: ifmin,i is the additive base of Svol, so Sifmin,i 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 S=min(Sraw,fmax), 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:

  1. A cap is not trader protection. minAmountOut is: 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.
  2. A cap manufactures its own pathologies. Composed per asset, ifmax,i, 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.
  3. 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 raw over the cap, by waiting out a keeper or by choosing a high-CI moment, chooses when it binds.

Scale. Uconf=100iui, 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: ifmin,i with fmin,i2θi 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.

InputSourceAggregation (shipped)Site
fmin,pAsset.minFeePbps, raised to max(fmin,i,Fi) on an interior legi over legs_walkLegs, floor at _priceInteriorLeg
σpFeedData.sigmaPbpsquadrature iσi2, one sqrt at the end_walkLegs, rooted _pathSpread
νpAsset.vegaBpsmax(νin,νout) over the endpoints only, a path constant_quotePath
upFeedData.confidenceBpsi over legs_walkLegs
Ustalestaleness excess (s)i of per-leg surcharges zσiτi/BPS_walkLegs

Confidence and staleness share one accumulator (PathAccumulator.riskPath) because _pathSpread only ever consumed their sum, and the fusion is exact: istalei+(iui)·(B/BPS)i(stalei+ui·B/BPS), 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.

σi is the NXR-signed Parkinson volatility stored directly on chain, floored at the realized |Δmark|/mark on each push (FeedMathLib.markMovePbps). Which feed supplies it depends on the leg’s oracle mode; see Oracles.

3.3. Volatility band

Svol=fmin,p+σp·νp100·BPS

Vega has base 10,000 = 1.0x. At ν=104 and σ=104 PBPS (1%), the volatility contribution is 104·104/106=100 PBPS = 1 bp above the floor. There is no hardcoded base: the base is the configured floor.

3.4. Staleness surcharge

Ustale=iz·σi·τiBPS,τi=max(0,tnow-tobs,i-gi),gi=min(ttli2,30)

with z=100 (STALE_Z) and the grace cap 30 s (STALE_GRACE_CAP_SECS), both file-private constants in Pricing.sol. τi is computed in _staleExcessOf off the feed the leg already read, the per-leg term in _staleTerm, which short-circuits to 0 when τi=0 or σi=0 (skips the sqrt), and the legs are summed at _walkLegs.

Per leg, not coupled. A coupled form, zσpmaxiτi/BPS, 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 2/2=70.7% of what they each owe (1414 against 2000 PBPS at σ=1%, τ=100 s: a 5.86 bp leak), while over-charging a fresh leg 1.41× for its neighbor’s lag. Pinned by the staleness regression tests (per-leg summation; a fresh leg pays no staleness premium).

The clock is tobs=min(tsrc,tupd), 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 /BPS is a unit normalization, not a tuning knob. σ is PBPS-scaled, so without it the term is 104× too large and pins the spread at its uint16 field ceiling the instant age crosses the grace, turning a ramp into a step. Worked: σ=104 PBPS (1%), τp=100 s 100·104·10/104=1000 PBPS = 10 bp. At τp=1800 s: 100·104·42/104=4200 PBPS = 42 bp.

The grace cap binds independently of ttl: 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 ×(fmin+σν/(100·BPS)+Ustale), 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 |Δ|>0.5Sleg: 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.

Ustale is a graceful-degradation layer below a hard revert, not instead of it. FeedMathLib.gate reverts:

  • StaleData at age > ttl
  • ZeroValue at mark == 0
  • ThresholdViolation at confidence > MAX_CONFIDENCE_HALT_BPS
  • FeatureDisabled(Err.Resource.FEED) on the guardian fast-freeze bit FEED_HALT_BIT

3.5. Confidence surcharge

Uconf=(iui)·BBPS=100iui

ui is leg i’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 (X,Y), S(XY)=S(YX) at the same block and the same pool state, for any trade size.

Proof. S is a function of exactly five quantities: ifmin,i, σp, νp, iui, Ustale (_pathSpread). Each is built by one of two constructions:

  1. A fold over the leg multiset with a commutative, associative operator: over {fmin,i}, {ui} and the per-leg staleness surcharges, and of squares for σp (_walkLegs).
  2. A max over the unordered pair {cIn, cOut}: νp alone (_quotePath).

Reversing the swap reverses the hop order. The route between X and Y 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 S is identical.

The theorem is robust to how the aggregates are built. , (·)2 and max 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 - feeOut

So, with yg the gross output from the leg walk:

ϕtoll=T(yg),ϕ=(yg-ϕtoll)·S2B,y=yg-ϕtoll-ϕ

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 S=1 PBPS does not truncate to zero on any economically relevant size.

5.2. Why there is no input-side skim

The gross output yg 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 S/2 per side, not S. 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

ϕp=ϕ·r100,ϕl=ϕ-ϕp

Pricing.splitFee, protoSharePct a uint8 with base 100. Live: r=20, 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)
QuantityReserve effect (out leg)Liability effect (out leg)Coverage effect
ϕp (protocol)-ϕp from reserves, moved to protocolFeesnonec falls
ϕl (LP)stays in reserves+ϕl (PoolLiquidity.accrueLpFee)c unchanged, LP claim rises via liquidityIndexWad
ϕtollstays in reservesnonec 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 c 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: Q telescopes exactly only at constant L, and accrueLpFee moves L mid-loop. The residual is pool-favorable (higher L lower c more toll), documented at Pricing.sol.

5.4. Worked example

1 ETH into a WETH/USDC pair, mark 2000 USDC/ETH, S=5000 PBPS (0.5%), κcov=0 on USDC (illustration only; policy is κ>0 everywhere), r=20.

yg2000,ϕtoll=0,ϕ=2000·50002·106=5,y=1995

ϕp=1 USDC leaves reserves for protocolFees; ϕl=4 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 S, which is what makes S 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 bid(0)=ask(0)=mid; with centered presets mid=mark exactly (Anchor Path Pricing §3.1). The spline contributes price impact only.
  • protoSharePct splits that one pot, it does not add to it (§5.3).
  • There is no static fee. Every term of S 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; _settleQuote touches amountIn nowhere.

5.6. What that costs a trader

All-in (spread plus impact) at par coverage, quiet tape and stressed tape:

PairLegsQuietStressed
USDT USDC10.36 bp0.56 bp
USDT DAI21.29 bp1.57 bp
WETH USDC15.26 bp6.16 bp
WETH USDC at σ=10%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 R and L be the output asset’s reserves and liabilities, c=R/L in WAD, and yg the gross output.

Q(c)=lnc-c+1,c0=min(RL,1),c1=min(R-ygL,1)

ΔQ=Q(c0)-Q(c1),T(yg)=ygygR0ΔQ0min(ΔQ·κcov·LBPS·WAD,yg)else

Pricing._covToll, _covQ (FixedPointMathLib.lnWad). Returns 0 immediately when κcov=0, L=0 or yg=0.

Q is concave with a unique maximum Q(1)=0, and Q(c)- as c0: a convex no-drain wall.

6.2. The three structural properties

Charge-only. ΔQ0T=0. 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 min(c,1) clamp restricts Q 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 ΔR=0 by construction (settlement is endpoint-only, PoolIOLib.sol), so ΔQ=0 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 c10 the toll saturates to yg, so amountOut 0 and the drain halts gracefully rather than reverting. At ygR the whole fill is tolled.

6.3. The marginal toll: a level shift, not a large-trade wall

Differentiate at q0+. With c=R/L and dc=-q/L:

ΔQ-Q(c)dc=(1c-1)qL

limq0+T(q)q=κcovBPS(1c-1)forc<1,=0exactlyforc1

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 c=0.9575 and κcov=100 bps: 0.01·(1/0.9575-1)=4.44×10-4=4.44 bp, against 4.44 bp read from the quote. RLUSD is not listed on the Arc fleet, and the live κcov there is 600 on a stable leg, so the same coverage costs 0.06·(1/0.9575-1)=26.6 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”, κcov(base)=0 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 κcov=0, 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 XbaseY the terminal is Y, so hub κ does not fire on that same-pool cross. A swap whose output is the hub pays hub κ when κ>0.

Consequence, combined with Theorem 1. Take a same-pool cross XrightarrowY at one block. The spread is identical in both directions. The toll is TY one way and TX the other. By §6.3 at small size that is

κcovBPS[(1cY-1)+-(1cX-1)+]

A trade whose output is the hub pays Thub when hub κ>0.

Interior vs hub. A non-base interior can carry κcov>0 and still be tolled at zero, because it is interior (ΔR=0), 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:

  • κcov>0 haircutSuppressorBps = 0 (Lemma B of the published coverage proofs), held by the single predicate PoolConfig.requireWallOk (PoolConfig.sol, reverts InvalidInput), called from setAssetParams, setRiskConfig and initAsset (which zeroes the BPS default 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: κcov>0 on every listed asset including the hub, enforced by requireNeverDepletable at every writer, and κ cannot be stripped from an asset carrying a FLAG_REQUIRES_WALL preset. 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 -4.0 PBPS to the LP at c=0.60). 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 m but on the skew-loaded mid r=m(1+δ(c)), where ψ is the int8 inventory-skew index and δ the price displacement it buys. The spread is symmetric about r:

bid=r(1-S2B),ask=r(1+S2B)

The skew is piecewise-linear in the coverage ratio, clamped at ±100 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:

  1. Mid skew δ(c): 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.
  2. Spline traverse direction: the buy and sell legs integrate the same monotone curve over different intervals from the anchor. Size-dependent, endpoint legs only.
  3. Coverage toll T: charge-only, terminal asset only, never rebated. Theorem 2.
  4. 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.

P=m(1+δ(c))±S2B,δ(c)=ψ(c)·κ100·B

with κ the dispersion. Because m is a frozen external mark between pushes, P drifts only through realized inventory c.

  • Inventory-moving flow walks up the skew and pays δ on top of fmin. 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 fmin.

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 rightarrow DAI at S=997 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 69+183=252 PBPS. USDC’s own 50 never enters, because minFee is per leg’s profile asset and the base supplies no leg profile.

TermPBPSShareBasis
fmin,p25225.3%configured: 69RLUSD+183DAI, read off the roster
Svol-fmin,p~20.2%computed: σpνp/(100·BPS) at the quoted σ
Uconf+Ustale74374.5%residual: S less the two rows above, not an independent reading

The last row is solved for, not measured. S=997 and the floor are both hard numbers (the floor is roster configuration and S 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 i(stalei+ui·B/BPS), 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 fmin,p, 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), so getSwapQuote reverted 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 fmin,i2θi, where θi 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 max. 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 max funds one leg and leaves the rest unfunded.

maxifmin,idoesnotmeetfmin,p2iθi

Measured on the reference roster (2. Deployments). 15 spokes, 105 crosses, at the shipped floors and thetas (2. Deployments):

Crosses that a max rule would under-fence72 of 105
Worst pair(U, USDTB)
Its floor under a max rulemax(73,78)=78 PBPS
Its requirement2(0.365+0.365)·100=146 PBPS
Deficit68 PBPS = 0.68 bp per pick-off, repeatable, size-unbounded
Crosses under-fenced under the shipped ifmin,i0 of 105

The sum rule clears every pair on the roster with no parameter change: 73+78=151146. A max rule measures +20,096 PBPS for a cross-spoke round trip with both marks walking θ against a per-leg 2θ 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 6×10,051=60,306 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 max(fmin,i,Fi), not bare fmin,i, with

Fi=wi·BB-w¯/2,wi=Σi·κiQ·ρi,w¯=10,000PBPS

computed per quote (it reads the leg’s live dispersion κi and its curve’s span Σi) 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 wi is peak-to-peak, so w¯ is a 1%-of-mark total range and ±0.5% 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: ΔRinterior=0 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 by haltAsset(pool, token, src)) is tested per hop at _quotePath.
  • Feed-relative depeg band. Tested per hop by PoolIOLib.priceBandGuardPath, which runs priceBandGuard on every interior node except the base, and by PoolIOLib.priceBandGuard on both endpoints.
  • Base parity halt. Tested wherever the path touches the base: _readBasePriceOrHalt is gated on hop == $.baseToken (Pricing.sol) and reverts BaseDepegged past BASE_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 X must be attested in units of assets[X].anchor, and mark = FeedMathLib.mark(feed) with no correction. At depth 2 a base-denominated rule is dimensionally wrong, not merely imprecise: stETH -> ETH -> USDC would compose stETH·ETH/USDC2.

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 2(θchild+θparent). 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

BehaviorStateReference
Fee once, output-side, S/2, post-tollshippedPricing._settleQuote
Toll charged before fee, terminal asset onlyshipped_covToll
κcov>0 on every listed asset, the base/hub included (old P3 “base never walled” is void)shipped, enforcedPoolConfig.requireNeverDepletable rejects κ=0 at every writer; requireWallOk only enforces κ>0suppressor=0
Direction-invariant spread (Theorem 1)shipped_pathSpread
min(c,1) clamp on the tollshipped_covToll
Sum / quadrature path aggregationshipped_walkLegs, _pathSpread, §9
No cap of any kind on the composed spreadshipped, deliberate§3.1; trader protection is minAmountOut
Per-leg staleness surcharge, summedshipped_walkLegs
Anchor-unit marks consumed as attested, quoteUnitshipped§10.3
MAX_DEPTH = 4 multi-anchor tree (8 legs, 9 nodes)shipped, activation gatedAnchorTreeLib.sol, Anchor Path Pricing §10
Interior leg priced by the same _legMid as the terminal legshippedPricing.sol
Interior-leg manipulation fence in the path floorshipped_interiorMidAndFence, _priceInteriorLeg, §10.2
Dispersion band bound to the preset’s fence cap at the writeshippedPricing.dispersionCap, PoolConfig.sanitizeDispersion
Re-anchor timelocked at the base-migration tier, atomic with the oracle configshippedrequestOp(..., UPDATE_ANCHOR, ...) / Admin.executeAnchorUpdate
Parent-depeg breaker (absFeedId, or a split refFeedId)not designed into any fieldAnchor Path Pricing §7.1

11.1. Open items

  • Full-drain toll yields a zero-output view quote (exec-side fixed). _covToll returns grossOut when grossOut >= R (Pricing.sol). The executing path is closed: Pricing.swap reverts Err.ZeroValue on out == 0 before 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: getSwapQuote returns a fully populated SwapQuote with amountOut == 0 rather than surfacing the failure, so a caller that skips an explicit amountOut > 0 check sees an executable-looking quote that can never fill.
  • Re-anchoring is timelocked and atomic with the oracle config. It queues at the CRITICAL tier with the anchor and the OracleConfig in one inseparable payload (requestOp(..., UPDATE_ANCHOR, ...)Admin.executeAnchorUpdate), because re-anchoring X to P while X’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’s Admin.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. _legScaleOut caps 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:

SiteWhich vegaBpsScope
_pathSpread volatility band, σpνp/(100·BPS)νp=max(νin,νout)the two endpoints only, applied to the whole path’s σp
_calculateDispersion, per legthe profile asset’s own Asset.vegaBpsthat 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 σp=iσi2, 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 νmax/νleg 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: ν0.

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 10,000/3,000=3.3× 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

ϕf=x·ffB

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

ProtocolFee taken inChargedDistribution
Uniswap V2/V3100% inputper hopone-sided LP
Trader Joe V2100% inputper hopone-sided LP
Curve V1100% outputper hopone-sided LP
Wombatinput, redistributedper hopsingle-asset LP
AIMM100% outputonce per swap, S/2LP claim via index; toll to coverage
AspectUniswap V3AIMM
Fee structurefixed tiers 0.01-1%multi-factor, σ + CI + staleness
Direction dependence of the widthnonenone (Theorem 1)
Inventory responsenonemid skew + terminal coverage toll
Quote centerpool reservesexternal keeper mark, no write-on-swap
LVR mitigationhooks onlyfresh mark + 2θ 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 R against unchanged L.

Surplus is not redeemable. At c1 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 Ustale. 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

FunctionFileRole
_pathSpreadPricing.solSvol+Ustale+Uconf, saturating narrow into uint16
_staleTermPricing.solzσiτi/BPS, per leg
_settleQuotePricing.soltoll, then fee, then split
_covToll / _covQPricing.solconvex coverage toll
_legMidPricing.solthe one offset law, interior and terminal alike
_interiorMidAndFencePricing.solinterior mid + its manipulation fence
dispersionCapPricing.solwrite-path half of the fence: the band ceiling
_priceInteriorLegPricing.solraises the leg floor to max(fmin,i,Fi)
splitFeePricing.solprotocol / LP division
_walkLegsPricing.solper-leg fold: sums, quadrature
swapPricing.solthe single state-mutating swap entry
FeedMathLib.gateFeedMathLib.solstale / dead / uncertain / paused halts
PoolIOLib.settlePoolIOLib.solendpoint-only settlement (there is no separate exec)
PoolLiquidity.accrueLpFeePoolLiquidity.solLP fee to liabilities + index

17. References