Anchor path pricing

A swap inside one pool is not a pair trade against a numeraire: it is a walk along the anchor tree between the two endpoint assets. This page states that topology, how each leg of the walk is priced, what settles and what does not, and the conditions under which a closed cycle through the tree cannot return more than it consumed. §1 gives the shape; §3 to §5 give the pricing law and the fee composition that keep it safe at depth.

A pool based on BTC or ETH still quotes tight stable-to-stable and LST-to-underlying swaps because each asset anchors to its correlated parent rather than to the pool base: USDT -> USDC, stETH -> ETH, sUSDe -> USDe. The traded pair is one edge of the tree, priced off one feed with one sigma, never routed through an uncorrelated numeraire.

1. Topology

Canonical. The depth bound, the leg count and “topology is configuration” are stated here and pointed at from Spread & Fees §10.1, Parametrization §12.1, Invariants I-15 and Pool §7.

WBTC base

WETH

USDC

stETH

weETH

USDT

DAI

USDe

sUSDe

  • Base: the root and the accounting numeraire. anchor = address(0).
  • Every other asset: exactly one parent, any asset in the tree, including a non-base one.
  • Depth: MAX_DEPTH = 4. A path is two walks that meet at the LCA, so the bound is MAX_PATH_LENGTH = 2 * MAX_DEPTH + 1 = 9 nodes and 2 * MAX_DEPTH = 8 legs, not 5 and 4. The worst case is two depth-4 leaves whose LCA is the root: 4 legs up, 4 legs down. Depth bounds one walk; a path is two of them.
  • Path: unique. In a tree exactly one simple path joins two nodes: up from the source to the lowest common ancestor (LCA), then down to the destination. Nothing to search: no route-selection surface, and no route path-dependence.
  • Endpoints: the two ends of the path. Every other node on the path is interior.
SwapPathLegs
child to parentstETH -> WETH1
siblings under a common parentUSDT -> USDC -> DAI2
deep leaf to the rootsUSDe -> USDe -> USDT -> USDC -> WBTC4
across the root, both sides deepstETH -> WETH -> WBTC -> USDC -> USDT -> USDe -> sUSDe6
worst case at MAX_DEPTH = 4two depth-4 leaves under different root children8

The tree drawn above produces a 6-leg path, and it is not the worst it admits: a second depth-4 branch under WETH gives 8. Every “sum over legs” budget in this document is sized against 8, not against the shape of the example.

USDT -> DAI in a WBTC-based pool touches one interior node (USDC) and prices off two stable feeds. It never consults the BTC mark.

1.1. What the anchor column configures

The contracts enforce the topology generally: validateAnchor accepts any parent up to MAX_DEPTH = 4, with cycle detection, detachment detection and LCA routing. What a pool runs is configuration: the anchor column of its risk-param file is the sole expression of its topology. An empty cell means base-anchored; filling one cell deepens the tree, no redeploy needed.

A flat roster (every leg base-anchored) makes each leg an endpoint leg (_executeLeg marks a leg interior only when the edge’s child is not one of the swap’s two endpoints), so every route is one or two legs. The interior fence of §3.2 is live code either way: the moment one anchor cell is filled it prices every transit. Its second-order margin has market tape only once deep routes run; until then it is pinned by tests.

The schema, the deploy scripts and every fence budget on this page stay general to AnchorTreeLib.MAX_DEPTH = 4; nothing special-cases a shallower tree. A deep edge needs the cross mark to exist: NX Rates must serve the pair as a first-class series with its own σ and its own push trigger (a pair registered in cexs.cross_pairs but excluded from materialization has no live mark and no sigma). Once NXR serves the cross, activation is one column change (write the parent into the child’s anchor) plus addAnchorFeeds() -> requestAnchorTree() -> (the CRITICAL tier delay) -> executeAnchorTree(). Every deep leg must carry quoteUnit = 0 (QUOTE_UNIT_ANCHOR) and its own reference feed: the shared USDC/USD reference speaks USD and cannot bound an anchor-denominated mark.

2. Settlement: endpoints only

A swap mutates exactly two reserves:

aIn.reserves += amtIn; aOut.reserves -= amountOut + protoFee;

