Quotes & Routing

A quote or route call returns a plan; the SDK turns that plan into approve and Pool.swap_qe legs; the wallet submits them as an EIP-5792 batch or as sequential transactions. Route SELECTION never happens on chain: it is a global search over every pool’s curve, coverage and marks, and running it on chain would cost more than the spread it saves. A chosen path settles leg by leg as above, or whole through Router.swap for one signature and an all-or-nothing fill (Composability §2). Venues, and therefore plans, resolve per chain.


1. When to use what

NeedCallThen
Price by token address, no state to readPOST /v1/chain/quote or /v1/chain/route (API & SDK Reference §2.4)planToLegs with serverFloors → buildSwapCalls
Best fill across listed pools on a chainPOST /v1/routeplanToLegs → buildSwapCalls
The same path, one transaction, one signaturePOST /v1/routebuild Part[] + Floor[] → Router.swap (example)
Price one leg exactlyPool.getSwapQuote on chain, or POST /v1/quote off chain (canonical sample)Pool.swap_qe

Single-pool swap_qe() is correct for a fixed venue: an aggregator hop, or arbitrage against one pool. For end-user swap UX, route.

The SDK’s @btr-protocol/sdk/amm f64 replica was removed; there is no offline pricer. Anything settling value prices off Pool.getSwapQuote (exact, on chain) or POST /v1/route and POST /v1/quote (exact, the same integer kernel off chain; canonical sample).


2. Pipeline

read pool state on chain

POST /v1/route

SwapPlan / legs

planToLegs (applies per-leg minOut)

buildSwapCalls: wrap? approve* swap* unwrap?

wallet_sendCalls, or N sequential txs

Pool.swap_qe pulls tokenIn from msg.sender:

  1. Multicall3 cannot run swaps. Under Multicall3.aggregate3, msg.sender is the Multicall3 contract, which holds neither the balance nor the allowance. Calls must originate from the user’s own account.
  2. Every leg of a split is funded from the user directly, so each needs its own approval.

3. HTTP

Host and full path list: API & SDK Reference.

POST /v1/route is a stateless ranking kernel: you supply the pricing state of every pool to consider, it returns the best single route and the best split across them. Reading that state on chain is your job (kernel contract).

curl -s https://api.btr.markets/v1/route \ -H 'content-type: application/json' \ -d '{ "pools": [{ "tag": "<pool tag>", "base": "<base symbol>", "base_decimals": <decimals>, "spokes": [ /* SpokeWire, each with decimals or address */ ] }], "token_in": "USDC", "token_out": "WETH", "amount_in": "0x…" }'

Wire rules, the full NamedPoolWire / SpokeWire shape and the response schema are canonical in API & SDK Reference §3, not restated here. Every leg needs its decimals resolvable (rule and error shape); passing decimals explicitly is the cheaper path, since the resolver otherwise looks each address up.

The response ranks routes and computes no minOut. Slippage is applied client-side, per leg (§5).


4. TypeScript

import { planToLegs, buildSwapCalls, wirePlanToSwap } from '@btr-protocol/sdk/router'; import { routeAsync } from '@btr-protocol/sdk/amm'; // 1. Addresses. /v1/venues is the source of truth: never hardcode. const venues = await fetch('https://api.btr.markets/v1/venues').then((r) => r.json()); const chain = venues[chainId]; // 2. Read pool state on chain, then POST it for ranking. `wires` is the NamedPoolWire[] built // from `pools` (NamedPool[], see poolStateToWire); the wire answer is snake_case, so // wirePlanToSwap turns it into the SwapPlan planToLegs reads. const res = await routeAsync({ pools: wires, token_in: 'USDC', token_out: 'WETH', amount_in: `0x${amountIn.toString(16)}`, }); const { plan } = wirePlanToSwap(pools, { tokenIn: 'USDC', tokenOut: 'WETH', amountIn: Number(amountIn) / 10 ** decimalsOf('USDC') }, res, decimalsOf); // 3. Plan → legs → calls. Single-hop plans only: a two-hop part needs `serverFloors` from // /v1/chain/route or planToLegs returns null. const legs = planToLegs(plan, { slippageFrac: 0.005, // 50 bps per direct leg tokenOf: (sym) => chain.tokens[sym], isOfficialPool: (pool) => official.has(pool.toLowerCase()), // from PoolFactory.isOfficialPool nativeIn: true, // user pays in the gas token amountInUnits: amountIn, // exact bigint - see below }); if (!legs) throw new Error('unroutable: missing pool address or token meta'); const calls = buildSwapCalls(legs, { recipient: user, sender: user, // required when a leg unwraps native wrappedNative, // NOT served by /v1/venues - see below deadline: BigInt(Math.floor(Date.now() / 1000) + 600), }); // 4. EIP-5792 where the wallet supports it; otherwise send calls[i] in order. await wallet.sendCalls?.({ calls: calls.map((c) => ({ to: c.to, data: c.data, value: c.value })), });

