> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ctrl.finance/llms.txt
> Use this file to discover all available pages before exploring further.

# Complete V12 security audit report

> The complete V12 security audit report supplied to Ctrl Finance.

# Audited by [V12](https://v12.sh/)

The only autonomous auditor that finds critical bugs. Not all audits are equal, so stop paying for bad ones. Just use V12. No calls, demos, or intros.

# Treasury rotation redirects accrued bounty reserves

**#228865**

* Severity: High
* Validity: Acknowledged

## Source locations

### `src/v2/CtrlLaunchHookV2.sol` (5 locations)

#### Lines 352-375 — *Pre-graduation bounty is accrued into the hook/Vault reserve without a treasury beneficiary.*

```
        return IHooks.beforeAddLiquidity.selector;
    }

    function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata hookData)
        external
        onlyPoolManager
        returns (bytes4, BeforeSwapDelta, uint24)
    {
        LaunchState storage launch = _validatedLaunch(key);
        if (!launch.seeded) revert InvalidPool();

        bool exactInput = params.amountSpecified < 0;
        bool nativeSpecified = exactInput == params.zeroForOne;
        uint256 feeAmount;

        if (nativeSpecified) {
            uint256 nativeAmount = _absoluteAmount(params.amountSpecified);
            feeAmount = exactInput ? _feeFromGross(nativeAmount) : _feeOnTop(nativeAmount);
            _mintAndAccrue(key.toId(), launch, feeAmount, hookData);
        }

        return (
            IHooks.beforeSwap.selector,
            feeAmount == 0 ? BeforeSwapDeltaLibrary.ZERO_DELTA : toBeforeSwapDelta(feeAmount.toInt128(), 0),
```

⋯

#### Lines 409-420 — *Graduation resolves an empty beneficiary from the current treasury and releases the accumulated bounty.*

```
    }

    function afterInitialize(address, PoolKey calldata, uint160, int24) external pure returns (bytes4) {
        revert CallbackNotEnabled();
    }

    function afterAddLiquidity(
        address,
        PoolKey calldata,
        IPoolManager.ModifyLiquidityParams calldata,
        BalanceDelta,
        BalanceDelta,
```

⋯

#### Lines 454-493 — *Pre-graduation bounty amounts are accrued and stored without a fallback recipient snapshot.* — *Bounty is accrued as a per-token amount with no recipient state.* — *Pre-graduation bounty is accrued as an amount without a recipient snapshot.* — *Pre-graduation bounty is accrued into launch and Vault token-level reserves without a payout identity.*

```
    function _mintAndAccrue(PoolId poolId, LaunchState storage launch, uint256 feeAmount, bytes calldata hookData)
        private
    {
        if (feeAmount == 0) return;

        SwapContext memory context = _parseContext(hookData);
        ICtrlReferralRegistry registry = referralRegistry();
        address referralPayout = context.referrer == address(0) ? address(0) : registry.payoutOf(context.referrer);
        uint256 creatorAmount = FullMath.mulDiv(feeAmount, CREATOR_SHARE_BPS, BPS);
        uint256 referralAmount = referralPayout == address(0) ? 0 : FullMath.mulDiv(feeAmount, REFERRAL_SHARE_BPS, BPS);
        uint256 bountyAmount = launch.graduated ? 0 : FullMath.mulDiv(feeAmount, BOUNTY_SHARE_BPS, BPS);
        uint256 protocolAmount = feeAmount - creatorAmount - referralAmount - bountyAmount;

        IPoolManager manager = poolManager();
        ICtrlFeeVault vault = feeVault();
        manager.mint(address(vault), 0, feeAmount);
        vault.accrue(
            launch.token,
            launch.creatorPayout,
            creatorAmount,
            referralPayout,
            referralAmount,
            protocolAmount,
            bountyAmount
        );
        launch.bountyAccrued += bountyAmount;

        emit FeeAccrued(
            launch.token,
            poolId,
            context.beneficiary,
            context.referrer,
            referralPayout,
            feeAmount,
            creatorAmount,
            referralAmount,
            protocolAmount,
            bountyAmount
        );
    }
```

⋯

#### Lines 495-526 — *Threshold graduation resolves an empty beneficiary from the live treasury and releases the stored reserve.* — *Graduation resolves an empty beneficiary against the live treasury and releases the accumulated reserve.* — *Graduation resolves an empty beneficiary using the then-current treasury.* — *An empty beneficiary resolves against the current treasury and receives the accumulated reserve.*

```
    function _updatePrincipalAndGraduate(
        PoolId poolId,
        LaunchState storage launch,
        bool buy,
        uint256 poolNativeAmount,
        bytes calldata hookData
    ) private {
        uint256 previousPrincipal = launch.netEthPrincipal;
        uint256 newPrincipal;

        if (buy) {
            newPrincipal = previousPrincipal + poolNativeAmount;
        } else {
            newPrincipal = poolNativeAmount >= previousPrincipal ? 0 : previousPrincipal - poolNativeAmount;
        }
        launch.netEthPrincipal = newPrincipal;
        emit PrincipalUpdated(launch.token, poolId, previousPrincipal, newPrincipal);

        if (!launch.graduated && previousPrincipal < GRADUATION_THRESHOLD && newPrincipal >= GRADUATION_THRESHOLD) {
            SwapContext memory context = _parseContext(hookData);
            ICtrlFeeVault vault = feeVault();
            address beneficiary = context.beneficiary;
            if (beneficiary == address(0)) beneficiary = vault.treasury();

            uint256 bounty = launch.bountyAccrued;
            launch.bountyAccrued = 0;
            launch.graduated = true;
            launch.graduatedAt = uint64(block.timestamp);
            if (bounty != 0) vault.releaseBounty(launch.token, beneficiary, bounty);

            emit TokenGraduated(launch.token, poolId, beneficiary, newPrincipal, bounty, launch.graduatedAt);
        }
```

⋯

#### Lines 548-560 — *Empty or malformed context returns zero beneficiary and referrer, activating the treasury fallback.*

```
    function _parseContext(bytes calldata hookData) private pure returns (SwapContext memory context) {
        if (hookData.length != 64) return context;

        uint256 beneficiaryWord;
        uint256 referrerWord;
        assembly ("memory-safe") {
            beneficiaryWord := calldataload(hookData.offset)
            referrerWord := calldataload(add(hookData.offset, 0x20))
        }
        if (beneficiaryWord >> 160 != 0 || referrerWord >> 160 != 0) return context;
        context.beneficiary = address(uint160(beneficiaryWord));
        context.referrer = address(uint160(referrerWord));
    }
```

### `src/CtrlFeeVault.sol` (5 locations)

#### Lines 54-61 — *Reserved bounty is token-keyed and aggregate; no fallback treasury is stored.* — *Reserved bounty and total liability are tracked as amounts, with no recipient snapshot.*

