Swapping

This page traces a market swap from the moment an amount is typed into the app to the Swapped event, and names the function that owns each step. It is written for integrators reproducing the flow and for anyone debugging a swap that did not send, did not fill, or reverted. Routing and pricing are decided entirely off chain; the only on-chain call a swap makes is Pool.swap, one per leg, sent from the user’s own account. The economics of what that call charges live in Spread & Fees.


1. Two quote engines, one law

The quote is computed twice for the same input.

EngineWhereRole
@sdk/amm (quoteExactIn, rankSwap)in the browser, synchronousfirst paint, and the fallback
btr-quote serve_route (btr_core::route::rank_swap)POST /api/v1/route, Rust integer mathspreferred result once it lands for the exact input key

useSwapQuote returns backend ?? sdk. The backend path is on by default: USE_BACKEND_QUOTE is import.meta.env.VITE_USE_BACKEND_QUOTE !== '0', so it is opt-out, not opt-in. A stale comment in the same hook claims the opposite; the constant and the return statement are authoritative.

Both engines implement the same routing law with identical split parameters, so they agree. The number the app displays is normally the Rust one; the TS replica is what you see on first paint and what you get when the backend is unreachable.

The request carries a full pool snapshot:

  • the quartic curve header, packed;
  • reserves and liabilities as native-raw u128 hex;
  • mark as WAD hex;
  • per spoke, the σ, confidence, stale excess, κ and decimals.

The pool universe itself (reserves, ExternalOracle marks, NX fallback mids, per-asset profile and σ) is assembled once by useAllPools and shared by every quote path.

ConstantValueSite
Debounce before posting400 msuseSwapQuote
Re-poll interval8 suseSwapQuote
HTTP 429 cooldown20 s, global, rejects further postshttpBudget
Split slices64rankSwap / SplitOpts
minGainBps5idem
maxRoutes3idem

A throw or a timeout on /route leaves the previously held result for that key, or drops to the SDK result. No minAmountOut is ever computed server-side.


2. Route enumeration

enumerateRoutes emits three shapes, in this order of preference:

  1. Direct: any pool holding both tokens. The internal anchor-path hop (spoke→base→spoke on Arc’s flat roster, any tree path in general) is Pool.swap’s own business and is never a second transaction (Anchor Path Pricing).
  2. 2-hop: two pools joined by a shared token (USDC.b in the live fleet).
  3. 3-hop: through a bridging pool, emitted only when no 2-hop exists.

rankSwap quotes every route at 100% size and sorts by output. Split candidates must be pool-disjoint: waterFill quotes against unmutated pool state, so two routes sharing a pool would each assume the full depth. Overlapping routes are excluded outright rather than mispriced. A greedy 64-slice marginal water-fill allocates size across the survivors, and the split is kept only if it beats the best single route by more than 5 bps.


3. Fillability is a separate check

Ranking is by output only, so a plan through a drained leg can win on price, observed on a live instance: USDC.b → WBTC ranked a split through a $2.3k stable hub above a healthy direct cross. Both quote paths therefore walk the ranked singles for one that actually fills (routeFills / planFill) before returning. If none does, the quote returns unfillable plus a book-capacity ceiling instead of a price, and the form blocks the send (“Route can’t fill this size” / “Over book capacity”).

This is a client-side protection and has no chain-side twin. An over-capacity leg does not revert: the pool keeps the whole input and pays out its exhausted reserve, and minAmountOut cannot catch it because it was derived from that same clamped number. See Slippage & Price Impact §2.5.


4. Slippage becomes minOut

effectiveMaxSlippagePct resolves the setting at quote time:

ModeAllowanceDefaults and bounds
auto (labelled “Spread based”)(pctOfSpread / 100) · (2 · paidSpreadBps) / 100pctOfSpread default 50, clamped to [10, 300], step 5
fixed (default mode)fixedPctdefault 0.5%, clamped to [0, 100] then snapped to geometricLadder(0.01, 100) (1-2-5 per decade)

pctOfSpread is a percentage of the round-trip spread, so the 50% default is exactly the half-spread, which is what one swap is charged (Spread & Fees §5). With no book to scale against (paidSpreadBps null or 0: a cross-pool route whose legs never touch the display base, or the standalone form pop-out) the fixed value silently stands in. coerceSlippage reads any non-auto mode back as fixed.

planToLegs then turns the winning plan into ExecLeg[]:

  • parts sorted largest-first;
  • each part’s input carved from the exact bigint amountInUnits, never from the f64 plan.amountIn;
  • minOut = applySlip(quotedOut, slip) per leg.

