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
| Need | Call | Then |
|---|---|---|
| Price by token address, no state to read | POST /v1/chain/quote or /v1/chain/route (API & SDK Reference §2.4) | planToLegs with serverFloors → buildSwapCalls |
| Best fill across listed pools on a chain | POST /v1/route | planToLegs → buildSwapCalls |
| The same path, one transaction, one signature | POST /v1/route | build Part[] + Floor[] → Router.swap (example) |
| Price one leg exactly | Pool.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
Pool.swap_qe pulls tokenIn from msg.sender:
- Multicall3 cannot run swaps. Under
Multicall3.aggregate3,msg.senderis the Multicall3 contract, which holds neither the balance nor the allowance. Calls must originate from the user’s own account. - 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:
planToLegsfloors each direct leg atslippageFracof its quoted output.applySlipis the same helper for flooring amounts yourself; it resolves from@btr-protocol/sdk/utilsand 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
serverFloorsentry for the delivered token (/v1/chain/routemin_out)planToLegsreturnsnull. Hop 1 is floored atquoted·(1 - tol_pbps/1e6)under the server’s tolerance and itsminOutis leg 2’samountIn; 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
planToLegsagain 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’srefBandBpsover 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. RevertsThresholdViolation(sum, 600).MAX_ROUTE_MAX_DEV_BPS= 400. Σ of every leg’sIOracle.FeedData.maxDevBpsover the path, checked at quote time only:maxDevBpsbelongs to the oracle admin, so a config-time sum reads a number the checked party can change a block later. RevertsThresholdViolation(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. RevertsInvalidConfig.
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.
| Mint | Redeem |
|---|---|
market-first: swap_qe then deposit | withdrawTo: cross-asset exit in one call |
deposit-first: deposit then swapLiability | swapLiability 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:
- A pool appears at most once per route. Inside one pool
A→Cis already a single leg, soA→B→Cthrough the same pool settles the path twice and is strictly worse. - 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.
- No token is visited twice, and a route stops at
max_hopslegs: 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):
- Quote every enumerated route at 100% and rank by output net of cost.
SwapCostscarries 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. - The best
max_routes(default 3) singles are split candidates, and they may share a pool.water_fillhandsslices(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. - 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::Offnever splits;SplitMode::Onignoresmin_gain_bpsbut 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 , 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 / (pre-fee, pre-toll), the net touch / (after fee and coverage toll), the mid, mark, spread, the densified ladder, and the weight = total quoted size.
- The touch is an extremum, not a mean. A taker routes to the best pool: , , 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 : reference quantities, not executable prices. Invariant (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 . 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 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 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 , ask buckets ceil above ). The bucket width comes from each side’s rung extent, touch to far end, per pool, not from the distance to mid.