```
    mapping(address token => mapping(address recipient => uint256 amount)) public creatorClaimableEth;
    mapping(address recipient => uint256 amount) public referralClaimableEth;
    mapping(address recipient => uint256 amount) public protocolClaimableEth;
    mapping(address recipient => uint256 amount) public bountyClaimableEth;
    mapping(address token => uint256 amount) public reservedBountyEthForToken;
    uint256 public totalClaimableEth;
    uint256 public reservedBountyEth;
    uint256 public totalLiabilityEth;
```

⋯

#### Lines 102-116 — *Vault stores the bounty reserve by token and attributes only protocol fees immediately.* — *Accrual increments the reserved bounty and total liability but does not record its future fallback recipient.* — *The vault records a token-keyed bounty reserve without a recipient.*

```
        uint256 claimableIncrease = creatorAmount + referralAmount + protocolAmount;
        uint256 liabilityIncrease = claimableIncrease + bountyAmount;

        creatorClaimableEth[token][creatorPayout] += creatorAmount;
        if (referralAmount != 0) referralClaimableEth[referralPayout] += referralAmount;
        protocolClaimableEth[treasury] += protocolAmount;
        totalClaimableEth += claimableIncrease;
        reservedBountyEthForToken[token] += bountyAmount;
        reservedBountyEth += bountyAmount;
        totalLiabilityEth += liabilityIncrease;

        if (poolManager.balanceOf(address(this), 0) < totalLiabilityEth) revert Insolvent();
        emit FeeAllocationAccrued(
            token, creatorPayout, creatorAmount, referralPayout, referralAmount, treasury, protocolAmount, bountyAmount
        );
```

⋯

#### Lines 119-127 — *The Vault credits the reserve to the recipient supplied at release time.*

```
    function releaseBounty(address token, address recipient, uint256 amount) external onlyHook {
        if (token == address(0) || recipient == address(0)) revert ZeroAddress();
        if (amount > reservedBountyEthForToken[token]) revert ReservedBountyExceeded();

        reservedBountyEthForToken[token] -= amount;
        reservedBountyEth -= amount;
        bountyClaimableEth[recipient] += amount;
        totalClaimableEth += amount;
        emit BountyReleased(token, recipient, amount);
```

⋯

#### Lines 146-175 — *Protocol fee attribution snapshots the current treasury at accrual, while setTreasury mutates the live value for later reads.* — *The owner can change the live treasury between accrual and graduation.*

```
    function claimProtocol() external nonReentrant returns (uint256 amount) {
        amount = protocolClaimableEth[msg.sender];
        if (amount == 0) revert NoFees();
        protocolClaimableEth[msg.sender] = 0;
        _pay(payable(msg.sender), amount);
        emit ProtocolFeesClaimed(msg.sender, amount);
    }

    function claimGraduationBounty() external nonReentrant returns (uint256 amount) {
        amount = bountyClaimableEth[msg.sender];
        if (amount == 0) revert NoFees();
        bountyClaimableEth[msg.sender] = 0;
        _pay(payable(msg.sender), amount);
        emit GraduationBountyClaimed(msg.sender, amount);
    }

    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        ClaimCallback memory callback = abi.decode(data, (ClaimCallback));

        poolManager.burn(address(this), 0, callback.amount);
        poolManager.take(CurrencyLibrary.ADDRESS_ZERO, callback.recipient, callback.amount);
        return bytes("");
    }

    function setTreasury(address newTreasury) external onlyOwner {
        if (newTreasury == address(0)) revert ZeroAddress();
        address previousTreasury = treasury;
        treasury = newTreasury;
        emit TreasuryUpdated(previousTreasury, newTreasury);
```

⋯

#### Lines 171-176 — *Vault owner can change the treasury before the later fallback resolution.*

```
    function setTreasury(address newTreasury) external onlyOwner {
        if (newTreasury == address(0)) revert ZeroAddress();
        address previousTreasury = treasury;
        treasury = newTreasury;
        emit TreasuryUpdated(previousTreasury, newTreasury);
    }
```

### `docs/security.md` (2 locations)

#### Lines 104-109 — *The documented authority model describes treasury changes as future attribution and says the vault owner cannot redirect existing liabilities.*

```
| Factory owner | Pause or resume future launches; begin two-step owner transfer | Pause existing swaps, change pool constants, withdraw liquidity, redirect user fees |
| Vault owner | Set treasury for future launch/protocol fee attribution; begin two-step owner transfer | Take creator/referral/bounty balances, rewrite existing liabilities |
| V2 upgrade authority | Replace hook logic through the proxy; rotate authority; freeze upgrades permanently | Withdraw the locked position through any existing Locker function; bypass proxy authorization |
| Token creator | Change only that token's future creator payout | Change past liabilities, fee rates, supply, pool, graduation, or liquidity |
| Registered referrer | Change only its own future global payout | Redirect another referrer or past liabilities |
| Credited payout | Claim its own role-specific balance | Claim for or redirect another address |
```

⋯

#### Lines 331-340 — *Documentation states payout changes affect only future accrual.*

```
### Payout contract risk

Existing liabilities cannot be redirected. If a creator, referrer, treasury,
or bounty payout is a contract that cannot call the Vault or rejects ETH, its
claim will revert and the balance will remain stuck at that payout.

Payout changes affect only future accrual. UIs must warn before setting a
contract payout. Tests cover a reverting credited recipient, unaffected
unrelated claims, and cross-role reentrancy attempts during a successful outer
claim.
```

### `docs/architecture.md`

#### Lines 401-407 — *Documented intent requires treasury changes to affect only future accrual and existing liabilities to remain owned by their original recipient.*

```
Every claim pays only `msg.sender`. There is no `claimFor` function.

Changing a creator, referrer, or treasury payout affects only future accrual.
Existing liabilities remain owned by the address that originally earned them.
A smart-contract payout must be able to call its claim function and accept
native ETH. If it cannot, its existing balance remains recorded but cannot be
redirected by an administrator.
```

## Description

Pre-graduation bounty fees are accumulated in `launch.bountyAccrued` and `reservedBountyEthForToken`, but those records retain no fallback-recipient identity. When a threshold-crossing swap has a zero beneficiary, `_updatePrincipalAndGraduate` reads the current `vault.treasury()` and passes that address to `releaseBounty` for the entire reserve. Empty or malformed hook data produces the zero-valued context that enters this fallback path. Because `setTreasury` can update the live treasury between accrual and graduation, a later rotation changes the recipient of bounty amounts accrued under the prior treasury, whereas protocol fees are keyed to the treasury during `accrue`. Preserve the documented future-only payout semantics by snapshotting and accounting for the fallback recipient when bounty is accrued, rather than resolving a mutable treasury when the reserve is released.

## Root cause

`launch.bountyAccrued` and `reservedBountyEthForToken` store only amounts, while zero-beneficiary graduation resolves the fallback recipient from mutable `treasury` state at release time.

## Impact

The vault owner can set a replacement treasury before a fallback graduation and cause the token's accumulated pre-graduation bounty reserve to become claimable by that replacement address. The prior treasury has no claimable entry for the reserve once `releaseBounty` credits the new recipient, so bounty value accrued before the update is reassigned.

## Proof of concept

### Test case

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