PoolIOLib.sol. This holds at any depth. Interior assets are traversed for pricing and never credited or debited, so dR_interior = 0 and no path can drain an interior node. Two consequences:

  • The coverage toll stays terminal-only, charged once on the out endpoint (Pricing._covToll, called once from Pricing._settleQuote). dQ == 0 on interiors by construction, so a per-leg toll would charge for a displacement that did not happen.
  • Impact is charged on endpoint legs only. See section 3.

3. Leg pricing

LegMarkSkewSpline impactReserves
endpointparent-per-child markyesyesyes
interiorparent-per-child markyesnono

Skew applies on interior legs. Skew is pricing, not a charge for inventory change. If the interior asset is under-covered its worth to the pool is depressed, and pricing the leg at the undepressed mark hands the trader that asset’s depeg risk for free.

Interior skew cancels on a round trip only against unchanged interior coverage. Skew is a level, so the cancellation is exact within one transaction and does not extend across transactions: skew is a function of the interior node’s coverage, so the out-leg and the return leg read the same level only if nothing moved that coverage in between. An interposed trade that shifts an interior node’s coverage between the two crossings breaks the cancellation, and the residue is extractable. Measured on the reference model: +2.96 bp under the max aggregation the code shipped at the time, +1.01 bp under sum aggregation.

3.1. One pricing law per edge

Interior and terminal legs call the same expression, Pricing._legMid (Pricing.sol, called from _interiorMidAndFence). An edge is terminal on the routes whose endpoint is its child and interior on every other route, and a mid-tree anchor is a legal swap endpoint, so both readings are live at once. A correction applied on one side only (a re-centering, a clamp) puts two prices on one edge at one state, and a closed walk that crosses it once under each law extracts the difference atomically: no manipulation, no capital at risk, no state change.

The stored curve is centered at the write. NUQuartic.set shifts the submitted polygon so y(0) + y(BPS) == 0 before building the segments (NUQuartic._centre, NUQuartic.sol). It is shape-preserving (the fitted density y is untouched), idempotent, and exact but for the 1 Q-unit an odd sum cannot split. Consequence: the stored offset never exceeds w/2 for every stored shape, so no read path corrects for the fit and no admission bound on β=y(0)/Σ is needed: the shift pins it at -1/2 for every curve the pool will ever hold.

3.2. The interior fence

An interior leg contributes max(minFee_i, fence_i) to the path floor (Pricing._priceInteriorLeg), where

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

Fi is the fence, wi the leg’s mid swing, Σi its curve’s span, κi its live dispersion, ρi its dispRefPbps, and w¯ = INTERIOR_SWING_CAP_PBPS, a file-private constant in Pricing.sol.

The swing is peak-to-peak, and the two readings differ by a factor of 2. cap = 10,000 PBPS is a 1%-of-mark total range, i.e. a ±0.5% one-sided displacement either side of the mid. The code uses both readings and names the half explicitly rather than dividing inline: INTERIOR_HALF_SWING_PBPS = INTERIOR_SWING_CAP_PBPS / 2 = 5000, and the fence’s constant denominator is FENCE_LOW_MUL_PBPS = PBPS - INTERIOR_HALF_SWING_PBPS. _interiorMidAndFence compares a peak-to-peak swing against cap; _fenceOfSwingPbps divides by the half-derived multiplier. Any statement that reads cap as a one-sided ±1% band, or the denominator as PBPS - cap, is off by 2 in one direction or the other.

The extraction a cross-transaction coverage manipulation realizes is a price ratio, not an offset, so the fence is a ratio too, taken over a constant low multiplier (Pricing._fenceOfSwingPbps) rather than the leg’s own realized one. It is computed per quote: it reads the leg’s live dispersion and its curve’s span, never a stored fee. Its per-leg and composed ceilings, and why the uint16 spread field cannot saturate them away, are derived at Spread & Fees §9.

The fence is the leg’s fee floor, not a side channel. It folds straight into r.minFee as max(minFee_i, fence_i) at its one consumer and contributes to acc.minFeePath exactly like any other leg floor. There is no separate accumulator carrying it, and nothing bounds it from above: the path spread is a sum of floors and surcharges with no cap (Spread & Fees §3.1).

Swing above the cap reverts; it is never clamped (Pricing._interiorMidAndFence). A clamp is a law fork by construction, because it bites on the interior leg only, which is the defect in 3.1. Failing closed cannot fork it.

