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.

LayerMechanismScopeAttack vectorStorage
L1Reentrancy mutexSame transactionCallback reentrancyEIP-1153 transient
L1bFlash-in-flight flagSame transactionRepay-via-deposit principal double-countEIP-1153 transient
L2Per-holder receipt cooldownCross-transactionJIT liquidity / MEV bundlesPersistent, 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.

TokenPoolUserTokenPoolUserInconsistent statedeposit()transferFrom()callback deposit()

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, 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). Transient-cache helpers (oracle feeds, flash state) live in the dex-local TransientCacheLib.

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 (Oracle keeper).


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, any reserve-crediting inflow) to “repay”. The pool credits the reserve for the deposit and treats the flash as repaid, so the attacker walks away with 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 and swap: its only three 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 // ... }

PoolLiquidity.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 through pull.

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:

  1. Observe a pending large swap in the mempool.
  2. Deposit liquidity in the block ahead of it.
  3. Capture swap fees from the large trade.
  4. 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 (PoolLiquidity.accrueLpFee, 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 (PoolLiquidity.sol). The attacker holds real inventory for at least one block.

This extracts value that should go to long-term LPs, at one block of price risk rather than none.

3.2. Protected flows

Every outflow of a freshly minted receipt is covered, all at one enforcement point:

FlowEntryExitWhere enforced
Deposit → withdrawdeposit()withdraw(), withdrawTo()LPToken._beforeTokenTransfer (burn is the redeem path)
Deposit → rebalance outdeposit()swapLiability()LPToken._beforeTokenTransfer
Deposit → transfer outdeposit()LPToken.transferLPToken._beforeTokenTransfer
PoolAttackerPoolAttackerWithout Flow Guard (3 tx, 1 block)With Flow Guardtx1 deposittx2 victim swap, LP fee raises the indextx3 withdrawdeposit starts timerwithdraw BLOCKED

4. How the time-based flow guard works

4.1. Architecture

LPToken

locks[holder] = stamp,frozen

PoolStorage

flowCooldownSecs

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 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 under the variable block times of different EVM chains rather than tracking validator behaviour.


5. Configuration

5.1. Default value

uint16 constant DEFAULT_FLOW_COOLDOWN = 15; // 15 seconds

15 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 Admin.setFlowCooldown(pool, 0); // disables the JIT guard entirely

flowCooldownSecs = 0 disables the flow guard outright. Load-bearing, since swap and flash fees move the liquidity index: with no cooldown an LP can deposit, wait for a known-inbound swap, and withdraw the fee.

5.3. Maximum value

Constants.MAX_FLOW_COOLDOWN = 300 seconds, enforced at the write (PoolConfig.setFlowCooldown reverts 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 216-1 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

PropertyReentrancy guard (L1)Flow guard (L2)
ScopeSame transactionCross-transaction
DurationSingle function callflowCooldownSecs (default 15 s, max 300 s)
Attack vectorCallback reentrancyJIT liquidity / MEV
StorageTransient TLOAD/TSTORE flagPersistent {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
FrequencyEvery external callEntry + exit check
CleanupAutomatic at end of transactionNone 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, that same attacker needs no callback at all, only three transactions a builder will order for them.

Arm cost splits on whether mint writes the packed {stamp, frozen} slot from zero:

CaseSlot transitionCost
Holder’s first deposit into this legzero → non-zero20,000 SSTORE_SET + 2,100 cold access = ~22,100
Every later depositnon-zero → non-zero2,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.

Against a ~100-200k gas deposit that is 11-22% on a holder’s first deposit into a leg and 2.5-5% on 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 beacon 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)

  1. Multi-step attacks that require user interaction outside the callback.
  2. Cross-contract reentrancy: pool → A → B → pool.

8.2. Layer 2 limitations (time-based flow guard)

  1. Long-term statistical arbitrage: a depositor who stays a day still earns that day’s fees.
  2. Information-based trading: traders with alpha on future prices; not MEV extraction.
  3. Cross-pool attacks: coordination across multiple pools.
  4. Off-chain coordination: multiple wallets controlled by one entity.
  5. Oracle manipulation: handled by the oracle layer, not here.

8.3. Complementary defenses

LayerMechanismPrevents
L1: ReentrancyTransient storage flagCallback attacks during operation
L1b: Flash guardTransient flash-in-flight flagRepay-via-deposit principal double-count
L2: Flow guardTimestamped cooldownsJIT liquidity and MEV bundling
L3: PricingInventory-skew mid shift + convex coverage toll (spread itself is symmetric)Toxic/coverage-worsening trades
L4: OracleExternal keeper mark: TTL + confidence gates, staleness surcharge, push deviation clampPrice feed manipulation (no internal TWAP)
L5: VolatilitySpread adjustmentExploitation 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 Oracle keeper). Quoting off the fresh mark removes classical curve LVR; the comparison against slow Chainlink-primary designs is §12.3. Residual risks are push-latency LVR and OEV around the push itself (Oracle keeper).

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 numéraire that silently moves from $1.00 to $0.95 misprices every quote resolving through it by 5%, and the pool’s fresh spoke marks cannot themselves tell 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, exactly 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).

  • Pricing._readBasePriceOrHalt reads $.oracleConfigs[$.baseToken] and reverts NotConfigured(ORACLE, base) when cfg.primary == address(0) (Pricing.sol). A pool whose base has no oracle 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. PoolConfig rejects an INTERNAL base at config time (validateOracleMode, Err.InvalidInput) and at base-migration time (Err.BadConfig); those are the selectors to expect in each place.
  • Replacing the oracle implementation is one OracleBeacon.requestUpgradeLISTING delay → executeUpgrade for every leg on the chain at once; a leg’s OracleConfig names one immutable proxy address and nothing moves a single leg. There is no faster lane.