quoteLegAsync and routeAsync are thin SDK clients over /v1/quote and /v1/route, on the snake_case wire of §3; signatures in API & SDK Reference §5.2.

wrappedNative does not come from /v1/venues: its contracts map (API & SDK Reference §4.3) has no wrapped-native entry, so chain.contracts.wnative is undefined and buildSwapCalls builds a wrap call to nowhere. Source the wrapper address yourself, and only when you set nativeIn / nativeOut. Where the gas token is itself a listed pool asset, the wrap step most chains need does not apply to that asset’s legs at all.

Always pass amountInUnits. Without it the input leg is rebuilt from plan.amountIn, an f64 that cannot hold 18 decimals: a balance of 31049999999999999999 wei round-trips to 31050000000000000000, one wei above the balance, and transferFrom reverts. The approval is built from the same inflated figure, so it matches and hides the cause.

Single pool, no route:

import { getSwapQuote, swap, defaultDeadline } from '@btr-protocol/sdk/pool'; const q = await getSwapQuote(provider, pool, tokenIn, tokenOut, amountIn); const minOut = (q.amountOut * 9950n) / 10000n; // 50 bps await swap(provider, pool, { tokenIn, tokenOut, amountIn, minAmountOut: minOut, recipient: user, deadline: defaultDeadline(), });

5. Depth and slippage

POST /v1/depth builds a ladder for a UI order book from the same pricing kernel as /v1/quote. Its rows are JSON floats for rendering, not settlement arithmetic.

Slippage is applied per leg, never once across the plan:

  • planToLegs floors each direct leg at slippageFrac of its quoted output. applySlip is the same helper for flooring amounts yourself; it resolves from @btr-protocol/sdk/utils and the package root alike.
  • Splits fund each part from the user, so each part carries its own independent minOut.
  • Two-hop parts are floored by the server or not at all: without a serverFloors entry for the delivered token (/v1/chain/route min_out) planToLegs returns null. Hop 1 is floored at quoted·(1 - tol_pbps/1e6) under the server’s tolerance and its minOut is leg 2’s amountIn; leg 2 carries the part’s slice of the end-to-end server floor. This is conservative: leg 1 usually returns more than its floor, and the remainder stays with the user rather than stranding in the second hop.
  • A plan that sat long enough to go stale is re-quoted, not re-floored: call planToLegs again on the fresh plan.

Sizing guidance and the tolerance bands: Basic Operations §7. Mechanics of the spread itself: Slippage & Price Impact · Spread & Fees.


6. Path ceilings a route can trip

Three pool-side guards bound a composed anchor path. They apply to any multi-leg swap, Router or not, and a plan that trips one reverts rather than quoting wide.

  • MAX_ROUTE_REF_BAND_BPS = 600. Σ of every leg’s refBandBps over the path, checked at config and at quote (PoolConfigLib, PoolIOLib). It admits two crypto legs or four stable legs and refuses an eight-leg crypto chain. Reverts ThresholdViolation(sum, 600).
  • MAX_ROUTE_MAX_DEV_BPS = 400. Σ of every leg’s IOracle.FeedData.maxDevBps over the path, checked at quote time only: maxDevBps belongs to the oracle admin, so a config-time sum reads a number the checked party can change a block later. Reverts ThresholdViolation(sum, 400).
  • requireDistinctFeeds. No feed id may repeat among the children on one side of the turn. Across the turn a shared feed cancels exactly, so it is allowed; on one side the same error is counted twice in the same direction. Reverts InvalidConfig.

7. LP dual routes

Mint and redeem each have two viable strategies; which one wins depends on live inventory. rankDeposit and rankRedeem (@btr-protocol/sdk/router) rank them; buildDepositCalls and buildRedeemCalls turn the winner into calls. LP mechanics: Providing Liquidity.

MintRedeem
market-first: swap_qe then depositwithdrawTo: cross-asset exit in one call
deposit-first: deposit then swapLiabilityswapLiability then withdraw

rankDeposit returns a single ranked plan object, not an array. Worked examples: Cookbook §3.


8. Route search and depth aggregation

