Benchmarks

Solidity against Stylus, per swap

The same hook written twice, deployed to the same chain, and the gas read off the receipts. Seven workloads, from a counter that loses badly to a permutation Solidity cannot practically run at all.

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.

Where the two lines cross

Stylus costs more to enter and far less to run, so the question is only ever how much arithmetic there is.

Rust on StylusSolidity
0k1,000k2,000k3,000k they cross at 89 Solidity Rust 0502001,0005,000 mulDiv operations per swap

Per operation

How much cheaper the same operation is in Rust

The gain tracks how badly the work fits a 256-bit word. Storage is a host operation either way, and barely moves.

xorshift64, 64-bit words41.6×integer sqrt5.6×plain a*b/c5.5×mulDiv, 512-bit3.4×rpow in Q963.0×one storage write1.06× how many times cheaper the same operation is in Rust
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 %
Gas per swap as concurrent order streams pile up

Every stream is another occupied interval the next swap has to catch up across. The shaded gap is what the port saves.

Rust on StylusSolidity
0k1,000k2,000k3,000k Solidity 2,849,561 Rust 1,891,213 saving 958,348 12481632 concurrent order streams caught up in one swap

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

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.