10.2. Halt threshold

Constants.BASE_DEPEG_HALT_BPS = 500 (5%), PoolConstantsLib.sol. This is the canonical statement of the band; every other page quoting 500 bps refers here.

With pb the live base mark decoded to 1e18 and W=1018:

δb=|pb-W|·104W>500revertBaseDepegged(pb,δb)

The whole implementation is one reader, Pricing._readBasePriceOrHalt (Pricing.sol):

(bool found, IOracle.FeedData memory feed) = TCache.tryLoadFeed(TCache.TYPE_ORACLE_FEED, base); if (found) { basePrice = FeedMathLib.mark(feed); // gated at prime time, same tx, same verdict } else { IPool.OracleConfig storage cfg = $.oracleConfigs[base]; if (cfg.primary == address(0)) revert Err.NotConfigured(Err.Resource.ORACLE, base); feed = IOracle(cfg.primary).getFeed(cfg.feedId); basePrice = FeedMathLib.gate(feed); // PAUSED + STALE + DEAD + UNCERTAIN } uint256 deviation = basePrice > SC.WAD ? basePrice - SC.WAD : SC.WAD - basePrice; uint256 devBps = (deviation * SC.BPS) / SC.WAD; if (devBps > uint256(C.BASE_DEPEG_HALT_BPS)) revert Err.BaseDepegged(basePrice, devBps);

One reader, three call sites, and having exactly one is the invariant. A second base-price reader that open-coded the decode without the deviation check would let a depegged base silently rescale whatever it feeds.

Call siteWhy
Pricing._cacheEndpointThe base is a swap endpoint.
Pricing._quotePathThe base is an interior hop. Cannot hardcode 1e18 when a real oracle is pinned.
Pricing._legMarkAndFeesA QUOTE_UNIT_UOA leg mark is divided by pb 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, so nothing can be rescaled wrongly.

Ordering is fail-closed. FeedMathLib.gate runs before the deviation arithmetic (Pricing.sol), so a paused, stale, zero or over-uncertain base feed reverts on its own terms rather than being read as an in-band price. The transient-cache hit path skips the re-gate deliberately: 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

ScenarioBehavior
Base feed paused (guardian fast-freeze)Revert FeatureDisabled(Err.Resource.FEED) in FeedMathLib.gate, regardless of freshness
Base feed stale (a>τ)Revert StaleData, on the observed-at clock of Oracle keeper. The base staleness gate is that TTL alone: no sequencer-uptime feed sits in the quote path
Base mark decodes to 0Revert ZeroValue
Base feed confidence over the halt thresholdRevert ThresholdViolation
Base oracle unconfiguredRevert NotConfigured(ORACLE, base) (Pricing.sol). Not a bypass
δb500 bpsQuote proceeds; each asset prices off its own external mark
δb>500 bpsEvery 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: PoolBaseDepeg.t.sol (within-band, out-of-band, and the interior base-hub branch); PoolMarkDenomination.t.sol (the UOA division path).

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) rather than pasted per site. Endpoint order is immaterial: both must pass, and the guards are pure reverts over a transaction-frozen feed cache. There is exactly one form, and this is its canonical statement.

Feed-relative. With p the primary mark, pref the reference mark and bref = refBandBps, the guard halts when

|p-pref|pref·104>bref

refFeedId is keccak256(abi.encodePacked(base, quote)) on the reference ExternalOracle, not a MITCH u64; the keeper maps MITCH tickers to keccak feed ids off-chain. Example uses: WBTC against the BTC feed, XAUT against a gold feed. The revert selector is PriceOutsideRefBand(uint256 markWad, uint256 refWad).

One breaker, one code path. No per-asset absolute price bound exists: 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. Policy halts that an absolute bound would express belong to the guardian halt, a human decision on a fast on-chain lever.

Comparisons are numeric. Both marks are the oracle’s 1e18 WAD (getFeed returns a decoded mark, mark1e18), so the comparison is plain numeric; there is no packed-integer ordering hazard to decode around.

