Method
Every figure comes from a real transaction on a throwaway Arbitrum Nitro dev node in Docker — the cheapest chain that executes both EVM bytecode and WASM. One swap per transaction, so the cost is read off the receipt rather than estimated. Both implementations are deployed to the same node, against the same pool manager, and where both compute the same thing the benchmark checks they agree before it measures.
Both sides are optimised. Solidity is compiled with
optimizer = true, optimizer_runs = 200, which is what OpenZeppelin's
uniswap-hooks ships with. Rust is release-built with LTO,
opt-level = 3 and the pinned wasm-opt flags.
Gas is L2 execution gas only: the benchmarks set
ArbOwner.setL1PricePerUnit(0), because the L1 data-posting component is
identical across the variants being compared and would otherwise dwarf the difference.
Every Stylus figure is the cached one, since caching is a one-off bid
and a hook with users would be bid for. Where it matters the uncached figure is given
too.
1. The floor: a hook that only counts
./bench-counter.bash. The hook writes one storage slot per callback and
computes nothing, and a swap enters it twice. This is the worst case for Stylus, and it
comes first so everything below is read against it.
| Hook | Gas per swap | Hook costs |
|---|---|---|
| No hook | 115,065 | — |
Counter.sol, pure Solidity |
134,003 | +18,938 |
native-counter, Rust, cached |
168,941 | +53,876 |
native-counter, Rust, uncached |
199,595 | +84,530 |
Stylus costs 2.8× what Solidity does here, and that is the workload's fault rather than the port's. The 30,654 gas between the two Rust rows is the WASM load, paid twice per swap, and the rest is a hook with nothing for Stylus to win back.
2. Where the crossover is
./bench-compute.bash runs the identical loop in both languages and sweeps
how much of it there is. The interesting column is mulDiv, because that is
what Uniswap's own swap math is made of.
mulDivs per swap |
Solidity | Rust |
|---|---|---|
| 0 | 124,362 | 163,869 |
| 50 | 159,158 | 176,341 |
| 200 | 263,258 | 213,469 |
| 1,000 | 818,458 | 411,485 |
| 5,000 | 3,594,426 | 1,401,533 |
694 gas per mulDiv in Solidity against 247 in Rust, with about 39,000 gas
more to enter the Stylus contract: the two cross at 89 operations.
Stylus costs more to enter and far less to run, so the question is only ever how much arithmetic there is.
Per operation
The gain tracks how badly the work fits a 256-bit word. Storage is a host operation either way, and barely moves.
| Operation | What it needs | Solidity | Rust | Ratio |
|---|---|---|---|---|
| xorshift64 | 64-bit words | 115 | 2.8 | 41.6× |
integer sqrt, EulerSwap's |
512-bit intermediate plus bit length | 2,009 | 362 | 5.6× |
plain a * b / c |
one MUL, one DIV, checked |
278 | 51 | 5.5× |
mulDiv |
512-bit intermediate | 694 | 204 | 3.4× |
rpow, Bunni's LDF |
mulDiv in Q96, fits in 256 bits |
3,058 | 1,019 | 3.0× |
| one storage write | a host operation either way | 2,397 | 2,265 | 1.06× |
Storage does not move, and that is not an artefact: a cold slot costs 2,100 to touch and 100 to write in both languages. What Stylus shaves is only the arithmetic wrapped around the access — hashing the mapping key, and the loop. Paying off the entry fee on storage alone would take about 900 writes per call.
The same expression 100 times, changing only how many of a U256's four limbs
are non-zero, shows where the rest of the variation comes from:
100 × a*b/c |
Solidity | Rust | Ratio |
|---|---|---|---|
| operands ~232, one limb | 9,599 | 2,285 | 4.20× |
| operands ~296, two limbs | 9,599 | 2,825 | 3.40× |
| operands ~2128, four limbs | 9,599 | 4,083 | 2.35× |
The EVM's column is flat — MUL and DIV cost 5 gas whatever the
operands are. A U256 in WASM is four 64-bit limbs and only the non-zero
ones are paid for, so Rust's advantage narrows as the words fill up. It does not run
out.
3. What shipping hooks actually spend their gas on loses
./profile-hooks.bash answers the question generically rather than one hook at
a time. It swaps through one pool per hook and traces the transaction opcode by opcode,
summing gas by class, then subtracts the same swap through a pool with no hook. Only the
compute column moves in Stylus.
| Added over a hookless swap | Compute | Storage | Keccak | Compute share |
|---|---|---|---|---|
| LimitOrder | 4,777 | 2,200 | 144 | 67 % |
| PanopticOracle | 5,494 | 24,600 | 228 | 18 % |
Neither comes close to the bar, and the baseline row of that trace is the more useful number: a plain v4 swap is itself 26,656 of compute against 48,100 of storage. A hook has to compute more than twice what the AMM does before moving it is worth the entry fee.
LimitOrder was profiled with no orders resting, so its 67 % compute share is a large fraction of a very small number.
4. A TWAMM, against the one in production wins
./bench-twamm.bash, measured against
akshatmittal/v4-twamm-hook — the TWAMM by Uniswap Labs and Zaha Studio,
audited by ABDK and Certora, live on Base and Unichain. Both hooks on the same node,
same pool manager, same expiry grid, same order book.
| Rust | Production Solidity | |
|---|---|---|
| Swap, pool idle | 140,173 | 132,257 |
| Swap, one span of virtual orders | 305,104 | 339,612 |
| Per expiry crossed | 26,924 | 38,834 |
31 % cheaper per interval, 7,916 gas worse on an idle pool, and ahead from the first span of work. That 7,916 is where the cost of entering a cached Stylus hook comes from — measured, not quoted.
Isolating the arithmetic makes the split visible. Uniswap's own TWAMM runs on
ABDKMathQuad: IEEE 754 quadruple precision emulated in Solidity over
bytes16. Stylus forbids floating point outright, so the port is in fixed
point — and to keep that a language comparison rather than an algorithm comparison,
TwammHook.sol carries the identical fixed-point form alongside the
quad-float one. All three agree to two parts in 1018.
| Intervals | Solidity, quad floats | Solidity, fixed point | Rust, fixed point |
|---|---|---|---|
| 1 | 23,587 | 14,011 | 2,368 |
| 2 | 47,521 | 27,842 | 4,723 |
| 4 | 94,458 | 55,950 | 9,435 |
| 8 | 189,590 | 111,385 | 18,860 |
Per interval: 23,715 gas in Solidity as written, 13,911 in Solidity done differently, 2,356 in Rust. 1.7× is the algorithm and 6.0× is the language — the largest language gain measured here on arithmetic a real hook runs.
This is not a clean comparison of one program compiled two ways. The two hooks differ: this one settles once per catch-up where theirs settles per interval, and theirs computes earnings factors for both order pools. The 6× applies to the arithmetic, not to the storage wrapped around it.
Concurrency is the axis that decides it
The measurement above is one order stream over four expiries, and that is the quiet case. A pool anyone uses is never in it.
A TWAMM exists so people can sell size over time without moving the price, which is a thing many participants want to do at once and independently. Nobody coordinates their end times. So on a protocol with real volume the normal state of a pool is several long-term orders running concurrently, ending on different grid points, and a swap arriving after any gap in activity has to catch up across all of them in one call. Simultaneous streams are not the stress case for a TWAMM. They are the ordinary case, and the busier the protocol the more of them there are.
That is exactly where Stylus separates from Solidity, for a structural reason rather than an incidental one. Every stream puts another occupied interval on the grid, and catching the pool up is a loop over occupied intervals.
That matters because of how the two costs are shaped. The entry fee is paid once per swap regardless, and the per-interval saving is paid once per stream. Catching a pool up is a loop over occupied intervals, so with M streams ending between two touches of the pool the Rust hook is ahead by 11,910 × M − 7,916, from the two figures measured above. Break-even is at two thirds of a single expiry.
./bench-twamm-concurrent.bash measures that rather than projecting it. For
each M it places M streams on consecutive grid points, lets them all come due with
nobody touching the pool, and times the single swap that catches up across all of them.
Both hooks on the same node, same grid, same order sizes, Rust program cached.
| Streams at once | Rust | Production Solidity | Saving | Share of the swap |
|---|---|---|---|---|
| idle | 147,365 | 132,321 | −15,044 | — |
| 1 | 340,930 | 351,474 | +10,544 | 3 % |
| 2 | 353,116 | 377,841 | +24,725 | 7 % |
| 4 | 466,890 | 545,831 | +78,941 | 14 % |
| 8 | 695,352 | 880,744 | +185,392 | 21 % |
| 16 | 1,056,606 | 1,517,471 | +460,865 | 30 % |
| 32 | 1,891,213 | 2,849,561 | +958,348 | 34 % |
Every stream is another occupied interval the next swap has to catch up across. The shaded gap is what the port saves.
A swap through a hookless pool on the same node is 115,129.
At 32 concurrent streams the Solidity hook spends 2.85 million gas on a swap and the Rust one spends 1.89 million. The saving is more than eight times what an entire hookless swap costs, and it is still growing.
Read the marginal cost rather than the totals. Each additional concurrent stream costs 52,163 gas in Rust against 83,256 in Solidity, so the gap widens by about 31,000 per stream with no ceiling in sight. The per-stream figure falls on both sides as streams pile up, because the one-off costs of a swap spread wider, but it falls faster in Rust: what does not amortise is the arithmetic, and that is the part Stylus makes cheap.
So the single-stream measurement is the worst case Stylus will ever be shown in, and it already wins. A busy pool is not a harder test for the port, it is an easier one. The saving also grows faster than linearly, because the control settles once per interval where this hook settles once per catch-up.
Rows 1 to 4 and rows 8 to 32 come from two runs on separate nodes, whose idle baselines agree to within 64 gas. The second batches order submission inside the fixture, which is what makes the larger rows reachable: 128 separate round trips degrade a single-threaded dev node to a block every few minutes long before the interesting row arrives.
5. The pm-AMM's Gaussian solve wins
The pm-AMM's invariant is transcendental, so every swap must run a Gaussian solve. This is
the one workload here whose arithmetic genuinely cannot be optimised away.
native-gaussian is a bit-exact port of
primitivefinance/solstat, agreeing with the Solidity to the wei on chain.
| Solidity | Rust | Ratio | |
|---|---|---|---|
| Gaussian CDF | 5,137 | 2,082 | 2.47× |
| Solve, 8 iterations | 62,425 | 27,659 | 2.26× |
+26,850 gas per swap in Rust's favour, on every swap.
6. v4-core's own swap math wins big
./bench-v4-math.bash. SwapMath, TickMath,
SqrtPriceMath, FullMath and UnsafeMath, ported
function for function and tested against v4-core's own vectors. The control is not a
reimplementation: V4MathBench.sol calls Uniswap's libraries directly.
The workload is Pool.swap's loop without the storage — find the next
initialised tick, price the step, cross, repeat — which is what OpenZeppelin's
AntiSandwichHook and Uniswap's own SwapSimulator replay on every swap.
Neither is benchmarked here; this measures the replay itself, which is the part of them
that would move.
| Ticks crossed | Solidity | Rust | Saving |
|---|---|---|---|
| 4 | 42,451 | 46,522 | −4,071 |
| 8 | 58,870 | 48,380 | +10,490 |
| 32 | 155,342 | 62,055 | +93,287 |
4,056 gas per tick against 570 — 7.1×, and a replay crossing six or more ticks is a net win. This is bit-twiddling rather than big-number arithmetic — nineteen shifts and a conditional multiply for a tick's price — and Solidity pays a 5-gas opcode for every one of them with no cheaper way to write it.
7. Post-quantum signature primitives Solidity cannot
./bench-crypto.bash. Keccak-f[1600], SHAKE256 and ML-DSA's number-theoretic
transform — the two primitives a post-quantum signature check spends its gas on. Both
sides are pinned to NIST's SHAKE256 vectors and XKCP's permutation vector before
anything is timed, and the Solidity control is unrolled inline assembly, not the
readable version that would have made a strawman of it.
| Solidity | Rust | Ratio | |
|---|---|---|---|
| One 32-byte hash, through the built-in | 184 | 16 | 11.5× |
| One Keccak-f[1600] permutation, by hand | 101,662 | 158 | 643× |
| SHAKE256, absorb 200 B, squeeze 1088 B | 1,250,263 | 2,559 | 489× |
| One forward NTT, 256 coefficients | 244,873 | 2,128 | 115× |
The first row is free money for any hook that hashes: the Stylus host's keccak is 11.5×
cheaper than the EVM opcode. The second row is the one that matters.
keccak256 performs exactly one permutation internally, but it is Keccak-256
with the 0x01 pad compiled in, and SHAKE pads with 0x1f. So
SHAKE cannot come from any built-in in either language, and the permutation has to be
written out. The EVM can do Keccak, but only through the one door it provides,
and SHAKE is not behind that door.
Projected onto one ML-DSA-44 verification — about 90 permutations and 9 transforms — that is 11,353,437 gas in Solidity against 33,372 in Rust. A swap on Arbitrum costs about 115,000 gas with no hook. Solidity would spend ninety-nine swaps' worth to check one signature; Rust spends less than a third of one.
Other hooks that should win
Everything above is measured end to end. What follows is the shortlist that survives the same reasoning but has not been built yet, each priced by a rate measured on this page rather than by a multiplier from a documentation page.
| Hook design | What it runs per swap | The measured rate that prices it |
|---|---|---|
A router that simulates before it routes, such as Uniswap's
ALFMultiplexer |
One swap simulation per routing candidate | The replay, at 4,056 → 570 gas per tick. Three candidates over eight ticks is ~97,000 gas of Solidity arithmetic, eight times the bar. |
A Pendle-style fixed-rate AMM, such as TokiHook |
Prices in implied-yield space, so a fixed-point exp and
ln |
The TWAMM interval is two square roots and an exponential, at 13,911 → 2,356. One curve evaluation is about break-even by itself. |
| An options or perpetual hook pricing Black–Scholes | A Gaussian CDF, with exp and ln around it |
The pm-AMM result reached by another route: CDF 5,137 → 2,082, solve 62,425 → 27,659. |
| Ed25519, WebAuthn / P-256, or Poseidon2 over Goldilocks | Field multiplication by the thousand | Goldilocks modular multiply at 89 → 19. None of these has an EVM precompile, and a few hundred operations clears the bar. |
Three of those are one finding in different clothes. A hook wins when it evaluates a transcendental or replays the AMM, and loses when it looks something up.
And the shapes that keep looking like candidates and are not
- Anything gated on a precompile.
ecrecover, SHA-256, MODEXP, BN254 and BLS12-381 are all live on Arbitrum One and Stylus calls them at the same price. A Groth16 verifier is the trap: the pairing is precompiled, and only the field layer moves, at 1.42×. - Anything input-output bound. EulerSwap inverts its curve with a 255-bit square root and still spends 2 % of its gas on arithmetic, the rest querying lending vaults.
- Anything storage bound. An order book walks bins, and every bin is a storage read.
- Anything expensive because of the algorithm it picked. Fix that first, in Solidity, and see whether anything is left.
What these add up to
| Workload | Result in Rust | Why |
|---|---|---|
| Counter | +34,938 gas per swap | storage only, no compute at all |
| LimitOrder, PanopticOracle | nothing to win | 4,800 and 5,500 gas of arithmetic, needs ~62,000 |
| TWAMM interval | 31 % cheaper per expiry | 6.0× on the maths, enough of it to pay the entry |
| Gaussian solve | +26,850 gas saved per swap | irreducible transcendental work |
| v4 swap replay | 7.1× per tick crossed | bit manipulation the EVM prices per opcode |
| Keccak permutation, SHAKE, NTT | 115× to 643× | 64-bit words, and no EVM opcode for the primitive |
A bookkeeping hook is the wrong thing to port, and most hooks are bookkeeping hooks. The finding is not that Stylus computes slowly — it does not — but that hooks do not compute enough for it to matter. The ones that do compute enough are the ones nobody currently deploys, because in Solidity they cost too much to run.
Caveats
Gas here is L2 execution gas only, with the L1 data-posting price set to zero. On a real chain the L1 component is identical across the variants compared and dwarfs the differences being measured.
Nothing here is measured inside forge test: forge cannot execute WASM, so
every Stylus figure comes from a transaction on a dev node. The TWAMM comparison is
between two implementations of the same design, not one program compiled two ways. The
TWAMM idle figure moved by about 9,000 gas between runs, because whether a swap lands in
the same second as the previous one decides if the hook has a span to execute at all;
the per-expiry numbers are differences over four expiries and are stable.
Every sweep, the full method, and the search through Uniswap's hook registry that produced these candidates are in BENCHMARK.md.