applySlip works in bigint at 1e-6 granularity and rounds down. planToLegs throws for slippageFrac outside [0, 1), NaN included, so a 100% allowance, which is the top of the fixed ladder, cannot be submitted.

On a cross-pool part, leg2.amountIn = leg1.minOut with chained: true. Leg 2 is funded by leg 1’s floor, not its actual output; the positive remainder stays with the user in the intermediate token.


5. Floors are rebuilt against the chain before sending

The off-chain model is not bit-exact with Pricing._quotePath (measured 1.04 bps rich on Arc), and deliverable output decays with wall-clock even at a frozen mark, because _staleTerm is σ·√(age − grace) evaluated at block.timestamp (Spread & Fees §3.4). So immediately before the swap calls go out (after any approval has mined), refloorAgainstChain calls Pool.getSwapQuote(tokenIn, tokenOut, amountIn) per leg, chained legs priced off the previous leg’s new floor, and sets

minOut = applySlip(min(quotedOut, freshOut), slip)

If the live quote has fallen through applySlip(quotedOut, slip) entirely, the form throws (“price moved X against you…”) rather than lowering the floor. An RPC that cannot be read keeps the promised floor and is reported as unreadable, never as fine.

The User Guide §6 states that min received is always quoted output × (1 − max slippage). That is the number on screen. The floor that reaches calldata is the expression above: when the pool’s live getSwapQuote is below the displayed quote, the calldata floor is set lower than the displayed one.


6. Calls composed

buildApprovalCalls emits, in order:

  1. WNATIVE.deposit{value} if any leg wraps the gas token.
  2. ERC20.approve(pool, amount), deduplicated per (token, pool). amount is the exact Σ amountIn, or MAX_UINT256 when settings.approveMax is on (default off).

buildSwapExecCalls emits Pool.swap(tokenIn, tokenOut, amountIn, minOut, recipient, deadline) per leg, plus WNATIVE.withdraw(Σ minOut) when unwrapping. The deadline is now + 600 s, read at that call and not at plan time, because on the two-phase path the approvals mine first. buildSwapCalls bakes one shared deadline and is used only for the atomic path.

Composing the wrap in the user’s own batch rather than delegating it has two consequences:

  • unwrapOut withdraws Σ minOut, not the quoted output, so positive slippage stays with the user as wrapped native instead of reverting the batch.
  • Multicall3 cannot stand in, because Pool.swap pulls from msg.sender, which would be Multicall3.

Approvals are probed with a single aggregate3 multicall of ERC20.allowance(user, pool) per unique (token, pool); a failed leg is treated as needing approval, and wrap legs are skipped since their allowance is created inside the same batch. A missing approval never disables the button; the label becomes “Approve & Swap”. A 350 ms-debounced copy of the same probe drives that label.

Only plain ERC-20 approve is supported. There is no EIP-2612 or Permit2 path.


7. Sending

Atomic-required vs two-phase. nativeIn || nativeOut sets requireAtomic: one bundle, and the send is refused, not degraded, if Settings → Batch approve+tx is off or the wallet cannot do an atomic batch on this chain, because a landed wrap beside a reverted swap would strand funds. Every other swap runs two phases: approvals (awaiting mining on the sequential fallback), then the re-floor of §5, then the swap calls with a fresh deadline.

Batch mode. resolveBatchMode reads wallet_getCapabilities for the current chain:

Capability replyMode
atomic.status supported or ready (or legacy atomicBatch.supported)atomic
atomic.status unsupported, or a bare atomic / atomicBatch entrybatch
capabilities RPC failsunknown: probe atomic, then non-atomic

sendCalls posts wallet_sendCalls v2.0.0 {from, chainId, atomicRequired, calls} and polls wallet_getCallsStatus every 1.5 s up to 120 times (~3 min) before reporting “Batch confirmation timed out”. Status 400/500/600/FAILED throws, and a reverted receipt is replayed through eth_call to decode the reason. Only an unsupported-method class error (4200, -32601, 5760) degrades to sequential eth_sendTransaction; a user rejection or a revert never silently sequentialises.

Pre-flight simulation. Before any wallet prompt, sendCallsOrSequential eth_call-simulates the last call of the bundle with the real sender. A revert blocks the send with a decoded reason and no prompt, except for an allowance-shaped revert when an approve call is pending earlier in the same bundle. The sequential path re-simulates each call at its own send time.

Batching is best-effort for non-native swaps. requireAtomic is false there, so a wallet without wallet_sendCalls (or with the setting off) sends the legs as separate sequential transactions: a cross-pool 2-hop can land leg 1 and fail leg 2, leaving the user holding the hub token. Only native-in/out swaps refuse to degrade.


8. Submit gating