The dispersion floor is bound at the write. Pricing.dispersionCap sizes the widest band a preset’s fence can bound, w¯ρQ/Σ, and PoolConfig.sanitizeDispersion checks minDispersionPbps against it (PoolConfig.sol) at both writers (initAsset and setProfile), so a live asset never starts past the cap. That check reverts, never clamps: silently narrowing minDispersionPbps would move the asset’s own quiet-tape quote, so a floor above the cap is a config error. Above the floor, σ drives κ one-for-one (at ν=10000) up to the protocol-wide MAX_DISPERSION_PBPS = 900000 - no per-asset ceiling field exists; κ past the shape cap fails closed on the interior swing instead. Shipped preset caps: 5000 PBPS for presets 1, 2 and 5, 2500 for preset 3, 1000 for preset 4. No shipped minDispersionPbps exceeds its cap (widest is KRW1 at 3740 under 5000).

Cost: the fence is the leg’s live swing plus 0.5%, e.g. 324 PBPS on USDT (minDispersion 161, ratio 2) and 4089 on WETH. On a quiet tape the endpoint minFee is usually the binding floor, not this.

3.3. Rejected alternatives

Recorded so they are not retried:

AlternativeMeasuredWhy it fails
Clamp interior legs against interior reserves-9900 bpCaps a routing quantity against a balance the swap never spends. Destroys the tree: any cross larger than the thinnest interior node quotes short.
Extend the coverage wall kappa to interiorsno-opThe toll is terminal-only by construction: dQ = 0 on an interior because dR_interior = 0, so any kappa multiplies zero.
Freeze the edge rate for the duration of a pathno-opThe manipulation is cross-transaction. Freezing within one quote fixes nothing that was not already consistent within one quote.

3.4. Why the fence is a protocol constant

The attack class is route-dependent price inconsistency on one edge: an attacker crosses the same edge twice, once as an interior leg and once as a terminal one, and pockets the difference. It is closed by law identity (3.1), not by making the fence larger.

Four fence shapes fail against it, each failure a property of the shape rather than its size:

  • A multiple of dispersion. Assumes a swing of some fixed multiple of the dispersion, which no spline has. The real swing is Σκ/(Qρ), and nothing bounds that ratio at write time; the shipped book spans ratios 2, 4 and 10, so one constant under-charges the wide shapes by up to 5x.
  • The curve’s own span, charged linearly. A linear fee against a ratio extraction defends to first order only: the round trip nets -x2(3/4+β), so any shape below β=-3/4 is extractable.
  • Ratio-correct, sized per crossing. Fencing each crossing at its own live dispersion lets the cycle net (1/2+β)(x2-x1): the attacker picks which crossing is the volatile one. Linear in β, so bounding β does not close it.
  • Sized at each crossing’s live dispersion. With adaptive dispersion (the 2026-08-21 adaptive-dispersion change) σ itself moves the band between crossings, and the profile queue is timelocked but public, so the two crossings are schedulable either side of the execute and the config change itself becomes the channel. The product also overflows the uint16 spread at wide bands.

The design that holds is a protocol-constant swing cap with the interior mid re-centered at the write, one _legMid on every route, a revert above the swing cap, and the band bound at the write. Applying a correction to the interior leg but not the terminal one is what gives one edge two prices, so both corrections are applied on both. Coverage lives in the anchor-tree regression suite (the cross-law cycle asserted unprofitable at every band, the same edge priced on both routes at once, a mid-tree anchor settling as a real endpoint) and in the pricing-fence tests.

Impact must not be charged on interior legs. dR_interior = 0, so charging spline impact invents a cost for a displacement that did not occur, monetizes path-dependence, and replaces an exact round-trip identity with a size coincidence. The revenue argument for charging it is answered by fee composition (section 5) instead.

4. Cycle safety

A path is a unique tree path, so any closed trading cycle is a closed walk in a tree and therefore crosses every edge equally often upward and downward. Per-edge safety then gives cycle safety at any length and any depth:

Πcycle<1C0C1C2

C0. Marks in parent units. Every leg mark is attested as parent-per-child, never base-per-asset. See section 6.

