Example

A hook, start to finish

The whole of native-counter: five steps of Rust, an address mined to carry its permissions, and a deployment to Arbitrum. No Solidity at any point.

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.