UvRouter security
What the contract trusts, every external call it makes, what it cannot protect against, and how you can check the deployment yourself.
Contract
UvRouter on BNB Chain (chain 56). The source is verified on both BscScan and Sourcify.
0x8e625Bc6786F8DdA4DbC23bDeA0B9Ab7c625B893
The contract is about 340 lines of Solidity. It runs one exact-input swap along a path of AMM pools, for the uvrouter API. It has no owner, no admin function, no upgrade path and no pause. Every setting is fixed in the constructor.
This page is my own account of the contract. No outside firm has audited it. The internal reviews are published in full, with each finding and how I fixed it.
Design
Wallets using the Permit2 or native path never approve UvRouter itself. Input arrives in one of three ways.
- A Uniswap Permit2
permitWitnessTransferFromsignature. It is single use and binds the token, amount, nonce, deadline, spender (this contract) and a witness that covers every swap parameter. - Native BNB sent with the call (
swapNative). - An ERC-20 the caller approved and then spends in its own transaction (
swapWithAllowance), for smart wallets and aggregators. It pulls frommsg.senderonly. See Allowance path.
A hop names a venue index, a pool kind, a fee tier and an output token. It never names an address. The contract derives each pool with CREATE2 from factories or deployers fixed in the constructor, and calls that pool's own swap function. minOut is checked against the recipient's balance change, after the fee.
The fee comes out of the output at the rate the payer chose (feeBps). The contract refuses any rate above MAX_FEE_BPS, which is an immutable of at most 100 and is 50 on this deployment. Fees go to an immutable recipient.
Refunds follow three rules. Input that was never swapped goes back in full. A leftover intermediate token, for example when a pool ran dry part way, goes back less the same signed fee. Any output token still held after delivery, which happens with rebasing or reflection tokens, goes to the payer.
The contract holds nothing between transactions. Its only balances are tokens someone sent it by mistake, and the next swap that touches that token collects them.
Threat model
For each actor, what it can do and what it cannot, with the reason.
| Actor | Can | Cannot, and why |
|---|---|---|
| Anyone holding a user's Permit2 signature (a relayer, a bot, an aggregator) | Submit it unchanged and pay the gas, including before the user does. The parameters stay the same but the outcome may not: the submitter picks the moment within the deadline, so the price can be anywhere down to minOut. A transaction that originally carried the signature, such as a multi-step integrator call, then reverts on the used nonce. | Change the recipient, minOut, path, output token, unwrap flag, fee, amount, token, deadline or owner, because all of them are in the digest Permit2 verifies. Replay it, because the nonce is single use. Use it on another router, because the spender is bound. |
| A caller against another user's allowance | Nothing. | UvRouter never calls Permit2's allowance-based transferFrom, so a standing Permit2 allowance to it is unreachable. Its one ERC-20 transferFrom always has from = msg.sender, so a direct approval to UvRouter is spendable only by the approver's own call. |
| A fake pool or a spoofed callback | Nothing. | No hop carries an address. A V3 callback pays only if msg.sender equals the transient _expectedPool, which is set just before pool.swap, only ever holds a CREATE2-derived address, and is cleared on first use. A pool that asks for more than the hop's input, calls back twice or never calls back makes the swap revert. |
| A malicious token the user chose (input, output or intermediate) | Hurt the swap that uses it: report a fake balance, take a tax, revert. | Re-enter, because every entry point shares one transient lock. Reach other users' funds, because the contract holds nothing between transactions and user funds stay in user wallets behind single-use signatures or exact approvals. |
| A validator moving block time | Shift the deadline by seconds. | Change the price outcome, because minOut is checked on the recipient's balance. |
| Me, the deployer | Choose the constructor values, once: the fee ceiling, fee recipient, venues and caps. | Change anything later, pause, upgrade or move tokens. There is no privileged role. |
| A user or integrator building its own transaction, not through my API | Sign or pass any feeBps from 0 to MAX_FEE_BPS. The contract enforces a ceiling and no floor, so the fee is collected only on transactions my API builds and the user signs as built. This is a choice I made. A floor would need an immutable minimum or an operator co-signature, which means a key, and I left both out. | Make anyone else pay a fee they did not sign. Take more than MAX_FEE_BPS: FeeTooHigh reverts before anything moves. |
| A compromised or buggy uvrouter API | Choose every value the user is asked to sign. See the next section. | Spend without a signature, spend more than the signed amount, or spend after the deadline. |
What a compromised API can do
This is the largest risk in the system, and the contract cannot remove it. My API fills in what the user signs: the recipient (from the recipient parameter or the taker), minOut, the fee up to 50 bps, the hops, the token and the amount. If it were compromised, a user who signed without reading could lose up to one cap, about $10,000, per signature, for every user who signs while it is compromised.
The contract cannot tell a fair minOut from an unfair one. It has no price oracle, and a maximum deviation from the quote would either need one or bind a number that the same API chose. What limits the damage:
- The wallet shows everything that was chosen. The hops are signed as a typed array, so a wallet lists each hop's output token next to the recipient, output token and
minOut. A route through an unknown token, or a recipient that is not the user, is visible before signing. minOutis a raw integer in base units. Integrators and my own site must compare it with their own price, from a second quote or from the amount they showed the user, and not ask for a signature when it sits further under that than the stated slippage. That check belongs in the client.- At startup the API reads the contract's Permit2, WBNB,
MAX_FEE_BPS, fee recipient, witness type, venues and caps over RPC, and serves/v1/swaponly when they equal its config. A wrong contract address cannot make users sign permits to a stranger. - The API derives
minOutfrom the route's raw expected output for the whole sell amount, less the fee and the slippage setting (default 50 bps, maximum 500). It builds no transaction when the engine did not report that figure or took a solver fee out of the amount. The contract refusesminOut == 0. - Caps limit the loss to one capped input per signature.
The fee has the same property. On the Permit2 path the wallet shows feeBps as a bare number. On the native path it sits in calldata the wallet does not decode. A bad API could set 50 bps where my fee table says 5. The contract bounds that at 50 bps, the fee goes only to my own recipient, and minOut already includes it.
Every external call
| Where | Call | Target |
|---|---|---|
swapWithPermit2 | PERMIT2.permitWitnessTransferFrom(permit, (firstStop, amount), owner, witness, WITNESS_TYPE_STRING, sig) | Immutable Permit2. firstStop is the first pool (V2) or this contract. |
swapNative | WNATIVE.deposit{value} | Immutable WBNB. |
swapWithAllowance | tokenIn.transferFrom(msg.sender, firstStop, amountIn), accepting tokens that return no value | The input token the caller names, never Permit2. Pulls from the caller only. |
_swap | WNATIVE.withdraw(net), then recipient.call{value: net}("") | WBNB, then the recipient the payer chose. It is the last call, and minOut is checked after it. |
_swapV2 | pair.getReserves() and pair.swap(out0, out1, to, "") with empty data, so no flash callback | CREATE2(factory, keccak(token0, token1), init code hash), with a code check. |
_swapV3 | pool.swap(to, zeroForOne, amountIn, min or max sqrt limit, "") | CREATE2(deployer, keccak(abi.encode(token0, token1, fee)), init code hash), with a code check. |
_payPool (V3 callback) | tokenIn.transfer(pool, owed), with owed at most amountIn | The token of the hop in progress. |
_transfer | token.transfer(to, amount), accepting tokens that return no value | Path tokens only: the fee to the fee recipient, the output to the recipient, refunds to the payer, and the input to the first pair on the native path. |
_balanceOf | token.balanceOf(who) (view) | Path tokens. |
There is no delegatecall, no selfdestruct, no arbitrary call target and no arbitrary calldata. The external surface is the three swap entry points, witnessHash, the two V3 callbacks, the views (v2Venue, v3Venue, venueCounts, poolFor, inputCap) and the constant and immutable getters, including HOP_TYPE, HOP_TYPEHASH, MAX_FEE_BPS, FEE_BPS_CEILING and a version getter. You can list it from the compiled source with forge inspect UvRouter methodIdentifiers.
Signature and witness
The Permit2 witness is a typed EIP-712 struct. It holds the output token, minOut, the recipient, the unwrap flag, feeBps and the hops. Each hop is a typed struct of kind, venue index, fee tier and output token, so a wallet can show every one instead of an opaque hash. Permit2 itself binds the input token, amount, nonce, deadline and spender.
Two details matter when you rebuild the type string. EIP-712 sorts referenced types alphabetically, so the Permit2 string ends with Hop, Swap, TokenPermissions in that order. The contract exposes WITNESS_TYPE, WITNESS_TYPEHASH, HOP_TYPE and HOP_TYPEHASH, and witnessHash returns the hash for a given set of parameters.
Contract wallets (EIP-1271) and EIP-7702 accounts work. Permit2 calls the owner's isValidSignature whenever the owner has code, so the account's own signature format applies. The contract passes the signature through at any length. The API's calldata holds a 65-byte placeholder, and a signature of another length is spliced in as ABI bytes.
The API and the contract agree byte for byte. I generate typed-data JSON and calldata with the API's builder for several cases, including fees of 0, 1, 5, 15, 30, 50 and 2256-1, and check on the Solidity side that forge's own EIP-712 hash of that JSON equals the contract's digest and that the calldata hashes match.
Allowance path
swapWithAllowance(address tokenIn, uint256 amountIn, SwapParams p, uint256 deadline) exists for callers that take the user's ERC-20 into their own contract, approve a router and call it. Most aggregators and smart wallets work this way. The guarantees:
- Nobody can spend someone else's allowance.
fromis not a parameter, it ismsg.sender. A contract can be made to spend its own approval only by calling UvRouter itself. The contract has onetransferFrom, in one place. - The pull is exact.
transferFromasks for exactlyamountIn, so an exact approval ends at zero. Unswapped input, for example from a V3 pool that ran dry, goes back tomsg.senderin full. - It runs the same checks as the other entry points before the pull: the cap on
tokenIn,minOut > 0,feeBps <= MAX_FEE_BPS, a valid recipient, 1 to 4 hops, no return to the input token, unwrap only to WBNB, and the first pool derived with CREATE2. After the deadline it reverts. - Then it runs the same swap code: the same hops and callback rules, the fee on the output and on leftover intermediates,
minOuton the recipient's balance change, and refunds to the payer. - Fee-on-transfer input is priced on what arrived. A V2 first hop swaps the pair's balance minus its reserve, and a V3 first hop swaps this contract's balance. A taxed input into a V3 first hop reverts as a whole, as it does on the pools themselves.
- All entry points share one transient lock. An input token whose
transferFromre-enters any entry point or callback is refused. - Permit2 is refused as
tokenIn. It could never work, since Permit2's owntransferFromhas a different signature, and the explicit check makes that independent of selectors. - A contract caller works. Refunds are ERC-20 transfers to
msg.sender, and unwrapped output goes to the recipient the caller chose.
This path gives up one property of the Permit2 path: users who approve UvRouter directly have a standing approval. It can be spent only in a transaction the approver sends, or for a contract, one the contract makes. A lifted signature, a phishing page or a compromised API cannot pull from it, because they would need the approver to send a transaction, and the approver sees to, tokenIn and amountIn in its own calldata.
It does not protect against a bug in UvRouter that lets msg.sender be someone else, which I found no way to produce, or against an integrator contract that lets anyone make it call UvRouter with its own approval and balance. That second case is the integrator's own surface, the same as for any DEX it approves. An aggregator that max-approves leaves a standing unlimited approval, and the per-swap caps do not bound what a hypothetical from bug could take from a standing approval, one capped call at a time. What bounds a taker's loss is that the router pulls only from msg.sender and exactly amountIn. I recommend exact approvals, and the API returns the exact amount to approve.
Refunds go to msg.sender. When that is an integrator's contract, unspent input and a fee-charged intermediate land there, and whether the end user gets them back is the integrator's logic.
Caps and fee bounds
Per-swap input caps are immutable and checked on the input token before anything moves. The caps are about $10,000 each. Any other input token is refused (OTHER_TOKEN_CAP is 0). Native BNB and WBNB share one cap. The values are in the token table of the API docs. A bug can lose at most one capped input per swap call. One transaction can contain many calls, so this is a bound per call, not per transaction, and not a total volume limit. Raising a cap means deploying a new contract.
The fee rate is part of the signature on the Permit2 path and an explicit argument on the other two. MAX_FEE_BPS is 50 and cannot exceed the 100 bps ceiling that is compiled in. The API chooses a rate per swap from a table whose own maximum is 30 bps, so the contract's 50 is headroom and not the normal fee. The fee rounds down, so it is zero for outputs below 10,000 / feeBps base units, which is 2,000 at 5 bps and 200 at 50 bps. Fee math was fuzzed across output tokens with 0 to 24 decimals.
FeeTooHigh reverts in the input check, before any external call. I tested that with state-diff recording on every entry point.
Known limits and residual risks
What I accepted, stated plainly.
Out of scope
- Venues other than UniV2-style pairs and UniV3 or PancakeV3 pools: no Algebra, no V4 or Infinity, DODO, Curve, RFQ, 0x or Kyber. When the engine's best route uses one of them, the API answers
NO_EXECUTABLE_ROUTE. - V2 forks with per-pair fees. There is one fee per venue, and a fee set too low makes the pair's K check revert, so it fails safe.
- Split routes, exact-output swaps and more than 4 hops.
- Chains other than BNB Chain. The venue table and caps are BSC's.
Accepted risks
- A bad
minOutthat the user signs. See the compromised API section. - A bug in UvRouter can only touch the swap being executed: the input pulled for it, at most its cap, and the output passing through. It cannot touch wallets that did not sign or approve, and it cannot touch other users, since the contract holds no balance between transactions.
- The fee is optional on chain. Only transactions my API builds carry its fee. Anyone can sign fee 0 on their own transaction, and a front end that copies my API's response can set it to 0 and ask the user to sign again. This costs me revenue and puts no user at risk. The fee depends on pair, class and size bucket, so a user can split an order into a cheaper bucket, and
/v1/quoteand/v1/swapcan differ by one bucket at an edge. Each response carries its own signed rate. - Tokens left in the contract go to the next swap that touches that token. Only what was given away is lost. Dust donated in an intermediate token cannot block routes through it, because it is refunded less the fee.
- The
Swappedevent can be emitted with self-made tokens and pools. Volume or fee accounting must filter on the verified token list and read the event'sfeefield, since fees taken on intermediate refunds are not in it. - Thin V3 pools with a small tick spacing can burn a lot of gas when a swap exhausts them, because there is no price limit. Only the submitter pays, and the API routes only through the engine's pools.
- BNB forced in by selfdestruct or as a block reward is stuck forever. Nobody can take it.
- Fee-on-transfer and rebasing tokens.
minOutholds on what arrives, so a taxed output is measured correctly, and a rebasing output's residue goes to the payer. The API quotes only a short list of tokens whose transfers I checked. - The gas limit in API responses comes from a table of measured executions plus 25 percent. A V3 hop that crosses many ticks can still exceed it, and the swap then reverts without a loss.
- The caps are in token units, so their dollar value drifts with price.
- The fee recipient is permanent. If a token ever blocked that address, swaps with that output token and a nonzero fee would revert.
- A short Solidity compiler history. See the compiler section.
Compiler and settings
UvRouter is built with solc 0.8.38 through the legacy pipeline (no via-IR), optimizer on at 1,000,000 runs, EVM cancun, bytecode_hash = "none". Verification on BscScan and Sourcify uses the same settings.
I chose 0.8.38 from the official bug list (binaries.soliditylang.org/bugs_by_version.json), which showed an empty list for it on the day I checked. It was released on 2026-10-22, three weeks before deployment, and an empty list for a new release partly means nobody has looked hard yet. The alternative was 0.8.37 with one low-severity bug in the Yul optimizer, which could apply even without via-IR because the ABI coder's routines pass through the optimizer. I took the newer release and I re-checked the list on deploy day.
The test suite and the Rust and Solidity cross-check digests do not depend on the compiler version. An internal review found, before deployment, that my build configuration had been compiling UvRouter through via-IR by accident. I fixed it, and a test now fails if the compiled artifact's metadata says viaIR. Details are in review C.
Verify the deployment yourself
Everything the API assumes can be read from the contract. Use any BNB Chain RPC. The commands below use cast from Foundry and a public endpoint.
R=0x8e625Bc6786F8DdA4DbC23bDeA0B9Ab7c625B893
RPC=https://bsc-rpc.publicnode.com
cast call $R "PERMIT2()(address)" --rpc-url $RPC
cast call $R "WNATIVE()(address)" --rpc-url $RPC
cast call $R "MAX_FEE_BPS()(uint256)" --rpc-url $RPC
cast call $R "FEE_BPS_CEILING()(uint256)" --rpc-url $RPC
cast call $R "FEE_RECIPIENT()(address)" --rpc-url $RPC
cast call $R "OTHER_TOKEN_CAP()(uint256)" --rpc-url $RPC
cast call $R "WITNESS_TYPE()(string)" --rpc-url $RPC
cast call $R "venueCounts()(uint256,uint256)" --rpc-url $RPC
cast call $R "v2Venue(uint256)((address,bytes32,uint256))" 0 --rpc-url $RPC
cast call $R "v3Venue(uint256)((address,bytes32))" 0 --rpc-url $RPC
cast call $R "inputCap(address)(uint256)" 0x55d398326f99059fF775485246999027B3197955 --rpc-url $RPC
Expected values:
| Getter | Value |
|---|---|
PERMIT2 | 0x000000000022D473030F116dDEE9F6B43aC78BA3 |
WNATIVE | 0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c (WBNB) |
MAX_FEE_BPS | 50. FEE_BPS_CEILING is 100. |
FEE_RECIPIENT | 0x2C3CB874A04deAdbb312430c53d3be0e94d4D252, a plain externally owned account that only receives fees |
OTHER_TOKEN_CAP | 0 |
venueCounts | 2 V2 venues, 2 V3 venues |
| V2 venue 0, PancakeSwap V2 | factory 0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73, init code hash 0x00fb7f630766e6a796048ea87d01acd3068e8ff67d078148a3fa3f4a84f69bd5, fee 25 bps |
| V2 venue 1, Uniswap V2 | factory 0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6, init code hash 0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f, fee 30 bps |
| V3 venue 0, PancakeSwap V3 | pool deployer 0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9 (pools come from the PoolDeployer, not the factory), init code hash 0x6ce8eb472fa82df5469c6ab6d485f17c3ad13c8cd7af59b3d4a8026c5ce0f7e2 |
| V3 venue 1, Uniswap V3 | factory 0xdB1d10011AD0Ff90774D0C6Bb92e5C5c8b4461F7, init code hash 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54 |
inputCap | The values in the token table, in 18-decimal base units |
To check the code itself, compile the verified source with the settings above, and compare the runtime bytecode with cast code at the contract address. With bytecode_hash = "none" there is no metadata suffix to differ. Sourcify's full match and BscScan's verification make the same claim.
The source repository also holds a read-only CheckDeployed forge script that reads every immutable, venue and cap from a deployed router and compares them with the expected values. I will link the repository once it is public.
Reviews
Before deployment I ran four internal reviews of the contract and the API code that builds its transactions: two independent full reviews, one of the per-swap fee and one of the allowance entry point. They found no Critical or High issue in the contract. They did find one Medium, which concerns the API and not the contract, and a handful of Low and Info items. Every finding was either fixed with a regression test or accepted and written down above. A static analysis pass with Slither ended with no open item.
The last full run of the test suite, including tests against live BNB Chain state, passed 155 tests with none failing. The tests include fuzzing, invariants that no attack mutation succeeds, and fork tests whose outputs equal PancakeSwap's own quoter and router.
The reviews are not an outside audit. They are written down in full on the reviews page.
Bug bounty and contact
If you find a problem, message me privately on Telegram, @uvstarr1. Please do not test it with other people's funds. I scale the reward to the cap that was at risk.
Address changes
Caps and venues are immutable, so raising a cap or adding a venue means a new contract at a new address. I will post the new address in the API docs at least 3 weeks before the API switches to it. The old contract keeps working.