C1. One canonical integer per edge. Direction is expressed as multiply versus divide, never as a second construction:

M_e = _legMid(mark_parent_per_child, dispersion_child, curve_child, skew_child) upward (child -> parent): out = in * M_e / WAD downward (parent -> child): out = in * WAD / M_e

Never materialize 1/M. Never re-apply skew after inverting a rate: that is the reciprocity defect described in section 9.

C2. The spline is monotone nondecreasing. True by construction for the clamped quartic I-spline (NUQuartic), so it costs nothing to hold.

Integer discipline. Double flooring gives dn(up(x)) <= x and up(dn(x)) <= x for every x and every M, with no rounding hypothesis. This needs no fuzz evidence: floor is monotone and the two maps compose to the identity, so the sign result is unconditional. It bounds the sign, not the magnitude; the residual in wei is a function of M, of the decimal pair and of the sampling domain, so no single number characterizes it. Any bias in M cancels, because both directions consume the same integer. Only a direction-dependent construction of M can leak. Enforce it in code, not in prose:

require(isUpward ? out * WAD <= in * M : out * M <= in * WAD);

5. Fee composition over the path

Each leg ships minFee_i >= 2 * theta_i, so summing both sides gives the path its fence for free.

Every risk aggregate composes over the leg multiset; none of them reduces by max.

TermAggregationSite
minFeePathsum over all legs, each leg’s floor already raised to max(minFee_i, fence_i) on an interior leg_walkLegs, floor at _priceInteriorLeg
sigmaSqPathquadrature, iσi2, one sqrt at the end_walkLegs, rooted at _pathSpread
riskPath (confidence + staleness)sum over all legs of zσiτi/BPS+ui·(PBPS/BPS)_walkLegs
upper bound on the composed spreadnone, deliberately: see below_pathSpread doc comment

max is structurally under-fenced. An adversary picks the joint worst realization, so the path error is the sum of the per-leg thetas while max funds one leg. Quadrature for sigma matches what NX Rates already does when it composes a bridged pair. The staleness surcharge sums per leg rather than evaluating one coupled term at (max_i tau_i, sigma_path): one keeper feeds several edges, so an outage staleses them together, and the coupled form charged two equally stale legs 2/2=70.7% of what the legs each owe.

Every row says “over all legs”, interior ones included. That replaces charging impact on an interior leg: the interior leg pays through the fence, not through a fabricated displacement.

Nothing bounds the composed spread from above, by design. Trader protection is minAmountOut: exact, caller-set, per trade, bounding the quantity the trader cares about. A protocol-side cap on the quote would instead cap the pool’s own defense on exactly the tape where the premium must be paid, since a stale or high-CI leg is when a wide quote is the correct one. Full argument: Spread & Fees §3.1.

Overflow. The leg sums cannot wrap, and the single narrowing into SwapQuote.spreadPbps saturates rather than wrapping, so what saturates away is the risk premium and never the security floor. Arithmetic and the pinning test: Spread & Fees §9.

6. Marks and denomination

The feed for asset X must be attested in units of assets[X].anchor. The pool performs no re-denomination: mark = Oracle.mark(feed).

At depth >= 2 the base-denominated rule is dimensionally wrong, not merely imprecise: composing stETH -> ETH -> USDC from base-quoted marks yields stETH * ETH / USDC^2.

  • OracleConfig.quoteUnit (uint8) declares how a leg’s feed is denominated. QUOTE_UNIT_ANCHOR = 0 is the norm; QUOTE_UNIT_UOA = 1 is the unit-of-account bridge, and a bridged leg’s minFee must cover 2 * (theta_child + theta_parent) (PoolConstantsLib.sol). Read QUOTE_UNIT_UOA as the instruction “divide by the base mark”, not as a unit: it is legal only while the asset anchors directly to the base (PoolConfig.sol rejects it otherwise), because dividing by the base price says nothing about a deeper parent.
  • An anchor-quoted mark is consumed exactly as attested, with no re-denomination helper in the path. The UOA division is three lines inside Pricing._legMarkAndFees, reusing the same gated, depeg-banded base read the path already performs.
  • The depeg gate is feed-relative, never absolute. It is the pair refFeedId / refBandBps in OracleConfig, run on every interior node by PoolIOLib.priceBandGuardPath and on both endpoints by PoolIOLib.priceBandGuard, plus BASE_DEPEG_HALT_BPS = 500 on the base. An absolute price band would have to be denominated in something, and under multi-anchor there is no one unit to denominate it in; a same-unit comparison against a second attestation has no such problem (section 7.1). The absolute-band policy call belongs to the guardian halt.
  • Multi-leg marks telescope algebraically. Residual is at most 1 ulp per leg.

