uvrouter API
Swap quotes and ready-to-send swap transactions on BNB Chain. Two GET endpoints, JSON, no API key.
Overview
uvrouter quotes and builds swaps on BNB Chain. It prices a trade with the same routing engine that runs my CoW Protocol solver (uvsolver), over PancakeSwap and Uniswap V2 and V3 pools. A swap executes through one contract, UvRouter, which has no owner, no upgrade path and never holds user funds between transactions. There is no RFQ, no intents and no off-chain market maker: the output comes from on-chain AMM pools. I run it myself.
Base URL
https://router.uvstack.xyz
Everything is HTTPS GET and answers JSON with Cache-Control: no-store. Addresses in responses are lowercase. Amounts are decimal strings in base units. The machine-readable spec is openapi.json (OpenAPI 3.1).
Chains
| Chain | chainId | Status |
|---|---|---|
| BNB Smart Chain | 56 | live |
Any other chainId gets UNSUPPORTED_CHAIN. Both endpoints are exact input only (you give the sell amount).
GET /v1/quote
/v1/quoteA price for selling an exact amount. Nothing is signed or reserved.
Query parameters
| Name | Type | Notes |
|---|---|---|
chainId | integer, required | Must be 56. |
sellToken | address, required | 0x plus 40 hex characters, any case. Must be one of the supported tokens, or native BNB (below). |
buyToken | address, required | Same rules. Must differ from sellToken. |
sellAmount | string, required | Decimal integer in base units of sellToken, from 1 to 2128-1. Digits only. |
Native BNB. sellToken or buyToken may be the native coin, written 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE or the zero address (any case). It is priced as WBNB (0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c, 18 decimals) and the response echoes the address you sent, lowercase. Native against WBNB is a wrap, not a swap, and returns INVALID_TOKEN. Unknown query parameters are ignored.
Response
| Field | Type | Meaning |
|---|---|---|
chainId | integer | 56. |
sellToken, buyToken | address | Echo, lowercase. A native token is echoed as sent. |
sellAmount | string | Echo, base units. |
buyAmount | string | Expected output in base units of buyToken, after the fee. It is the route's expected output with no slippage cushion; apply your own. Gas is not netted out. |
feeBps | integer | The fee already taken out of buyAmount, in basis points. See Fees. |
route.hops[] | array | One entry per call the route makes, in order: venue (the router or pool address) and usually tokenIn and tokenOut. Informational. It is always one path, the one /v1/swap executes (see What it can build). |
gasEstimate | string or null | The engine's gas estimate for the route. For the gas limit of the UvRouter transaction use tx.gas from /v1/swap. |
quoteId | string | Unique id, useful when you write to me about a quote. |
validFor | integer | Seconds the price is worth acting on (15). Nothing enforces it on chain; minOut and deadline do (see Swap). |
curl
curl 'https://router.uvstack.xyz/v1/quote?chainId=56&sellToken=0x55d398326f99059fF775485246999027B3197955&buyToken=0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c&sellAmount=1000000000000000000'
response (1 USDT to WBNB, taken 2026-11-14)
{
"chainId": 56,
"sellToken": "0x55d398326f99059ff775485246999027b3197955",
"buyToken": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
"sellAmount": "1000000000000000000",
"buyAmount": "1670076516394888",
"feeBps": 2,
"route": {
"hops": [
{
"venue": "0xb971ef87ede563556b2ed4b1c0b0019111dd85d2",
"tokenIn": "0x55d398326f99059ff775485246999027b3197955",
"tokenOut": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c"
}
]
},
"gasEstimate": "316000",
"quoteId": "1a1da726b76-14",
"validFor": 15
}
GET /v1/swap
/v1/swapThe same quote plus the transaction that executes it on the UvRouter contract. Takes every parameter of /v1/quote and these:
| Name | Type | Notes |
|---|---|---|
taker | address, required | The payer: signs the permit, or sends the BNB. Not the zero address. |
recipient | address | Receives the output. Default: taker. Not the zero address and not the UvRouter contract. |
slippageBps | integer | 0 to 500. Default 50. Sets minOut. |
approval | permit2 or erc20 | How an ERC-20 sell is authorised; ignored for native in. permit2: the taker signs a Permit2 witness. erc20: the taker approves UvRouter and sends swapWithAllowance itself. Default: erc20 when taker is a contract, else permit2 (EOAs and EIP-7702 accounts). |
nonce | string | A Permit2 nonce (decimal, 256 bits) you have not used for this taker. Default: generated, unique per response. |
Native BNB
As on /v1/quote, sellToken and buyToken may be the native coin. It is priced as WBNB, and the response echoes the native address you sent.
- Native in:
tx.valueequalssellAmount,permit2isnull, no signature and no approval. The call isswapNative(params, deadline). - Native out: the contract unwraps and sends BNB to
recipient(unwrapNative: truein the signed data). A contract recipient must accept plain BNB or the swap reverts.
Response
Every field of the quote response, plus:
| Field | Type | Meaning |
|---|---|---|
minOut | string | Least the recipient must receive: buyAmount x (10000 - slippageBps) / 10000, rounded down. The contract checks it against the recipient's balance change and reverts below it. |
slippageBps | integer | Echo of the value used. |
deadline | integer | Unix seconds, 120 s after the response. The permit and the transaction are both dead after it. |
tx.to | address | The UvRouter contract. Use this value, do not hard-code it (see Versioning). |
tx.data | hex string | Calldata. For an ERC-20 sell it carries a 65-byte zero placeholder where the signature goes. |
tx.value | string | Wei to send: sellAmount for native in, else "0". |
tx.gas | string | A gas limit with about 25% headroom over my fork-test figures. Unused gas is not charged. |
permit2 | object or null | null for native in and for approval=erc20. Otherwise typedData (an eth_signTypedData_v4 payload) and signatureOffset. |
allowance | object or null | The ERC-20 allowance the taker needs before sending tx: token, spender (Permit2 for permit2, UvRouter for erc20) and amount (least needed, base units; the erc20 flow pulls exactly this). null for native in. |
gasEstimate | string | Replaces the quote's figure: expected gas of this transaction. tx.gas is this plus 25%. |
curl, ERC-20 in (taker is a placeholder; this was never signed)
curl 'https://router.uvstack.xyz/v1/swap?chainId=56&sellToken=0x55d398326f99059fF775485246999027B3197955&buyToken=0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c&sellAmount=1000000000000000000&taker=0x000000000000000000000000000000000000dEaD'
response (tx.data shortened here; the real one is 1418 characters)
{
"chainId": 56,
"sellToken": "0x55d398326f99059ff775485246999027b3197955",
"buyToken": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
"sellAmount": "1000000000000000000",
"buyAmount": "1667792331532151",
"feeBps": 2,
"route": {
"hops": [
{
"venue": "0xb971ef87ede563556b2ed4b1c0b0019111dd85d2",
"tokenIn": "0x55d398326f99059ff775485246999027b3197955",
"tokenOut": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c"
}
]
},
"gasEstimate": "316000",
"quoteId": "1a1da726b76-9",
"validFor": 15,
"minOut": "1659453369874490",
"slippageBps": 50,
"deadline": 1794667043,
"tx": {
"to": "0x8e625bc6786f8dda4dbc23bdea0b9ab7c625b893",
"data": "0xf4af5da500000000000000000000000055d398326f99059ff775485246999027b3197955...",
"value": "0",
"gas": "413750"
},
"permit2": {
"typedData": {
"domain": {
"chainId": 56,
"name": "Permit2",
"verifyingContract": "0x000000000022d473030f116ddee9f6b43ac78ba3"
},
"message": {
"deadline": "1794667043",
"nonce": "1305543742891162411723926365419359454820703564147307053065",
"permitted": {
"amount": "1000000000000000000",
"token": "0x55d398326f99059ff775485246999027b3197955"
},
"spender": "0x8e625bc6786f8dda4dbc23bdea0b9ab7c625b893",
"witness": {
"feeBps": "2",
"hops": [
{
"fee": 100,
"kind": 1,
"tokenOut": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
"venue": 1
}
],
"minOut": "1659453369874490",
"recipient": "0x000000000000000000000000000000000000dead",
"tokenOut": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
"unwrapNative": false
}
},
"primaryType": "PermitWitnessTransferFrom",
"types": {
"EIP712Domain": [
{
"name": "name",
"type": "string"
},
{
"name": "chainId",
"type": "uint256"
},
{
"name": "verifyingContract",
"type": "address"
}
],
"Hop": [
{
"name": "kind",
"type": "uint8"
},
{
"name": "venue",
"type": "uint8"
},
{
"name": "fee",
"type": "uint24"
},
{
"name": "tokenOut",
"type": "address"
}
],
"PermitWitnessTransferFrom": [
{
"name": "permitted",
"type": "TokenPermissions"
},
{
"name": "spender",
"type": "address"
},
{
"name": "nonce",
"type": "uint256"
},
{
"name": "deadline",
"type": "uint256"
},
{
"name": "witness",
"type": "Swap"
}
],
"Swap": [
{
"name": "tokenOut",
"type": "address"
},
{
"name": "minOut",
"type": "uint256"
},
{
"name": "recipient",
"type": "address"
},
{
"name": "unwrapNative",
"type": "bool"
},
{
"name": "feeBps",
"type": "uint256"
},
{
"name": "hops",
"type": "Hop[]"
}
],
"TokenPermissions": [
{
"name": "token",
"type": "address"
},
{
"name": "amount",
"type": "uint256"
}
]
}
},
"signatureOffset": 612
}
}
curl, native BNB in (0.001 BNB to USDT)
curl 'https://router.uvstack.xyz/v1/swap?chainId=56&sellToken=0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE&buyToken=0x55d398326f99059fF775485246999027B3197955&sellAmount=1000000000000000&taker=0x000000000000000000000000000000000000dEaD'
response (tx.data shortened)
{
"chainId": 56,
"sellToken": "0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"buyToken": "0x55d398326f99059ff775485246999027b3197955",
"sellAmount": "1000000000000000",
"buyAmount": "598748607347905850",
"feeBps": 2,
"route": {
"hops": [
{
"venue": "0xb971ef87ede563556b2ed4b1c0b0019111dd85d2",
"tokenIn": "0xbb4cdb9cbd36b01bd1cbaebf2de08d9173bc095c",
"tokenOut": "0x55d398326f99059ff775485246999027b3197955"
}
]
},
"gasEstimate": "316000",
"quoteId": "1a1da726b76-12",
"validFor": 15,
"minOut": "595754864311166320",
"slippageBps": 50,
"deadline": 1794667061,
"tx": {
"to": "0x8e625bc6786f8dda4dbc23bdea0b9ab7c625b893",
"data": "0x30865baf0000000000000000000000000000000000000000000000000000000000000040...",
"value": "1000000000000000",
"gas": "376250"
},
"permit2": null
}
What it can build
One path of up to 4 hops over PancakeSwap V2, Uniswap V2 (BSC), PancakeSwap V3 and Uniswap V3 pools, with no parallel legs. Both endpoints route over exactly these, so a quote is always a route /v1/swap can build. A large trade that would do better split across pools or on another venue (Algebra, DODO, V4, Infinity and so on) is quoted on the best single path instead. If no such path exists the answer is NO_ROUTE.
Signing and submitting
ERC-20 in
- The taker approves Permit2 (
0x000000000022D473030F116dDEE9F6B43aC78BA3) forsellToken, once. Nobody approves UvRouter. - Sign
permit2.typedDatawitheth_signTypedData_v4, unchanged. It is Permit2'sPermitWitnessTransferFromwith a witness that holds output token,minOut, recipient, unwrap flag,feeBpsand every hop as a typed list, so the wallet shows them. - Write the 65-byte signature (
r || s || v,v= 27 or 28) intotx.dataat byte offsetpermit2.signatureOffset, over the zero placeholder. The offset counts bytes of the decoded calldata, selector included. - Send
{to, data, value, gas}from any account. The signature binds every parameter, so whoever submits it cannot change the recipient, amount, path,minOutor fee.
Contract wallets and EIP-7702 accounts (signatures that are not 65 bytes): replace everything in tx.data from byte signatureOffset - 32 to the end with the ABI encoding of the bytes (32-byte length, the bytes, zero padding to a multiple of 32). Permit2 then asks the owner's isValidSignature.
If you would rather encode the call yourself, the ABI is:
swapWithPermit2(
((address token, uint256 amount) permitted, uint256 nonce, uint256 deadline) permit,
address owner,
(address tokenOut, uint256 minOut, address recipient, bool unwrapNative,
uint256 feeBps, (uint8 kind, uint8 venue, uint24 fee, address tokenOut)[] hops) p,
bytes signature) returns (uint256)
swapNative(SwapParams p, uint256 deadline) payable returns (uint256)
ERC-20 in with approval=erc20
- The taker approves UvRouter (
allowance.spender) for at leastallowance.amountofsellToken. Nothing is signed. - Send
txfrom the taker. The call isswapWithAllowance(tokenIn, amountIn, params, deadline); it pulls exactlyamountInfrommsg.senderand from nobody else, so an approval of that amount ends at zero. Unused input is refunded to the sender.
Native in
Send the transaction from the taker as returned: value is the sell amount and the deadline is inside the calldata. There is nothing to sign.
Semantics
- minOut is enforced on the recipient's balance change, so a fee-on-transfer output token cannot short the recipient unnoticed. Below it the call reverts with
TooLittleReceived. - deadline is unix seconds. After it a native swap reverts with
Expiredand a permit swap fails in Permit2. Request a fresh response rather than editing an old one. - nonce is a Permit2 unordered nonce. A used nonce reverts, so a signed response executes once.
- Input that was not swapped is refunded in full to the taker.
- Check
minOutagainst your own price before asking anyone to sign. A wallet shows it as a raw integer.
Calling from a contract
The contract does not pull tokens with transferFrom from a calling contract. A contract can use the native path, an EIP-1271 signature through Permit2, or approval=erc20: approve, then call swapWithAllowance from the contract.
Fees
The only charge is a fee in basis points of the output. I choose it per swap, per pair and size, and it comes back as feeBps in both endpoints. The current table runs from 0 to 30 bps; where it has no entry the default is 5 bps. The response you act on is the fee you pay: it can differ between two calls for the same pair when the size or the data changes.
buyAmount is already net of it, so you do not subtract anything. On /v1/swap the fee is part of what the user signs (feeBps in the witness, and an argument of swapNative). The contract refuses any fee above MAX_FEE_BPS, an immutable set to 50 bps (0.5%), and a submitter cannot change the signed value. The fee is taken on chain from the output and sent to a fixed recipient address set at deployment.
Errors
Every error is JSON with a stable code. Branch on code, not on message.
{
"error": {
"code": "UNSUPPORTED_CHAIN",
"message": "chainId 1 is not served"
}
}
| Code | HTTP | Meaning |
|---|---|---|
INVALID_REQUEST | 400 | A parameter is missing or unreadable: chainId not an integer, a required parameter absent; on /v1/swap also taker or recipient not an address, zero, or recipient the router; slippageBps not an integer in 0 to 500; nonce not a decimal integer; approval not permit2 or erc20, or erc20. |
UNSUPPORTED_CHAIN | 400 | chainId is not 56. |
INVALID_TOKEN | 400 | A token is not a 0x plus 40 hex address, or sell and buy token are the same (native BNB and WBNB count as the same). |
UNSUPPORTED_TOKEN | 400 | Not a supported token. |
INVALID_AMOUNT | 400 | sellAmount is not a decimal integer, or is outside 1 to 2128-1; on /v1/swap also above the per-swap cap of the sell token. |
RATE_LIMITED | 429 | Over a rate limit. Retry-After header in seconds. |
BUSY | 503 | The concurrency cap is full. Answered at once, not queued. Retry-After: 1. |
NO_ROUTE | 422 | A valid request with no route over the supported venues for this pair and amount, or the output after fee (or minOut) rounds to zero. |
NO_EXECUTABLE_ROUTE | 422 | A valid request, but the route found is not one the contract can execute, or (on /v1/swap) the quote cannot be turned into a transaction. Rare: quotes are routed over the contract's venues only. |
TIMEOUT | 504 | The quote did not finish in 950 ms. |
UPSTREAM_ERROR | 502 | The routing backend is down or answered something unusable. Message: quote backend unavailable. |
NOT_FOUND | 404 | Unknown path. Also /v1/swap while it is switched off, which includes the first moments after a restart until the API has read the contract back. |
Retry BUSY, TIMEOUT and UPSTREAM_ERROR after a short wait. NO_ROUTE and NO_EXECUTABLE_ROUTE are answers about that request, not faults.
On-chain reverts
| Error | Cause |
|---|---|
TooLittleReceived | The recipient would receive less than minOut. Price moved; request again. |
Expired | Native swap sent after deadline. |
OverCap | Input above the per-swap cap, see Tokens. |
FeeTooHigh | feeBps above 50. Not produced by this API. |
NoPool, NoOutput | A pool in the route is missing or returned nothing. |
Rate limits and concurrency
Current values. Both endpoints share them, and a refused request counts too.
| Limit | Value |
|---|---|
| Per client IP | 120 requests a minute (2 a second sustained), burst of 20 |
| All clients together | 300 a minute (5 a second), burst of 30 |
| Quotes computed at once | 4. A fifth at the same moment gets BUSY immediately. |
| Time per request | 950 ms, then TIMEOUT |
A client that sends one or two requests a second, even a few at a time, is inside all of it. Past four requests in flight you will see BUSY. If you need more, say so on Telegram and I will raise it. These numbers are config, not part of the v1 compatibility promise.
Latency
I have no long-run figure to publish yet. What I measured on launch day: 10 calls from one machine, total time including the TLS handshake, 0.2 to 0.65 s, with quotes at 0.58 and 0.65 s and swaps at 0.38 to 0.48 s when a route was built. A NO_EXECUTABLE_ROUTE answer took 0.35 to 0.64 s. There is no SLA. The server gives up at 950 ms, so a slow answer becomes a TIMEOUT and not a long wait.
Tokens and caps
Quotes and swaps cover the same seven tokens, and both sides of a trade must be in the list. I only list tokens whose transfers I have checked for tax or rebasing, because the routing engine's number is what the pools pay out, before any tax the token takes on the way. Anything else returns UNSUPPORTED_TOKEN. I add tokens on request.
The contract also caps the input of one swap. A swap above the cap reverts with OverCap. Caps are fixed at deployment, so raising them means a new contract (see Versioning). /v1/swap refuses an amount above the cap with INVALID_AMOUNT before pricing it. /v1/quote still prices it (a quote is not a transaction), so check the cap yourself when you quote for a later swap. Output is not capped.
| Token | Address | Decimals | Cap per swap |
|---|---|---|---|
| WBNB | 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c | 18 | 15 (native BNB counts here) |
| USDT | 0x55d398326f99059fF775485246999027B3197955 | 18 | 10,000 |
| USDC | 0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d | 18 | 10,000 |
| BUSD | 0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56 | 18 | 10,000 |
| ETH | 0x2170Ed0880ac9A755fd29B2688956BD959F933F8 | 18 | 3 |
| BTCB | 0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c | 18 | 0.1 |
| CAKE | 0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82 | 18 | 4,000 |
Contract
UvRouter on BNB Chain, source verified on both BscScan and Sourcify:
0x8e625Bc6786F8DdA4DbC23bDeA0B9Ab7c625B893
No owner, no admin functions, no upgrade path, no pause. Fee cap, fee recipient, venues and per-swap caps are set in the constructor and cannot change. Users never approve it. Input arrives only through a Permit2 signature or as native BNB sent with the call. Pools are derived on chain from fixed factories, so a hop names a venue index, never an address. The API reads the contract's settings back at startup and serves /v1/swap only when they match what it expects.
Security
The security page has the threat model, every external call the contract makes, its trust assumptions and known limits, and how to check the deployment yourself. There is no paid audit. I reviewed the contract in four internal reviews, which are published in full. Its design limits what one bug can lose: input caps per swap and no standing approvals.
To report a problem, message me privately on Telegram, @uvstarr1. I scale a reward to the cap that was at risk.
Versioning and change policy
/v1 is stable. I will not remove or rename a field or an error code, change a type or a unit, or change what an existing field means inside /v1. A breaking change ships only as /v2, and I announce it in the changelog below at least 3 weeks before it goes live. That notice says how long /v1 keeps running.
These can happen in /v1 without notice: new response fields, new optional parameters, new tokens and chains, new error codes (handle an unknown code by its HTTP status), and changes to rate limits.
The contract address is the one exception to "stable". Caps and venues are immutable, so raising a cap or adding a venue means a new contract and a new tx.to. I will post the new address here at least 3 weeks before the API switches to it, and the old contract keeps working. Read tx.to from each response and you will follow the switch without a change on your side.
Changelog
- v1 launch.
GET /v1/quoteandGET /v1/swapon BNB Chain (56). Quotes route over the swap contract's venues as one path, so every quote is one/v1/swapcan build. Permit2, ERC-20 approval and native BNB flows. UvRouter at0x8e625Bc6786F8DdA4DbC23bDeA0B9Ab7c625B893, maximum fee 50 bps. OpenAPI spec published.