import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {CtrlLaunchHookV2} from "../src/v2/CtrlLaunchHookV2.sol";
import {CtrlUpgradeableTestBase} from "./CtrlUpgradeableTestBase.sol";

contract PocTest is CtrlUpgradeableTestBase {
    address internal constant ROTATED_TREASURY = address(0xB0B0);

    function testPocTreasuryRotationRedirectsAlreadyAccruedFallbackBounty() public {
        (address token,,,) = _launch(bytes32("bounty-redirect"), 0);

        // A pre-graduation generic V4 buy with empty hookData exercises the real
        // CtrlLaunchHookV2._parseContext fallback path: beneficiary/referrer are zero.
        vm.prank(USER);
        BalanceDelta firstDelta = universalRouter.swap{value: 1 ether}(
            hook.poolKey(token),
            IPoolManager.SwapParams({
                zeroForOne: true,
                amountSpecified: -int256(1 ether),
                sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
            }),
            bytes(""),
            USER,
            0
        );
        assertGt(uint128(firstDelta.amount1()), 0, "first generic swap must execute against the real pool");

        uint256 historicBountyReserve = vault.reservedBountyEthForToken(token);
        uint256 originalTreasuryProtocolAtAccrual = vault.protocolClaimableEth(TREASURY);
        uint256 launchBountyAccrued = hook.getLaunch(token).bountyAccrued;

        assertGt(historicBountyReserve, 0, "setup must accrue a bounty reserve before graduation");
        assertEq(launchBountyAccrued, historicBountyReserve, "hook and vault agree on reserved bounty");
        assertFalse(hook.getLaunch(token).graduated, "first buy must remain below graduation threshold");
        assertGt(originalTreasuryProtocolAtAccrual, 0, "protocol fees are attributed to the original treasury at accrual");
        assertEq(vault.protocolClaimableEth(ROTATED_TREASURY), 0, "new treasury had no protocol fees at historic accrual");
        assertEq(vault.bountyClaimableEth(TREASURY), 0, "bounty not released before graduation");
        assertEq(vault.bountyClaimableEth(ROTATED_TREASURY), 0, "new treasury not credited before rotation/graduation");

        // The vault owner rotates the treasury after the zero-beneficiary bounty reserve has accrued.
        vault.setTreasury(ROTATED_TREASURY);
        assertEq(vault.treasury(), ROTATED_TREASURY, "public treasury rotation succeeded");

        // A later generic V4 buy also supplies empty hookData and crosses the graduation threshold.
        // _updatePrincipalAndGraduate resolves the zero beneficiary from the live vault.treasury(),
        // then releases the whole token-level reserve through CtrlFeeVault.releaseBounty().
        vm.prank(USER);
        BalanceDelta graduationDelta = universalRouter.swap{value: 3.25 ether}(
            hook.poolKey(token),
            IPoolManager.SwapParams({
                zeroForOne: true,
                amountSpecified: -int256(3.25 ether),
                sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1
            }),
            bytes(""),
            USER,
            0
        );
        assertGt(uint128(graduationDelta.amount1()), 0, "graduating swap must execute against the real pool");

        CtrlLaunchHookV2.LaunchState memory launched = hook.getLaunch(token);
        assertTrue(launched.graduated, "second buy crosses the graduation threshold");
        assertEq(launched.bountyAccrued, 0, "graduation releases all accumulated bounty");
        assertEq(vault.reservedBountyEthForToken(token), 0, "token bounty reserve was released");

        uint256 originalTreasuryBountyAfter = vault.bountyClaimableEth(TREASURY);
        uint256 rotatedTreasuryBountyAfter = vault.bountyClaimableEth(ROTATED_TREASURY);

        // Security impact: the nonzero bounty reserve that existed before setTreasury() is not
        // claimable by the original treasury. Because releaseBounty credited a single live-fallback
        // recipient and the token reserve is now zero, that historic reserve is included in the
        // replacement treasury's bounty claim. A patch that snapshots/accounting-splits the fallback
        // treasury at accrual would give the original treasury at least historicBountyReserve here.
        assertEq(originalTreasuryBountyAfter, 0, "original treasury received none of its historic fallback reserve");
        assertGt(
            rotatedTreasuryBountyAfter,
            historicBountyReserve,
            "rotated treasury received the historic reserve plus the crossing swap reserve"
        );
        assertEq(
            vault.protocolClaimableEth(TREASURY),
            originalTreasuryProtocolAtAccrual,
            "protocol fees demonstrate accrual-time treasury attribution for the first buy"
        );
        assertGt(vault.protocolClaimableEth(ROTATED_TREASURY), 0, "new treasury only receives protocol fees after rotation");
    }
}
```

### Setup script

```
#!/bin/bash
set -e

# Standalone PoC reproduction. Run from the repository root of a checkout at
# the audited commit, with the language toolchain installed.

# Place the downloaded PoC files at these paths before running:
#   test/Poc.t.sol

# install dependencies
npm ci

# build and run
forge build --via-ir
forge test --via-ir --match-path test/Poc.t.sol -vvv
```

### Output

```
[output truncated: 859 lines & 39.896484375 KB skipped]
[PASS] testPocTreasuryRotationRedirectsAlreadyAccruedFallbackBounty() (gas: 2459201)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 45.30ms (1.70ms CPU time)

Ran 1 test suite in 46.97ms (45.30ms CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)

</test-stdout>

<test-stderr>

</test-stderr>
```

### Considerations

PoC executed successfully with `forge test --via-ir --match-path test/Poc.t.sol -vvv` using the in-scope V2 ERC-1967 hook harness. It is an offline Foundry reproduction against local Uniswap V4/position-manager mocks supplied by the repository, not a Robinhood mainnet fork. The demonstrated redirect is privileged: it requires the vault owner to rotate treasury between bounty accrual and fallback graduation.

### Validation reasoning

PoC validation command completed successfully.

## Remediation

### Explanation

Snapshots each token's fallback bounty by the treasury active at accrual, and on zero-beneficiary V2 graduation releases each snapshotted portion to its original treasury; explicit beneficiaries retain the existing whole-bounty behavior.

### Patch

```diff theme={"system"}
diff --git a/src/interfaces/ICtrlProtocol.sol b/src/interfaces/ICtrlProtocol.sol
--- a/src/interfaces/ICtrlProtocol.sol
+++ b/src/interfaces/ICtrlProtocol.sol
@@ -1,66 +1,68 @@
 // SPDX-License-Identifier: MIT
 pragma solidity ^0.8.26;
 
 import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
 import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol";
 
 interface ICtrlFeeVault {
     function treasury() external view returns (address);
 
     function accrue(
         address token,
         address creatorPayout,
         uint256 creatorAmount,
         address referralPayout,
         uint256 referralAmount,
         uint256 protocolAmount,
         uint256 bountyAmount
     ) external;
 
     function releaseBounty(address token, address recipient, uint256 amount) external;
+
+    function releaseFallbackBounty(address token, uint256 amount) external returns (address recipient);
 }
 
 interface ICtrlReferralRegistry {
     function payoutOf(address referrer) external view returns (address);
 }
 
 interface ICtrlLaunchHook {
     function SEED_HOOK_DATA() external view returns (bytes32);
     function poolKey(address token) external view returns (PoolKey memory);
     function poolIdForToken(address token) external view returns (PoolId);
     function registerLaunch(address token, address creator, address creatorPayout) external returns (PoolId);
     function updateCreatorPayout(address token, address newPayout) external;
 }
 
 interface ICtrlPositionLocker {
     function registerPosition(address token, PoolId poolId, uint256 positionId) external;
     function noteTokenDust(address token, uint256 amount) external;
 }
 
 interface ICtrlLaunchRouter {
     function buyExactIn(
         address token,
         address recipient,
         address beneficiary,
         address referrer,
         uint256 amountOutMinimum,
         uint256 deadline
     ) external payable returns (uint256 amountOut);
 }
 
 interface IPositionManagerMinimal {
     function nextTokenId() external view returns (uint256);
     function ownerOf(uint256 tokenId) external view returns (address);
     function getPositionLiquidity(uint256 tokenId) external view returns (uint128);
     function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable;
 }
 
 interface IPermit2Allowance {
     function approve(address token, address spender, uint160 amount, uint48 expiration) external;
 }
 
 interface IERC721Receiver {
     function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
         external
         returns (bytes4);
 }