7. NX Rates data contract, per anchored pair

An anchored pair is a first-class series, not a quotient computed at read time. For each edge (child, parent):

  • Its own catalog index, its own on-chain feedId = keccak256(child, parent), its own persisted .s10 history, its own Parkinson sigma, and its own keeper push trigger with theta_bps derived from the cross’s realized volatility.
  • Pinned synth preferred: SynthPath::new("DAI/USDC", [DAI/USD +1, USDC/USD -1]). A pinned quotient of one consistent USD vector telescopes exactly and still carries its own sigma and its own push trigger.
  • Never derive_legs for an anchored pair. It returns the first pivot that merely resolves, which may already have latched a dead book.
  • Avoid a native venue book for an anchored pair unless the basis is fenced. An independent stETH/ETH book can disagree with stETH/USD divided by ETH/USD, and no fence measures that composition error. The intra-pool cycle stays safe either way; the pool would quote a stale composite.
  • If the ratio is not its own push trigger the fee is under-funded: both legs can sit inside their own theta bands while the ratio drifts to the sum of them, and no push fires.
  • Depeg reference: see section 7.1. The reference feed must be attested in the same unit as the primary, so an anchored pair references the anchored pair, never the absolute leg.

7.1. What refFeedId is, and what it is not

OracleConfig.refFeedId / refPrimary is an agreement check between two independent attestations of the same quantity. It is not a peg test.

The band compares the primary mark against the reference mark in the same unit:

uint256 dev = pRaw > refP ? pRaw - refP : refP - pRaw; if (dev * SC.BPS > refP * uint256(oc.refBandBps)) revert Err.PriceOutsideRefBand(pRaw, refP);

PoolIOLib.sol. By convention refFeedId is keccak256(token, USDC) on a distinct oracle instance: PoolConfig.validateOracleConfig rejects refPrimary == primary (PoolConfig.sol) and proves both reachable. So the reference is a second, independent signer set attesting the same pair, and the band measures how far the two disagree. It is a manipulation cross-check, and it fails closed on a stale, dead or over-uncertain reference.

Pointing it at the absolute leg would brick the pool. For a primary of stETH/ETH at roughly 1.15, a reference of stETH/USD at roughly 4000 gives dev / refP of about 99.97%, past any band a uint16 can hold. The first swap reverts and every subsequent one does too. This is not a tuning problem: the two feeds are not the same quantity, so their difference is not a deviation.

A parent-depeg breaker does not exist, in any form. Making stETH/ETH say something about ETH needs a new field, either absFeedId plus absBandBps carrying the child-versus-USD attestation alongside the anchored primary, or a split of refFeedId into an agreement band and a separate absolute-depeg band. Neither is built. Until one is, an anchored child inherits its parent’s depeg risk with no automatic breaker, and the only lever is the guardian halt (haltAsset(pool, token, src) with the guardian bit): the per-asset Asset carries no absolute price band, and the parity halt at BASE_DEPEG_HALT_BPS = 500 tests the base mark against 1e18; it re-runs on every hop and every endpoint that reads the base (_readBasePriceOrHalt), but it says nothing about any other asset’s peg. A per-asset peg breaker would need a new field on Asset and is on the roadmap rather than in the deployed contracts, so do not design around one today: the controls that exist for a non-base leg are the ref band, the staleness and confidence gates, and haltAsset.

