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

ChainchainIdStatus
BNB Smart Chain56live

Any other chainId gets UNSUPPORTED_CHAIN. Both endpoints are exact input only (you give the sell amount).

GET /v1/quote

GET/v1/quote

A price for selling an exact amount. Nothing is signed or reserved.

Query parameters

NameTypeNotes
chainIdinteger, requiredMust be 56.
sellTokenaddress, required0x plus 40 hex characters, any case. Must be one of the supported tokens, or native BNB (below).
buyTokenaddress, requiredSame rules. Must differ from sellToken.
sellAmountstring, requiredDecimal 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

FieldTypeMeaning
chainIdinteger56.
sellToken, buyTokenaddressEcho, lowercase. A native token is echoed as sent.
sellAmountstringEcho, base units.
buyAmountstringExpected 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.
feeBpsintegerThe fee already taken out of buyAmount, in basis points. See Fees.
route.hops[]arrayOne 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).
gasEstimatestring or nullThe engine's gas estimate for the route. For the gas limit of the UvRouter transaction use tx.gas from /v1/swap.
quoteIdstringUnique id, useful when you write to me about a quote.
validForintegerSeconds 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

GET/v1/swap

The same quote plus the transaction that executes it on the UvRouter contract. Takes every parameter of /v1/quote and these:

NameTypeNotes
takeraddress, requiredThe payer: signs the permit, or sends the BNB. Not the zero address.
recipientaddressReceives the output. Default: taker. Not the zero address and not the UvRouter contract.
slippageBpsinteger0 to 500. Default 50. Sets minOut.
approvalpermit2 or erc20How 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).
noncestringA 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.

Response

Every field of the quote response, plus:

FieldTypeMeaning
minOutstringLeast 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.
slippageBpsintegerEcho of the value used.
deadlineintegerUnix seconds, 120 s after the response. The permit and the transaction are both dead after it.
tx.toaddressThe UvRouter contract. Use this value, do not hard-code it (see Versioning).
tx.datahex stringCalldata. For an ERC-20 sell it carries a 65-byte zero placeholder where the signature goes.
tx.valuestringWei to send: sellAmount for native in, else "0".
tx.gasstringA gas limit with about 25% headroom over my fork-test figures. Unused gas is not charged.
permit2object or nullnull for native in and for approval=erc20. Otherwise typedData (an eth_signTypedData_v4 payload) and signatureOffset.
allowanceobject or nullThe 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.
gasEstimatestringReplaces 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

  1. The taker approves Permit2 (0x000000000022D473030F116dDEE9F6B43aC78BA3) for sellToken, once. Nobody approves UvRouter.
  2. Sign permit2.typedData with eth_signTypedData_v4, unchanged. It is Permit2's PermitWitnessTransferFrom with a witness that holds output token, minOut, recipient, unwrap flag, feeBps and every hop as a typed list, so the wallet shows them.
  3. Write the 65-byte signature (r || s || v, v = 27 or 28) into tx.data at byte offset permit2.signatureOffset, over the zero placeholder. The offset counts bytes of the decoded calldata, selector included.
  4. Send {to, data, value, gas} from any account. The signature binds every parameter, so whoever submits it cannot change the recipient, amount, path, minOut or 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

  1. The taker approves UvRouter (allowance.spender) for at least allowance.amount of sellToken. Nothing is signed.
  2. Send tx from the taker. The call is swapWithAllowance(tokenIn, amountIn, params, deadline); it pulls exactly amountIn from msg.sender and 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

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"
  }
}
CodeHTTPMeaning
INVALID_REQUEST400A 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_CHAIN400chainId is not 56.
INVALID_TOKEN400A 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_TOKEN400Not a supported token.
INVALID_AMOUNT400sellAmount 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_LIMITED429Over a rate limit. Retry-After header in seconds.
BUSY503The concurrency cap is full. Answered at once, not queued. Retry-After: 1.
NO_ROUTE422A 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_ROUTE422A 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.
TIMEOUT504The quote did not finish in 950 ms.
UPSTREAM_ERROR502The routing backend is down or answered something unusable. Message: quote backend unavailable.
NOT_FOUND404Unknown 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

ErrorCause
TooLittleReceivedThe recipient would receive less than minOut. Price moved; request again.
ExpiredNative swap sent after deadline.
OverCapInput above the per-swap cap, see Tokens.
FeeTooHighfeeBps above 50. Not produced by this API.
NoPool, NoOutputA 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.

LimitValue
Per client IP120 requests a minute (2 a second sustained), burst of 20
All clients together300 a minute (5 a second), burst of 30
Quotes computed at once4. A fifth at the same moment gets BUSY immediately.
Time per request950 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.

TokenAddressDecimalsCap per swap
WBNB0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c1815 (native BNB counts here)
USDT0x55d398326f99059fF775485246999027B31979551810,000
USDC0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d1810,000
BUSD0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D561810,000
ETH0x2170Ed0880ac9A755fd29B2688956BD959F933F8183
BTCB0x7130d2A12B9BCbFAe4f2634d864A1Ee1Ce3Ead9c180.1
CAKE0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82184,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