Reproduced Exploit
Valkyrie — a zero-liquidity swap blocks a 1000 LP deposit
FullRangeHook.beforeSwap marks hasAccruedFees on every swap and does not require the position to have liquidity. A swap while liquidity == 0 sets the flag. The next addLiquidity sees the flag, calls _rebalance, and removes liquidity as a signed delta. That delta is 0. Position.update reverts Cannot…
Chain
Other
Category
dos
Date
Mar 2025
Source
AuditVault
EVM Playground
Source-level debugger — step opcodes and Solidity in sync
The attack is replayed in an in-browser EVM preloaded with the exact dumped fork state. The execution tree shows every call; step by Solidity line or by opcode across all depths — source, Stack, Memory, Storage, Balances (native / ERC-20 / NFT), Transient storage and Return value stay in sync. Click a tree node, opcode, or source line to jump. No backend, no live RPC.
Source & credit. Reproduction of a public audit finding curated by AuditVault — the original finding: 61558-swaps-performed-in-pools-with-zero-liquidity-can-cause-a-com. Standalone Foundry PoC and full write-up: 61558-swaps-performed-in-pools-with-zero-liquidity-can-cause-a-com_exp in the
evm-hack-registrymirror.
Vulnerability classes: vuln/dos/frozen-funds · vuln/logic/missing-check · vuln/logic/incorrect-state-transition
Reproduction: a self-contained Foundry PoC that compiles and runs offline with only
forge-std— no fork and no RPC. Trace: output.txt. PoC: test/61558-swaps-performed-in-pools-with-zero-liquidity-can-cause-a-com_exp.sol.
Key info#
| Impact | HIGH — addLiquidity reverts and 1000 LP stays with the depositor. The hook holds 0 |
| Protocol | Paladin Valkyrie FullRangeHook |
| Vulnerable code | beforeSwap sets hasAccruedFees with no liquidity check |
| Bug class | A zero-liquidity swap corrupts the rebalance flag |
| Finding | Cyfrin (Draiakoo) — Paladin Valkyrie, 12 March 2025 · id 61558 |
| Report | 2025-03-12-cyfrin-paladin-valkyrie-v2.0.md |
| Source | AuditVault finding |
| Status | Fixed in Valkyrie commit 46fa611. Reproduced here as a local synthetic. |
| Compiler | solc 0.8.27, cancun, via IR |
TL;DR#
FullRangeHook.beforeSwap marks hasAccruedFees on every swap and does not require the position to have liquidity. A swap while liquidity == 0 sets the flag. The next addLiquidity sees the flag, calls _rebalance, and removes liquidity as a signed delta. That delta is 0. Position.update reverts CannotUpdateEmptyPosition when both the delta and the stored liquidity are 0.
The depositor still holds 1000 LP. The hook's balance and liquidity stay 0. The deposit cannot land.
The fixed beforeSwap reverts "zero liquidity" before setting the flag. hasAccruedFees stays false, and the same addLiquidity(1000 ether) credits the hook with 1000 LP.
Vulnerable code#
function beforeSwap() external {
// @> VULN: liquidity is not checked, so a zero-liquidity swap still sets the flag
if (!hasAccruedFees) hasAccruedFees = true;
}
function _updatePosition(int256 liquidityDelta) internal view {
if (liquidityDelta == 0) {
if (liquidity == 0) revert CannotUpdateEmptyPosition();
}
}
The report's fix:
function beforeSwap() external {
+ require(liquidity > 0, "zero liquidity");
if (!hasAccruedFees) hasAccruedFees = true;
}
_rebalance uses liquidityDelta = -int256(liquidity), which is the removal the real hook asks Uniswap for. On an empty position that is a 0 poke, and the position library rejects it.
Attack walkthrough#
- The depositor is minted 1000 LP and approves the hook.
liquidityis 0. beforeSwapruns. The flag becomes true. Liquidity is still 0.addLiquidity(1000 ether)enters thehasAccruedFeesbranch and calls_rebalance._updatePosition(0)hitsliquidity == 0and revertsCannotUpdateEmptyPosition.- The call is caught. The depositor still holds 1000 LP. The hook holds 0.
liquidityis still 0. The flag is still true, because the reverting add did not clear it.
Control: beforeSwap on the fixed hook reverts while liquidity is 0, and the flag stays false. addLiquidity(1000 ether) then transfers 1000 LP into the hook and sets liquidity to 1000 LP.
Diagrams#
Impact#
A swap on an empty FullRangeHook pool makes the following deposit revert. 1000 LP cannot be added. The hook's position stays at 0. The fixed hook rejects that swap, and the same deposit moves the 1000 LP in.
Remediation#
Require liquidity > 0 at the start of beforeSwap, which is what Valkyrie commit 46fa611 does. Do not set hasAccruedFees for a swap that has no position to rebalance.
Sources#
Taxonomy#
severity/highvuln/dos/frozen-funds·vuln/logic/missing-check·vuln/logic/incorrect-state-transition
Sources & further analysis#
Reproductions & code
- Standalone PoC + full trace: 61558-swaps-performed-in-pools-with-zero-liquidity-can-cause-a-com_exp (evm-hack-registry mirror).
- AuditVault finding: 61558-swaps-performed-in-pools-with-zero-liquidity-can-cause-a-com.
Alerts & third-party analyses
These dashboards index community alerts tweets, post-mortems, and independent write-ups. Reach them through the protocol name above to cross-check this reproduction against other analyses.