Flow Guards
Two attack timescales: a callback re-entering mid-operation inside one transaction, and a searcher depositing ahead of a known swap and withdrawing after it across a block. Then two circuit breakers on price itself: the base parity halt against nominal 1e18, and the feed-relative reference band every spoke must arm before it can be listed.
| Layer | Mechanism | Scope | Attack vector | Storage |
|---|---|---|---|---|
| L1 | Reentrancy mutex | Same transaction | Callback reentrancy | EIP-1153 transient |
| L1b | Flash-in-flight flag | Same transaction | Repay-via-deposit principal double-count | EIP-1153 transient |
| L2 | Per-holder receipt cooldown | Cross-transaction | JIT liquidity / MEV bundles | Persistent, one slot per holder |
1. Layer 1: reentrancy guard (transaction level)
1.1. What it protects against
Reentrancy: an external contract calls back into the pool before the running operation completes.
1.2. Implementation (EIP-1153)
// TransientGuard.sol
abstract contract TransientGuard is ReentrancyGuardTransient {
// Solady gates its transient path on mainnet by default; this override
// forces TSTORE/TLOAD on every chain.
function _useTransientReentrancyGuardOnlyOnMainnet()
internal view virtual override returns (bool) { return false; }
}Pool inherits TransientGuard (TransientGuard.sol), which overrides Solady’s mainnet-only default so
TSTORE/TLOAD is forced on every chain, and marks every external entrypoint nonReentrant: deposit,
withdraw, swap_qe, swapLiability, donate and the rest. The mutex is Solady’s slot, not a
hand-rolled one: 0 = unlocked, 1 = operation in progress, cleared at the end of the transaction. A
second, slot-isolated transient flag blocks reserve-crediting inflows while a flash callback is in
flight (§2). Marks are not cached in transient
storage: a pool call loads each leg’s mark word once at its start and reads it from memory.
1.3. What the reentrancy guard does not prevent
Same-block attacks that use no callback: JIT liquidity bundles, multi-transaction atomic sequences in one block, sandwiches, and oracle staleness attacks. Those need Layer 2, or the oracle gates (Mark Relayer).
2. Layer 1b: flash-loan reserve guard (transaction level)
2.1. What it protects against
A flash loan pushes tokens out via flashSend without debiting reserves; the reserves are made whole when the borrower repays. So the borrower must not be able to “repay” through a path that credits reserves, which would count the same principal twice: once as the outstanding loan, once as a fresh deposit.
Attack (CRITICAL, now blocked): an ERC-3156 borrower, inside the flash callback, calls deposit() (or donate/swap_qe, any reserve-crediting inflow) to “repay”. The pool credits the reserve for the deposit and treats the flash as repaid, so the attacker keeps the loan principal. Proven with a PoC (PoolFlashExploit.t.sol, EvilBorrower) and pinned by a regression test.
2.2. Implementation (EIP-1153)
A dedicated transient flag marks the window while a flash callback runs, and the single reserve-crediting chokepoint (PoolIOLib.pull, used by deposit, donate, swap_qe and swapCoop_cd: its only four call sites) refuses to run while it is set:
// libraries/PoolIOLib.sol
uint256 private constant FLASH_INFLIGHT_SLOT =
0x9b4f3bbfca54a0e6e7a1f989e7a8421747090cf08b7f435d15e27a960bfc0532; // keccak256("btr.pool.flashInFlight.v1")
function enterFlash() internal { assembly { tstore(FLASH_INFLIGHT_SLOT, 1) } }
function exitFlash() internal { assembly { tstore(FLASH_INFLIGHT_SLOT, 0) } }
function requireNoFlash() internal view {
uint256 v; assembly { v := tload(FLASH_INFLIGHT_SLOT) }
if (v != 0) revert Err.InvalidState();
}
// pull(), the reserve-crediting inflow chokepoint, is gated at its top:
function pull(...) internal returns (uint256) {
requireNoFlash(); // blocked while a flash callback is in flight
// ...
}PoolLiquidityLib.flashSend calls enterFlash() before pushing the loan and flashAccount calls exitFlash() after settlement, so the flag is set for exactly the callback window.
Two properties follow:
- Slot-isolated from Solady’s
ReentrancyGuard(distinct keccak namespace), so the two guards compose without interference. - The repayment path is unaffected: legitimate ERC-3156 repayment is a plain
transfer/approve, which never routes throughpull.
2.3. Why it is distinct from layer 1
The reentrancy guard bounds re-entry into the same operation. The flash guard bounds reserve credits while a loan is out. A flash callback is an intended external call, not reentrancy, so the mutex never fires on it and the reserve guard is the specific defense.
3. Layer 2: flow guard (block-level MEV protection)
3.1. What it protects against
JIT (Just-In-Time) liquidity is an MEV strategy in four steps:
- Observe a pending large swap in the mempool.
- Deposit liquidity in the block ahead of it.
- Capture swap fees from the large trade.
- Withdraw liquidity in the same block, in a third transaction ordered after the victim’s.
The shape is live on this AMM: a swap’s LP fee is booked into the leg’s liabilities and raises
liquidityIndexWad (PoolLiquidityLib._raiseIndex, called from PoolIOLib.settle and the flash and
cross-withdraw paths), so a depositor present for the swap earns a pro-rata share of it.
Step 4 is not atomic-achievable against a third party’s swap. It needs three transactions in one
block (deposit, victim, withdraw), which a bundle builder can order but a single transaction cannot
contain, and the flash-callback shape that would collapse it into one is blocked: withdrawTo calls
requireNoFlash (PoolLiquidityLib.sol). The attacker holds real inventory for at least one block.
This extracts value from long-term LPs at one block of price risk.
3.2. Protected flows
Every outflow of a freshly minted receipt is covered, all at one enforcement point:
| Flow | Entry | Exit | Where enforced |
|---|---|---|---|
| Deposit → withdraw | deposit() | withdraw(), withdrawTo() | LPToken._beforeTokenTransfer (burn is the redeem path) |
| Deposit → rebalance out | deposit() | swapLiability() | LPToken._beforeTokenTransfer |
| Deposit → transfer out | deposit() | LPToken.transfer | LPToken._beforeTokenTransfer |
The cooldown is not the only exit gate on a cross-asset withdrawTo: a cross exit is a liability
swap plus a same-asset exit, so it also needs LIABILITY_SWAP_ENABLED_BIT on both legs. A leg with
liability swaps off refuses cross exits; a same-asset exit is never gated by a switch, only by a halt.
4. How the time-based flow guard works
4.1. Architecture
Each leg receipt (LPToken, one clone per (pool, leg)) holds locks[holder] = {uint32 stamp, uint224 frozen}. mint stamps block.timestamp and adds the minted quantity to frozen, resetting it when the previous lock has already expired. _beforeTokenTransfer then bounds outflow by balance - frozen until stamp + cooldown, so the guard covers withdraw (a burn), swapLiability and plain ERC-20 transfers alike. A lock older than MAX_FLOW_COOLDOWN_SECS is short-circuited without reading the pool at all, since no live window can reach that far.
The window is the pool’s own flowCooldownSecs, read by STATICCALL: one source of truth, no per-token copy to keep in sync.
The lock freezes an amount, not an account: a dust deposit routed through an ERC-4626 wrapper locks the dust, not the wrapper’s whole pooled balance. Minting to an arbitrary recipient is therefore forbidden; otherwise a third party could dust-mint once per window and hold a victim’s whole recent balance frozen indefinitely.
4.2. Time-based, not block-based
The window is measured in seconds, so it is identical across EVM chains with different block times.
5. Configuration
5.1. Default value
uint16 constant DEFAULT_FLOW_COOLDOWN_SECS = 15; // 15 seconds15 s spans several blocks on every target chain and stays inside a normal user round-trip.
5.2. Admin control
The owner adjusts the cooldown through Admin:
Admin.setFlowCooldown(pool, 30); // 30 seconds. RAISE-ONLY on this laneThe guard cannot be disabled. PoolConstantsLib.MIN_FLOW_COOLDOWN_SECS = 1, and both writers hold the
value in [MIN_FLOW_COOLDOWN_SECS, MAX_FLOW_COOLDOWN_SECS]: PoolConfigLib.setFlowCooldown and the
flowCooldownSecs field of PoolConfigLib.setFeeParams each revert InvalidInput on 0. Swap and
flash fees move the liquidity index, so with no cooldown an LP could deposit, wait for a
known-inbound swap, and withdraw the fee.
The two lanes differ by direction. setFlowCooldown is raise-only (it also reverts below the live
value), because a raise is defensive and applies instantly. A lowering re-opens that fee skim for
every outstanding lock at once, so it rides the UPDATE_FEES payload and pays the TUNING delay.
5.3. Maximum value
PoolConstantsLib.MAX_FLOW_COOLDOWN_SECS = 300 seconds, enforced at both writes (PoolConfigLib.setFlowCooldown and setFeeParams revert InvalidInput above it). The cap exists because the guard gates ERC-20 transfers of a live receipt: an unbounded uint16 would let one untimelocked admin key freeze every holder for s, about 18.2 h.
6. Error handling
A cooldown violation reverts Err.CooldownActive(). The lock is public (LPToken.locks(holder)), so an integrator sizes an exit from balance - frozen rather than guessing at a remaining-seconds argument.
7. Defense-in-depth architecture
7.1. L1 vs L2 comparison
| Property | Reentrancy guard (L1) | Flow guard (L2) |
|---|---|---|
| Scope | Same transaction | Cross-transaction |
| Duration | Single function call | flowCooldownSecs (default 15 s, max 300 s) |
| Attack vector | Callback reentrancy | JIT liquidity / MEV |
| Storage | Transient TLOAD/TSTORE flag | Persistent {stamp, frozen} per holder |
| Gas | ~100 per TSTORE/TLOAD, ~100-200 per operation | ~22,100 to arm a holder’s first lock, ~5,000 on every later one; ~2,100 to check (cold SLOAD), ~100 warm |
| Frequency | Every external call | Entry + exit check |
| Cleanup | Automatic at end of transaction | None needed; the stamp expires |
Neither layer covers the other’s case. Without L1, a malicious token re-enters withdraw() from its transfer callback during deposit() and pulls funds out mid-operation. Without L2, the same attacker needs no callback, only three transactions ordered by a builder.
Arm cost splits on whether mint writes the packed {stamp, frozen} slot from zero:
| Case | Slot transition | Cost |
|---|---|---|
| Holder’s first deposit into this leg | zero → non-zero | 20,000 SSTORE_SET + 2,100 cold access = ~22,100 |
| Every later deposit | non-zero → non-zero | 2,900 SSTORE_RESET + 2,100 cold access = ~5,000 |
The slot never returns to zero (_beforeTokenTransfer is view and nothing clears it on expiry), so a holder pays the 22,100 exactly once per leg and 5,000 thereafter. mint additionally STATICCALLs the pool for flowCooldownSecs, so the true arm cost is a little above both figures.
The exit check writes nothing: a view SLOAD, ~2,100 cold and ~100 warm, plus the same STATICCALL only when the lock is live and younger than MAX_FLOW_COOLDOWN_SECS.
A holder’s first deposit into a seeded leg costs about 219k (Pool §12), so the arm is about 10% of the first deposit and about 2% of every later one. The reentrancy guard stays under 1% in both cases.
7.2. Layer 2 storage
// LPToken.sol
struct Lock { uint32 stamp; uint224 frozen; }
mapping(address => Lock) public locks;stamp (uint32, good until year 2106) and frozen pack into one slot, so the check is one SLOAD. The window itself is a single uint16 flowCooldownSecs in the Pool’s own default storage, no ERC-7201 namespacing; each pool proxy is a fresh storage space, so pools and leg receipts cannot collide.
8. What flow guards do not protect against
8.1. Layer 1 limitations (reentrancy guard)
- Multi-step attacks that require user interaction outside the callback.
- Cross-contract reentrancy: pool → A → B → pool.
8.2. Layer 2 limitations (time-based flow guard)
- Long-term statistical arbitrage: a depositor who stays a day still earns that day’s fees.
- Information-based trading: traders with alpha on future prices; not MEV extraction.
- Cross-pool attacks: coordination across multiple pools.
- Off-chain coordination: multiple wallets controlled by one entity.
- Oracle manipulation: handled by the oracle layer, not here.
8.3. Complementary defenses
| Layer | Mechanism | Prevents |
|---|---|---|
| L1: Reentrancy | Transient storage flag | Callback attacks during operation |
| L1b: Flash guard | Transient flash-in-flight flag | Repay-via-deposit principal double-count |
| L2: Flow guard | Timestamped cooldowns | JIT liquidity and MEV bundling |
| L3: Pricing | Inventory-skew mid shift + convex coverage toll (spread itself is symmetric) | Toxic/coverage-worsening trades |
| L4: Oracle | External keeper mark: allowlisted relayers, 120 s blob age, TTL + confidence gates, staleness surcharge, push deviation clamp | Price feed manipulation (no internal TWAP) |
| L5: Volatility | Spread adjustment | Exploitation during uncertainty |
9. Base-token depeg halt: motivation
The base token is the pool’s unit of account: reserves, liabilities and every
QUOTE_UNIT_UOA mark resolve into it. If the base loses its peg, each leg’s mark stays correct
in isolation while every quote that passes through the base is wrong by the size of the depeg. Two
breakers close that gap: the base parity halt against nominal 1e18, and the feed-relative reference
band every spoke must arm before it can be listed.
AIMM prices every asset off a fresh external keeper mark (deviation + heartbeat), not an internal TWAP or pool-reserve state (see Mark Relayer). Quoting off the fresh mark removes classical curve LVR; the comparison against slow Chainlink-primary designs is Foundations §19.2. Residual risks are push-latency LVR and OEV around the push itself (Mark Relayer).
A QUOTE_UNIT_UOA mark is divided by the base’s own price to reach anchor units, and on a flat roster the base is the interior node of every cross. A base that moves from $1.00 to $0.95 misprices every quote resolving through it by 5%, and the spoke marks cannot detect that the unit of account has shifted. The risk is sharpest on any deployment target with a single block producer, where a sequencer-aware adversary can pin the base-token price across multiple blocks.
The solution: the base token carries a depeg feed compared against nominal 1e18 parity, gating every quote, while each asset continues to quote off its own external mark. The base depeg feed is a circuit-breaker, not the price source.
10. Depeg halt mechanism
10.1. Configuration: there is no separate base-oracle authority
The base token is priced through its normal, timelocked OracleConfig, like every spoke. There is no Admin.setBaseTokenOracle, no per-pool “base oracle” slot, no 1e18-pinned mode, and no untimelocked halt knob (no such function exists anywhere in the deployed contract set; PoolConstantsLib.sol states the rule in-source).
PricingLib._readBasePriceOrHaltgates the base’s mark word (MarkWordLib.gate: halted, stale, dead, uncertain) before the parity check; an unsynced or unregistered lane fails as stale. A pool whose base has no mark cannot quote at all. The halt is not opt-in.- The base must be EXTERNAL mode. The halt reads the base mark against the outside world, so the base cannot be this pool quoting itself.
PoolConfigLibrejects an INTERNAL base at config time (validateOracleMode,Err.InvalidInput) and at base-migration time (Err.InvalidConfig); those are the selectors to expect in each place. - Replacing the oracle is a fleet upgrade (
GOVERNANCE): the mark store lives in the Pool implementation and moves with it, for every leg on the chain at once; a leg’sOracleConfignames one store lane and nothing moves a single leg. There is no faster lane.
10.2. Halt threshold
The band is per asset: a 4-bit code in RiskConfig.flags bits 12-15 (stored in Asset.refBandBps
bits 12-15), read as code·250 bps (PoolConstantsLib.DEPEG_STEP_BPS). On the base, code 0 reads as
2,500 bps and BASE_DEPEG_MAX_CODE = 10 caps it there; a raise queues as UPDATE_RISK. This is the
canonical statement of the band.
With the live base mark decoded to 1e18, and the base’s band in bps:
The whole implementation is one reader, PricingLib._readBasePriceOrHalt (PricingLib.sol):
basePrice = MarkWordLib.gate(baseWord); // halted, stale, dead, uncertain
uint256 devBps = ((basePrice > SC.WAD ? basePrice - SC.WAD : SC.WAD - basePrice) * SC.BPS) / SC.WAD;
uint256 lim = baseWord >> MarkWordLib.DEPEG_SHIFT;
lim = lim == 0 ? 2500 : lim * C.DEPEG_STEP_BPS;
if (devBps > lim) revert Err.BaseDepegged(basePrice, devBps);The same code on a spoke with a USD-attested mark (a UOA leg, or an INTERNAL leg’s gate mark)
reverts PriceOutsideRefBand(mark, WAD) instead: an absolute USD depeg halt beside the relative
reference band. It measures nothing on an anchor-attested leg, whose mark is not in USD.
One reader, three call sites. A single reader is the invariant: a second base-price reader that decoded without the deviation check would let a depegged base rescale whatever it feeds.
| Call site | Why |
|---|---|
PricingLib._cacheEndpoint | The base is a swap endpoint. |
PricingLib._quotePath | The base is an interior hop. Cannot hardcode 1e18 when a real oracle is pinned. |
PricingLib._legMarkAndFees | A QUOTE_UNIT_UOA leg mark is divided by inline to reach anchor units. A stale, dead, uncertain or depegged base halts before it can be a denominator. |
PoolIOLib.priceBandGuard needs no base price: it compares the primary mark against the reference mark raw against raw, and both carry the primary’s catalog unit. Nothing is re-denominated there.
Ordering is fail-closed. FeedMathLib.gate runs before the deviation arithmetic (PricingLib.sol), so a paused, stale, zero or over-uncertain base feed reverts with its own selector rather than being read as an in-band price. The transient-cache hit path skips the re-gate: the feed was gated at prime time in the same transaction, and block.timestamp does not move inside a transaction, so the verdict is identical.
10.3. Failure modes
| Scenario | Behavior |
|---|---|
| Base feed paused (guardian fast-freeze) | Revert FeatureDisabled(Err.Resource.FEED) in FeedMathLib.gate, regardless of freshness |
| Base feed stale () | Revert StaleData, on the observed-at clock of Mark Relayer. The base staleness gate is that TTL alone: no sequencer-uptime feed sits in the quote path |
| Base mark decodes to 0 | Revert ZeroValue |
| Base feed confidence over the halt threshold | Revert ThresholdViolation |
| Quote proceeds; each asset prices off its own external mark | |
Every swap that reads the base (an endpoint, an interior hop, or a QUOTE_UNIT_UOA leg, which on a flat roster is every swap) reverts BaseDepegged until the depeg resolves or governance re-points the base oracle through the timelock (§10.5) |
There is no configuration in which the base depeg halt is off while swaps remain live.
Test coverage: DepegHalt.t.sol (within-band, out-of-band, and the interior base-hub branch); PoolMarkDenomination.t.sol (the UOA division path).
Nothing on chain fires below 2500 bps. There is no 5% threshold in the contracts: a 5% or a 20% base depeg quotes exactly as parity does. The off-chain keeper page at 5% is an alert, not a halt.
10.3.1. Inside the band: accepted economics
The halt sits at 25% by design. A base that slips a few percent should keep trading and earning fees; past 25% the unit of account has collapsed and the pool stops. The cost of that choice, between a 5% and a 25% depeg:
- Solvency values the base at par. The base is the numeraire: its reserves and liabilities count one for one, and its mark is compared against
1e18only by the halt.QUOTE_UNIT_UOAlegs divide by the live base mark and stay correctly denominated. QUOTE_UNIT_ANCHORspokes lag. Their feed attests X/base. When the base depegs by , true X/base moves by ; until the pushed mark catches up, a taker buys X at the lagging mark.
With the mark lag error, the leg’s kappaCovBps and its liability, the coverage wall caps one drain at
The drain stops at , where the marginal toll equals the lag.
Illustrative example, with : an 11% depeg the mark has not absorbed gives , so , and .
What bounds it:
| Lever | Effect |
|---|---|
| Keeper pushes | A base move past θ trips the deviation trigger on every X/base feed; each push resets |
| Coverage wall | Caps at any ; a higher lowers it (7.4% of at 300, 3.3% at 1,500, same ) |
| Guardian halt | haltAsset, or batchRiskOp across legs, stops a leg in one transaction |
| Parity halt | Past the base’s depeg band (code·250 bps, 2,500 at code 0) every path through the base reverts BaseDepegged |
10.4. Per-asset price band (depeg guard for spokes)
The base halt covers the numeraire. Every spoke carries its own price band, armed on both endpoint marks and every interior hop by PoolIOLib.priceBandGuardAll: one definition, called from every value-moving path (settle, cross withdrawTo, swapLiability). Endpoint order is immaterial: both must pass, and the guards are pure reverts over a transaction-frozen feed cache. This is the canonical statement of the band.
Feed-relative. With the primary mark, the reference mark and = refBandBps, the guard halts when
The reference is the same lane’s R tier in the one mark store: a second signed attestation of the same pair, under its own roster and EIP-712 domain. Example: XAUT’s primary XAUT-USDC mark checked against the reference tier’s XAUT-USDC mark on the same lane. The revert selector is PriceOutsideRefBand(uint256 markWad, uint256 refWad).
One breaker, one code path. A spoke is bounded relative to an independent attestation of the same pair, never against a number frozen at listing time. A relative band is symmetric, needs no re-denomination through the base price on UOA legs, and leaves nothing to keep in agreement with a second breaker. Where the mark is USD-attested, the leg’s depeg code (§10.2) adds an absolute test against 1.0 in the same priceBandGuard call, reverting PriceOutsideRefBand(mark, WAD). Policy halts beyond that belong to the guardian halt, a human decision on a fast on-chain lever.
Comparisons are numeric. Both marks decode from one mark word to 1e18 WAD, so the comparison is plain numeric; there is no packed-integer ordering hazard to decode around.
Denomination. The ref band compares raw against raw. This is guaranteed at config time: the reference mark carries the primary’s catalog unit, USD when quoteUnit = UNIT_OF_ACCOUNT and the anchor cross otherwise. Same-unit comparison needs no base-price division and therefore no base reader at all.
Reference independence is structural, then a roster property. PoolConfigLib.validateOracleConfig requires the leg’s lane to be registered; every registered lane carries both tiers, each with its own roster and EIP-712 domain, so an armed band always has a distinct reference. The reference is gated by FeedMathLib.gate on every read, so a halted, stale, dead or over-uncertain reference fails closed. Signer disjointness between the two rosters is a deployment obligation nothing on-chain checks (Mark Relayer §4.4).
Every spoke must be bounded, in both modes. PoolConfigLib.requireExternalSpokeBound refuses refBandBps == 0 (NotConfigured(ORACLE, token)) and refBandBps > MAX_EXTERNAL_REF_BAND_BPS = 500 (InvalidInput). An armed ref band is a listing precondition for every non-base leg, EXTERNAL and INTERNAL alike. Only the base is exempt: it carries the parity halt of §10.2 instead. Route-level ceilings on the composed band: Quotes & Routing.
This is the spoke-side complement to per-spoke coverage skew: skew widens on a drift, the band halts past a hard limit.
Tests: AimmInvariants.t.sol::test_refBand_halts_out_of_band_swap, ::test_refBand_halts_input_asset_swap, ::test_refBand_stale_reference_feed_fails_closed, ::test_refBand_halts_cross_withdraw, ::test_refBand_halts_cross_withdraw_input_asset, ::test_refBand_stale_reference_feed_halts_cross_withdraw, ::test_refPrimary_independent_reference_bounds_walked_mark.
10.5. Coverage at depth (configuration versus capability)
Coverage tracks topology, and topology is configuration. The tree is general to MAX_DEPTH = 4. On a pool whose anchor column is empty every leg is base-anchored, so a route has at most one interior node and that node is always the base: the parity halt on that one node plus the two endpoint ref bands cover every node on every route. Once anchor cells are filled, priceBandGuardPath supplies the interior coverage. The code is live whether or not a route exercises it; its band behaviour has no market tape until deep routes run.
At MAX_DEPTH = 4 a route may traverse interior pivots that are neither the base nor an endpoint. Those pivots are banded at settlement: PoolIOLib.priceBandGuardPath loops every interior hop and runs the ref band on each one except the base, which is covered by its own parity halt instead (PoolIOLib.sol).
The ref band generalises across the path; the parity halt does not. PricingLib._quotePath walks the interior hops but calls _readBasePriceOrHalt only if (hop == $.baseToken) (PricingLib.sol), so parity tests one mark, the numeraire’s, at most once per route, at any depth. priceBandGuardPath runs on every interior hop.
The parity halt covers the depth-1 case, where the base is the only possible interior node. With anchors filled, every node the path prices through must be sound, and the ref band carries that requirement. An interior node’s mark multiplies into the composed rate, so a depegged wrapper mid-chain is exploitable in the same way as a depegged endpoint. Endpoint-only guarding is therefore insufficient; priceBandGuardPath closes the gap.
The ref band does not become an absolute depeg breaker at any depth. It covers every node, but on each node it measures agreement, not distance from par.
The reference band is a same-unit agreement check between two independent attestations of the same pair (§10.4), and the band measures how far the two disagree. An anchored pair therefore references the anchored pair, never an absolute leg. Pointing an anchored stETH/ETH primary () at a stETH/USD reference () gives a relative deviation of , past any uint16 band: the pool halts on the first swap and stays halted.
The per-asset USD depeg band (§10.2) is absolute but reads only a USD-attested mark, so it does not reach an anchor-attested child either.
So an anchored child inherits its parent’s depeg risk with no automatic breaker, and the only lever is the guardian halt: haltAsset, or collapseAnchor to re-root the child toward the base and halt it in the same write. This is a gating constraint on activating any anchored child. See Invariants I-17.
11. Watcher / pause composition
The depeg halt composes with the on-chain incident levers; authority and delays for every lever are in Access control.
- The halt is automatic and on-chain, no off-chain watcher required. Every quote re-reads the base price (§10.2, three call sites).
- The operator levers are per-asset, not pool-wide.
Admin.batchRiskOpsweeps halt/unhalt across many (pool, token) pairs in one transaction with per-leg try/catch, so a whole-pool halt is atomic; it enumerates assets (off-chain viaPoolFactory.getPoolTokens) instead of flipping one flag. - No watcher is required to keep the pool safe, but one should still monitor for
BaseDepeggedreverts so the operator can decide whether to re-point the base oracle (UPDATE_ANCHOR,GOVERNANCE) or migrate the base token (MIGRATE_BASE_TOKEN,GOVERNANCE). The base’s band is its depeg code x 250 bps, at most 2,500 bps; a raise or cut is anUPDATE_RISKop (TUNING). - Keeper page at 5%. The keepers page the operator when the hub’s mark leaves parity by 5%. It is an alert only: no halt, no on-chain write. The pool keeps quoting until 25% (§10.3.1); the page buys time for the guardian to decide whether to halt legs first.
12. Oracle-side guarantees
Both depeg breakers on this page assume the marks feeding them are themselves bounded. That chain is specified elsewhere and not repeated:
- The mandatory per-feed push clamp and its ceiling: Mark Relayer.
- The relayer list on
publishMarksand the 120 s segment age: a signature binds the price, not the order segments land in, so an open push let anyone holding two signed segments push the extreme one, trade, push the other and trade back. The relayers (the keeper accounts) are part of each tier’s roster commitment, so adding or removing one issetAuthatGOVERNANCE(7 days in production), cancellable by a guardian or the owner while pending. There is no instant revoke; a lost relayer key is covered by the others on the list. - The k-of-n signer quorum and its ceremony: Mark Relayer.
- The one property neither contract enforces, signer disjointness between a primary and its reference: Mark Relayer.
The cumulative-manipulation bound of §10.4 holds only on a deployment that satisfies that last one.
13. Related documentation
- Security overview
- Guards
- Mark Relayer - external-mark feed architecture + freshness/confidence gates
- 1.1.4. Spread & Fees
- 1.1.6. Toxic Flow Mitigation
- Foundations §18 Capital efficiency - why the binary halt tradeoff is accepted (curated venue economics)
- Foundations §10 Circular/Orbital market makers - sphere invariant for geometric isolation comparison
- Foundations §19 AMM landscape - peer comparison including depeg-handling axes
- Manifesto §12.2 - hybrid-oracle structural differentiator