Swapping

Routing and pricing are decided entirely off chain. The only on-chain call a swap makes is Pool.swap_qe, one per leg, sent from the user’s own account. What that call charges: Spread & Fees.


1. One quote engine

There is no local pricer. useSwapQuote sends token addresses, never pool state: one POST /v1/chain/quote when one pool holds both tokens, else POST /v1/chain/route for the itinerary plus one /v1/chain/quote per hop of the best for the display metrics. The service reads each pool’s own getSwapQuote through Multicall3.aggregate3, one call per hop layer, all pinned to one block, and returns the floor with it (§5). Chain quotes price at most two hops and no splits. When the service does not answer, the form keeps the last result for that input key or shows the error, never a locally computed number. The SDK’s f64 pricers (quoteExactIn, rankSwap) are deleted (Quotes & Routing).

ConstantValueSite
Debounce before posting400 msuseSwapQuote
Re-poll interval8 s, ×6 in low-data modeuseSwapQuote, pollEvery
HTTP 429 cooldown20 s, global, rejects further postshttpBudget

The depth panel, the LP preview and the LP route plans still price on the book-upload kernels (POST /v1/depth, /v1/quote, /v1/route) over the pool universe useAllPools assembles (API & SDK Reference §3).


2. Route enumeration

A pair whose tokens share a pool is quoted on that pool alone (sharedPoolTag’s first core; a pair listed in two cores is not ranked across them, so the displayed number and the send floor read the same book). Any other pair goes to /v1/chain/route, which prices every itinerary of at most two hops through the hub with getSwapQuote and picks the largest amount_out. /v1/route ranks and splits over pool state the caller uploads: Anchor Path Pricing §14.2–14.3.


3. Fillability is a separate check

Every chain-quote number is the pool’s own getSwapQuote, so a size the pool cannot fill is refused, not priced: /v1/chain/route lists each refused candidate in refused[] (halt, swap disabled, coverage wall, zero output) and picks best from the rest. When nothing fills, the form shows the chain’s reason and blocks the send (“Route can’t fill this size”).

The chain enforces the same wall (Inventory Management §6.2): a gross output up to 2% of the delivering leg’s liabilities past its coverage cap (COV_OVERSHOOT_BPS = 200) is clamped to the cap, keeping the excess input, and a larger one reverts ThresholdViolation. Inside that band minAmountOut cannot catch the overpay, since it derives from the same clamped quote.


4. Slippage becomes minOut

effectiveMaxSlippagePct resolves the slippage setting at quote time:

ModeAllowanceDefaults and bounds
auto (labelled “Spread based”, default mode)(pctOfSpread / 100) · (2 · paidSpreadBps) / 100pctOfSpread default 50, clamped to [10, 300], step 5
fixedfixedPctdefault 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 is used. 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 on a direct leg: the server floor from serverFloors when /v1/chain/* authored one for the delivered token (the app path), else applySlip(quotedOut, slip). A chained part is floored by the server or not at all: with no serverFloors entry for its delivered token planToLegs returns null (slippageFrac never floors a two-hop part), and hop 1 is floored at quoted·(1 - tol_pbps/1e6) under the server’s tolerance, not applySlip.

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. The floor on screen is the floor in calldata

/v1/chain/quote (one pool) or /v1/chain/route (across cores) returns amount_out and min_out = amount_out·(1e6 - tol_pbps)/1e6 from the pool’s own getSwapQuote under the user’s tolerance policy (useServerFloor). The receive row and the minimum received are both read off that answer; while a request is in flight the previous answer stays on screen and the send is refused.

Immediately before the swap calls go out, the form fetches the floor again at the exact input (fetchServerFloor). If the fresh amount_out is below the displayed min_out, it throws (“price moved past your slippage tolerance”) and does not lower the floor; if the price service does not answer, it refuses to send. The displayed floor is then handed to planToLegs as serverFloors and encoded as is: no local out × (1 - slip), no chain read, no min(quoted, fresh).


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_qe(tokenIn, tokenOut, amountIn, minOut, recipient, deadline) per leg, plus WNATIVE.withdraw(Σ minOut) when unwrapping. The deadline is now + 600 s, read at that call, 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:

  • 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_qe 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 falls back to sequential sends.

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 token is itself an ERC-20 pool asset (see deployed instances for which chains do this), 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_qe(address tokenIn, address tokenOut, uint256 amountIn, uint256 minAmountOut, address recipient, uint256 deadline) payable returns (uint256) is nonReentrant, whenInitialized, beforeDeadline. It delegates to PricingLib.swapExactIn, 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 ZeroValue if out == 0 (dust);
  5. revert ThresholdViolation(out, minAmountOut) if out < minAmountOut, before any ledger write;
  6. settle: hook recall, liquidity floor, reserves, LP fee (index raise), protocol fee (treasury shares), depeg band guard on both endpoints and every interior node;
  7. push output, emit Swapped.

Pool.getSwapQuote(address, address, uint256) is the view twin used by §5; the two entries are PricingLib.getAnchorPathQuote and getAnchorPathQuoteView. Leg pricing along the anchor tree: Anchor Path Pricing.

9.1. Revert surface for one leg

RevertConditionSite
Expiredpast deadlinePool.sol
InvalidInputtokenIn == tokenOut; non-zero msg.value on an ERC-20 pull; missing wnativePricingLib.sol, PoolIOLib.sol
ZeroValueamountIn == 0, or amountOut == 0 on dustPricingLib.sol
FeatureDisabled(ASSET | SWAP | FEED)halt mask, swap bit unset, guardian feed haltPoolIOLib.sol, FeedMathLib.sol
NotFound(ASSET), InvalidPath, InvalidAnchor, DepthExceededpath findingAnchorTreeLib.sol
StaleDataage past the per-feed ttlSecsFeedMathLib.sol
ThresholdViolationconfidence > MAX_CONFIDENCE_HALT_BPSFeedMathLib.sol
ThresholdViolation(gross, G*)gross output more than 2% of the output leg’s liabilities past its coverage cap (Inventory Management §6.2)PricingLib.sol
ThresholdViolation(sum, 400)Σ maxDevBps over the route above MAX_ROUTE_MAX_DEV_BPS (Quotes & Routing)PricingLib.sol
BaseDepeggedbase parity outside its depeg bandPricingLib.sol
PriceOutsideRefBanddepeg band against the reference oraclePoolIOLib.sol
Overflowinterior σ swing past MAX_INTERIOR_SWING_PBPS; uint128 reserve overflowPricingLib.sol, PoolIOLib.sol
InsufficientAmountpost-recall liquid reserve < out + minLiquidityPoolIOLib.sol
ThresholdViolation(out, minAmountOut)the slippage floorPricingLib.sol

Halt semantics: Flow Guards. Feed gating: Mark Relayer. The reference band guards a wedged tier, not a compromised one: it bounds a compromised mark only as far as the two tiers’ signer rosters are independent, which the chain does not enforce (Mark Relayer).


10. Known integration gaps

  • 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
legChainQuote, chainRoutefront chainQuote, @btr-protocol/sdkPOST /v1/chain/quote, POST /v1/chain/route
planToLegs, applySlip@sdk/router, @sdk/utilsplan → ExecLeg[], bigint floors
useServerFloor, fetchServerFloorfront useServerFloor, chainQuote/v1/chain/quote / /v1/chain/route min_out, displayed and encoded
buildApprovalCalls, buildSwapExecCalls, buildSwapCalls@sdk/routercalldata composition
useSwapQuotefrontthe form’s chain quote
useAllPools, wirePoolsfront useAllPools, backendQuotepool state for /v1/depth, the /v1/quote LP preview and the LP route plans
resolveBatchMode, sendCalls, sendCallsOrSequentialfrontEIP-5792 and the sequential fallback
chain_api::quote_route, chain_api::route_routebtr-quotechain-mode handlers behind /v1/chain/*
PricingLib.swapExactIn, getAnchorPathQuotedexon-chain execution and quoting