8. Guards

  • MAX_DEPTH = 4, so MAX_PATH_LENGTH = 2 * MAX_DEPTH + 1 = 9 nodes and 8 legs (section 1). Err.DepthExceeded and a cycle error are live reverts, not decoration.
  • validateAnchor walks to the root with both an explicit current == asset cycle check and an unconditional step cap. A disconnected 2-cycle has finite walk length and never reaches the root, so a depth counter alone does not catch it.
  • The reference band is what generalizes, not the parity halt. PoolIOLib.priceBandGuardPath loops the interior hops and runs priceBandGuard on each one except the base, and PoolIOLib.priceBandGuard runs on both endpoints, so every priced node of the path carries an agreement check. The parity halt does not generalize: _readBasePriceOrHalt is gated on hop == $.baseToken (Pricing.sol), so it tests the base mark against 1e18 at BASE_DEPEG_HALT_BPS = 500 and tests nothing else, however deep the path. The base is skipped by the path guard precisely because the parity halt already covers it. Neither check is a parent-depeg breaker: the ref band is an agreement check and cannot express a peg test (section 7.1).
  • Coverage toll stays terminal-only at any depth (section 2). Per-leg tolling would be a bug.
  • Hub κ is output-only, not keyed off because the token is the base. A walled asset may be a parent. κ=0 is forbidden on every listed asset including the hub (Invariants §I-9).

9. Three defects a multi-leg path can carry, and where each is closed

Depth beyond 1 is safe only if three failure modes are closed, each in a different place and none by restricting the topology: a basis error, a write-path error and an aggregation error. A flat roster exhibits none of them only because it has no interior leg to exhibit them on.

  • Reciprocity basis (safety, extractable). If a downward leg inverts the rate but not the skew multiplier m, a closed cycle returns m^2 and leaks m^2 - 1 per cycle, scaling with the skew actually loaded and with the cycle length. Closed by condition C1 (section 4): one canonical integer per edge, direction expressed as multiply versus divide, and skew never re-applied after an inversion. Enforce it in code, not in prose.
  • Oracle-push bias (safety). If interior, skew-loaded mids were written back into the internal feed, a trader could bias a stored mark at zero inventory cost by choosing direction. Closed by keeping leg prices read-only: nothing on the pricing path writes a mark. It must stay that way.
  • Interior-leg under-charge (revenue). An interior leg that contributes nothing to the path fee is free routing through a node whose depeg risk the pool is carrying. Closed by fee composition (section 5): every leg a path crosses, interior included, pays into the path floor, and the interior leg’s own floor is the fence of section 3.2. Charging impact instead would not close it, and would break the round-trip identity.

Two properties are sometimes mistaken for defects and are not. Route path-dependence cannot arise: in a tree the path between two nodes is unique, so there is no decomposition for a quote to depend on. Wrapper feeds are a per-pair data question, not a structural limit: an anchored pair whose feed is mapped to the underlying (so that cbBTC/WBTC reads identically 1.0) carries no information and must not be listed as an edge until NX Rates serves it as a first-class series. Section 7 states that contract.

10. Implementation status

Depth up to 4 is the design and it is built. AnchorTreeLib.MAX_DEPTH = 4 and MAX_PATH_LENGTH = 9 are live constants (AnchorTreeLib.sol); validateAnchor walks to the root under both an explicit cycle check and an unconditional step cap. Pinned by the anchor-validation tests:

  • depths 2, 3 and 4 accepted;
  • a direct depth-5 write rejected DepthExceeded;
  • the 8-leg / 9-node worst case;
  • the detached 2-cycle that only the step cap terminates.

The depth bound is enforced at quote time, not as a storage invariant. validateAnchor bounds only the node being written. Re-parenting a node that already carries descendants lengthens every chain beneath it and revalidates none of them, so an admin re-anchor can leave a depth-5 asset in storage. That is caught fail-closed by the router: findRoutingPath re-walks under its own bound, so getSwapQuote, swap, cross-asset withdrawTo and swapLiability all revert InvalidPath for any endpoint deeper than MAX_DEPTH, and an over-deep tree can never price. Assets that remain at depth 4 or less after the re-parent keep quoting normally, and the over-deep asset’s LPs can still exit through the same-asset withdraw path, which does not route. collapseAnchor moves one way toward the root and cannot deepen anything; setBaseToken is depth-preserving on factory pools, because the roster completeness scan forces every child of the old base into the spokes array and re-anchors it to the new base at depth 1. The deployment scripts stand up a real tree in three stages, addAnchorFeeds()requestAnchorTree()executeAnchorTree(), and a local test-chain run formed depth 2 and read it back, formed depth 4, and had depth 5 rejected.