diff --git a/src/CtrlFeeVault.sol b/src/CtrlFeeVault.sol
--- a/src/CtrlFeeVault.sol
+++ b/src/CtrlFeeVault.sol
@@ -1,187 +1,231 @@
 // SPDX-License-Identifier: MIT
 pragma solidity ^0.8.26;
 
 import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
 import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
 import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
 import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
 import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
 import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
 import {ICtrlFeeVault} from "./interfaces/ICtrlProtocol.sol";
 
 /// @notice Pull-based accounting vault backed by PoolManager native-ETH ERC-6909 claims.
 contract CtrlFeeVault is Ownable2Step, ReentrancyGuard, IUnlockCallback, ICtrlFeeVault {
     using CurrencyLibrary for Currency;
 
     struct ClaimCallback {
         address payable recipient;
         uint256 amount;
     }
 
     error AlreadyInitialized();
     error Insolvent();
     error InvalidAccrual();
     error NoFees();
     error NotHook();
     error NotInitializer();
     error NotPoolManager();
     error ReservedBountyExceeded();
     error ZeroAddress();
 
     event HookInitialized(address indexed hook);
     event FeeAllocationAccrued(
         address indexed token,
         address indexed creatorPayout,
         uint256 creatorAmount,
         address indexed referralPayout,
         uint256 referralAmount,
         address treasury,
         uint256 protocolAmount,
         uint256 bountyAmount
     );
     event BountyReleased(address indexed token, address indexed recipient, uint256 amount);
     event CreatorFeesClaimed(address indexed token, address indexed recipient, uint256 amount);
     event ReferralFeesClaimed(address indexed recipient, uint256 amount);
     event ProtocolFeesClaimed(address indexed recipient, uint256 amount);
     event GraduationBountyClaimed(address indexed recipient, uint256 amount);
     event TreasuryUpdated(address indexed previousTreasury, address indexed newTreasury);
 
     IPoolManager public immutable poolManager;
     address public immutable initializer;
     address public hook;
     address public override treasury;
 
     mapping(address token => mapping(address recipient => uint256 amount)) public creatorClaimableEth;
     mapping(address recipient => uint256 amount) public referralClaimableEth;
     mapping(address recipient => uint256 amount) public protocolClaimableEth;
     mapping(address recipient => uint256 amount) public bountyClaimableEth;
     mapping(address token => uint256 amount) public reservedBountyEthForToken;
+    mapping(address token => mapping(address recipient => uint256 amount)) public fallbackBountyEthForToken;
+    mapping(address token => address[] recipients) private _fallbackBountyRecipients;
     uint256 public totalClaimableEth;
     uint256 public reservedBountyEth;
     uint256 public totalLiabilityEth;
 
     constructor(address initialOwner, address poolManager_, address treasury_, address initializer_)
         Ownable(initialOwner)
     {
         if (
             initialOwner == address(0) || poolManager_ == address(0) || treasury_ == address(0)
                 || initializer_ == address(0)
         ) {
             revert ZeroAddress();
         }
         poolManager = IPoolManager(poolManager_);
         treasury = treasury_;
         initializer = initializer_;
     }
 
     modifier onlyHook() {
         if (msg.sender != hook) revert NotHook();
         _;
     }
 
     function initializeHook(address hook_) external {
         if (msg.sender != initializer) revert NotInitializer();
         if (hook != address(0)) revert AlreadyInitialized();
         if (hook_ == address(0)) revert ZeroAddress();
         hook = hook_;
         emit HookInitialized(hook_);
     }
 
     function accrue(
         address token,
         address creatorPayout,
         uint256 creatorAmount,
         address referralPayout,
         uint256 referralAmount,
         uint256 protocolAmount,
         uint256 bountyAmount
     ) external onlyHook {
         if (token == address(0) || creatorPayout == address(0)) revert InvalidAccrual();
         if (referralAmount != 0 && referralPayout == address(0)) revert InvalidAccrual();
 
         uint256 claimableIncrease = creatorAmount + referralAmount + protocolAmount;
         uint256 liabilityIncrease = claimableIncrease + bountyAmount;
 
         creatorClaimableEth[token][creatorPayout] += creatorAmount;
         if (referralAmount != 0) referralClaimableEth[referralPayout] += referralAmount;
         protocolClaimableEth[treasury] += protocolAmount;
         totalClaimableEth += claimableIncrease;
         reservedBountyEthForToken[token] += bountyAmount;
+        if (bountyAmount != 0) {
+            if (fallbackBountyEthForToken[token][treasury] == 0) _fallbackBountyRecipients[token].push(treasury);
+            fallbackBountyEthForToken[token][treasury] += bountyAmount;
+        }
         reservedBountyEth += bountyAmount;
         totalLiabilityEth += liabilityIncrease;
 
         if (poolManager.balanceOf(address(this), 0) < totalLiabilityEth) revert Insolvent();
         emit FeeAllocationAccrued(
             token, creatorPayout, creatorAmount, referralPayout, referralAmount, treasury, protocolAmount, bountyAmount
         );
     }
 
     function releaseBounty(address token, address recipient, uint256 amount) external onlyHook {
         if (token == address(0) || recipient == address(0)) revert ZeroAddress();
         if (amount > reservedBountyEthForToken[token]) revert ReservedBountyExceeded();
 
+        _consumeFallbackBounty(token, amount);
         reservedBountyEthForToken[token] -= amount;
         reservedBountyEth -= amount;
         bountyClaimableEth[recipient] += amount;
         totalClaimableEth += amount;
         emit BountyReleased(token, recipient, amount);
     }
 
+    function releaseFallbackBounty(address token, uint256 amount) external onlyHook returns (address recipient) {
+        if (token == address(0)) revert ZeroAddress();
+        if (amount != reservedBountyEthForToken[token]) revert ReservedBountyExceeded();
+
+        address[] storage recipients = _fallbackBountyRecipients[token];
+        if (recipients.length == 1) recipient = recipients[0];
+        for (uint256 i; i < recipients.length; ++i) {
+            address fallbackRecipient = recipients[i];
+            uint256 recipientAmount = fallbackBountyEthForToken[token][fallbackRecipient];
+            delete fallbackBountyEthForToken[token][fallbackRecipient];
+            bountyClaimableEth[fallbackRecipient] += recipientAmount;
+            emit BountyReleased(token, fallbackRecipient, recipientAmount);
+        }
+        delete _fallbackBountyRecipients[token];
+        reservedBountyEthForToken[token] = 0;
+        reservedBountyEth -= amount;
+        totalClaimableEth += amount;
+    }
+
     function claimCreator(address token) external nonReentrant returns (uint256 amount) {
         amount = creatorClaimableEth[token][msg.sender];
         if (amount == 0) revert NoFees();
         creatorClaimableEth[token][msg.sender] = 0;
         _pay(payable(msg.sender), amount);
         emit CreatorFeesClaimed(token, msg.sender, amount);
     }
 
     function claimReferral() external nonReentrant returns (uint256 amount) {
         amount = referralClaimableEth[msg.sender];
         if (amount == 0) revert NoFees();
         referralClaimableEth[msg.sender] = 0;
         _pay(payable(msg.sender), amount);
         emit ReferralFeesClaimed(msg.sender, amount);
     }
 
     function claimProtocol() external nonReentrant returns (uint256 amount) {
         amount = protocolClaimableEth[msg.sender];
         if (amount == 0) revert NoFees();
         protocolClaimableEth[msg.sender] = 0;
         _pay(payable(msg.sender), amount);
         emit ProtocolFeesClaimed(msg.sender, amount);
     }
 
     function claimGraduationBounty() external nonReentrant returns (uint256 amount) {
         amount = bountyClaimableEth[msg.sender];
         if (amount == 0) revert NoFees();
         bountyClaimableEth[msg.sender] = 0;
         _pay(payable(msg.sender), amount);
         emit GraduationBountyClaimed(msg.sender, amount);
     }
 
     function unlockCallback(bytes calldata data) external returns (bytes memory) {
         if (msg.sender != address(poolManager)) revert NotPoolManager();
         ClaimCallback memory callback = abi.decode(data, (ClaimCallback));
 
         poolManager.burn(address(this), 0, callback.amount);
         poolManager.take(CurrencyLibrary.ADDRESS_ZERO, callback.recipient, callback.amount);
         return bytes("");
     }
 
     function setTreasury(address newTreasury) external onlyOwner {
         if (newTreasury == address(0)) revert ZeroAddress();
         address previousTreasury = treasury;
         treasury = newTreasury;
         emit TreasuryUpdated(previousTreasury, newTreasury);
     }
 
     function isSolvent() external view returns (bool) {
         return poolManager.balanceOf(address(this), 0) >= totalLiabilityEth;
     }
 
+    function _consumeFallbackBounty(address token, uint256 amount) private {
+        address[] storage recipients = _fallbackBountyRecipients[token];
+        uint256 remaining = amount;
+        while (remaining != 0) {
+            uint256 last = recipients.length - 1;
+            address recipient = recipients[last];
+            uint256 recipientAmount = fallbackBountyEthForToken[token][recipient];
+            if (recipientAmount <= remaining) {
+                remaining -= recipientAmount;
+                delete fallbackBountyEthForToken[token][recipient];
+                recipients.pop();
+            } else {
+                fallbackBountyEthForToken[token][recipient] = recipientAmount - remaining;
+                remaining = 0;
+            }
+        }
+    }
+
     function _pay(address payable recipient, uint256 amount) private {
         totalClaimableEth -= amount;
         totalLiabilityEth -= amount;
         poolManager.unlock(abi.encode(ClaimCallback({recipient: recipient, amount: amount})));
     }
 }

