---
title: "Swapping"
description: "From the /swap form to the chain: two quote engines, off-chain route ranking, minOut re-floored against the pool, EIP-5792 batching, and one Pool.swap per leg."
audience: both
type: explanation
status: live
lang: en
updated: "2026-08-29"
publish: true
---
# 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](/docs/1-1-4-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 `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.

| 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:

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](/docs/1-1-3-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](/docs/1-1-5-slippage-price-impact#25-the-buy-side-traverse-saturates-and-depth-cannot-stop-a-drain).

---

## 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](/docs/1-1-4-spread-fees#5-the-fee)). 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](/docs/1-1-4-spread-fees#34-staleness-surcharge)). 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

```text
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](/docs/4-1-swapping#4-slippage-becomes-minout) 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 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:

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

| 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](/docs/3-5-depeg-halt) and [Flow Guards](/docs/3-3-flow-guards); feed gating is in [Oracles](/docs/3-4-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](/docs/5-2-2-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

| 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](/docs/1-1-4-spread-fees): what one `Pool.swap` charges
- [Slippage & Price Impact](/docs/1-1-5-slippage-price-impact): impact, traverse saturation, MEV-protected RPCs
- [Anchor Path Pricing](/docs/1-1-3-anchor-path-pricing): the internal legs of a single `Pool.swap`
- [Pool](/docs/1-2-1-pool): the module and its full ABI surface
- [Quotes & Routing](/docs/5-2-2-quotes-routing): SDK entry points for integrators