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 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.

ActorCanCannot, 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 allowanceNothing.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 callbackNothing.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 timeShift the deadline by seconds.Change the price outcome, because minOut is checked on the recipient's balance.
Me, the deployerChoose 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 APISign 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 APIChoose 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 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

WhereCallTarget
swapWithPermit2PERMIT2.permitWitnessTransferFrom(permit, (firstStop, amount), owner, witness, WITNESS_TYPE_STRING, sig)Immutable Permit2. firstStop is the first pool (V2) or this contract.
swapNativeWNATIVE.deposit{value}Immutable WBNB.
swapWithAllowancetokenIn.transferFrom(msg.sender, firstStop, amountIn), accepting tokens that return no valueThe input token the caller names, never Permit2. Pulls from the caller only.
_swapWNATIVE.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.
_swapV2pair.getReserves() and pair.swap(out0, out1, to, "") with empty data, so no flash callbackCREATE2(factory, keccak(token0, token1), init code hash), with a code check.
_swapV3pool.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 amountInThe token of the hop in progress.
_transfertoken.transfer(to, amount), accepting tokens that return no valuePath 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.
_balanceOftoken.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:

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

Accepted risks

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:

GetterValue
PERMIT20x000000000022D473030F116dDEE9F6B43aC78BA3
WNATIVE0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c (WBNB)
MAX_FEE_BPS50. FEE_BPS_CEILING is 100.
FEE_RECIPIENT0x2C3CB874A04deAdbb312430c53d3be0e94d4D252, a plain externally owned account that only receives fees
OTHER_TOKEN_CAP0
venueCounts2 V2 venues, 2 V3 venues
V2 venue 0, PancakeSwap V2factory 0xcA143Ce32Fe78f1f7019d7d551a6402fC5350c73, init code hash 0x00fb7f630766e6a796048ea87d01acd3068e8ff67d078148a3fa3f4a84f69bd5, fee 25 bps
V2 venue 1, Uniswap V2factory 0x8909Dc15e40173Ff4699343b6eB8132c65e18eC6, init code hash 0x96e8ac4277198ff8b6f785478aa9a39f403cb768dd02cbee326c3e7da348845f, fee 30 bps
V3 venue 0, PancakeSwap V3pool deployer 0x41ff9AA7e16B8B1a8a8dc4f0eFacd93D02d071c9 (pools come from the PoolDeployer, not the factory), init code hash 0x6ce8eb472fa82df5469c6ab6d485f17c3ad13c8cd7af59b3d4a8026c5ce0f7e2
V3 venue 1, Uniswap V3factory 0xdB1d10011AD0Ff90774D0C6Bb92e5C5c8b4461F7, init code hash 0xe34f199b19b2b4f47f68442619d555527d244f78a3297ea89325f843f87b8b54
inputCapThe 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.