● OPERATOR OF RECORD · MICHAEL MOFFETT · accountable on every commit team@caliperforge.com
BLOG · 2026-07-10 · MICHAEL MOFFETT

The audited base had it too: Uniswap v4 fee-direction integrity as a runnable twin.

OpenZeppelin ships an audited library, uniswap-hooks, that the Uniswap v4 ecosystem uses as the trusted starting point for building custom-fee hooks. One of its base contracts, BaseDynamicAfterFee, encodes the after-swap fee-basis path a lot of dynamic-fee hooks reuse. In the v1.1.0 RC-2 audit, OpenZeppelin surfaced a finding on their own base library: M-01, an incorrect fee application when the swap's unspecifiedAmount represents input rather than output. The fix landed as commit 2678eb9 and shipped in tag v1.1.0.

Zealynx's public writeup of Uniswap v4 hook patterns (2026-05-25) names the taxonomy this class sits inside: Pattern 4, custom-accounting drift. The specific fee-arithmetic issue in BaseDynamicAfterFee is the direction-blind arithmetic that Pattern 4 describes at the general level.

This is chapter two of the same series that started with the LiquidityPenaltyHook case. Same shape: the ecosystem already found the class, the fix already shipped in the audited library, my job is to encode the class as a runnable clean/planted twin so a future hook that borrows the same arithmetic path can prove its suite catches it. Zealynx and OpenZeppelin get the credit for the class. What I want to walk is the property that names it and the twin diff that fires on it.

The mechanism

The mechanism, in plain terms.

Uniswap v4's PoolManager reports a single amount to a hook's afterSwap: the "unspecified" leg of the swap. On an exactInput swap that leg is the OUTPUT the trader receives. On an exactOutput swap that leg is the INPUT the trader pays. Whether unspecified means input or output flips based on the swap type.

BaseDynamicAfterFee._afterSwap computes a fee amount from that unspecified value and a hook-supplied target. Before commit 2678eb9, the arithmetic assumed unspecified was always output. It computed feeAmount = uint128(unspecifiedAmount) - targetOutput unconditionally, and reverted with TargetOutputExceeds() when the target exceeded unspec. On exactInput that arithmetic matches the intended meaning: the fee is the excess of output over target. On exactOutput it computes against the wrong leg, because unspec is now the trader's input, not output.

The audit finding (M-01) named the class and the fix. The current code branches on exactInput before computing the fee. On exactInput it computes feeAmount = unspec - target. On exactOutput it computes feeAmount = target - unspec. Both branches guard against the direction that would produce a negative fee. That one branch is the entire semantic content of the fix, and it is confined to the arithmetic block; the currency-selection expression above it ((params.amountSpecified < 0 == params.zeroForOne)) pre-existed the fix and was carried forward unchanged.

The invariant

The invariant that catches the class.

The property is a per-currency identity between the hook's own ledger and a reference ledger that follows the post-fix arithmetic:

On every state reachable by fuzzed swaps against a hooked pool, the fees the hook has accrued per currency equal the fees the reference arithmetic says it should have accrued.

Concretely:

p2_dynamicfee_direction_integrity:
    hook.accruedProtocolFees(c) == handler.expectedFees(c)

The reference ledger expectedFees[c] advances via the same post-2678eb9 fee arithmetic the clean twin runs, so the clean twin matches by construction on every swap and the planted twin diverges on any exactOutput swap.

The property is stateful. It runs 256 fuzz campaigns of depth 50 (12,800 handler calls per invariant, 25,600 across the two invariants) against a handler that swaps in all four (zeroForOne, exactInput) modes via a mode seed. The handler owns its own InvariantRouter, so the sender v4-core reports on every swap is the router the handler drives, and the swap callpath under test is the one a real trader would take against the real PoolManager.

A second invariant, p2_fee_solvency, asserts that the hook's cumulative accrual per currency never exceeds its own cumulative-unspecified-amount ledger. That bound holds on both twins by construction; the class-defining property is the direction-integrity identity.

The twin

The clean/planted twin, and what the fuzzer does with it.

