What we are building
A hook that counts four callbacks per pool — beforeSwap,
afterSwap, beforeAddLiquidity and
beforeRemoveLiquidity. It is deliberately the simplest thing that is still a
real hook, so the mechanics are visible with no maths in the way.
Be clear about what this example is for. A counter is the worst workload for Stylus: it writes a storage slot per callback and computes nothing, so it costs about 2.8× what the Solidity version does. It is here to show the shape of a hook, not to argue for one. The benchmarks say which hooks are worth writing this way.
The full source is
stylus/native-counter/src/lib.rs,
and the base it builds on is stylus/base-hook.
Setup
curl -L https://foundry.paradigm.xyz | bash && foundryup
cargo install --locked cargo-stylus
rustup target add wasm32-unknown-unknown
git clone --recurse-submodules https://github.com/youtpout/stylus-hook
cd stylus-hook
Writing it
Storage and the entry point
An ordinary Stylus contract. The hook owns its state directly — this is the contract the pool manager calls.
#[storage]
#[entrypoint]
pub struct Counter {
before_swap_count: StorageMap<FixedBytes<32>, StorageU256>,
after_swap_count: StorageMap<FixedBytes<32>, StorageU256>,
before_add_liquidity_count: StorageMap<FixedBytes<32>, StorageU256>,
before_remove_liquidity_count: StorageMap<FixedBytes<32>, StorageU256>,
}
The pool manager is not a field. Solidity keeps it in an
immutable, which costs nothing to read; the Stylus SDK has no
equivalent, so a constructor argument could only go to storage and every callback
would pay a cold SLOAD — 2,100 gas — just to check its caller. A Rust
const lives in the WASM and is free, so
build.rs bakes the address in at compile time:
include!(concat!(env!("OUT_DIR"), "/pool_manager.rs")); // gives you POOL_MANAGER
Measured, that is worth 2,568 gas per swap on this hook, which is called twice. The
cost is that the address is fixed at build time, and every cargo
command needs it in the environment:
POOL_MANAGER=0x46b1852A2896902CcE7C5613AD5da97497a6F0C6 \
cargo build -p stylus-native-counter
A static is not an alternative. It compiles, and a Stylus
program is instantiated per call, so anything written to one is silently gone by the
next call.
Declare the pool manager and the permissions
permissions() must match the callbacks you implement exactly, because
this is what the mined address will encode and what v4 will read back out of it.
impl HookConfig for Counter {
fn pool_manager(&self) -> Address {
POOL_MANAGER
}
fn permissions(&self) -> Permissions {
Permissions::none()
.with_before_swap()
.with_after_swap()
.with_before_add_liquidity()
.with_before_remove_liquidity()
}
}
Those four flags are 0x0ac0, and that is what the low 14 bits of the
deployed address will have to be.
Validate both addresses in the constructor
validate_hook_address reverts unless the deployed address carries
exactly the declared flags — the same assertion Solidity's BaseHook
makes. It is why a plain cargo stylus deploy cannot deploy a hook, and
why step 5 mines a salt first.
The constructor also takes the pool manager, purely to compare it with the baked-in
constant. Building against the wrong $POOL_MANAGER would otherwise give
you a hook that compiles, deploys, mines a valid address, and then silently rejects
every call the pool manager makes.
#[public]
#[implements(IHooks)]
impl Counter {
#[constructor]
pub fn constructor(&mut self, pool_manager: Address) -> Result<(), Vec<u8>> {
if pool_manager != POOL_MANAGER {
return Err(PoolManagerMismatch { baked: POOL_MANAGER, given: pool_manager }.abi_encode());
}
HookGuards::validate_hook_address(self)
}
pub fn before_swap_count(&self, pool_id: FixedBytes<32>) -> U256 {
self.before_swap_count.get(pool_id)
}
}
The check costs 239 gas per swap — not the constructor, which runs once,
but the slightly larger program, which ArbWasm prices by compiled size.
Cheap for turning a silent misconfiguration into a failed deployment.
Implement the callbacks
Write only the ones permissions() declares. The rest keep the base
implementation and revert with HookNotImplemented. Each returns its own
selector, which v4 checks on the way out.
#[guarded_hooks]
#[public]
impl IHooks for Counter {
fn before_swap(
&mut self,
_sender: Address,
key: PoolKey,
_params: SwapParams,
_hook_data: Bytes,
) -> Result<(FixedBytes<4>, BeforeSwapDelta, U24), Vec<u8>> {
// the caller is the pool manager and `key` names this hook: both already checked
Self::bump(&mut self.before_swap_count, key.to_id());
Ok((selector::BEFORE_SWAP, ZERO_DELTA, U24::ZERO))
}
fn after_swap(
&mut self,
_sender: Address,
key: PoolKey,
_params: SwapParams,
_delta: BalanceDelta,
_hook_data: Bytes,
) -> Result<(FixedBytes<4>, i128), Vec<u8>> {
Self::bump(&mut self.after_swap_count, key.to_id());
Ok((selector::AFTER_SWAP, 0))
}
}
BaseHook.sol is an abstract contract, so its onlyPoolManager
guard cannot be forgotten. Rust has no abstract types, so
#[guarded_hooks] does the same job as a procedural macro: it inserts
the caller and pool-key checks into every method below it, and costs nothing.
Put #[guarded_hooks] only on the impl IHooks
block. A hook's own entry points — an order book, a claim, a read
of the counters — must not require the pool manager as caller, and the attribute
would lock them out.
A hook that returns a non-zero delta has to settle its own books before the lock
closes. PoolManagerCalls wraps unlock, swap,
take, settle, sync and extsload;
native-twamm uses them for real.
Mine the address and deploy
The address derives from the init code, so rebuilding the contract changes the salt. Mine after the final build, never before.
export POOL_MANAGER=0xYourPoolManager # baked in at build time, so set it first
cd stylus
cargo stylus get-initcode --contract stylus-native-counter | tail -1 > initcode.hex
cargo run -p stylus-hook-miner -- \
--initcode-file initcode.hex \
--permissions before-swap,after-swap,before-add-liquidity,before-remove-liquidity \
--constructor-signature "$(cargo stylus constructor --contract stylus-native-counter | tail -1)" \
--constructor-args $POOL_MANAGER
It prints the mined address, the salt, and the deploy command to run:
cargo stylus deploy --contract stylus-native-counter \
--deployer-salt 0x… \
--constructor-args $POOL_MANAGER \
--endpoint https://sepolia-rollup.arbitrum.io/rpc \
--private-key $PRIVATE_KEY
A contract too large for one code fragment cannot use
get-initcode: the init code would contain fragment addresses that do
not exist until the fragments are deployed.
deploy_fragmented_hook in bench-lib.bash is the way round
it.
Testing before you deploy
Forge cannot execute WASM, so the two sides are tested separately and pinned to each other wherever they implement the same maths.
cd uniswap && forge test # the Solidity twins, through a real local v4 stack
cd stylus && cargo test # the Rust contracts, against stylus_sdk::testing::TestVM
The counter's own tests cover what actually goes wrong with a hook: that the callbacks count per pool, that a caller other than the pool manager is rejected, that a pool key naming a different hook is rejected, that undeclared callbacks revert, and that the constructor refuses an address without the flags.
The test that matters most for any Rust hook lives in the base:
selectors_match_uniswap_ihooks asserts that all ten callback selectors
computed from the Rust ABI equal the ones in the compiled IHooks.sol. A
hook written in Rust is only a hook if the pool manager's calls land on the right
methods.
Then ./prove-native-hook.bash does the whole path for real. It stands up an
Arbitrum Nitro dev node in Docker, mines the address, deploys the Rust hook to it, and
has a real PoolManager drive every callback the hook declares:
ok landed on the mined address
ok the code at that address is an activated Stylus program
ok its constructor ran and validated the address
ok beforeAddLiquidity reached the Rust hook
ok beforeSwap reached the Rust hook
ok afterSwap reached the Rust hook
ok beforeRemoveLiquidity reached the Rust hook
ok beforeDonate reverts with HookNotImplemented
Deploying to Arbitrum
Uniswap v4 and Stylus are both live on Arbitrum One and Arbitrum Sepolia, so no local node is needed. Go to Sepolia first — the address depends on the init code, so a testnet hook and a mainnet hook are different deployments regardless.
| What | Where |
|---|---|
| Arbitrum Sepolia RPC | https://sepolia-rollup.arbitrum.io/rpc |
StylusDeployer |
0xcEcba2F1DC234f70Dd89F2041029807F8D03A990, which is
cargo stylus deploy's default |
CacheManager, Arbitrum One |
0x51dEDBD2f190E0696AFbEE5E60bFdE96d86464ec |
PoolManager |
from Uniswap's v4 deployment list for the chain, passed as
$POOL_MANAGER |
Check the contract against a live endpoint before spending anything:
cargo stylus check -e https://sepolia-rollup.arbitrum.io/rpc
Then bid for the program cache
A Stylus contract is compressed WASM, and the node must fetch, decompress and instantiate
it before running a byte. Arbitrum charges that as init gas on every call.
Caching is not a state that warms up during a transaction — it is a property of the
deployment, won by a one-off bid on CacheManager, and a hook nobody has bid
for pays the full load forever.
On this counter the difference is 17,482 gas per entry uncached against 2,187 cached, and the hook is entered twice per swap. That is 30,590 gas per swap, and it is the single largest lever on what a Stylus hook costs. Space in the cache is finite and low bids get evicted, so treat it as an operational commitment rather than a deployment step.
Build settings to copy
Three, all in this repository's Cargo.toml and Stylus.toml
files, and all worth taking wholesale.
| Setting | Why |
|---|---|
opt-level = 3 |
Cargo's default compiles for size, on a platform that charges for execution. Roughly 2× the gas on arithmetic-bound work. |
--llvm-memory-copy-fill-lowering in the wasm-opt flags |
Without it, opt-level 2 or 3 emits a
DataCount section ArbOS refuses to activate. The build
succeeds, cargo stylus check passes, and the failure
appears only at deployment — which is what makes the opt-level cap look
unavoidable. |
build.rs for the pool manager |
Turns a cold SLOAD per callback into a free constant read.
|
One habit to go with them: never build a U256 from a string at
runtime. It is the most readable option in Rust and about a hundred times the cost of
the arithmetic it feeds.