Flat or deep is configuration, not a build gap. There is no anchorTreeEnabled flag: the anchor column in a pool’s risk-param file is that pool’s topology (§1.1). Three things gate the first fill of a deep edge:

GateRequirement
The interior-leg pricing law (section 3) independently auditedmust pass before the first fill
The cross pair served by NX Rates as a first-class series with its own σ and its own push triggerregistration alone is not enough: a pair registered in cexs.cross_pairs but excluded from materialization has no live mark and no sigma
The indexer reading quoteUnit from chain instead of re-deriving denomination from the feed descriptorrequired before any deep leg: UOA is legal only while anchor == baseToken

None of the three is a contract gap. PoolConfig already refuses a deep leg that is not quoteUnit = 0, and the deploy scripts already stand the tree up in three stages.

Still open in the contracts themselves:

ItemStateCitation
Parent-depeg breaker (absFeedId or a split refFeedId)field does not existsection 7.1

Denomination and re-anchoring are settled: OracleConfig carries uint8 quoteUnit (QUOTE_UNIT_ANCHOR = 0, QUOTE_UNIT_UOA = 1, PoolConstantsLib.sol), and there is no untimelocked writer for an anchor. Anchor and oracle config travel as one payload through requestOp(..., UPDATE_ANCHOR, ...)executeAnchorUpdate at the CRITICAL base-migration tier (requestOp UPDATE_ANCHOR and executor executeAnchorUpdate, both on Admin.sol), with a guardian collapseAnchor that may only move a leg toward the root and halts it in the same write (Admin.sol).

10.1. Admin gating for a re-anchor

  • Timelocked at the base-migration tier. Re-anchoring is a re-rooting of the subtree, so it queues at the CRITICAL tier (requestOp(..., UPDATE_ANCHOR, ...)executeAnchorUpdate).
  • Atomic with the oracle config: one payload {token, anchor, OracleConfig}, never separable. Re-anchoring X to P while X’s feed is still attested in base units misprices the leg by the parent’s price (roughly 3500x on stETH) and drains the reserve in one block.
  • The guardian emergency path (collapseAnchor) may only re-anchor toward the root, and halts the leg in the same write. That direction always fails safe: the root feed always exists, and collapsing the tree only lengthens paths, which raises the summed fee.
  • A partially re-anchored tree routes but prices differently mid-flight. Move one asset per queued payload, or set HALT_MASK on the subtree first.

11. Vocabulary

tree, path, depth, anchor, LCA, interior, edge, leg all describe the intra-pool anchor topology and are accurate.

route and hop mean something else and are reserved for the inter-pool axis: BTR has no on-chain router, so a cross-pool trade arrives as several Swapped logs and the collector indexes it with a hop_index. Do not use route or hop for legs inside one pool.

depth also names the liquidity depth axis of the spline (startDepth, endDepth). That is an unrelated homonym and is never the tree depth. A third sense, the impact denominator, is the leg’s raw reserves (depth = reserves == 0 ? 1 : reserves in Pricing.quoteSwap and _priceEdgeHop). There is no “effective” or “virtual” depth: the denominator is the balance, so those two senses need no further disambiguation.

12. One call, not two

A composite swap mutates only the two endpoint reserves. Splitting the same trade into two swap calls makes the intermediate asset an endpoint: its reserves move, its coverage skew shifts the second leg’s mid, and the coverage toll and the S/2 fee are each charged once per call, so the trader pays both twice. Neither the output nor the resulting reserves match. Route an intra-pool trade as one call.

13. Properties

  • Bounded quoting: at most 8 legs and 9 nodes, no routing search, path resolved by two walks to the LCA.
  • Single price surface: the path is unique, so the quote is path-independent by construction.
  • Correlated pairs quote tight regardless of base: the fee, sigma and mark of USDT -> USDC come from that edge, not from the pool numeraire.
  • Unified accounting: one base still backs every asset for measurement, without forcing every price through it.
  • Manipulation-checked, not depeg-checked: per-edge skew plus an agreement band on every node of the path. A parent-depeg breaker would be a separate field and is roadmap, not shipped (section 7.1); do not claim the tree is depeg-safe until it exists.

14. Multi-pool routing (off-chain)

