FinSwap Quote API

Quotes and unsigned swap transactions for FinSwap on the QuantumFin testnet (chain id 20143). Version 1.

Testnet only. FinSwap runs on the QuantumFin testnet. Testnet tokens (QFIN, WQFIN, QTA, QTB, QTC and any pool-discovered token) have no value and cannot be redeemed for anything. The service is research software for the project's own testnet; it gives no guarantee of availability or of any execution price.

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.

GET /api/v1/quote

ParameterRequiredMeaning
in (alias inputMint)yesToken paid: an address (0x + 40 hex) or QFIN.
out (alias outputMint)yesToken received: an address or QFIN. Must differ from in.
amountyesBase units, a positive integer up to 2112−1 (the largest pool reserve). The input for ExactIn, the output for ExactOut.
swapModenoExactIn (default) or ExactOut.
slippageBpsnoauto (default: price impact + 20 bps, kept within 30–200 bps; 50 bps when the impact is unknown) or an integer 0–5000.
maxLegsno1–3 (default 3). 1 disables split routes.

Unknown or repeated parameters are rejected (400). The response fields:

FieldMeaning
idQuote id, accepted by /swap until expiresAt (30 s after quotedAt).
inToken, outTokenAddress, symbol, decimals and verification tier (native, listed, pool, unknown) with a reason.
inAmount, outAmountQuoted amounts (router or aggregator answer at contextBlock).
minOut / maxInThe slippage limit the transaction will carry: minimum total output (ExactIn) or maximum input (ExactOut).
priceImpactBps, impactTierImpact against the pools' mid price, fees excluded; tier ok, confirm (above 5%) or blocked (above 15%).
routekind (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.
gasEstimateGas 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, expiresAtHead height of the reserves used, quote time, expiry.
cachefresh, block-exact or block-bucket (see caching).
request, notesThe 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):

FieldRequiredMeaning
quoteyesThe quote id, or the whole quote object from /quote.
userAddressyesThe account that will sign and send (pays the input, and is simulated as the sender).
recipientnoReceiver of the output (default userAddress). Wrap/unwrap always credit the sender.
deadlineSecondsno30–86400 (default 1200), counted from the later of the server clock and the latest block's timestamp.
allowHighImpactnotrue 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 fieldMeaning
txUnsigned transaction: from, to, data, value, gas, chainId (hex quantities), ready for eth_sendTransaction.
approveUnsigned approval transaction (plus token, spender, amount), or null.
allowanceCurrent and required allowance, or null for QFIN input.
simulationstatus: ok, skipped (approval first) or unavailable (node did not answer); gasSource.
warningsFor example INSUFFICIENT_BALANCE (reported, not hidden; the swap would fail until funded).
quote, deadline, recipientThe 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.

FieldContents
block, gasPriceWei, chainIdHead height and timestamp of the snapshot; the node's gas price.
contractsFactory, routers, aggregator, PoolLens, multicall, limit orders, DCA, lending, its TWAP oracle, forecast and launch factories.
pools, poolTotalEvery FinSwap pool: pair, token0, token1, reserve0, reserve1, LP totalSupply.
tokensAs /tokens: native, listed and pool-discovered tokens with tier and reason.
twap.feedsPer asset: QFTwapOracle twap() status (Ok, NoFeed, NoObservation, WindowTooOld, LowLiquidity, ZeroPrice), prices, window, lagging.
lending.marketsPer asset: rate parameters, cash, totalBorrows, reserves, supplied, utilizationBps, borrow/supply APR (bps), borrowCap, collateralEnabled, oracle price and priceLagging.
tradeLimit orders: total, open, live (open and unexpired); DCA: total, open, due; over the newest 200 of each.
forecaststotal markets and the newest page of cards: id, market, question, poolYes, poolNo, status, result, closeTime. Paging: ?forecastOffset=(0…)&forecastLimit=(1–48, default 12).
launchLaunch 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:

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.

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.

HTTPCodeWhen
400INVALID_TOKEN, INVALID_AMOUNT, INVALID_SLIPPAGE, INVALID_SWAP_MODE, INVALID_MAX_LEGS, SAME_TOKENA quote parameter is malformed or out of range.
400UNKNOWN_PARAM, DUPLICATE_PARAM, CONFLICTING_PARAMSUnexpected, repeated or contradictory parameters.
400INVALID_JSON, INVALID_BODY, UNKNOWN_FIELD, INVALID_ADDRESS, INVALID_DEADLINE, INVALID_QUOTE, QUOTE_INCONSISTENT, INVALID_FIELD, RECIPIENT_UNSUPPORTEDThe /swap body is malformed.
404QUOTE_NOT_FOUND, NOT_FOUNDUnknown quote id or endpoint.
405 / 413 / 414 / 415METHOD_NOT_ALLOWED, BODY_TOO_LARGE, URL_TOO_LONG, UNSUPPORTED_MEDIA_TYPEWrong method, body over 16 KiB, URL over 2048 characters, body not JSON.
409QUOTE_WORSEThe re-quote's limit is worse than the quote's; includes quote.
410QUOTE_EXPIREDThe quote is older than 30 s.
422NO_ROUTE, INSUFFICIENT_LIQUIDITY, NO_QUOTENo pool path, pools too shallow, or the router refused every candidate.
422PRICE_IMPACT_TOO_HIGH, SIMULATION_REVERTEDImpact above 15% without allowHighImpact; the simulated swap reverts (reason given). Both include quote.
429RATE_LIMITEDSee rate limits; honour Retry-After.
503GLOBAL_RATE_LIMITEDAll clients together are above 15 quotes/s, 5 swap builds/s or 10 account reads/s; honour Retry-After.
503STREAM_LIMIT_PER_IP, STREAM_LIMIT_GLOBALMore than 4 event streams from your address, or 2000 in total.
400 / 404INVALID_PARAM, INVALID_POOL, UNKNOWN_POOL, INVALID_TOKENS, INVALID_SPENDERS, TOO_MANY_READSMalformed /state, /events, /candles or /account input; a pool that is not a FinSwap pool.
404 / 502ARCHIVE_NOT_INDEXED, ARCHIVE_UNAVAILABLEThe archive has no data for the pool, or did not answer.
502 / 503 / 504UPSTREAM_UNAVAILABLE, BUSY, UPSTREAM_TIMEOUTThe node did not answer, the API is at capacity, or the node was too slow.

Rate limits and caching