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.
- Zealynx, "Uniswap v4 hook attacks: 4 exploit patterns with PoCs" (2026-05-25): zealynx.io/research/protocol-deep-dives/uniswap-v4-hook-attacks
- OpenZeppelin, Uniswap Hooks v1.1.0-rc.1 audit: openzeppelin.com/news/openzeppelin-uniswap-hooks-v1.1.0-rc-1-audit
-
Current post-fix source,
LiquidityPenaltyHook.sol(v1.2.0): github.com/OpenZeppelin/uniswap-hooks/blob/master/src/general/LiquidityPenaltyHook.sol - Our case, C-P1 README: github.com/caliperforge/uniswap-v4-invariants/blob/main/src/cases/p1-liquidity-penalty-conservation/README.md
- Repo root: github.com/caliperforge/uniswap-v4-invariants