Overview
Base URL: https://finswap.io/api/v1. All responses are JSON (UTF-8). Amounts are integers in base units written as decimal
strings (an 18-decimal token: "1000000000000000000" is 1 token). Times are ISO 8601 UTC. Addresses are returned in lower case.
Native QFIN is written QFIN (or the sentinel 0xeeee…eeee); inside pools it is routed as WQFIN.
The API is read-only. It never holds keys and never signs or sends: /swap returns call data that the user signs in their own wallet
(the QuantumFin wallet, through EIP-1193 eth_sendTransaction, which applies the account's post-quantum authorization).
How a route is chosen
The same way as the swap page on finswap.io, using the same code: every path of up to three pools is ranked from the pools' current reserves,
net of its estimated gas; the best eight are quoted by the FinSwap router (getAmountsOut/getAmountsIn) at the latest block.
For exact-input token-to-token swaps the best split over 2 and 3 pool-disjoint routes is also quoted by FinSwapAggregator.quoteSplit, and
a split is used only when its quoted output, net of its larger gas cost, beats the best single route. Every amount returned is an on-chain quote; local
reserve math only decides which candidates to ask about.
- ExactOut is single-route only: the aggregator's split swap is exact-input.
- Swaps with native QFIN on either side use the QFIN router's single route (quote WQFIN for a split). QFIN↔WQFIN is a 1:1 wrap/unwrap on the WQFIN contract.
- Every FinSwap pool charges 0.30% of its input (
fee: 30basis points per pool).
GET /api/v1/quote
| Parameter | Required | Meaning |
|---|---|---|
in (alias inputMint) | yes | Token paid: an address (0x + 40 hex) or QFIN. |
out (alias outputMint) | yes | Token received: an address or QFIN. Must differ from in. |
amount | yes | Base units, a positive integer up to 2112−1 (the largest pool reserve). The input for ExactIn, the output for ExactOut. |
swapMode | no | ExactIn (default) or ExactOut. |
slippageBps | no | auto (default: price impact + 20 bps, kept within 30–200 bps; 50 bps when the impact is unknown) or an integer 0–5000. |
maxLegs | no | 1–3 (default 3). 1 disables split routes. |
Unknown or repeated parameters are rejected (400). The response fields:
| Field | Meaning |
|---|---|
id | Quote id, accepted by /swap until expiresAt (30 s after quotedAt). |
inToken, outToken | Address, symbol, decimals and verification tier (native, listed, pool, unknown) with a reason. |
inAmount, outAmount | Quoted amounts (router or aggregator answer at contextBlock). |
minOut / maxIn | The slippage limit the transaction will carry: minimum total output (ExactIn) or maximum input (ExactOut). |
priceImpactBps, impactTier | Impact against the pools' mid price, fees excluded; tier ok, confirm (above 5%) or blocked (above 15%). |
route | kind (single, split, wrap, unwrap), the contract that executes it (executor, to), the token spender to approve (null for QFIN input), and legs: share of the input in basis points, token path, pools (pair, fee in bps), amountIn, amountOut. legCounts lists the 1/2/3-leg alternatives considered with gas in output-token units. |
gasEstimate | Gas units from a model fitted to testnet receipts, the node's gas price and the cost in wei of QFIN. /swap sizes the actual limit with eth_estimateGas when it can. |
contextBlock, quotedAt, expiresAt | Head height of the reserves used, quote time, expiry. |
cache | fresh, block-exact or block-bucket (see caching). |
request, notes | The normalized request (used by /swap to re-quote) and explanations such as why a route is single-route. |
Excerpt of a real response (testnet block 90690, before the cache field was added; token fields shortened):
{
"id": "e5a6b62865f8e2a132035a711acc1010",
"chainId": 20143,
"inToken": {"address": "0x8f671306db16488770d0b6296c1b122dcd20c94b", "symbol": "QTA", "decimals": 18, "tier": "listed"},
"outToken": {"address": "0x719b26b39a242a785a492fc2fbd557fddb5e6ea8", "symbol": "QTB", "decimals": 18, "tier": "listed"},
"swapMode": "ExactIn",
"inAmount": "1000000000000000000",
"outAmount": "1988070994463907345",
"minOut": "1982106781480515622",
"slippageBps": 30, "slippageMode": "auto",
"priceImpactBps": 0, "impactTier": {"tier": "ok", "reason": ""},
"route": {
"kind": "single", "executor": "router",
"to": "0x317a821b1c85777fdafc2bd930589f1bba0f3b00", "spender": "0x317a821b1c85777fdafc2bd930589f1bba0f3b00",
"legs": [{"share": 10000,
"path": ["0x8f671306db16488770d0b6296c1b122dcd20c94b", "0x719b26b39a242a785a492fc2fbd557fddb5e6ea8"],
"pools": [{"pair": "0x6c43c44bc359f74c971107c0e72a3d3cd55fcb0b", "fee": 30}],
"amountIn": "1000000000000000000", "amountOut": "1988070994463907345"}],
"legCounts": [
{"legs": 1, "totalOut": "1988070994463907345", "pools": 1, "gasUnits": "255000", "gasCostOut": "114832466563532084", "netOut": "1873238527900375261"},
{"legs": 2, "totalOut": "2037501776446391274", "pools": 4, "gasUnits": "677000", "gasCostOut": "304868940641220475", "netOut": "1732632835805170799"}]
},
"gasEstimate": {"units": "255000", "source": "receipt-model", "gasPriceWei": "100000000000", "costWei": "25500000000000000"},
"contextBlock": 90690,
"quotedAt": "2026-11-13T11:32:34.437Z", "expiresAt": "2026-11-13T11:33:04.437Z",
"request": {"in": "0x8f671306…94b", "out": "0x719b26b3…6ea8", "amount": "1000000000000000000", "swapMode": "ExactIn", "slippageBps": "auto", "maxLegs": 3},
"notes": []
}
Here a 2-leg split would have paid more gross, but less after its extra gas, so the single route was chosen.
POST /api/v1/swap
Body (application/json, at most 16 KiB):
| Field | Required | Meaning |
|---|---|---|
quote | yes | The quote id, or the whole quote object from /quote. |
userAddress | yes | The account that will sign and send (pays the input, and is simulated as the sender). |
recipient | no | Receiver of the output (default userAddress). Wrap/unwrap always credit the sender. |
deadlineSeconds | no | 30–86400 (default 1200), counted from the later of the server clock and the latest block's timestamp. |
allowHighImpact | no | true to build a swap whose price impact is above 15% (refused otherwise). |
The server re-quotes with the quote's request and applied slippage. If the fresh limit is worse than the quote's
(minOut lower, or maxIn higher) it refuses with 409 QUOTE_WORSE and returns the fresh quote; otherwise the
transaction carries the fresh limit, which is at least as protective as the one you saw. An expired quote is refused (410).
Then it reads the input allowance and balance of userAddress. If the allowance is short, approve is an
approve(spender, amount) transaction for exactly the amount needed (never unlimited); send it first and wait for it to execute. Without a
pending approval, the swap is simulated as userAddress (eth_call); a revert is refused with
422 SIMULATION_REVERTED and the reason. Gas is eth_estimateGas + 30% + 10 000, or the receipt model ×1.5 when it cannot be estimated.
| Response field | Meaning |
|---|---|
tx | Unsigned transaction: from, to, data, value, gas, chainId (hex quantities), ready for eth_sendTransaction. |
approve | Unsigned approval transaction (plus token, spender, amount), or null. |
allowance | Current and required allowance, or null for QFIN input. |
simulation | status: ok, skipped (approval first) or unavailable (node did not answer); gasSource. |
warnings | For example INSUFFICIENT_BALANCE (reported, not hidden; the swap would fail until funded). |
quote, deadline, recipient | The fresh quote the transaction encodes, the unix deadline, the receiver. |
Contracts called: the FinSwap router (swapExactTokensForTokens, swapTokensForExactTokens),
FinSwapAggregator.swapSplit for splits, the QFIN router (swapExactETHForTokens, swapETHForExactTokens,
swapExactTokensForETH, swapTokensForExactETH) and WQFIN deposit()/withdraw(uint256).
Their addresses are in /health under config.
GET /api/v1/tokens
Native QFIN, the listed tokens of finswap.io's token list, and every token found in a FinSwap pool, each with tier, label,
reason, decimals, testToken and the number of pools holding it. Tier pool tokens are not reviewed:
anyone can deploy a token with any name or symbol, so check the address.
GET /api/v1/health
status (ok, degraded: a node or the archive does not answer, down: no node answers, HTTP 200 in all cases),
the head height reported by each node (named primary, fallback-1, …), archive reachability and lag in blocks, the SHA-256 of the
config file in use, the contract addresses, and the age of the reserve cache.
GET /api/v1/state
One JSON snapshot of what FinSwap pages read when they open, built on the server once per new block (at most one build per second,
one at a time) and kept in memory, so a page needs one request instead of dozens of chain reads. Every value is a chain read at
block.height; a section whose reads fail carries {"error": …} instead of guessed data.
| Field | Contents |
|---|---|
block, gasPriceWei, chainId | Head height and timestamp of the snapshot; the node's gas price. |
contracts | Factory, routers, aggregator, PoolLens, multicall, limit orders, DCA, lending, its TWAP oracle, forecast and launch factories. |
pools, poolTotal | Every FinSwap pool: pair, token0, token1, reserve0, reserve1, LP totalSupply. |
tokens | As /tokens: native, listed and pool-discovered tokens with tier and reason. |
twap.feeds | Per asset: QFTwapOracle twap() status (Ok, NoFeed, NoObservation, WindowTooOld, LowLiquidity, ZeroPrice), prices, window, lagging. |
lending.markets | Per asset: rate parameters, cash, totalBorrows, reserves, supplied, utilizationBps, borrow/supply APR (bps), borrowCap, collateralEnabled, oracle price and priceLagging. |
trade | Limit orders: total, open, live (open and unexpired); DCA: total, open, due; over the newest 200 of each. |
forecasts | total markets and the newest page of cards: id, market, question, poolYes, poolNo, status, result, closeTime. Paging: ?forecastOffset=(0…)&forecastLimit=(1–48, default 12). |
launch | Launch factory rules, which quote tokens it allows, number of launches. |
Caching: the response has a strong ETag (the content hash) and Cache-Control: no-cache; send it back in
If-None-Match and the API answers 304 Not Modified with no body until the next snapshot. With
Accept-Encoding: gzip the body is gzip-compressed (about 3.6 kB for 13 kB of JSON on the testnet, 2026-11-13).
X-State-Block gives the block height. When the chain has moved on, the API answers at once from the latest snapshot and
rebuilds in the background, so the snapshot can trail the head by about one build (1–3 s); block.height says which block it describes.
GET /api/v1/stream (Server-Sent Events)
Use it with new EventSource('https://finswap.io/api/v1/stream'). Events:
snapshot(on connect):{block, hash, timestamp, resume}. Fetch/stateif you do not hold that snapshot.block(per new snapshot;id= block height):{block, prevBlock, timestamp, hash, prevHash, delta}.deltalists only what changed sinceprevBlock:pools,tokens,twapFeeds,lendingMarkets,forecastMarkets(each{changed: [full entries], removed: [keys]}), andtrade,launch,gasPriceWei,forecastTotal,poolTotalwhen they changed. Apply deltas in order; ifprevBlockis not the block you hold, fetch/stateagain.- A comment line (
: heartbeat) every 15 s keeps proxies from closing the connection.
Resume: on reconnect the browser sends Last-Event-ID (or pass ?lastEventId=); the API replays the missed block
events if it still holds them (the last 128), otherwise it sends a snapshot event with "resume": "unavailable".
Limits: 4 streams per client address and 2000 in total; beyond that 503 with STREAM_LIMIT_PER_IP or
STREAM_LIMIT_GLOBAL and Retry-After.
POST /api/v1/account
Body {"address": "0x…", "tokens"?: [addresses, ≤ 50], "spenders"?: [addresses, ≤ 10]} (tokens × spenders ≤ 200). One address's
native QFIN balance; per token its balance and allowance to each spender (default: router, nativeRouter, aggregator,
limitOrders, dca, lending); its non-zero LP balances; its lending position (collateral and debt values, per-market supplied
and debt); its open limit orders and DCA plans (newest 100 each). Read in one batch at block; never cached across addresses
(Cache-Control: no-store). Rate limits as /quote, and at most 10 account reads/s across all clients.
GET /api/v1/events and /api/v1/candles
The chain archive's pool events and price candles (explorer.quantumfin.io), for FinSwap pools only, shared between all visitors: identical
requests within one block cost one archive request. The archive's JSON is passed through unchanged, with X-Served-Through-Height.
/events?pool=<pair>[&from=<block>][&to=<block>][&limit=1–100][&cursor=<next>]: newest first;nextin the body is the cursor of the next page./candles?pool=<pair>&interval=1m|5m|15m|1h|4h|1d&from=<unix s>&to=<unix s>[&invert=0|1].
GET /api/v1/events/recent
The newest archive events page (100 events, newest first) of every FinSwap pool in one answer:
{block, limit, pools: {<pair>: {servedThrough, ageMs, page}}}. page is the archive's body, unchanged;
servedThrough its X-Served-Through-Height. Kept in memory and re-read for a pool only when its reserves changed
(at most once per 5 s per pool), after 30 s, or when the archive had not yet reached the change. A pool without a page is absent: read
/events for it. A page whose oldest item is above your cursor while next is set does not cover your range.
GET /api/v1/tokens/meta
Holder count and first appearance of every pool token, from the chain archive: {tokens: {<address>: {holders, holdersAgeMs,
firstSeenHeight, firstSeenTime}}}. Holder counts are re-read at most every 5 minutes; null where the archive gave no value.
GET /api/v1/page/<name> and POST /api/v1/portfolio
Page prefetches for finswap.io (loan-offers, forecasts, vault-packs, bundles, as configured):
the API runs the page's own read code at the state snapshot's block, every contract read as Multicall3 aggregate3 at that block number,
and answers {version, page, chainId, block: {height, timestamp}, contracts, calls: [[to, data, result] | [to, data, null, revertData]],
archive: {path: {servedThrough, body}}, recent: {address: {servedThrough, page}}, extra}. Archive bodies are unchanged. extra
holds values computed from those reads with the page's code (bundle value-per-share history and 24 h changes; forecast card activity).
Built at start-up and rebuilt after every new snapshot (at most every 2 s per page; with no visitors the snapshot, and so the pages, every 10 s), so a request is answered from memory. POST /api/v1/portfolio with {address, parts}
(balances by default: native, listed and pool tokens and every pool's LP balance in one aggregate3 at the block;
positions: lending, orders, plans, forecast shares and v1 leftovers as a prefetch, at its own stated block, reused 30 s per address and answered at once up to 5 min old while a new read runs (fresh: true reads anew); history: the archive's newest
transactions and their details, cached 60 s per address). Per-request, Cache-Control: no-store; counts toward the account limit.
Pools at block.height
/state reads the pools (PoolLens), their LP supply and every pool token's totalSupply in one call at exactly
block.height (block-tagged eth_call) and says so with poolsAtBlock: true; tokenSupply maps each
pool token to its supply at that block. With poolsAtBlock true the router's amounts at that block follow from the reserves with the
Uniswap V2 integer formula (0.30% fee). If the node refuses the block-tagged read, the latest reserves are given with poolsAtBlock: false.
Examples
curl
# 1. Quote 1 QTA -> QTB
curl -s 'https://finswap.io/api/v1/quote?in=0x8f671306db16488770D0B6296c1b122dcd20c94B&out=0x719B26B39a242a785A492Fc2FBD557fDDB5E6eA8&amount=1000000000000000000' > quote.json
# 2. Build the unsigned transactions (re-quoted server side)
jq '{quote: ., userAddress: "0xYOUR_ADDRESS"}' quote.json \
| curl -s -X POST https://finswap.io/api/v1/swap -H 'content-type: application/json' --data @- > swap.json
# 3. Inspect: approve (null if the allowance suffices) and tx
jq '{approve, tx, simulation, warnings}' swap.json
# QFIN in, exact output, fixed 0.5% slippage
curl -s 'https://finswap.io/api/v1/quote?in=QFIN&out=0x646b843d6170d178fC3cAb8C72BC54Bae1cf3BAe&amount=1000000000000000000&swapMode=ExactOut&slippageBps=50'
JavaScript (browser, EIP-1193 wallet)
const API = 'https://finswap.io/api/v1';
const provider = window.ethereum; // the QuantumFin wallet's EIP-1193 provider
const [account] = await provider.request({method: 'eth_requestAccounts'});
// 1. Quote
const q = new URLSearchParams({in: '0x8f671306db16488770D0B6296c1b122dcd20c94B', out: '0x719B26B39a242a785A492Fc2FBD557fDDB5E6eA8',
amount: '1000000000000000000', slippageBps: 'auto'});
const quote = await (await fetch(`${API}/quote?${q}`)).json();
if (quote.error) throw new Error(quote.error.message);
// 2. Build (the server re-quotes; 409 QUOTE_WORSE returns a fresh quote to show the user)
const build = async quote => (await fetch(`${API}/swap`, {method: 'POST', headers: {'content-type': 'application/json'},
body: JSON.stringify({quote, userAddress: account})})).json();
let swap = await build(quote);
if (swap.error) throw new Error(`${swap.error.code}: ${swap.error.message}`);
// The QuantumFin wallet answers eth_sendTransaction with a request id and reports progress through
// qfin_getTransactionReceipt (states: queued, ordered_finalized, then executed), as finswap.io does.
const waitFor = async id => {
for (let i = 0; i < 60; i++) {
await new Promise(res => setTimeout(res, 2500));
const r = await provider.request({method: 'qfin_getTransactionReceipt', params: [id]}).catch(() => null);
const rc = r?.receipt ?? r;
if (!rc?.state || rc.state === 'queued' || rc.state === 'ordered_finalized') continue;
if (rc.succeeded === false || rc.status === '0x0' || rc.status === 0) throw new Error('reverted: ' + (rc.revertReason ?? 'no reason'));
return rc;
}
throw new Error('no receipt after 150 s');
};
// 3. Approve first if needed, then rebuild (re-simulated, gas from eth_estimateGas)
if (swap.approve) {
const {token, spender, amount, ...tx} = swap.approve;
await waitFor(await provider.request({method: 'eth_sendTransaction', params: [tx]}));
swap = await build(swap.quote);
if (swap.error) throw new Error(`${swap.error.code}: ${swap.error.message}`);
}
// 4. Swap: the wallet shows the call and signs it
const id = await provider.request({method: 'eth_sendTransaction', params: [swap.tx]});
console.log('submitted', id, (await waitFor(id)).state);
Rebuild with swap.quote only within its 30 s validity; otherwise request a new quote.
Errors
Errors are {"error": {"code", "message", "field"?}}, plus "quote" (the fresh quote) where noted.
| HTTP | Code | When |
|---|---|---|
| 400 | INVALID_TOKEN, INVALID_AMOUNT, INVALID_SLIPPAGE, INVALID_SWAP_MODE, INVALID_MAX_LEGS, SAME_TOKEN | A quote parameter is malformed or out of range. |
| 400 | UNKNOWN_PARAM, DUPLICATE_PARAM, CONFLICTING_PARAMS | Unexpected, repeated or contradictory parameters. |
| 400 | INVALID_JSON, INVALID_BODY, UNKNOWN_FIELD, INVALID_ADDRESS, INVALID_DEADLINE, INVALID_QUOTE, QUOTE_INCONSISTENT, INVALID_FIELD, RECIPIENT_UNSUPPORTED | The /swap body is malformed. |
| 404 | QUOTE_NOT_FOUND, NOT_FOUND | Unknown quote id or endpoint. |
| 405 / 413 / 414 / 415 | METHOD_NOT_ALLOWED, BODY_TOO_LARGE, URL_TOO_LONG, UNSUPPORTED_MEDIA_TYPE | Wrong method, body over 16 KiB, URL over 2048 characters, body not JSON. |
| 409 | QUOTE_WORSE | The re-quote's limit is worse than the quote's; includes quote. |
| 410 | QUOTE_EXPIRED | The quote is older than 30 s. |
| 422 | NO_ROUTE, INSUFFICIENT_LIQUIDITY, NO_QUOTE | No pool path, pools too shallow, or the router refused every candidate. |
| 422 | PRICE_IMPACT_TOO_HIGH, SIMULATION_REVERTED | Impact above 15% without allowHighImpact; the simulated swap reverts (reason given). Both include quote. |
| 429 | RATE_LIMITED | See rate limits; honour Retry-After. |
| 503 | GLOBAL_RATE_LIMITED | All clients together are above 15 quotes/s, 5 swap builds/s or 10 account reads/s; honour Retry-After. |
| 503 | STREAM_LIMIT_PER_IP, STREAM_LIMIT_GLOBAL | More than 4 event streams from your address, or 2000 in total. |
| 400 / 404 | INVALID_PARAM, INVALID_POOL, UNKNOWN_POOL, INVALID_TOKENS, INVALID_SPENDERS, TOO_MANY_READS | Malformed /state, /events, /candles or /account input; a pool that is not a FinSwap pool. |
| 404 / 502 | ARCHIVE_NOT_INDEXED, ARCHIVE_UNAVAILABLE | The archive has no data for the pool, or did not answer. |
| 502 / 503 / 504 | UPSTREAM_UNAVAILABLE, BUSY, UPSTREAM_TIMEOUT | The node did not answer, the API is at capacity, or the node was too slow. |
Rate limits and caching
- Per client IP: 10 requests/s with bursts of 20, and 120 requests per minute, across
/quote,/swap,/tokensand/health. Over the limit:429withRetry-Afterin seconds. Successful responses carryX-RateLimit-Remaining. /stateanswers from memory (304 when unchanged) and does not count against the global limits; it still counts against the per-client limit, so poll it at most about once a second or use/stream.- Across all clients together: at most 15 quotes/s, 5 swap builds/s and 10 account reads/s. Beyond that the API answers
503 GLOBAL_RATE_LIMITEDwithRetry-After; this protects the shared testnet nodes, so back off and retry. - Quotes are cached per block (blocks take about 20 s). Within a block, the same request again returns the same on-chain quote
(
"cache": "block-exact"), and an amount within 1% of an earlier one (same first three digits and digit count) reuses that route with amounts computed for your amount from the block's reserves ("cache": "block-bucket"), done only where that computation was checked to reproduce the router's on-chain answer exactly."cache": "fresh"means quoted on chain now. Responses areCache-Control: no-store. - CORS: any origin may call GET and POST with a JSON body; no cookies or credentials are used or accepted.
- A quote is valid for 30 s. Re-quote rather than retrying an old one.