Denomination. The ref band compares raw against raw. That is a config-time contract, not luck: 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 mandatory when armed. PoolConfig.validateOracleConfig requires refFeedId != 0, refPrimary != 0 and refPrimary != primary, and calls getFeed on both to prove reachability. The reference is gated by FeedMathLib.gate on every read, so a dead or over-uncertain reference fails closed rather than anchoring the band to a corpse price. Address inequality is the on-chain floor only; signer and admin disjointness is a deployment obligation nothing on-chain checks, and the deployments that satisfy it are enumerated in Oracle keeper.

Every spoke must be bounded, in both modes. PoolConfig.requireExternalSpokeBound is a single-clause predicate: refBandBps == 0 reverts NotConfigured(ORACLE, token). An armed ref band is a listing precondition for every non-base leg, EXTERNAL and INTERNAL alike. Only the base is exempt, and only because it carries the parity halt of §10.2 instead.

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, ::test_refPrimary_zero_failClosed.

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 - it is live code whether or not a route exercises it yet, and its band behavior has market tape only once 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 is what generalises across the path; the parity halt does not. The two guards scale in opposite directions and the source says so. Pricing._quotePath walks the interior hops but calls _readBasePriceOrHalt only if (hop == $.baseToken) (Pricing.sol), so parity tests exactly one mark, the numeraire’s, and at most once per route, however deep the route runs. priceBandGuardPath runs on every interior hop there is.

The base parity halt is the depth-1 special case of “the pivot must be sound”, correct when the base was the only interior node a route could have. Multi-anchor turns that into “every node the path prices through must be sound”, and the ref band is the guard that carries it. An interior node’s mark multiplies straight into the composed rate, so a depegged wrapper mid-chain is picked off exactly as an endpoint one would be. That is why endpoint-only guarding is not enough and priceBandGuardPath exists.

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.

refFeedId 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 (1.15) at a stETH/USD reference (4000) gives a relative deviation of 99.97%, past any uint16 band: the pool halts on the first swap and stays halted.

Making a parent depeg visible needs a new field, absFeedId plus absBandBps, or a split of refFeedId into an agreement band and an absolute-depeg band. Neither exists.

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, not a limitation that deeper operation would grow out of. 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.batchRiskOp sweeps 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 via PoolFactory.getPoolTokens) instead of flipping one flag. The distinction affects how the sweep is built, not whether it lands.
  • No watcher is required to keep the pool safe, but one should still monitor for BaseDepegged reverts so the operator can decide whether to re-point the oracle, widen the band through governance, or migrate the base token.

12. Comparison vs other designs

12.1. vs OrbSwap (CCMM / Orbital)

OrbSwap’s sphere invariant gives intrinsic geometric depeg isolation: a depegged asset drains asymmetrically through pure curve geometry, no oracle is involved. Within OrbSwap’s domain (pegged-only baskets) this does the work an oracle would do, with no external trust assumption.

AIMM cannot match this for two reasons:

  1. AIMM serves mixed-volatility baskets (stables + LSTs + majors). Volatile assets have no natural peg; the sphere invariant is undefined.
  2. AIMM’s price source is decoupled from reserves (external-mark-driven, not invariant-driven). The reserve geometry that gives OrbSwap its isolation does not exist in AIMM by design, and that decoupling is what gives AIMM regime-adaptive quoting in the first place.

For a stables-only deployment, OrbSwap’s intrinsic isolation is the better tool. AIMM’s base depeg halt applies where mixed volatility makes the sphere unavailable.

12.2. vs Curve V1 amplification penalty

Curve V1’s stableswap invariant uses an amplification coefficient A that flattens the curve near the equal-balance point and reverts to constant-product geometry as imbalance grows. A depegged asset organically drains the pool at progressively worse prices, similar in spirit to OrbSwap but with a 1D rather than n-sphere geometry.

Curve V1’s mechanism is gradual (price impact rises smoothly with imbalance) where the base depeg halt is binary (quote-or-halt at the 500 bps line). Curve loses LPs to depeg arb continuously up to the amplification cliff; AIMM stops the bleeding hard at 500 bps but cannot price the band-edge regime gracefully.

Tradeoff: Curve V1 preserves availability at the cost of LP losses; AIMM’s halt preserves LP value at the cost of availability.

External-only AMMs (some DODO and Swaap variants, am-AMM) quote off a slow Chainlink feed for every price, on every asset. Chainlink updates on a deviation or heartbeat schedule, so the mark is stale between updates and arbitrageurs extract value in the lag windows. The size of that leak is deployment-dependent and is not quantified here.

AIMM is external-mark-primary too, but its mark is a fast keeper push (per-asset deviation band θ + heartbeat), not a slow Chainlink feed: the stale gap is bounded to θ, and any keeper lag past the grace is priced by the staleness surcharge (Spread & Fees §3.4). No Chainlink feed sits anywhere in the quote path, so the heartbeat-lag arb surface does not exist here.


13. 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 10dmax ceiling: Oracle keeper.
  • The k-of-n signer quorum and its ceremony: Oracle keeper.
  • The one property neither contract enforces, signer disjointness between a primary and its reference: Oracle keeper.

The cumulative-manipulation bound of §10.4 holds only on a deployment that satisfies that last one.