Everything above prices one pool; this section composes several into one swap. Source of truth: the @btr-protocol/sdk router (enumerateRoutes, quoteRoute, rankSwap) and its depth-routing module. Route selection is never on chain: the winner is planned off-chain, then executed either as one Pool.swap per leg or as a single Router.swap carrying the whole path (see Composability §2).

14.1. Single-pool quote law

A pool settles every swap along the endpoints’ anchor path; on flat rosters that is the intra-pool spoke→base→spoke hop: one leg walk in, the toll/spread/fee settlement of §5, one leg walk out. Deeper anchors add interior legs priced at mid (§3). The off-chain replica is quoteExactIn(state, in, out, amt) in @btr-protocol/sdk - the same quartic ladder, dispersion law (Liquidity Shaping §6.2) and _pathSpread composition, so an off-chain quote and the chain agree to integer rounding. A route of one leg is this call.

14.2. Two hops through a shared hub

Pools that list a common token (in practice the hub, USDC.b on every Arc core) compose into a 2-leg route: tokenIn → hub on pool A, hub → tokenOut on pool B. enumerateRoutes builds these by intersecting each pool’s token set (sharedTokens), excluding tokenIn/tokenOut themselves as middles. Legs fill sequentially: leg 2 spends leg 1’s net output (quoteRoute), so the route’s output is exactly what an atomic batch of the two swaps delivers, and the route’s input capacity is the first leg’s reserve clip (maxIn) - downstream legs see less flow, never more.

Hub accounting follows §2: the hub leaves pool A (its coverage there falls, hub κ tolls the first leg’s output) and enters pool B (no toll - the wall is output-only).

14.3. Three hops, only when two hops cannot exist

If no pool pair shares a token, enumerateRoutes falls back to a bridging pool A --x--> M --y--> B over every shared-token pair (x ≠ y). When one hub token is listed on every core, two hops always exist and this arm never fires; rankSwap still prices whatever is enumerated.

14.4. Ranking and splits: marginal water-fill

rankSwap(pools, in, out, amountIn):

  1. Quote every enumerated route at 100% size; rank by output (the singles inspection rows).
  2. Split candidates must be pool-disjoint: the water-fill quotes each route against the pool’s unmutated state, so two routes sharing a pool would each assume they alone draw its full depth - double-counting liquidity enough to pass the gas guard on a plan that then reverts or short-fills. At most maxRoutes (default 3) disjoint routes participate.
  3. waterFill places slices (default 64) equal slices greedily on the route with the best next marginal output. Per-route output is concave (spline impact + inventory skew, capped flat at the reserve clip) and the spread floor is a proportional rate with no fixed activation cost, so equalising marginal price is optimal-in-the-limit; the greedy form is robust across the toll/skew kinks. A route past its clip stops winning slices (its marginal hits ~0); leftover amount is not routable (partial fill).
  4. The split is kept only if it beats the best single route by more than minGainBps (default 5 bps) - the gas guard for the extra leg.

14.5. Depth books compose with the same primitives

The rendered book is not a second model:

  • Direct pairs: aggregateDepthCurves (depthAgg.ts) densifies each pool’s depthCurve polyline onto a 1/2/5 price ladder and merges same-price buckets across pools. depthCurve vertices are pinned by the acceptance invariant quoteExactIn(cumBase).grossOut == cumTok (aimm.ts), so integrating a book side at netPrice reproduces the router’s single-pool amountOut exactly (the rung’s price is the pre-haircut skew price; netPrice carries the half-spread + toll the quote charges) - chart and quote are the same math at different sample densities.
  • Routed pairs (no single pool holds both tokens): aggregatePairDepth (depthRoute.ts) first tries direct pools, then composes one synthetic DepthCurve per enumerated route (aggregateRouteDepthCurves): each leg’s ask/bid ladder is chained input→output in the exact quoteRoute order - leg 2 spends leg 1’s net output, haircuts (half-spread + coverage toll) folded into netPrice - and the composed curves merge mid-outward through the same assembler as direct books. Route-composed candidates are also kept pool-disjoint, for the same double-count reason as the splitter.

Consequence: the winning rankSwap plan and the DepthPanel’s VWAP bands can never disagree about price - both are integrals of the same quartic ladder with the same spread/toll haircuts, and the routed book exists precisely when rankSwap finds a cross-pool route.