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

The reference hook had it too: Uniswap v4 liquidity-penalty conservation as a runnable twin.

The Uniswap Foundation ships a reference set of v4 hooks through OpenZeppelin. One of them, LiquidityPenaltyHook, is designed to blunt just-in-time liquidity by donating a share of a position's in-window fees back to the in-range LPs when that position is removed inside a short block window after being added. It is a small, careful piece of code. The kind of hook a team copies as a starting point.

In May, Zealynx published a public writeup of four exploit patterns against Uniswap v4 hooks. One of the four (their Pattern 3, donation-griefing via a direct call bypass) targets exactly this hook family. OpenZeppelin's own Uniswap Hooks v1.1.0 audit surfaced the same class independently, and the current v1.2.0 hook already ships the fix: an add-time fee-state guard.

The credibility hook is that the reference implementation had it too. The class is not exotic. It sits inside the interaction between v4-core's automatic fee collection and any hook that computes something (a penalty, a fee, a rebate) against a position's accrued fee state at removal time. This post is not the discovery. Zealynx and OpenZeppelin already did that work, and they get the credit. What I want to walk is the invariant that names the class, and the clean/planted twin I just made public in caliperforge/uniswap-v4-invariants that fires on it.

The mechanism

The mechanism, in plain terms.

v4-core auto-collects a position's accrued fees on any modifyLiquidity call against an existing position. That is a load-bearing detail. If you add liquidity to a position that already exists (an "increase"), v4-core reports the fees it just collected via the feesAccrued argument on the afterAddLiquidity callback. From the pool's point of view, those fees are settled at that moment. If your hook keeps its own ledger of the fees a position has earned inside the current penalty window, and it does not capture the auto-collected fees at add-time, they are gone from that ledger by the time the next remove computes its penalty.

Zealynx's Pattern 3 is the sequence that exploits this: a JIT actor runs addLiquidity → swap → increaseLiquidity → removeLiquidity inside one penalty window. The increaseLiquidity call triggers v4-core's auto-collect, which resets the hook's view of the position's in-window fees. The subsequent removeLiquidity then computes the penalty against a base that is effectively zero. The expected penalty (the decayed share of the fees the position actually earned during the window) is positive. The donated penalty is roughly zero. The griefed LPs eat the difference.

The v1.2.0 fix is a one-line snapshot inside _afterAddLiquidity: on every add-event, capture the fees v4-core just auto-collected into the hook's pendingPenaltyBase for that position. That single line closes the gap. The current LiquidityPenaltyHook ships it.

The invariant

The invariant that catches the class.

The property is a conservation identity across a position's lifetime:

Over any sequence of add / swap / increase / remove calls on a position, the total penalty the hook has donated equals the expected penalty computed from the position's lifetime in-window fees and its last-add block.

Concretely, the case defines two ledgers on the hook itself. penaltyDonated(P) is the running sum of what the hook has actually donated across P's removals. expectedPenaltyDonated(P) is a reference ledger that accrues the position's in-window fees from feesAccrued on every callback and applies the same linear decay from the last-add block that the hook itself uses. The invariant asserts:

p1_liquidity_penalty_conservation:
    hook.penaltyDonated(P) == hook.expectedPenaltyDonated(P)

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 adds, removes, swaps, and rolls blocks. A second invariant, p1_penalty_solvency, asserts that the hook never over-donates past its pre-funded budget. Both twins hold solvency by construction; the class-defining property is the conservation identity.

Two implementation choices keep the case honest. The suite deploys the real Uniswap v4 PoolManager from a pinned submodule (tag v4.0.0) rather than a mock, so the flash-accounting donate / sync / settle dance runs against the real protocol. Every modifyLiquidity and swap call is driven through the repo's own InvariantRouter, not the v4-core test routers, so the callpath under test is the one a real trader or LP would take.

The twin

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

The twin diff is a single hunk inside afterAddLiquidity. The clean twin includes the guard:

feesSinceEpochStart[posKey] += fees;
pendingPenaltyBase[posKey]  += fees;   // clean guard
lastAddBlock[posKey] = block.number;

The planted twin omits the pendingPenaltyBase += fees line. Everything else is identical. Same reference ledger, same solvency check, same donation callpath, same test handler.

On the clean twin, all six tests pass, both invariants hold across 25,600 fuzzed calls with zero reverts, and zero INVARIANT VIOLATED markers are printed. On the planted twin, the fuzzer's original counterexample of 5 handler calls shrinks in seconds to a 3-call sequence: swap → addLiquidity → removeLiquidity, all inside the 10-block penalty window on top of the position seeded at setup. The initial add is the handler's init() call; the shrunk trace realizes the add → swap → increase → remove pattern the class requires. INVARIANT VIOLATED p1_liquidity_penalty_conservation prints twice (once from the stateful campaign, once from a deterministic regression that scripts the same shape). Solvency still holds, because the planted twin only under-donates.

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. Zealynx surfaced Pattern 3 in their public writeup. OpenZeppelin's Uniswap Hooks v1.1.0 audit surfaced the same class through an independent path. The v1.2.0 hook already ships the add-time fee-state guard. I did not discover a live vulnerability. I encoded the class as a runnable clean/planted twin against the real PoolManager, and I made the campaign visible: 256 x 50 fuzz runs, 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 "hook computes something against v4-core's fee-state, and v4-core silently resets that state on any add-event" class. If you own a hook family where a similar property matters (a fee, a rebate, a penalty, a snapshot of anything v4-core touches on the way in), the invariant shape ports. The bring-your-hook scaffold in the repo is the intended path for that port.

What that does not get you: a claim that CaliperForge audited OpenZeppelin's hook, a claim that anything in production is currently exploitable 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 Zealynx (the writeup, uniswap-v4-hook-attacks) and to OpenZeppelin (the v1.1.0 audit and the current hook source). The case scorecards, the twin diff, and the CI evidence live in caliperforge/uniswap-v4-invariants.

Reproducing

Running it.

The case is at src/cases/p1-liquidity-penalty-conservation/. Both twins are runnable with the pinned Foundry toolchain (forge 1.7.1, solc 0.8.26, v4-core at v4.0.0):

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

Scorecards for both runs are at docs/scorecards/p1-liquidity-penalty-conservation/. CI is green on main at commit 0df2093 across both the ci and bring-your-hook workflows.

Sources

Primary references and case artifacts.