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.
- 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 isMAX_PATH_LENGTH = 2 * MAX_DEPTH + 1 = 9nodes and2 * MAX_DEPTH = 8legs, 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.
| Swap | Path | Legs |
|---|---|---|
| child to parent | stETH -> WETH | 1 |
| siblings under a common parent | USDT -> USDC -> DAI | 2 |
| deep leaf to the root | sUSDe -> USDe -> USDT -> USDC -> WBTC | 4 |
| across the root, both sides deep | stETH -> WETH -> WBTC -> USDC -> USDT -> USDe -> sUSDe | 6 |
worst case at MAX_DEPTH = 4 | two depth-4 leaves under different root children | 8 |
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 fromPricing._settleQuote).dQ == 0on 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
| Leg | Mark | Skew | Spline impact | Reserves |
|---|---|---|---|---|
| endpoint | parent-per-child mark | yes | yes | yes |
| interior | parent-per-child mark | yes | no | no |
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 is untouched), idempotent, and exact but for the 1 Q-unit
an odd sum cannot split. Consequence: the stored offset never exceeds for every stored
shape, so no read path corrects for the fit and no admission bound on is
needed: the shift pins it at 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
is the fence, the leg’s mid swing, its curve’s span, its live
dispersion, its dispRefPbps, and = 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 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 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, , 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 ) 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:
| Alternative | Measured | Why it fails |
|---|---|---|
| Clamp interior legs against interior reserves | -9900 bp | Caps 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 interiors | no-op | The 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 path | no-op | The 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 , 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 , so any shape below is extractable.
- Ratio-correct, sized per crossing. Fencing each crossing at its own live dispersion lets the cycle net : 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
uint16spread 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:
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_eNever 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.
| Term | Aggregation | Site |
|---|---|---|
minFeePath | sum over all legs, each leg’s floor already raised to max(minFee_i, fence_i) on an interior leg | _walkLegs, floor at _priceInteriorLeg |
sigmaSqPath | quadrature, , one sqrt at the end | _walkLegs, rooted at _pathSpread |
riskPath (confidence + staleness) | sum over all legs of | _walkLegs |
| upper bound on the composed spread | none, 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
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 = 0is the norm;QUOTE_UNIT_UOA = 1is the unit-of-account bridge, and a bridged leg’sminFeemust cover2 * (theta_child + theta_parent)(PoolConstantsLib.sol). ReadQUOTE_UNIT_UOAas the instruction “divide by the base mark”, not as a unit: it is legal only while the asset anchors directly to the base (PoolConfig.solrejects 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/refBandBpsinOracleConfig, run on every interior node byPoolIOLib.priceBandGuardPathand on both endpoints byPoolIOLib.priceBandGuard, plusBASE_DEPEG_HALT_BPS = 500on 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.s10history, its own Parkinson sigma, and its own keeper push trigger withtheta_bpsderived 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_legsfor 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, soMAX_PATH_LENGTH = 2 * MAX_DEPTH + 1 = 9nodes and 8 legs (section 1).Err.DepthExceededand a cycle error are live reverts, not decoration.validateAnchorwalks to the root with both an explicitcurrent == assetcycle 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.priceBandGuardPathloops the interior hops and runspriceBandGuardon each one except the base, andPoolIOLib.priceBandGuardruns on both endpoints, so every priced node of the path carries an agreement check. The parity halt does not generalize:_readBasePriceOrHaltis gated onhop == $.baseToken(Pricing.sol), so it tests the base mark against1e18atBASE_DEPEG_HALT_BPS = 500and 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 returnsm^2and leaksm^2 - 1per 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:
| Gate | Requirement |
|---|---|
| The interior-leg pricing law (section 3) independently audited | must pass before the first fill |
| The cross pair served by NX Rates as a first-class series with its own and its own push trigger | registration 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 descriptor | required 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:
| Item | State | Citation |
|---|---|---|
Parent-depeg breaker (absFeedId or a split refFeedId) | field does not exist | section 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
CRITICALtier (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_MASKon 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 -> USDCcome 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/sdkrouter (enumerateRoutes,quoteRoute,rankSwap) and its depth-routing module. Route selection is never on chain: the winner is planned off-chain, then executed either as onePool.swapper leg or as a singleRouter.swapcarrying 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):
- Quote every enumerated route at 100% size; rank by output (the
singlesinspection rows). - 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. waterFillplacesslices(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).- 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’sdepthCurvepolyline onto a 1/2/5 price ladder and merges same-price buckets across pools.depthCurvevertices are pinned by the acceptance invariantquoteExactIn(cumBase).grossOut == cumTok(aimm.ts), so integrating a book side atnetPricereproduces the router’s single-poolamountOutexactly (the rung’spriceis the pre-haircut skew price;netPricecarries 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 syntheticDepthCurveper enumerated route (aggregateRouteDepthCurves): each leg’s ask/bid ladder is chained input→output in the exactquoteRouteorder - leg 2 spends leg 1’s net output, haircuts (half-spread + coverage toll) folded intonetPrice- 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.