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.
| Engine | Where | Role |
|---|---|---|
@sdk/amm (quoteExactIn, rankSwap) | in the browser, synchronous | first paint, and the fallback |
btr-quote serve_route (btr_core::route::rank_swap) | POST /api/v1/route, Rust integer maths | preferred 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
u128hex; - 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.
| Constant | Value | Site |
|---|---|---|
| Debounce before posting | 400 ms | useSwapQuote |
| Re-poll interval | 8 s | useSwapQuote |
| HTTP 429 cooldown | 20 s, global, rejects further posts | httpBudget |
| Split slices | 64 | rankSwap / SplitOpts |
minGainBps | 5 | idem |
maxRoutes | 3 | idem |
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:
- 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-hop: two pools joined by a shared token (USDC.b in the live fleet).
- 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:
| Mode | Allowance | Defaults and bounds |
|---|---|---|
auto (labelled “Spread based”) | (pctOfSpread / 100) · (2 · paidSpreadBps) / 100 | pctOfSpread default 50, clamped to [10, 300], step 5 |
fixed (default mode) | fixedPct | default 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
bigintamountInUnits, never from thef64plan.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
getSwapQuoteis below the displayed quote, the calldata floor is set lower than the displayed one.
6. Calls composed
buildApprovalCalls emits, in order:
WNATIVE.deposit{value}if any leg wraps the gas token.ERC20.approve(pool, amount), deduplicated per(token, pool).amountis the exact ΣamountIn, orMAX_UINT256whensettings.approveMaxis 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:
unwrapOutwithdraws Σ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.swappulls frommsg.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 reply | Mode |
|---|---|
atomic.status supported or ready (or legacy atomicBatch.supported) | atomic |
atomic.status unsupported, or a bare atomic / atomicBatch entry | batch |
| capabilities RPC fails | unknown: 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:
- not connected
- wrong network
- submitting
- order kind ≠ market
- no amount
- no feed
- unfillable
- markets closed / feed stale
- loading
- gas reserve
- over capacity
- 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:
- resolve wrap sentinels, then the risk-flag gate on both endpoints;
PoolIOLib.pull:transferFrom, ordepositfor native in;getAnchorPathQuote:AnchorTreeLib.findRoutingPath, prime and gate every hop’s feed, walk the legs, apply spread, fee and coverage toll;- revert if
out == 0; - settle: hook recall, liquidity floor, reserves, protocol fee, LP fee, depeg band guard on both endpoints and every interior node;
- revert
ThresholdViolationifout < minAmountOut; - 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
| Revert | Condition | Site |
|---|---|---|
Expired | past deadline | Pool.sol |
InvalidInput | tokenIn == tokenOut; non-zero msg.value on an ERC-20 pull; missing wnative | Pricing.sol, PoolIOLib.sol |
ZeroValue | amountIn == 0, or amountOut == 0 after a full coverage-wall drain | Pricing.sol |
FeatureDisabled(ASSET | SWAP | FEED) | halt mask, swap bit unset, guardian feed halt | PoolIOLib.sol, FeedMathLib.sol |
NotFound(ASSET), InvalidPath, CycleDetected, DepthExceeded | path finding | AnchorTreeLib.sol |
NotConfigured(ORACLE) | no oracle for the asset | Pricing.sol |
StaleData | age past the per-feed ttlSecs | FeedMathLib.sol |
ThresholdViolation | confidence > MAX_CONFIDENCE_HALT_BPS | FeedMathLib.sol |
BaseDepegged | base parity outside the halt band | Pricing.sol |
PriceOutsideRefBand | depeg band against the reference oracle | PoolIOLib.sol |
Overflow | interior σ swing past INTERIOR_SWING_CAP_PBPS; uint128 reserve overflow | Pricing.sol, PoolIOLib.sol |
InsufficientAmount | post-recall liquid reserve < out + protoFee + minLiquidity | PoolIOLib.sol |
ThresholdViolation(out, minAmountOut) | the slippage floor | Pricing.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
routeAsyncfor this. It sentpools: PoolState[]where the deployedbtr-quoteexpects 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.withdrawtakes ΣminOut(§6). The remainder stays with the user in the intermediate or wrapped token.
11. Implementation reference
| Symbol | Package | Role |
|---|---|---|
enumerateRoutes, quoteRoute, rankSwap | @sdk/amm | route shapes, quoting, ranking and splitting |
quoteExactIn | @sdk/amm | the off-chain AIMM replica |
planToLegs, applySlip | @sdk/router, @sdk/utils | plan → ExecLeg[], bigint floors |
refloorLeg | @sdk/router | per-leg floor rebuilt from getSwapQuote |
buildApprovalCalls, buildSwapExecCalls, buildSwapCalls | @sdk/router | calldata composition |
useAllPools, useSwapQuote | front | pool universe, dual quote |
resolveBatchMode, sendCalls, sendCallsOrSequential | front | EIP-5792 and the sequential fallback |
rank_swap / serve_route | btr-quote | the Rust integer ranker behind POST /api/v1/route |
Pricing.swap, getAnchorPathQuote | dex | on-chain execution and quoting |
12. Related documentation
- Spread & Fees: what one
Pool.swapcharges - Slippage & Price Impact: impact, traverse saturation, MEV-protected RPCs
- Anchor Path Pricing: the internal legs of a single
Pool.swap - Pool: the module and its full ABI surface
- Quotes & Routing: SDK entry points for integrators