Internal reviews

Four reviews of UvRouter before it went live, with every finding and what I did about it.

About these reviews

No outside firm has audited UvRouter. I reviewed it myself in four passes. Reviews A and B were two independent full reviews, started from different prompts: one was given only the source and the threat model, the other the code plus well-known router-drain post-mortems, without my conclusions. Each wrote its own tests and proofs of concept. Reviews C and D were targeted at one change each. After A and B I fixed the findings with a regression test for each, and both reviewers re-checked the fixes.

All four finished before the contract was deployed. Where a finding describes a state of the code or the build, that state was found and fixed before deployment. The deployed contract is the one on the security page. Severities are the reviewers' own.

ReviewScopeCriticalHighMediumLowInfo
AContract, deploy scripts, tests and the API's transaction builder00159
BContract and builder, eight properties00014
CThe per-swap signed fee and the build settings00016
DThe allowance entry point and the API code around it00013

Review D found nothing in the contract itself. Its Low finding and one Info item were in the API.

Review A

A full review of the contract, the deploy scripts, the tests and the Rust code that builds transactions. Verdict: no way found for anyone other than the signer to move a user's funds, redirect output, replay or re-target a signature, or make a fake pool pull tokens. The one Medium is not a contract bug. It is how much a compromised or buggy API can take from a user who signs, because the route was signed as an opaque hash and minOut as a raw integer.

What held

Findings

IDSeverityFindingOutcome
A-06MediumThe route is signed as an opaque hash and minOut as a raw integer. A compromised API could return minOut = 1 and a hop through an attacker-seeded pool and take a swap, up to one cap per signature.Reduced and documented. Hops are now a typed array that wallets display. Recipient, output token and minOut were already readable. The residual risk is on the security page.
A-01LowThe fee could be skipped. A last hop into a self-made dust pool returned the real output as a fee-free refund. A fork proof of concept paid no fee on a 612 USDT swap.Fixed. A leftover intermediate token pays the swap's fee on its refund, and a path may not return to the input token. The same proof of concept now pays the fee and the user gets no more.
A-07LowminOut could sit further under the real output than the stated slippage, if the engine's dry run charged a fee in the sell token.Fixed in the API. minOut comes from the route's raw output for the whole sell amount, and no transaction is built without it or with a solver fee.
A-12LowThe API never checked its config against the deployed contract, so a wrong contract address would have made users sign a permit to an arbitrary spender.Fixed. The API reads the contract back at startup and answers 404 on /v1/swap until everything matches, retrying every minute. The deploy script runs the same read-back.
A-16LowA 65-byte signature placeholder excluded contract wallets and EIP-7702 accounts, which would revert.Fixed. A signature of any length is spliced into the calldata.
A-04LowA lifted signature can be executed first, which reverts the transaction that carried it. The swap runs as signed, but the executor picks the moment.Documented for integrators. The parameters are the same, the outcome may not be.
A-02InfoAny token sitting in the router goes to the next caller.Accepted, documented.
A-03InfoThe fee rounds to zero on tiny outputs.Accepted, documented.
A-05InfoSwapped can be emitted with self-made tokens.Accepted. Accounting must filter on the verified token list.
A-08InfoThin V3 pools with a small tick spacing can burn a lot of gas.Accepted. Only the submitter pays.
A-09InfoForce-sent BNB is stuck.Accepted. Nobody can take it.
A-10InfoA Solidity compiler bug affecting transient storage clearing under via-IR in 0.8.28 to 0.8.33.The compiler pin moved to 0.8.38. See review C, which found the build was via-IR after all.
A-11InfoThe fee recipient came from an environment variable with no check and cannot be rotated.Fixed. It is a constant in the deploy config, and the script reads every immutable, venue and cap back.
A-13InfoThe gas estimate is a fixed table and can under-estimate.Fixed with 25 percent headroom on the gas limit.
A-15InfoGenerated nonces could collide across API processes.Fixed with a random per-process salt in the nonce's top bits.

Review A also corrected my own notes in several places: the API does fill in the recipient, front-running gives the same parameters and not the same outcome, intermediate tokens are refunded, and the per-swap bound holds per call and not per transaction. Not covered: the engine's internals and the token prices behind the cap sizes.

Review B

A property-by-property review with 29 tests of its own, including fuzz runs and five fork tests. Seven of eight properties held and one failed on an edge case.

PropertyResult
The signature binds exactly the signed swap, with no replay and no third-party changeHolds. Fourteen fields were each changed in turn (output token, minOut, recipient, unwrap flag, each hop field, hop count, amount, token, nonce, deadline, owner) and each change was rejected. Dirty high bits in calldata are rejected.
Funds go only to derived pools, and the callback pays only the live pool, only what it owes, onceHolds, including a token hook calling the callback during a live swap. No other code can sit at a derived PancakeSwap V3 address.
The recipient receives at least minOut by balanceHolds, including taxed outputs and a recipient that is a pool, the fee recipient or the token.
ReentrancyHolds from the output token, the input token and the unwrap recipient.
The router holds no user funds after a callFailed on an edge case, finding B-1.
Caps and fee mathHolds. Native and WBNB share one cap, unlisted tokens are refused, and the fee is bounded and rounds down.
Native BNB in and out, and stray BNBHolds. Forced BNB cannot be extracted.
The API's builder matches the contractHolds. Six cases built by the real builder match the contract for the witness hash, the Permit2 digest and both calldata forms. A signature made by cast over the typed-data JSON recovers against the contract's digest.

