API & SDK Reference
Public, unauthenticated, read-only HTTP API. Writes happen on chain from the user’s own wallet: no key, no authenticated surface. Every REST resource is under /v1; three infra paths are not (§2). Below: the path list, the request shapes of every POST, the error model, and the @btr-protocol/sdk entrypoints that resolve.
1. Hosts
https://api.btr.markets/v1 # canonical: use this in all examplesREST: https://api.btr.markets/v1/*. WebSocket: wss://api.btr.markets/stream.
OpenAPI 3.1: https://btr.markets/openapi.json, served from btr.markets only (api.btr.markets/openapi.json is a 404). It covers every path in §2 with the request and response schemas below. https://data.btr.markets also resolves to the gateway, but is not the canonical host; do not build calls against it.
Infra-only ingress rewrites, not user URLs: btr.markets /api/* → gateway, btr.markets /docs-api/* and /blog-api/* → docs service.
Every chain-scoped response carries a chainId. GET /v1/venues has one entry per registered chain; GET /v1/chains says which are live. Write chain handling as a lookup over that map, never a pinned id.
1.1. Conventions
| Property | Value |
|---|---|
| Auth | None. No key, no header, no signed request |
| Rate limit | Per client IP, 600 requests per 60 s window, one budget shared by /v1/md/*, /v1/assets (GET and POST), the chain quotes (/v1/chain/quote, /v1/chain/route) and the book POSTs (/v1/quote, /v1/quote-path, /v1/route, /v1/depth, /v1/indicators, /v1/lp-rows), plus /v1/pools/{tag}/params on an indexer miss. Over budget: 429 {"error":"rate_limited"} with Retry-After in seconds. Other paths are unmetered. No RateLimit-* headers |
| CORS | Allowlist, not wildcard; see below |
| Caching | GET responses are edge-cached for a few seconds; polling faster returns the same body |
| Versioning | One version, /v1, pinned in the path. Fields are added, never removed or retyped. A breaking change would ship under a new major prefix beside /v1, never in place |
| Deprecation | RFC 8594 Deprecation / Sunset headers, sunset ≥180 days out. Nothing is deprecated today, so no such header is emitted |
1.2. CORS is an allowlist
The gateway answers an allowlisted Origin with a matching Access-Control-Allow-Origin. Any other Origin gets 403 {"error":"origin not allowed"}.
Origin: https://btr.markets → access-control-allow-origin: https://btr.markets
Origin: http://localhost:3000 → access-control-allow-origin: http://localhost:3000
Origin: https://your-app.com → 403 {"error":"origin not allowed"}Local development against http://localhost:3000 works; a deployed third-party front end calling the gateway directly from the browser does not. Proxy through your own backend, or ask to have your origin allowlisted. Server-side calls (cURL, Node, Rust, Python) are unaffected: CORS is a browser policy, not a server restriction.
1.3. Error model
Most errors are a JSON object with a single error key:
{"error": "unknown preset 'close'"}Some are text/plain. Do not assume a parseable body; check content-type, or guard your .json() in a try.
| Status | Meaning | Body |
|---|---|---|
400 | Malformed request: unparseable body, or a missing or rejected query value | JSON on /v1/quote, /v1/route, /v1/lp-rows, /v1/timeseries, /v1/oracle/history, /v1/indicators (tf off the supported ladder, or from not in the past); plain text on /v1/depth (unparseable body), /v1/md/ohlc (tf missing), /v1/indicators (symbol or preset missing) and GET /stream |
403 | Browser Origin not on the allowlist (§1.2) | JSON |
404 | No such path, wrong method (the POST endpoints 404 on GET), unregistered chain, unindexed resource, or an unknown symbol on /v1/indicators | JSON; plain text unknown symbol on /v1/md/ohlc/{sym} |
422 | Well-formed request the service refuses: a halted or uncertain leg (/v1/quote, /v1/quote-path), {"error":"no_route","reason":"capacity"} (/v1/route), no depth for pair (/v1/depth), an unknown preset, a GET /v1/indicators series whose newest bar is older than staleAfterSec ({"error":"stale series","ageSec":…,"staleAfterSec":…}, no rows), or a body that failed to deserialize on /v1/depth | JSON; plain text for the /v1/depth deserialize failure |
408 | A POST body not fully read within 5 s | JSON |
413 | A POST body over 1 MiB | JSON |
414 | A request target over 8192 bytes | JSON |
429 | Over the per-IP budget (§1.1) | JSON {"error":"rate_limited"}, Retry-After in seconds |
502 | Upstream failure, or an upstream body over 2 MiB. Not always retryable | plain text, error code: 502 |
503 | A chain registered but not deployed on a chain-scoped read, {"chainId":<id>,"error":"chain <id> not live"}; route planning past its 2 s deadline; /v1/chain/* RPC unavailable (rpc_unavailable: …); a full request lane: {"error":"busy"} with Retry-After: 1 (bulk busy, quote busy, bulk response limit on the bulk and forward paths). Retry after the delay | JSON |
501 | /v1/chain/route with max_hops above 2 or split: true | JSON |
504 | A quote-service request past its deadline (12 s on /v1/chain/*, 10 s on the book POSTs): {"error":"deadline"}. Retry; it is not a verdict on liquidity | JSON |
Request limits that answer 400: /v1/route and /v1/depth refuse more than 64 pools, more than 256 spokes in one pool, more than 512 spokes in total or over 2,000,000 units of estimated search work (planner work limit) and token names over 128 characters (token name limit); /v1/lp-rows refuses more than 256 legs (too many LP legs); /v1/timeseries and its bulk form refuse more than 10,000 points per query or 100,000 per batch (timeseries work limit), over-long or too many query parameters (query parameter limit) and an unparseable from/to (invalid time bound). /v1/chain/route with the same token on both sides is 400 (tokens must differ), not 422.
Status codes are not uniform across the POST endpoints for the same class of failure: a missing-field body returns 400 (JSON) on /v1/quote, /v1/route and /v1/chain/*, 422 (plain text) on /v1/depth. Branch on the status you got, not on the endpoint family. Deserialization failures name the first missing field, so iterate on the message.
A structurally valid request is priced as sent. mark: "0x0" returns 200 with every amount 0x0, and a curve the pricer rejects is 400 {"error":"InvalidInput"}. Reject a zero mark before sending; the chain reverts on it.
2. Path surface
Served by the gateway at https://api.btr.markets. Paths not listed here are internal and unsupported. /health, /ready and /stream are unversioned on purpose: the first two are probes every BTR service answers at the same path, and /stream frames carry a type, so a new frame is a new type, not a new URL. Chain-scoped routes take ?chainId= (decimal; absent: the default chain; unparseable: 400; unregistered: 404; registered, not deployed: 503).
2.1. GET
| Path | Returns | Query |
|---|---|---|
/health | Gateway liveness: 200 once serving, 503 while starting | - |
/ready | 200 when the market-data stream is connected, else 503 | - |
/v1/health | Indexer health for one chain: feed freshness, asset coverage, ingest cursor, every served chain’s readiness | chainId |
/v1/md/tickers | Current mark per provider ticker (§4.1) | - |
/v1/md/tickers/detail | Provider ticker id to base/quote | - |
/v1/md/ohlc/{sym} | OHLC bars | tf (required, seconds), from, to (epoch ms, snapped to the bar grid), limit |
/v1/md/tickers/detail/{sym} | Base/quote for one provider ticker | - |
/v1/md/price/{sym} | Latest price for one symbol | - |
/v1/indicators | One technical-indicator series | symbol, preset (both required); tf (default 30), fast, slow, signal, from (window start, epoch ms; default and cap: the newest 10 000 tf buckets, at most 30 d) |
/v1/assets | Metadata for named assets | addresses (required) |
/v1/assets/pools | Deployed pools, spokes and LP receipts | chainId |
/v1/assets/icons/{file} | Token and network icons (SVG, PNG) | - |
/v1/activity | Recent swap, deposit and withdraw events | limit (1–200, default 200), before, pool, asset, action, payer, receiver, token_in, token_out |
/v1/liquidity | Deposits and withdrawals bucketed per pool, symbol and side | pool, asset, side (deposit, withdraw), grain, from, to |
/v1/abis/{name} | Lean ABI array for one contract | - |
/v1/venues | Deployed contracts, tokens, feeds and pools, keyed by chain id | - |
/v1/summary | Protocol and per-pool totals: TVL, volume, fees, coverage toll (tollUsd, LP revenue outside lpFeesUsd), and at protocol, pool and asset scope feeApr, strategyApr (hook), tollApr (memo, not in the total), apr (fee + hook), apy (compounded; null above 100% APR or under a 24h window, read apr then) and liabilitiesUsd (the rate base). Assets add hooked, venueApr, windowMs, hookWindowMs. null is unknown, never 0 | window (<n>{m,h,d,w} or ms, 1h..365d; default 7d, the same window /v1/pools/metrics uses) |
/v1/pools | Deployed pools + symbol rosters | - |
/v1/pools/metrics | The /v1/summary yield fields, one row per pool asset | window (with a unit, default 7d) |
/v1/timeseries | One metric bucketed | metric (required; an unknown one is a 400 listing all; skew.psi, which never served data, is removed for skew.bps), pool, asset, by=asset, grain (1m to 1d, never finer than the span allows), from, to (epoch ms, span at most 1 year, default 48 h) |
/v1/oracle/history | Feed price history, newest first | feed (required): feed name (WETH-USDC) or feedId; a bare symbol or tickerId returns 404. from, to, limit (1 to 2000, default 200) |
/v1/oracle/feeds | The chain’s live oracle feed registry: name, symbol, token, feedId, tickerId (decimal string), tickerIdHex and decoded MITCH fields, served from the baked deployment records (unregistered chain: 404; registered, not deployed: 503) | chainId |
/v1/chains | Served chain ids, each with default and live flags | - |
/v1/safety/history | Halt and wedge events, decoded, newest first. An unreadable store is 503, never an empty list | from, to (default 30 d), limit (1 to 2000) |
/v1/safety/guards | What the guards keeper attempted, dry runs included (/v1/safety/history shows what landed) | chainId, from, to (default 30 d), limit (1 to 2000) |
/v1/safety/breakers | The automatic breakers: latest gates, trip history | chainId, from, to (default 30 d), limit (1 to 2000) |
/v1/oracle/pushes | Feed push history, paged by a before cursor | feed, tier, before, limit, chainId |
/v1/oracle/pushes/{tx} | One push’s signed payload, by transaction hash | chainId |
/v1/pools/{pair}/density | Fitted target density for one leg, e.g. WETH-USDC | window (14d default, 7d) |
/v1/pools/{tag}/params | One pool’s live risk parameters and every leg’s, read at one block in display units (bps, whole tokens, named flags). A leg’s oracle.gate includes its ref feed (refGate) when the band is armed. fitThetaBps is the density fit’s recommended θ, keyed by symbol across chains and null once fit.stale; it is not the keeper’s running value. Cached 5 s per pool. Unknown tag: 404; a failed chain read: 502 | chainId |
{name} on /v1/abis serves Pool, Admin, Flash, PoolFactory, AccessControl, LPToken, MarkStore and ExternalOracleV4 (legacy oracle, read-only); any other name, Router included, returns 404 {"error":"no such abi"}. The ABIs track current source (Contract Addresses). The same ABIs ship statically in @btr-protocol/sdk/abis (§5.1), a zero-server-trust surface.
2.2. POST
| Path | Purpose |
|---|---|
/v1/chain/quote | One pool’s getSwapQuote from the chain at 1 to 64 sizes, with the server’s floor (§2.4) |
/v1/chain/route | Best itinerary from the chain, with end-to-end floors (§2.4) |
/v1/quote | Price one exact-in leg against supplied leg state |
/v1/route | Rank routes and splits across supplied pool states |
/v1/depth | Build a depth ladder across supplied pool states |
/v1/lp-rows | Ranked LP exit/entry rows plus the debt-swap destination, from supplied pool state (§3.4) |
/v1/timeseries/bulk | Up to 200 timeseries queries in one request (§3.5) |
/v1/quote-path | Price a one- or two-leg path, settled once as the chain settles it (§3.1) |
/v1/indicators | One indicator series over caller-supplied bars (§3.5) |
/v1/assets | Metadata for up to 200 addresses (§3.5) |
GET on /v1/chain/quote, /v1/chain/route, /v1/quote, /v1/quote-path, /v1/route, /v1/depth, /v1/lp-rows or /v1/timeseries/bulk returns 404, a method mismatch, not a missing endpoint. /v1/indicators and /v1/assets serve both methods.
2.3. WebSocket
/stream: live price batches at wss://api.btr.markets/stream. A plain GET returns 400 because the request is not an upgrade. Send {"type":"subscribe","symbols":["ETH-USDC"]} (case and hyphen insensitive, agg:spot: prefix optional; at most 128 per socket, within 30 s of connecting or the socket closes); {"type":"unsubscribe","symbols":[…]} drops them and {"type":"ping","t":…} answers {"type":"pong","t":…}. Updates arrive as {"type":"price_batch","updates":[{"symbol","pair","price","bid","ask"}]}, primed with the current quote on subscribe, then only moved quotes. At most 8 sockets per IP.
2.4. Chain quotes (/v1/chain/*)
Token addresses in. The service resolves the pool from its deployment records and reads every number from Pool.getSwapQuote with one Multicall3.aggregate3 pinned to one block (block in the response): one call on quote, one per hop layer on route. It authors the slippage floor from the same quote. This is the surface the app prices on.
slippage is one of:
{"mode":"spread","pct":10..300}: tolerance as a percentage of the quote’s own spread.{"mode":"relative","pbps":100..999000}: a fixed tolerance.
Out of range is a 400, not a clamp. Every floor satisfies min_out = amount_out·(1e6 - tol_pbps)/1e6.
POST /v1/chain/quote
| Field | Type | Notes |
|---|---|---|
token_in, token_out | string | Token addresses; a symbol is 400 unknown token |
amounts_in | 0x hex[] | 1 to 64 non-zero sizes, native raw units |
slippage | object | See above |
pool | string, optional | Pool tag (btr-stable). Required when two pools hold the pair: 422 ambiguous_pool otherwise |
chain_id | u64, optional | ?chainId= wins; absent both: the default chain |
curl -s 'https://api.btr.markets/v1/chain/quote?chainId=<chainId>' \
-H 'content-type: application/json' \
-d '{"token_in":"0x…","token_out":"0x…","amounts_in":["0xf4240"],
"slippage":{"mode":"spread","pct":50}}'{
"chain_id": <chainId>, "block": {"number": 0, "timestamp": 0}, "source": "chain",
"amounts": [{
"amount_out": "0x…", "spread_pbps": 0, "proto_fee": "0x…", "lp_fee": "0x…", "cov_toll": "0x…",
"mark_price": "0x…", "mid_price": "0x…", "route_hops": ["0x…"], "hop_amounts": ["0x…"],
"tol_pbps": 0, "min_out": "0x…"
}],
"flags": {"in": 0, "out": 0, "swap_enabled": true, "halted": false, "swap_gated": false, "open_path": true}
}One slot per size, in request order. A size the pool refuses is {"refused":{"name","selector","args"}} in its slot (a revert, or zero_out), never a zero price. swap_gated is true when either token carries the swap allowlist flag: only an executor holding AccessControl.perms bit 10 can fill it, anyone else reverts NotAuthorized. open_path is true when both tokens admit an executor without it. The response header x-btr-witnessed is 1 when a second RPC operator corroborated the answer, 0 when not. Direct pool only: a pair no single pool holds is 422 no_route; take /v1/chain/route.
POST /v1/chain/route
| Field | Type | Notes |
|---|---|---|
token_in, token_out | string | Token addresses |
amount_in | 0x hex | Non-zero, native raw units |
slippage | object | See above |
max_hops | 1 or 2, optional | Default 2. Above 2 is 501 |
split | bool, optional | true is 501 |
chain_id | u64, optional | As on quote |
{
"chain_id": <chainId>, "block": {"number": 0, "timestamp": 0}, "source": "chain",
"best": {
"parts": [{"hops": [{"pool": "0x…", "token_in": "0x…", "token_out": "0x…", "amount_in": "0x…", "amount_out": "0x…"}]}],
"floors": [{"token": "0x…", "amount_out": "0x…", "tol_pbps": 0, "min_out": "0x…"}]
},
"singles": [{"hops": []}],
"refused": [{"name": "…", "selector": "0x…", "args": []}]
}best is direct or two hops through the hub, null when nothing fills. floors is end-to-end, one per output token: encode it as the swap’s minOut, never a per-hop figure. singles ranks every priced itinerary; refused names each candidate the pool refused (halt, swap disabled, coverage). flags lists every {pool, token} flag word read at block; swap_gated and open_path are set over best’s legs, false when best is null. Past the 12 s deadline: 504.
Errors on both: 400 (body, token, slippage), 422 (no_route, ambiguous_pool), 429, 501, 503 (rpc_unavailable: …, or the chain not live), 504 (deadline).
3. The pricing POST endpoints are stateless kernels
/v1/quote, /v1/quote-path, /v1/route and /v1/depth hold no pool state; /v1/chain/* (§2.4) reads the chain instead. You do not pass a pool address and get a price back. You pass the entire pricing state of every leg involved (curve, reserves, liabilities, mark, σ, confidence, staleness, fee params) and the service runs the same integer pricing kernel the contracts run. It is the on-chain pricer, callable off chain, on state you supply.
- Reading pool state is your job, on chain.
/v1/venuesgives the addresses; the SDK’s storage readers (@btr-protocol/sdk/pool) give the slots. - The kernel is authoritative for arithmetic, not for freshness. Stale inputs produce a wrong number with no warning.
- All 256-bit values cross the wire as
0xhex strings. A JSON number above 2^53 loses precision and JavaScript stringifies large integers as1e+21, which the deserializer rejects. - Field names are snake_case (
token_in,amount_in,sigma_pbps).
3.1. POST /v1/quote
Required body fields, all at the top level:
| Field | Type | Notes |
|---|---|---|
curve | object | { header: string, segs: SegWire[], m: number }; SegWire = {c0,c1,c2,c3,c4,s}, all 0x hex |
min_dispersion_pbps | u32 | |
vega_bps | u16 | |
min_fee_pbps | u32 | |
kappa_cov_bps | u16 | |
amount_in | 0x hex | |
reserves | 0x hex u128 | native raw units of the token |
liabilities | 0x hex u128 | native raw units |
mark | 0x hex U256 | WAD (1e18) |
sigma_pbps | u32 | |
selling | bool | |
counterparty | object | {reserves, liabilities, vega_bps, kappa_cov_bps}: the swap’s other endpoint; see below |
confidence_bps | u16 | The feed’s mark CI. Absent, null or above 1,000 (MAX_CONFIDENCE_HALT_BPS) returns 422 {"error":"leg uncertain: …"}, as the pool reverts on it |
stale_excess | u32 | seconds of staleness beyond TTL |
proto_share_pct | u8 | |
flags | u16, optional | the feed’s flag word; bit 0 is the guardian halt. Default 0. A halted leg is refused (422), not quoted |
lp | object, optional | LP preview for this leg’s own book: deposit/withdraw a raw amount and get back post-trade coverage and diluted APR alongside the swap price |
The canonical request shape, once per corpus (other pages link here, never restate it):
curl -s https://api.btr.markets/v1/quote \
-H 'content-type: application/json' \
-d '{
"curve": {"header": "0x…", "segs": [], "m": 0},
"min_dispersion_pbps": 100,
"vega_bps": 1,
"min_fee_pbps": 1,
"kappa_cov_bps": 100,
"amount_in": "0x…",
"reserves": "0x…",
"liabilities": "0x…",
"mark": "0x…",
"sigma_pbps": 10000,
"selling": true,
"counterparty": {
"reserves": "0x…", "liabilities": "0x…", "vega_bps": 10000, "kappa_cov_bps": 600
},
"confidence_bps": 5,
"stale_excess": 0,
"proto_share_pct": 20
}'counterparty: a leg is not a path
The body above describes one leg. The contract prices a path, and its settle tail reads two things off the path’s two endpoints that no leg carries:
acc.vegaBps = max(cIn.vegaBps, cOut.vegaBps): the spread’s vega is the endpoint maximum, not the walked leg’s own value._covToll(cOut, …): the convex coverage toll is charged on whichever endpoint the swap delivers. On a spoke→base sell that endpoint is the hub, which is not exempt: every listed asset including the base runs50 ≤ κ ≤ BPS.
counterparty is that far-side endpoint, the pool’s base on a direct spoke↔base swap, in that token’s native raw units. Required; a body without it is a 400. There is no default: the only candidate (a zero endpoint) silently drops the hub’s coverage toll and quotes a price the pool will not fill, a gap reaching hundreds of bps at size on the live fleet.
A cross (spoke→base→spoke) is one path, not two legs: the chain charges the path spread once, at the tail, so two summed /v1/quote calls miss its figure by ~4 bps. Price it with POST /v1/quote-path: {"legs": [sell, buy]}, each a /v1/quote body plus decimals_in and decimals_out, at most two legs. Neither leg’s counterparty is read on that path; send {"reserves":"0x0","liabilities":"0x0","vega_bps":0,"kappa_cov_bps":0} on both. /v1/quote refuses that all-zero endpoint: 400 {"error":"missing counterparty: explicit zero endpoint is not a book"}.
Response:
{
"amount_out": "0x…", "gross_out": "0x…", "avg_price": "0x…",
"mid_price": "0x…", "mark_price": "0x…", "spread_pbps": 0,
"cov_toll": "0x…", "proto_fee": "0x…", "lp_fee": "0x…", "saturated": false,
"premium_bps": 0, "impact_bps": 0, "fee_bps": 0, "toll_bps": 0, "cost_bps": 0,
"coverage_pre": null, "coverage_post": null, "fee_apr_after": null, "strategy_apr_after": null
}The trailing float fields are derived display metrics over the same fill (premium_bps mid vs mark, impact_bps curve slippage before fees, fee_bps/toll_bps the fee and toll each over gross, cost_bps the signed whole taker cost); the coverage_*/*_apr_after quartet is null unless the request carried lp, in which case they preview that deposit or withdraw against this leg’s book.
saturated: true means the gross output was clamped at the delivering book’s coverage argmax: amount_out is the flat top of the wall, and more input buys no more output. Treat that size as unfillable, not as a price. Past a band of 2% of that book’s liabilities above the argmax the request fails instead, as the chain reverts.
There is no min_out. Derive your own slippage floor from amount_out; see Basic Operations §7.
3.2. POST /v1/route
| Field | Type | Required |
|---|---|---|
pools | NamedPoolWire[] | yes |
token_in | string | yes |
token_out | string | yes |
amount_in | 0x hex U256 | yes |
slices | u32 | no |
min_gain_bps | u64 | no |
max_routes | usize | no |
max_hops | usize | no; default 2 |
split | "auto" | "off" | "on" | no; default auto |
costs | {gas_per_leg_out?, gas_per_part_out?, approval_per_part_out?}, each 0x hex in out-token raw units | no; omitted ranks on gross output |
NamedPoolWire = { tag, base, spokes: SpokeWire[] } plus addr, base_address, base_decimals and the hub’s endpoint book: base_reserves, base_liabilities (both 0x hex u128), base_vega_bps and base_kappa_cov_bps.
The four hub fields are the /route and /depth form of counterparty above, and they are all-or-nothing. Report the whole book or every direct leg touching your base, sell and buy, quotes zero and leaves the route set: /v1/route answers 422 {"error":"no_route","reason":"capacity"} and /v1/depth 422 {"error":"no depth for pair"} when nothing else reaches the pair. Each missing field would drop a charge the chain makes.
base_address and base_decimals are individually optional, not jointly omissible: a wire carrying neither is rejected with 400 {"error":"USDC: wire omits both base_decimals and base_address"}. Supply at least one so the decimal resolver can work. Same per spoke.
SpokeWire = { token, pricing, reserves, liabilities, mark, sigma_pbps, confidence_bps, stale_excess, proto_share_pct } plus address, decimals and an optional flags (bit 0 = guardian halt, default 0): a halted spoke drops out of the routing universe and out of /depth, the same way an omitted confidence_bps does. pricing is the same five-field object that heads a quote request: { curve, min_dispersion_pbps, vega_bps, min_fee_pbps, kappa_cov_bps }. Give each spoke decimals or address: omitting both fails the same way the base does.
Response:
{
"best_amount_out": "0x…",
"best_net_amount_out": "0x…",
"best_is_split": false,
"best_parts": [{ "legs": [], "fraction": "0x…", "amount_out": "0x…" }],
"singles": [{ "legs": [], "amount_in": "0x…", "amount_out": "0x…", "net_amount_out": "0x…", "hops": 1 }],
"metrics": { "mid": 0, "mark": 0, "gross": 0, "avg": 0, "impact_bps": 0, "fee_bps": 0, "toll_bps": 0 },
"routes_considered": 1,
"halted": []
}best_net_amount_out is best_amount_out less the request’s CostWire (gas/approval terms); it equals best_amount_out when no costs were sent, and is the ranking key rather than raw output. halted names every pool_tag:token spoke dropped from the routing universe: feed halted (flags bit 0), or confidence_bps absent or above 1,000. metrics are the best plan’s display floats; each single carries its own.
LegWire = { pool_tag, token_in, token_out, amount_in, amount_out, saturated }, where saturated has the /quote meaning above for any hop of that leg. No minOut is computed anywhere in the response; it is the caller’s to apply per leg.
3.3. POST /v1/depth
| Field | Type | Required |
|---|---|---|
pools | NamedPoolWire[] | yes |
from | string | yes |
to | string | yes |
samples | u32 | no; sweep resolution per side per pool, default 48 |
max_hops | usize | no; same clamp /v1/route applies, default 2 |
Response: { mark, mid, bid, ask, premium_bps, cost_bid_bps, cost_ask_bps, oev_bps, step, bids: Row[], asks: Row[], poolCount }, Row = { price, size, cum }. cost_bid_bps / cost_ask_bps / oev_bps are null when nothing is takeable on that side. These are JSON floats; this endpoint is for rendering a book, not for settlement arithmetic.
3.4. POST /v1/lp-rows
Ranks exit and entry rows for LP moves over the legs you send, in asset units (not raw).
| Field | Type | Required |
|---|---|---|
legs | {symbol, reserves, liabilities, haircut_frac, fee_apr?, strategy_apr?, flags?, index_wad?, cap?}[] | yes |
amount | f64 face amount | no |
mode | "coverage" | "apr" | no; default coverage |
entry | "mint" | "liability" | no; default mint |
exclude | string | no |
src, src_cap | debt-swap source leg and its ceiling | no |
limit | rows per list | no; hard cap 32 |
pool_c_wad | 0x hex WAD, pool coverage R/L | no |
Response: { exit_rows: Row[], lp_rows: Row[], destination: string | null }, Row = {symbol, coverage, cov_after, apr_after, haircut_frac, feasible, c_degraded}. c_degraded: true means no pool_c_wad was sent and cov_after is the C-blind estimate. destination is the first feasible leg for a debt swap from src, null when the source cannot cover the face. A bad body is 400.
3.5. Other POST bodies
| Path | Body | Response |
|---|---|---|
/v1/indicators | {bars: {t, open, high, low, close}[], preset, fast?, slow?, signal?}, 1 to 20 000 bars | Row array, as the GET’s rows (§4.5). 422 on an unknown preset or bad periods |
/v1/assets | {addresses: string[]}, at most 200 | {assets: (Asset | null)[]} aligned with the request, as the GET |
/v1/timeseries/bulk | {queries: {metric, …}[]}, at most 200, each a map of the GET parameters | {series: []} in request order. A URL chainId applies to queries that name none. All failing is 502; a partial batch is 200 with source: "error" on the failed entries |
4. Response shapes
4.1. /v1/md/tickers
[{"ticker":288583527640858624,"mid":291807.65,"bid":291221.02,"ask":292394.28,
"ci":12367,"confidence":0,"flags":64,"age_ms":77084658,"status":"dead"}]tickeris the data provider’s numeric instrument id, not a symbol./v1/md/tickersis the provider’s whole market universe, not the oracle roster.GET /v1/oracle/feedsmapstickerId(a decimal string) tosymbolfor every listed feed;/v1/poolsand/v1/assets/poolscarry no ticker ids. Ids are u64 and 13 of them are not exact as JavaScript numbers: readtickerwith a BigInt-aware JSON parser.- Gate on
statusandage_ms. A ticker whose feed has gone stale reportsstatus: "dead"and keeps its lastmid. A client that readsmidwithout checkingstatusprices off a dead feed.
4.2. /v1/activity
{"chainId":<chainId>,"events":[{"id":"0x…:47","kind":"swap","pool":"btr-<name>",
"payer":"0x…","amountIn":3.6386154,"amountOut":1.99187305,"logIndex":47}]}id is txHash:logIndex. Paginate backwards: pass the response’s nextBefore as before.
4.3. /v1/venues
Keyed by chain id as a string (.["<chainId>"] in jq). Each entry carries chain_id, name, contracts, tokens, pools, feeds, feed_meta, ref_feeds and seeds. contracts holds the roles in that chain’s deployment record. There is no wrapped-native key. Same records as Contract Addresses; read addresses from one of the two, never hardcode them.
4.4. Floats on the wire
The GET surface returns JSON floats for prices and amounts (mid, amountIn, amountUsd): display values. Exact integer arithmetic lives on the POST kernels and on chain, where every 256-bit value is 0x hex. Never settle against a GET float.
4.5. Indicator presets
preset accepts exactly: ema-trend, emacd, rsima, rsimacd, adxma, adxmacd, sdevma, sdevmacd.
Anything else returns 422 {"error":"unknown preset '…'"}.
5. SDK
@btr-protocol/sdk: TypeScript source on GitHub, not on npm and not built. It runs under Bun or a bundler that compiles TS; plain Node refuses it (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING).
bun add github:btr-protocol/sdk
bun pm trust @btr-protocol/sdkThe postinstall (bun run fetch-abis) writes src/abis/Pool.ts and Admin.ts from the committed, hash-pinned snapshot: it needs bun, not the network. Bun blocks dependency postinstalls by default, hence bun pm trust. Until it has run, the package root, /pool, /router and /abis fail with Cannot find module './Admin.js'.
5.1. Reachable entrypoints
Once installed as above, these subpaths resolve. Nothing outside this table is importable, whatever a file in the source tree appears to export.
| Import | Surface |
|---|---|
@btr-protocol/sdk | Re-exports ./pool, ./router, ./amm, ./venues, ./oracle, ./eth, ./types, the ./utils helpers, the chain client (chainQuote, chainRoute, ChainError, resetChainClientState) and setApiRoot, getApiRoot, withChainId |
@btr-protocol/sdk/pool | getSwapQuote, swap, deposit, withdraw, getPoolData, getAsset, getCoverageRatio, getLPBalance, defaultDeadline, NATIVE_TOKEN, POOL_ABI, storage readers, types |
@btr-protocol/sdk/router | planToLegs, buildSwapCalls, buildApprovalCalls, buildSwapExecCalls, planToRouterPlan, buildRouterCalls, buildRouterApprovalCalls, buildRouterSwapExecCalls, buildDepositCalls, buildRedeemCalls, totalValue, rankDeposit, rankRedeem |
@btr-protocol/sdk/amm | Wire and curve types (SwapPlan, NamedPoolWire, SpokeWire, CurveWire) and curve helpers, including poolStateToWire. The f64 pricers (quoteExactIn, rankSwap) are removed; price via /v1/chain/*, /v1/route or Pool.getSwapQuote |
@btr-protocol/sdk/abis | POOL_ABI, ADMIN_ABI, FLASH_ABI, POOL_FACTORY_ABI, ACCESS_CONTROL_ABI, LP_TOKEN_ABI, POOL_HOOKS_ABI, ROUTER_ABI, MARK_STORE_ABI, EXTERNAL_ORACLE_V4_ABI. Oracle reads (getFeed, feedOf, laneOf) go to PoolFactory through POOL_FACTORY_ABI; MARK_STORE_ABI is the quorum-only writer side on the Pool implementation; the V4 ABI is the legacy oracle, read-only |
@btr-protocol/sdk/eth | Contract, RPC client helpers |
@btr-protocol/sdk/eth/wallets | Wallet helpers |
@btr-protocol/sdk/oracle | Signed-quote decode / digest / recover / quorum |
@btr-protocol/sdk/governance | Timelock schedule helpers (delayOf, govDelays, hasZeroDelay), acGeneration, holds and ACCESS_CONTROL_LEGACY_ABI |
@btr-protocol/sdk/venues | Static deployment registry |
@btr-protocol/sdk/utils | applySlip, maths / format / encoding helpers |
@btr-protocol/sdk/utils/format · /utils/logger · /types | Formatting, logging, shared types |
Not importable: btrFetch and fetchAbi exist in the source tree but no index re-exports them. Fetch ABIs and venues with plain fetch against the paths in §2.1:
const API = 'https://api.btr.markets';
const poolAbi = await fetch(`${API}/v1/abis/Pool`).then((r) => r.json());
const venues = await fetch(`${API}/v1/venues`).then((r) => r.json());applySlip is re-exported from @btr-protocol/sdk/utils and the package root; both resolve to the same function. The router module uses it internally but does not re-export it.
5.2. Signatures
Every pool function takes an EIP-1193 provider as its first argument (window.ethereum or equivalent, not a viem client) and the pool address as its second.
getSwapQuote(provider, pool, tokenIn, tokenOut, amountIn: bigint): Promise<SwapQuote>
getCoverageRatio(provider, pool, token): Promise<bigint>
getLPBalance(provider, pool, user, token): Promise<bigint>
getPoolData(provider, pool, tokens: Array<{ address: Address; symbol: string; name: string }>, poolName: string): Promise<PoolData>
swap(provider, pool, { tokenIn, tokenOut, amountIn, minAmountOut, recipient, deadline? }): Promise<Hex>
deposit(provider, pool, { token, amount }): Promise<Hex>
withdraw(provider, pool, { token, lpAmount, minAmountOut, deadline? }): Promise<Hex>deadline defaults to defaultDeadline(), 600 seconds out. To opt out, pass a far-future literal such as 0xffffffffn: the contract only checks block.timestamp > deadline.
Routing:
planToLegs(plan: SwapPlan, { slippageFrac, tokenOf, isOfficialPool, nativeIn?, nativeOut?, amountInUnits?, serverFloors? }): ExecLeg[] | null
buildSwapCalls(legs: ExecLeg[], opts: BuildOpts): ExecCall[]Chain quotes:
chainQuote(req: ChainQuoteRequest, opts?: ChainClientOpts): Promise<ChainQuoteResponse> // POST /v1/chain/quote
chainRoute(req: ChainRouteRequest, opts?: ChainClientOpts): Promise<ChainRouteResponse> // POST /v1/chain/routechain_id is required on both requests; opts takes signal, timeoutMs (default 15 s) and now. Every failure throws ChainError with a closed kind: bad_request, no_route, rate_limited, rpc_unavailable, deadline, not_implemented, refused (the pool refused a size), not_found (a 404: unserved chainId or removed path, not worth a retry), transport, floor_violation (a served floor off min_out = amount_out·(1e6 - tol_pbps)/1e6 or wider than the request’s policy allows), stale_block (an answer from an older block than one already seen for the pair). A 429 or 503 starts one client-wide cooldown for Retry-After seconds; resetChainClientState() clears it and the block guard.
quoteLegAsync, quotePathAsync, routeAsync and depthAsync (@btr-protocol/sdk/amm) are thin clients over POST /v1/quote, /v1/quote-path, /v1/route and /v1/depth. routeAsync and depthAsync are typed on the snake_case wire (RouteRequestWire, DepthRequestWire, both carrying NamedPoolWire[]); legToQuoteBody builds the quoteLegAsync body. Base URL resolves from the explicit argument, else BTR_API, else the SDK default. Inside the SDK, rankDeposit calls routeAsync, the liability quote calls quoteLegAsync and quotePathAsync.
planToLegs and buildSwapCalls work on any plan of the right shape. isOfficialPool is required and fails closed: a plan may name any pool address, official or not, so a plan naming a pool the callback rejects yields null rather than an approve against an unverified address; feed it PoolFactory.isOfficialPool or a set derived from it. serverFloors carries the /v1/chain/* end-to-end min_out per output token; when present the builder encodes that floor and picks no tolerance of its own.
There are no first-class senders for withdrawTo, swapLiability, donate or Flash.flashLoan. Encode those with encodeFunctionData against POOL_ABI / FLASH_ABI from @btr-protocol/sdk/abis, or against a live ABI from /v1/abis.