Source of truth: the btr-core route module (enumerate_routes, quote_route, rank_swap), served by btr-quote as POST /v1/route and POST /v1/depth. Every leg is quoted by Pricing::quote_exact_in, the integer mirror of PricingLib.sol, so a route of one leg is a single-pool quote. Vocabulary: route and hop name this inter-pool axis only (Anchor Path Pricing §11).

8.1. Enumeration

enumerate_routes deepens iteratively over the pool/token graph, shortest route first, under three rules:

  1. A pool appears at most once per route. Inside one pool A→C is already a single leg, so A→B→C through the same pool settles the path twice and is strictly worse.
  2. An interior token must be held by at least two pools. A token listed in one pool is only ever an endpoint, so the search grows with the shared assets, not the listed ones.
  3. No token is visited twice, and a route stops at max_hops legs: default 2 (DEFAULT_MAX_HOPS), ceiling 4 (MAX_HOPS_CEIL). At most 256 routes are emitted (MAX_ROUTES_ENUM), shortest-first, so the cap drops the long tail.

Legs fill sequentially: leg 2 spends leg 1’s net output (quote_route), so a route’s output is what an atomic batch of its swaps delivers. The hub leaves pool A (its coverage there falls, hub κ tolls the first leg’s output) and enters pool B (no toll: the wall is output-only).

8.2. Ranking and splits

rank_swap(pools, token_in, token_out, amount_in, opts):

  1. Quote every enumerated route at 100% and rank by output net of cost. SwapCosts carries gas per leg plus per-part and approval overhead for each part beyond the first, in out-token raw units supplied by the caller, so a 2-leg route that grosses more than the direct one wins only if it clears the extra call.
  2. The best max_routes (default 3) singles are split candidates, and they may share a pool. water_fill hands slices (default 64) equal slices to the route with the best current marginal output and applies each slice’s book move to a working copy of the pools, so two routes through one pool degrade each other as they would on chain.
  3. The allocation is re-priced sequentially in execution order, and the split is kept only if it beats the best single by more than min_gain_bps (default 5) and by more than its extra parts cost. SplitMode::Off never splits; SplitMode::On ignores min_gain_bps but still refuses a split that is worse net of cost. The first candidate whose every part fills its allocated size wins.

8.3. Depth books

POST /v1/depth does not densify curves: it sweeps the same serve_quote that answers /v1/quote, samples rungs per side per pool (default 48), so each rung’s net price is what that size fills at. A pair one pool holds reads that pool’s hub-spoke book; a pair no single pool holds is composed from enumerated routes, with max_hops clamped as /v1/route clamps it. Capacity is the delivering side: the bid ladder tops out at the hub’s base reserves, the ask ladder at the spoke reserve. The route plan and the rendered book cannot disagree about price, because both are the same integer quote read at different sizes.

8.4. Aggregating depth across pools

aggregateDepthCurvesAsync (@btr-protocol/sdk) builds the venue-level book behind the swap depth panel and chart liquidity bands. The chain quotes one pool at a time. Each pool has its own coverage, hence its own skew mid mp, so two pools on the same pair do not share a mid; the gap is the inter-pool arbitrage, not a spread.

Per pool the aggregator keeps the skew touch bp / ap (pre-fee, pre-toll), the net touch bp~ / ap~ (after fee and coverage toll), the mid, mark, spread, the densified ladder, and the weight wp = total quoted size.

  • The touch is an extremum, not a mean. A taker routes to the best pool: b=maxp∈Bbp, a=minp∈Aap, identically on the net touch. The sets hold only pools quoting that side, so a one-sided (reserve-clipped) pool cannot drag the side it does not quote. A size-weighted touch prints a bid below the best bid and an ask above the best ask, which no router would route to.
  • Sizes add on the ladder. Rungs from different pools in the same price bucket are summed. Venue mid, mark and spread are depth-weighted means m¯=∑mpwp/∑wp: reference quantities, not executable prices. Invariant b≤m¯≤a (empty side not binding), enforced by clamping the mean into the touch interval.
  • Pre-fee the touch can cross. Each pool’s sides meet at its own mid, so with distinct mids b>a. That is the cross-pool arbitrage, not a spread. The executable statement is the net touch, which crosses only when mid dispersion exceeds the two crossing costs; consumers gate on a~>b~ before drawing a cost band.
  • The ladder is priced on the net basis. Each rung takes the executable price at its own size (half the path spread plus the toll evaluated there), so a binding toll widens the ladder with depth. Sizes stay gross, since cum·m is not monotone into the coverage wall.
  • No rung is priced through the touch. Each pool’s buckets open strictly beyond its own net touch (bid buckets floor below bp~, ask buckets ceil above ap~). The bucket width comes from each side’s rung extent, touch to far end, per pool, not from the distance to mid.