diff --git a/src/v2/CtrlLaunchHookV2.sol b/src/v2/CtrlLaunchHookV2.sol
--- a/src/v2/CtrlLaunchHookV2.sol
+++ b/src/v2/CtrlLaunchHookV2.sol
@@ -1,590 +1,597 @@
 // SPDX-License-Identifier: MIT
 pragma solidity ^0.8.26;
 
 import {Initializable} from "@openzeppelin-v5.6.1/contracts/proxy/utils/Initializable.sol";
 import {UUPSUpgradeable} from "@openzeppelin-v5.6.1/contracts/proxy/utils/UUPSUpgradeable.sol";
 import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
 import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
 import {Hooks} from "@uniswap/v4-core/src/libraries/Hooks.sol";
 import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
 import {SafeCast} from "@uniswap/v4-core/src/libraries/SafeCast.sol";
 import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
 import {BalanceDelta, BalanceDeltaLibrary} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
 import {
     BeforeSwapDelta,
     BeforeSwapDeltaLibrary,
     toBeforeSwapDelta
 } from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol";
 import {Currency, CurrencyLibrary} from "@uniswap/v4-core/src/types/Currency.sol";
 import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
 import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
 import {ICtrlFeeVault, ICtrlReferralRegistry} from "../interfaces/ICtrlProtocol.sol";
 
 /// @notice Upgradeable V2 hook for Ctrl launch pools.
 /// @dev The ERC-1967 proxy address, rather than the implementation address, must encode
 ///      the Uniswap V4 callback permission bits. Upgrade authority should be a timelock
 ///      or multisig in production.
 /// @custom:oz-upgrades
 contract CtrlLaunchHookV2 is Initializable, UUPSUpgradeable, IHooks {
     using BalanceDeltaLibrary for BalanceDelta;
     using PoolIdLibrary for PoolKey;
     using SafeCast for uint256;
 
     uint256 public constant BPS = 10_000;
     uint256 public constant TRADING_FEE_BPS = 100;
     uint256 public constant CREATOR_SHARE_BPS = 8_000;
     uint256 public constant REFERRAL_SHARE_BPS = 500;
     uint256 public constant BOUNTY_SHARE_BPS = 250;
     uint256 public constant GRADUATION_THRESHOLD = 4.2 ether;
 
     uint24 public constant LP_FEE = 0;
     int24 public constant TICK_SPACING = 200;
     int24 public constant INITIAL_TICK = 204_200;
     int24 public constant TICK_LOWER = -887_200;
     int24 public constant TICK_UPPER = INITIAL_TICK;
     bytes32 public constant SEED_HOOK_DATA = keccak256("CTRL_INITIAL_LIQUIDITY_V1");
 
     struct LaunchState {
         address token;
         address creator;
         address creatorPayout;
         uint256 netEthPrincipal;
         uint256 bountyAccrued;
         uint64 graduatedAt;
         bool seeded;
         bool graduated;
         bool exists;
     }
 
     struct SwapContext {
         address beneficiary;
         address referrer;
     }
 
     /// @custom:storage-location erc7201:ctrl.storage.CtrlLaunchHookV2
     struct HookStorage {
         address poolManager;
         address positionManager;
         address feeVault;
         address referralRegistry;
         address initializer;
         address factory;
         address upgradeAuthority;
         address pendingUpgradeAuthority;
         bool upgradesFrozen;
         mapping(PoolId poolId => LaunchState launch) launches;
         mapping(address token => PoolId poolId) poolIdForToken;
     }
 
     // keccak256(abi.encode(uint256(keccak256("ctrl.storage.CtrlLaunchHookV2")) - 1)) & ~bytes32(uint256(0xff))
     bytes32 private constant HOOK_STORAGE_LOCATION = 0xcc4d0a39645cc9985e3965d4882e32eee8fd4ca9ad3348705e04b8042d7c2200;
 
     error AlreadyInitialized();
     error CallbackNotEnabled();
     error InitialLiquidityAlreadySeeded();
     error InvalidInitialLiquidity();
     error InvalidPool();
     error InvalidSwapDelta();
     error NotCreator();
     error NotFactory();
     error NotInitializer();
     error NotPoolManager();
     error NotPendingUpgradeAuthority();
     error NotUpgradeAuthority();
     error PartialFill();
     error TokenAlreadyRegistered();
     error TokenNotRegistered();
     error UpgradesAreFrozen();
     error ZeroAddress();
 
     event FactoryInitialized(address indexed factory);
     event LaunchRegistered(
         address indexed token, PoolId indexed poolId, address indexed creator, address creatorPayout
     );
     event InitialLiquiditySeeded(address indexed token, PoolId indexed poolId, int256 liquidityDelta);
     event CreatorPayoutUpdated(address indexed token, address indexed previousPayout, address indexed newPayout);
     event FeeAccrued(
         address indexed token,
         PoolId indexed poolId,
         address indexed beneficiary,
         address referrer,
         address referralPayout,
         uint256 feeAmount,
         uint256 creatorAmount,
         uint256 referralAmount,
         uint256 protocolAmount,
         uint256 bountyAmount
     );
     event PrincipalUpdated(
         address indexed token, PoolId indexed poolId, uint256 previousPrincipal, uint256 newPrincipal
     );
     event TokenGraduated(
         address indexed token,
         PoolId indexed poolId,
         address indexed beneficiary,
         uint256 principal,
         uint256 bounty,
         uint64 graduatedAt
     );
     event UpgradeAuthorityTransferStarted(address indexed previousAuthority, address indexed pendingAuthority);
     event UpgradeAuthorityTransferred(address indexed previousAuthority, address indexed newAuthority);
     event UpgradesFrozen(address indexed authority);
 
     /// @custom:oz-upgrades-unsafe-allow constructor
     constructor() {
         _disableInitializers();
     }
 
     modifier onlyPoolManager() {
         if (msg.sender != _getHookStorage().poolManager) revert NotPoolManager();
         _;
     }
 
     modifier onlyFactory() {
         if (msg.sender != _getHookStorage().factory) revert NotFactory();
         _;
     }
 
     function initialize(
         address poolManager_,
         address positionManager_,
         address feeVault_,
         address referralRegistry_,
         address initializer_,
         address upgradeAuthority_
     ) external initializer {
         if (
             poolManager_ == address(0) || positionManager_ == address(0) || feeVault_ == address(0)
                 || referralRegistry_ == address(0) || initializer_ == address(0) || upgradeAuthority_ == address(0)
         ) {
             revert ZeroAddress();
         }
 
         // This executes through the proxy constructor, so address(this) is the permanent hook address.
         Hooks.validateHookPermissions(IHooks(address(this)), getHookPermissions());
 
         HookStorage storage $ = _getHookStorage();
         $.poolManager = poolManager_;
         $.positionManager = positionManager_;
         $.feeVault = feeVault_;
         $.referralRegistry = referralRegistry_;
         $.initializer = initializer_;
         $.upgradeAuthority = upgradeAuthority_;
         emit UpgradeAuthorityTransferred(address(0), upgradeAuthority_);
     }
 
     function poolManager() public view returns (IPoolManager) {
         return IPoolManager(_getHookStorage().poolManager);
     }
 
     function positionManager() public view returns (address) {
         return _getHookStorage().positionManager;
     }
 
     function feeVault() public view returns (ICtrlFeeVault) {
         return ICtrlFeeVault(_getHookStorage().feeVault);
     }
 
     function referralRegistry() public view returns (ICtrlReferralRegistry) {
         return ICtrlReferralRegistry(_getHookStorage().referralRegistry);
     }
 
     function bootstrapInitializer() public view returns (address) {
         return _getHookStorage().initializer;
     }
 
     function factory() public view returns (address) {
         return _getHookStorage().factory;
     }
 
     function upgradeAuthority() public view returns (address) {
         return _getHookStorage().upgradeAuthority;
     }
 
     function pendingUpgradeAuthority() public view returns (address) {
         return _getHookStorage().pendingUpgradeAuthority;
     }
 
     function upgradesFrozen() public view returns (bool) {
         return _getHookStorage().upgradesFrozen;
     }
 
     function implementationVersion() public pure virtual returns (uint64) {
         return 1;
     }
 
     function initializeFactory(address factory_) external {
         HookStorage storage $ = _getHookStorage();
         if (msg.sender != $.initializer) revert NotInitializer();
         if ($.factory != address(0)) revert AlreadyInitialized();
         if (factory_ == address(0)) revert ZeroAddress();
         $.factory = factory_;
         emit FactoryInitialized(factory_);
     }
 
     function transferUpgradeAuthority(address newAuthority) external {
         HookStorage storage $ = _getHookStorage();
         _checkUpgradeAuthority($);
         if (newAuthority == address(0)) revert ZeroAddress();
         $.pendingUpgradeAuthority = newAuthority;
         emit UpgradeAuthorityTransferStarted($.upgradeAuthority, newAuthority);
     }
 
     function acceptUpgradeAuthority() external {
         HookStorage storage $ = _getHookStorage();
         if (msg.sender != $.pendingUpgradeAuthority) revert NotPendingUpgradeAuthority();
         address previousAuthority = $.upgradeAuthority;
         $.upgradeAuthority = msg.sender;
         $.pendingUpgradeAuthority = address(0);
         emit UpgradeAuthorityTransferred(previousAuthority, msg.sender);
     }
 
     /// @notice Permanently disables future implementation upgrades.
     function freezeUpgrades() external {
         HookStorage storage $ = _getHookStorage();
         _checkUpgradeAuthority($);
         $.upgradesFrozen = true;
         $.pendingUpgradeAuthority = address(0);
         emit UpgradesFrozen(msg.sender);
     }
 
     function getHookPermissions() public pure returns (Hooks.Permissions memory permissions) {
         permissions.beforeInitialize = true;
         permissions.beforeAddLiquidity = true;
         permissions.beforeSwap = true;
         permissions.afterSwap = true;
         permissions.beforeSwapReturnDelta = true;
         permissions.afterSwapReturnDelta = true;
     }
 
     function poolKey(address token) public view returns (PoolKey memory key) {
         key = PoolKey({
             currency0: CurrencyLibrary.ADDRESS_ZERO,
             currency1: Currency.wrap(token),
             fee: LP_FEE,
             tickSpacing: TICK_SPACING,
             hooks: IHooks(address(this))
         });
     }
 
     function poolIdForToken(address token) public view returns (PoolId) {
         return _getHookStorage().poolIdForToken[token];
     }
 
     function registerLaunch(address token, address creator, address creatorPayout)
         external
         onlyFactory
         returns (PoolId poolId)
     {
         if (token == address(0) || creator == address(0) || creatorPayout == address(0)) {
             revert ZeroAddress();
         }
         HookStorage storage $ = _getHookStorage();
         if (PoolId.unwrap($.poolIdForToken[token]) != bytes32(0)) revert TokenAlreadyRegistered();
 
         PoolKey memory key = poolKey(token);
         poolId = key.toId();
         if ($.launches[poolId].exists) revert TokenAlreadyRegistered();
 
         $.launches[poolId] = LaunchState({
             token: token,
             creator: creator,
             creatorPayout: creatorPayout,
             netEthPrincipal: 0,
             bountyAccrued: 0,
             graduatedAt: 0,
             seeded: false,
             graduated: false,
             exists: true
         });
         $.poolIdForToken[token] = poolId;
         emit LaunchRegistered(token, poolId, creator, creatorPayout);
     }
 
     function updateCreatorPayout(address token, address newPayout) external onlyFactory {
         if (newPayout == address(0)) revert ZeroAddress();
         LaunchState storage launch = _launchForToken(token);
         address previousPayout = launch.creatorPayout;
         launch.creatorPayout = newPayout;
         emit CreatorPayoutUpdated(token, previousPayout, newPayout);
     }
 
     function getLaunch(address token) external view returns (LaunchState memory) {
         return _launchForToken(token);
     }
 
     function getLaunchByPoolId(PoolId poolId) external view returns (LaunchState memory) {
         LaunchState memory launch = _getHookStorage().launches[poolId];
         if (!launch.exists) revert TokenNotRegistered();
         return launch;
     }
 
     function beforeInitialize(address sender, PoolKey calldata key, uint160 sqrtPriceX96)
         external
         view
         onlyPoolManager
         returns (bytes4)
     {
         LaunchState storage launch = _validatedLaunch(key);
         if (sender != factory() || sqrtPriceX96 != TickMath.getSqrtPriceAtTick(INITIAL_TICK)) revert InvalidPool();
         if (launch.seeded) revert InvalidPool();
         return IHooks.beforeInitialize.selector;
     }
 
     function beforeAddLiquidity(
         address sender,
         PoolKey calldata key,
         IPoolManager.ModifyLiquidityParams calldata params,
         bytes calldata hookData
     ) external onlyPoolManager returns (bytes4) {
         LaunchState storage launch = _validatedLaunch(key);
         if (launch.seeded) revert InitialLiquidityAlreadySeeded();
         if (
             sender != positionManager() || params.tickLower != TICK_LOWER || params.tickUpper != TICK_UPPER
                 || params.liquidityDelta <= 0 || hookData.length != 32
                 || keccak256(hookData) != keccak256(abi.encodePacked(SEED_HOOK_DATA))
         ) {
             revert InvalidInitialLiquidity();
         }
 
         launch.seeded = true;
         emit InitialLiquiditySeeded(launch.token, key.toId(), params.liquidityDelta);
         return IHooks.beforeAddLiquidity.selector;
     }
 
     function beforeSwap(address, PoolKey calldata key, IPoolManager.SwapParams calldata params, bytes calldata hookData)
         external
         onlyPoolManager
         returns (bytes4, BeforeSwapDelta, uint24)
     {
         LaunchState storage launch = _validatedLaunch(key);
         if (!launch.seeded) revert InvalidPool();
 
         bool exactInput = params.amountSpecified < 0;
         bool nativeSpecified = exactInput == params.zeroForOne;
         uint256 feeAmount;
 
         if (nativeSpecified) {
             uint256 nativeAmount = _absoluteAmount(params.amountSpecified);
             feeAmount = exactInput ? _feeFromGross(nativeAmount) : _feeOnTop(nativeAmount);
             _mintAndAccrue(key.toId(), launch, feeAmount, hookData);
         }
 
         return (
             IHooks.beforeSwap.selector,
             feeAmount == 0 ? BeforeSwapDeltaLibrary.ZERO_DELTA : toBeforeSwapDelta(feeAmount.toInt128(), 0),
             0
         );
     }
 
     function afterSwap(
         address,
         PoolKey calldata key,
         IPoolManager.SwapParams calldata params,
         BalanceDelta delta,
         bytes calldata hookData
     ) external onlyPoolManager returns (bytes4, int128) {
         PoolId poolId = key.toId();
         LaunchState storage launch = _validatedLaunch(key);
         bool exactInput = params.amountSpecified < 0;
         bool buy = params.zeroForOne;
         bool nativeSpecified = exactInput == params.zeroForOne;
         int128 nativeDelta = delta.amount0();
         uint256 feeAmount;
         if ((buy && nativeDelta >= 0) || (!buy && nativeDelta <= 0)) revert InvalidSwapDelta();
 
         if (nativeSpecified) {
             uint256 requestedNative = _absoluteAmount(params.amountSpecified);
             feeAmount = exactInput ? _feeFromGross(requestedNative) : _feeOnTop(requestedNative);
             uint256 expectedPoolNative = exactInput ? requestedNative - feeAmount : requestedNative + feeAmount;
             if (_absoluteAmount(nativeDelta) != expectedPoolNative) revert PartialFill();
         } else {
             uint256 actualPoolNative = _absoluteAmount(nativeDelta);
             feeAmount = buy ? _feeOnTop(actualPoolNative) : _feeFromGross(actualPoolNative);
             _mintAndAccrue(poolId, launch, feeAmount, hookData);
         }
 
         _updatePrincipalAndGraduate(poolId, launch, buy, _absoluteAmount(nativeDelta), hookData);
         return (IHooks.afterSwap.selector, nativeSpecified ? int128(0) : feeAmount.toInt128());
     }
 
     function afterInitialize(address, PoolKey calldata, uint160, int24) external pure returns (bytes4) {
         revert CallbackNotEnabled();
     }
 
     function afterAddLiquidity(
         address,
         PoolKey calldata,
         IPoolManager.ModifyLiquidityParams calldata,
         BalanceDelta,
         BalanceDelta,
         bytes calldata
     ) external pure returns (bytes4, BalanceDelta) {
         revert CallbackNotEnabled();
     }
 
     function beforeRemoveLiquidity(
         address,
         PoolKey calldata,
         IPoolManager.ModifyLiquidityParams calldata,
         bytes calldata
     ) external pure returns (bytes4) {
         revert CallbackNotEnabled();
     }
 
     function afterRemoveLiquidity(
         address,
         PoolKey calldata,
         IPoolManager.ModifyLiquidityParams calldata,
         BalanceDelta,
         BalanceDelta,
         bytes calldata
     ) external pure returns (bytes4, BalanceDelta) {
         revert CallbackNotEnabled();
     }
 
     function beforeDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) {
         revert CallbackNotEnabled();
     }
 
     function afterDonate(address, PoolKey calldata, uint256, uint256, bytes calldata) external pure returns (bytes4) {
         revert CallbackNotEnabled();
     }
 
     function _mintAndAccrue(PoolId poolId, LaunchState storage launch, uint256 feeAmount, bytes calldata hookData)
         private
     {
         if (feeAmount == 0) return;
 
         SwapContext memory context = _parseContext(hookData);
         ICtrlReferralRegistry registry = referralRegistry();
         address referralPayout = context.referrer == address(0) ? address(0) : registry.payoutOf(context.referrer);
         uint256 creatorAmount = FullMath.mulDiv(feeAmount, CREATOR_SHARE_BPS, BPS);
         uint256 referralAmount = referralPayout == address(0) ? 0 : FullMath.mulDiv(feeAmount, REFERRAL_SHARE_BPS, BPS);
         uint256 bountyAmount = launch.graduated ? 0 : FullMath.mulDiv(feeAmount, BOUNTY_SHARE_BPS, BPS);
         uint256 protocolAmount = feeAmount - creatorAmount - referralAmount - bountyAmount;
 
         IPoolManager manager = poolManager();
         ICtrlFeeVault vault = feeVault();
         manager.mint(address(vault), 0, feeAmount);
         vault.accrue(
             launch.token,
             launch.creatorPayout,
             creatorAmount,
             referralPayout,
             referralAmount,
             protocolAmount,
             bountyAmount
         );
         launch.bountyAccrued += bountyAmount;
 
         emit FeeAccrued(
             launch.token,
             poolId,
             context.beneficiary,
             context.referrer,
             referralPayout,
             feeAmount,
             creatorAmount,
             referralAmount,
             protocolAmount,
             bountyAmount
         );
     }
 
     function _updatePrincipalAndGraduate(
         PoolId poolId,
         LaunchState storage launch,
         bool buy,
         uint256 poolNativeAmount,
         bytes calldata hookData
     ) private {
         uint256 previousPrincipal = launch.netEthPrincipal;
         uint256 newPrincipal;
 
         if (buy) {
             newPrincipal = previousPrincipal + poolNativeAmount;
         } else {
             newPrincipal = poolNativeAmount >= previousPrincipal ? 0 : previousPrincipal - poolNativeAmount;
         }
         launch.netEthPrincipal = newPrincipal;
         emit PrincipalUpdated(launch.token, poolId, previousPrincipal, newPrincipal);
 
         if (!launch.graduated && previousPrincipal < GRADUATION_THRESHOLD && newPrincipal >= GRADUATION_THRESHOLD) {
             SwapContext memory context = _parseContext(hookData);
             ICtrlFeeVault vault = feeVault();
             address beneficiary = context.beneficiary;
-            if (beneficiary == address(0)) beneficiary = vault.treasury();
 
             uint256 bounty = launch.bountyAccrued;
             launch.bountyAccrued = 0;
             launch.graduated = true;
             launch.graduatedAt = uint64(block.timestamp);
-            if (bounty != 0) vault.releaseBounty(launch.token, beneficiary, bounty);
+            if (bounty != 0) {
+                if (beneficiary == address(0)) {
+                    beneficiary = vault.releaseFallbackBounty(launch.token, bounty);
+                } else {
+                    vault.releaseBounty(launch.token, beneficiary, bounty);
+                }
+            } else if (beneficiary == address(0)) {
+                beneficiary = vault.treasury();
+            }
 
             emit TokenGraduated(launch.token, poolId, beneficiary, newPrincipal, bounty, launch.graduatedAt);
         }
     }
 
     function _validatedLaunch(PoolKey calldata key) private view returns (LaunchState storage launch) {
         if (
             !key.currency0.isAddressZero() || key.fee != LP_FEE || key.tickSpacing != TICK_SPACING
                 || address(key.hooks) != address(this)
         ) {
             revert InvalidPool();
         }
         PoolId poolId = key.toId();
         launch = _getHookStorage().launches[poolId];
         if (!launch.exists || Currency.unwrap(key.currency1) != launch.token) revert InvalidPool();
     }
 
     function _launchForToken(address token) private view returns (LaunchState storage launch) {
         HookStorage storage $ = _getHookStorage();
         PoolId poolId = $.poolIdForToken[token];
         launch = $.launches[poolId];
         if (!launch.exists) revert TokenNotRegistered();
     }
 
     function _parseContext(bytes calldata hookData) private pure returns (SwapContext memory context) {
         if (hookData.length != 64) return context;
 
         uint256 beneficiaryWord;
         uint256 referrerWord;
         assembly ("memory-safe") {
             beneficiaryWord := calldataload(hookData.offset)
             referrerWord := calldataload(add(hookData.offset, 0x20))
         }
         if (beneficiaryWord >> 160 != 0 || referrerWord >> 160 != 0) return context;
         context.beneficiary = address(uint160(beneficiaryWord));
         context.referrer = address(uint160(referrerWord));
     }
 
     function _feeFromGross(uint256 gross) private pure returns (uint256) {
         return FullMath.mulDiv(gross, TRADING_FEE_BPS, BPS);
     }
 
     function _feeOnTop(uint256 net) private pure returns (uint256) {
         uint256 gross = FullMath.mulDivRoundingUp(net, BPS, BPS - TRADING_FEE_BPS);
         return gross - net;
     }
 
     function _absoluteAmount(int256 amount) private pure returns (uint256) {
         return amount < 0 ? uint256(-amount) : uint256(amount);
     }
 
     function _authorizeUpgrade(address) internal view override {
         HookStorage storage $ = _getHookStorage();
         _checkUpgradeAuthority($);
         if ($.upgradesFrozen) revert UpgradesAreFrozen();
     }
 
     function _checkUpgradeAuthority(HookStorage storage $) internal view {
         if (msg.sender != $.upgradeAuthority) revert NotUpgradeAuthority();
     }
 
     function _getHookStorage() private pure returns (HookStorage storage $) {
         assembly ("memory-safe") {
             $.slot := HOOK_STORAGE_LOCATION
         }
     }
 }
```

### Affected files

* `src/interfaces/ICtrlProtocol.sol`
* `src/CtrlFeeVault.sol`
* `src/v2/CtrlLaunchHookV2.sol`

### Validation output

```
[output truncated: 1104 lines & 86.1513671875 KB skipped]

Ran 1 test suite in 53.07ms (44.27ms CPU time): 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/Poc.t.sol:PocTest
[FAIL: original treasury received none of its historic fallback reserve: 250000000000000 != 0] testPocTreasuryRotationRedirectsAlreadyAccruedFallbackBounty() (gas: 2713184)

Encountered a total of 1 failing tests, 0 tests succeeded

Tip: Run `forge test --rerun` to retry only the 1 failed test
```