Findings

IDSeverityFindingOutcome
B-1LowA rebasing or reflection output token left a residue in the router. The contract refunded the input and the intermediates but never the output token, so with a 0.1 percent rebase per transfer about 0.1 percent of the output stayed behind, for the next swap to collect.Fixed. The output token is swept to the payer after delivery. A regression test and an invariant that the router is empty after any route cover it.
I-1InfoThe wallet showed the route only as an opaque hash.Fixed with A-06.
I-2InfoCaps limit the signed input of one swap, not total volume or donated tokens.By design, documented.
I-3InfoForced BNB is stuck.Same as A-09.
I-4InfoNothing checked that the API's fee setting equalled the contract's.Fixed with A-12.

Review C: the per-swap fee

After reviews A and B I changed the fee from one constant fixed at construction to a rate per swap, signed in the Permit2 witness and passed as an argument on the native path. Review C covered that change, the fee engine in the API, and the compiler pin. It found no way for anyone but the signer to change a swap's fee, to charge above MAX_FEE_BPS, or to use the refund paths against a user. The full suite, with fork tests, passed 115 of 115 at that point.

What the review checked included: type strings rebuilt from their parts; wallet typed data hashed by forge's independent EIP-712 code and executed on the real Permit2; a submitter trying every other fee value (each failed with an invalid signer or FeeTooHigh, and the signature stayed usable); contract wallets; FeeTooHigh before any external call, shown by state-diff recording; fee math for output tokens with 0 to 24 decimals; minOut measured after the fee; two stalled intermediate hops; and the native path. In the API, four independent bounds hold the fee: config validation, a serve-time clamp on every table entry, a check in the transaction builder, and the contract's own limit. No request parameter reaches the fee.

IDSeverityFindingOutcome
C-1LowUvRouter was built through via-IR by accident, the opposite of what my notes said. My build configuration restricted a Permit2 dependency to via-IR, and forge compiles every file that imports a restricted file under the restricted profile, so UvRouter's import of a Permit2 interface pulled UvRouter into it. On solc 0.8.28 that build met the conditions of a known high-severity transient-storage bug. With 0.8.38 no known bug applies in either pipeline, but via-IR on the newest compiler was a less conservative choice than I had documented.Fixed before deployment. Only two files that need via-IR are restricted, UvRouter builds through the legacy pipeline, and a test fails if the compiled metadata says viaIR. The suite passed on both builds. Nothing was deployed with the earlier configuration.
C-2Infosolc 0.8.38 was 23 days old. An empty bug list on a new release partly means nobody has looked yet.Kept 0.8.38 with the legacy pipeline, and re-checked the bug list on deploy day.
C-3InfoA caller can sign fee 0. The only risk is lost revenue. Accounting must read the fee field of Swapped, which excludes fees on intermediate refunds.Documented as a design choice.
C-4InfoThe fee is the API's choice up to 50 bps. The wallet shows a bare feeBps number, and on the native path the fee is in calldata the wallet does not decode.Bounded by the immutable limit and covered by the compromised-API risk. Signing screens should print the fee as a percentage.
C-5InfoThe fee rounds to zero below 10,000 / feeBps base units of output.Accepted. Negligible for 18-decimal tokens.
C-6InfoThe fee depends on pair, class and size bucket, so splitting an order can reach a cheaper bucket, and quote and swap can differ by one bucket at an edge. Bad input data to the fee table can push fees only up to the table's maximum of 30 bps, never past the contract's 50.Accepted. Each response carries the rate that is signed.
C-7InfoMy notes said the build was legacy.Corrected with C-1.

Review D: the allowance path

Before deployment I added swapWithAllowance, so smart wallets and aggregators can pay an ERC-20 from their own balance with approve and transferFrom. Review D covered it with five questions. The contract change held on all five and the review found nothing in the contract. The full suite, including fork tests, passed 155 of 155.

IDSeverityFindingOutcome
D-1LowThe API checked the input cap after calling the pricing engine. An over-cap request still never got a transaction, but it cost an engine call and could hide the cap error behind an engine error.Fixed. The cap is checked before the engine is called.
D-2InfoThe API's ERC-20 mode was not yet reachable from the server. It was safe, since such a request returned an error, and the code that would choose the mode per request had not been written.Wired in before launch, with a version gate on the contract, automatic mode selection from the taker's code, and the cap check first.
D-3InfoWith an integrator as the caller, refunds stop at the integrator. A generic-call integrator that max-approves can have its own stray balances spent by its next caller. That is the integrator's surface, as with any DEX.No contract change. Stated for integrators on the security page.
D-4InfoTwo statements in my notes were too strong: that an aggregator approves per call, which a max-approving integrator does not, and that the caps bound the loss from a hypothetical from bug on standing approvals, which they do not.Corrected. The real limit is exact approvals, which the API already returns.

The review also confirmed that a comment cleanup I made in the same change altered no bytecode, by compiling the source with and without the five code additions and comparing hashes. Not covered: the Slither rerun, the pricing engine, and the integrators' own contracts.

Tests and tools

Back to the security notes. To report a problem, message me on Telegram, @uvstarr1.