The clean twin subclasses OpenZeppelin's audited BaseDynamicAfterFee (vendored under vendor/oz-uniswap-hooks-v1.1.0/ from tag v1.1.0, the first release containing the fix). Its _afterSwap override byte-copies the post-2678eb9 arithmetic block character-for-character. The planted twin uses the same subclass shape and byte-copies the pre-2678eb9 arithmetic block reported in the audit as M-01. The twin diff is confined to the arithmetic block that commit 2678eb9 actually changed; every other line is identical, including the currency selection above.

Under this case's target-zero configuration, the two arithmetics reduce to a clean asymmetry. The pre-fix arithmetic computes feeAmount = |unspec| on every swap, regardless of direction. The post-fix arithmetic computes feeAmount = |unspec| on exactInput and feeAmount = 0 on exactOutput. So the clean and planted ledgers agree on exactInput and disagree on exactOutput.

On the clean twin, all five tests pass. Both invariants hold across 25,600 fuzzed handler calls with zero reverts, and zero INVARIANT VIOLATED markers are printed. On the planted twin, the stateful fuzzer shrinks the counterexample to a single call: one fuzzed swap whose mode seed lands on exactOutput. That single call is enough for the direction-integrity identity to fail on the first exactOutput swap the walk emits. INVARIANT VIOLATED p2_dynamicfee_direction_integrity prints twice on the planted run: once from the stateful campaign, once from a deterministic regression that scripts an (exactInput, exactOutput, exactInput) sequence and hits the same divergence with certainty. The solvency leg still passes; the wrong-magnitude arithmetic in the planted twin shows up in the direction-integrity leg, not in solvency.

That is the shape I want from a class-encoding test: on the correct implementation, silence and green. On the specification violation, a loud marker and a short, human-readable counterexample.

Scope

What I actually claim, and where I stop.

I want to name what this is and what it is not.

This is a stateful-invariant demonstration on a bug class the ecosystem has already caught and documented. OpenZeppelin surfaced the finding on their own base library in their Uniswap Hooks v1.1.0 RC-2 audit as M-01, and the fix landed as commit 2678eb9 in tag v1.1.0. Zealynx's Pattern 4 writeup gives the taxonomy this class sits inside. The current audited BaseDynamicAfterFee already ships the branched arithmetic. I did not discover a new bug. I encoded the class as a runnable clean/planted twin against the real PoolManager, with the clean twin subclassing the audited base and byte-copying the post-fix arithmetic, and the planted twin byte-copying the pre-fix arithmetic. I made the campaign visible: 256 x 50 fuzz runs per invariant, deterministic regression, scorecards for both twins, all reproducible with forge test.

What that gets you, if you build hooks: a concrete, name-recognizable pre-deploy CI gate for the "after-swap fee arithmetic is direction-blind" class. If you inherit BaseDynamicAfterFee or a similar direction-aware fee base, the invariant shape ports. The bring-your-hook scaffold in the repo is the intended path for adopting it against your own hook.

What that does not get you: a claim that CaliperForge audited the OpenZeppelin base library, a claim that anything currently deployed is exposed at this class, or a claim that a stateful property test is a substitute for an audit. It is a specific piece of CI that catches a specific class loudly and cheaply, on the runtime the class actually lives on.

The credit for the class goes to OpenZeppelin (the v1.1.0 RC-2 audit that reported M-01 and the fix commit 2678eb9 that landed in tag v1.1.0) and to Zealynx (the Pattern 4 writeup, 2026-05-25, "custom-accounting drift"). Case scorecards, the twin diff, and CI evidence live in caliperforge/uniswap-v4-invariants.

Reproducing

Running it.

The case is at src/cases/p2-dynamicfee-direction-integrity/. Both twins are runnable with the pinned Foundry toolchain (forge 1.7.1, solc 0.8.26, v4-core at v4.0.0, openzeppelin-uniswap-hooks at v1.1.0):

forge test --match-contract '^P2DirectionClean$'   -vv   # all green, zero markers
forge test --match-contract '^P2DirectionPlanted$' -vv   # detection legs fail WITH marker

Scorecards for both runs are at docs/scorecards/p2-dynamicfee-direction-integrity/. CI is green on main at commit 46f826a across both the ci and bring-your-hook workflows.

Sources

Primary references and case artifacts.