submitState resolves one reason in this priority order:

  1. not connected
  2. wrong network
  3. submitting
  4. order kind ≠ market
  5. no amount
  6. no feed
  7. unfillable
  8. markets closed / feed stale
  9. loading
  10. gas reserve
  11. over capacity
  12. insufficient balance

Balance is compared in base units against the spendable balance. Where the gas balance is an ERC-20 (Arc USDC), the reserve is subtracted first (BATCH_GAS_UNITS = 400,000 with a 0.002-token floor), otherwise a max-size swap reverts TransferFromFailed paying its own fee.

Limit and stop orders are not implemented. The deep-linked order kinds render “Limit orders not available yet”, disable the button, and the submit handler toasts and returns.


9. On chain

Pool.swap(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, address recipient, uint256 deadline) payable returns (uint256) is nonReentrant, whenInitialized, beforeDeadline. It delegates to Pricing.swap, which runs:

  1. resolve wrap sentinels, then the risk-flag gate on both endpoints;
  2. PoolIOLib.pull: transferFrom, or deposit for native in;
  3. getAnchorPathQuote: AnchorTreeLib.findRoutingPath, prime and gate every hop’s feed, walk the legs, apply spread, fee and coverage toll;
  4. revert if out == 0;
  5. settle: hook recall, liquidity floor, reserves, protocol fee, LP fee, depeg band guard on both endpoints and every interior node;
  6. revert ThresholdViolation if out < minAmountOut;
  7. push output, emit Swapped.

Pool.getSwapQuote(address, address, uint256) is the view twin used by §5; the two entries are Pricing.getAnchorPathQuote and getAnchorPathQuoteView.

9.1. Revert surface for one leg

RevertConditionSite
Expiredpast deadlinePool.sol
InvalidInputtokenIn == tokenOut; non-zero msg.value on an ERC-20 pull; missing wnativePricing.sol, PoolIOLib.sol
ZeroValueamountIn == 0, or amountOut == 0 after a full coverage-wall drainPricing.sol
FeatureDisabled(ASSET | SWAP | FEED)halt mask, swap bit unset, guardian feed haltPoolIOLib.sol, FeedMathLib.sol
NotFound(ASSET), InvalidPath, CycleDetected, DepthExceededpath findingAnchorTreeLib.sol
NotConfigured(ORACLE)no oracle for the assetPricing.sol
StaleDataage past the per-feed ttlSecsFeedMathLib.sol
ThresholdViolationconfidence > MAX_CONFIDENCE_HALT_BPSFeedMathLib.sol
BaseDepeggedbase parity outside the halt bandPricing.sol
PriceOutsideRefBanddepeg band against the reference oraclePoolIOLib.sol
Overflowinterior σ swing past INTERIOR_SWING_CAP_PBPS; uint128 reserve overflowPricing.sol, PoolIOLib.sol
InsufficientAmountpost-recall liquid reserve < out + protoFee + minLiquidityPoolIOLib.sol
ThresholdViolation(out, minAmountOut)the slippage floorPricing.sol

Halt semantics are in Depeg Halt and Flow Guards; feed gating is in Oracles. Note that the refFeedId band does not defend against a compromised push quorum: mark and reference oracles carry the same three attesters and the same key secrets, as the code comment at PoolIOLib.sol records.


10. Known integration gaps

  • The SDK used to export routeAsync for this. It sent pools: PoolState[] where the deployed btr-quote expects the wire pool shape of §1, and camelCase field names where it requires snake_case, so it 400’d on every call. It had no callers and has been removed; the app has always had its own client (Quotes & Routing).
  • Positive slippage is not returned on chained or unwrapped paths: leg 2 is sized on leg 1’s floor (§4) and WNATIVE.withdraw takes Σ minOut (§6). The remainder stays with the user in the intermediate or wrapped token.

11. Implementation reference

SymbolPackageRole
enumerateRoutes, quoteRoute, rankSwap@sdk/ammroute shapes, quoting, ranking and splitting
quoteExactIn@sdk/ammthe off-chain AIMM replica
planToLegs, applySlip@sdk/router, @sdk/utilsplan → ExecLeg[], bigint floors
refloorLeg@sdk/routerper-leg floor rebuilt from getSwapQuote
buildApprovalCalls, buildSwapExecCalls, buildSwapCalls@sdk/routercalldata composition
useAllPools, useSwapQuotefrontpool universe, dual quote
resolveBatchMode, sendCalls, sendCallsOrSequentialfrontEIP-5792 and the sequential fallback
rank_swap / serve_routebtr-quotethe Rust integer ranker behind POST /api/v1/route
Pricing.swap, getAnchorPathQuotedexon-chain execution and quoting