FeeLockerV4R2b

0x42afbbafdf45a42e73f8eabb6400b2a14088333b

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
16,349 bytes
ABI entries
51
15 read · 10 write
License
mit

Contract information

Address
0x42afbbafdf45a42e73f8eabb6400b2a14088333b
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x31bcd6da8a…2109f2e2a4

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (15)

DEAD()address
MAX_EVER_ASSIGNED()uint256
MAX_PAYOUT_WALLETS()uint256
assignedFloorOf(address, address)uint16
assignedRecipients(address)address[]
factory()address
gasTank()address
gasTankBps()uint16
getLock(address)bool, uint128, address, address, uint16, address[], uint16[]
isGraduated(address)bool
liquidityOf(address)uint128
lockTicks(address)int24, int24
poolManager()address
withdrawableETH(address)uint256
withdrawableToken(address, address)uint256

Events (9)

CommunityTakeoverCreatorTransferredETHWithdrawnFeesCollectedPayoutConfigSetPositionGraduatedPositionLockedSwapFeeCreditedTokenWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "factory_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "poolManager_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "gasTank_",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "gasTankBps_",
        "type": "uint16"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyGraduated",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "AlreadyLocked",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "BadPayoutConfig",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "FloorViolation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotCreator",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotFactory",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotHook",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotPoolManager",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotTreasury",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToWithdraw",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "PayoutAlreadyInitialized",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "TooManyAssignees",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnknownToken",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "oldCreator",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newCreator",
        "type": "address"
      }
    ],
    "name": "CommunityTakeover",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "oldCreator",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newCreator",
        "type": "address"
      }
    ],
    "name": "CreatorTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "ETHWithdrawn",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "tokenAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "splitBps",
        "type": "uint16"
      }
    ],
    "name": "FeesCollected",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address[]",
        "name": "wallets",
        "type": "address[]"
      },
      {
        "indexed": false,
        "internalType": "uint16[]",
        "name": "weights",
        "type": "uint16[]"
      }
    ],
    "name": "PayoutConfigSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint128",
        "name": "newLiquidity",
        "type": "uint128"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethToLp",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "tokensToLp",
        "type": "uint256"
      }
    ],
    "name": "PositionGraduated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint128",
        "name": "liquidity",
        "type": "uint128"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "creator",
        "type": "address"
      }
    ],
    "name": "PositionLocked",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "ethAmount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "creatorBps",
        "type": "uint16"
      }
    ],
    "name": "SwapFeeCredited",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "to",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "TokenWithdrawn",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "DEAD",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_EVER_ASSIGNED",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "MAX_PAYOUT_WALLETS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "a",
        "type": "address"
      }
    ],
    "name": "assignedFloorOf",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "assignedRecipients",
    "outputs": [
      {
        "internalType": "address[]",
        "name": "",
        "type": "address[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "collectFees",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "tokenAmount",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "ethAmount",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "newCreator",
        "type": "address"
      }
    ],
    "name": "communityTakeover",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "factory",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "gasTank",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "gasTankBps",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "getLock",
    "outputs": [
      {
        "internalType": "bool",
        "name": "exists",
        "type": "bool"
      },
      {
        "internalType": "uint128",
        "name": "liquidity",
        "type": "uint128"
      },
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "treasury",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "creatorSplitBps",
        "type": "uint16"
      },
      {
        "internalType": "address[]",
        "name": "payoutWallets",
        "type": "address[]"
      },
      {
        "internalType": "uint16[]",
        "name": "payoutWeights",
        "type": "uint16[]"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "int24",
        "name": "newTickLower",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "newTickUpper",
        "type": "int24"
      }
    ],
    "name": "graduateRerange",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "ethToLp",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "tokensToLp",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "isGraduated",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "liquidityOf",
    "outputs": [
      {
        "internalType": "uint128",
        "name": "",
        "type": "uint128"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "components": [
          {
            "internalType": "Currency",
            "name": "currency0",
            "type": "address"
          },
          {
            "internalType": "Currency",
            "name": "currency1",
            "type": "address"
          },
          {
            "internalType": "uint24",
            "name": "fee",
            "type": "uint24"
          },
          {
            "internalType": "int24",
            "name": "tickSpacing",
            "type": "int24"
          },
          {
            "internalType": "contract IHooks",
            "name": "hooks",
            "type": "address"
          }
        ],
        "internalType": "struct PoolKey",
        "name": "key",
        "type": "tuple"
      },
      {
        "internalType": "int24",
        "name": "tickLower",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "tickUpper",
        "type": "int24"
      },
      {
        "internalType": "uint128",
        "name": "liquidity",
        "type": "uint128"
      },
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "treasury",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "creatorSplitBps",
        "type": "uint16"
      }
    ],
    "name": "lockLiquidity",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "lockTicks",
    "outputs": [
      {
        "internalType": "int24",
        "name": "tickLower",
        "type": "int24"
      },
      {
        "internalType": "int24",
        "name": "tickUpper",
        "type": "int24"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "poolManager",
    "outputs": [
      {
        "internalType": "contract IPoolManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address[]",
        "name": "wallets",
        "type": "address[]"
      },
      {
        "internalType": "uint16[]",
        "name": "weights",
        "type": "uint16[]"
      }
    ],
    "name": "setInitialPayout",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address[]",
        "name": "wallets",
        "type": "address[]"
      },
      {
        "internalType": "uint16[]",
        "name": "weights",
        "type": "uint16[]"
      }
    ],
    "name": "setPayoutConfig",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "newCreator",
        "type": "address"
      }
    ],
    "name": "transferCreator",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "bytes",
        "name": "data",
        "type": "bytes"
      }
    ],
    "name": "unlockCallback",
    "outputs": [
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "withdrawETH",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "withdrawToken",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "withdrawableETH",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "withdrawableToken",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

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

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IPoolManager} from "v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {PoolKey} from "v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "v4-core/src/types/PoolId.sol";
import {ModifyLiquidityParams} from "v4-core/src/types/PoolOperation.sol";
import {BalanceDelta, BalanceDeltaLibrary} from "v4-core/src/types/BalanceDelta.sol";
import {Currency, CurrencyLibrary} from "v4-core/src/types/Currency.sol";
import {CurrencySettler} from "v4-core/test/utils/CurrencySettler.sol";
import {TickMath} from "v4-core/src/libraries/TickMath.sol";
import {StateLibrary} from "v4-core/src/libraries/StateLibrary.sol";
import {LiquidityAmounts} from "v4-core/test/utils/LiquidityAmounts.sol";

/// @title FeeLockerV4R2b (keep-then-settle: pulls sell fees from the hook at collect)
/// @notice Uniswap-V4 launch locker, revision 2 (V4R2): holds every launch's Uniswap V4 liquidity position FOREVER
/// (identical custody model to FeeLockerV4 — no function can ever remove, transfer or burn liquidity),
/// and is now the SINGLE SOURCE OF TRUTH for the fee-payout configuration, the creator role, and the
/// dedicated-share floor ratchet. The V4 hook kept a parallel payout config that money never flowed
/// through (dead code) while the V4 locker — where money DOES move — lacked the ratchet; V4R2 removes that
/// divergence by construction: the hook keeps NO payout state at all.
///
/// V4R2 distribution rules (the operator-locked fee redesign, 2026-07-19):
///   1. ETH-DENOMINATED fees are split creator/protocol by the hook's per-trade epoch-weighted split
///      (every wei attributed to the MC band it was actually earned in), with the protocol's slice
///      further split treasury / gas-tank by the immutable `gasTankBps`.
///   2. TOKEN-DENOMINATED fees (the sell-side LP fee of unmigrated pools) go 100% TO THE CREATOR's
///      payout wallets. The treasury NEVER accrues launch tokens — the platform's take is ETH only.
///   3. PULL-ONLY: nothing here ever pushes fees to a user wallet. Credits accumulate in
///      `withdrawableETH`/`withdrawableToken` until the credited address itself signs a withdrawal.
///      The gas tank (the platform's own operational wallet) is the only non-user credit.
///
/// The dedicated-share floor ratchet (ported from FoundryHookV4Variable, now enforced where the money
/// moves): once a NON-creator address is assigned a positive share of the creator split, that share can
/// never be reduced or removed — only increased. The creator's OWN share is exempt. Floors persist
/// across `transferCreator`/`communityTakeover` (which now PRESERVE the payout config instead of
/// resetting it, matching the promise the product makes).
contract FeeLockerV4R2b is IUnlockCallback, ReentrancyGuard {
    using SafeERC20 for IERC20;
    using PoolIdLibrary for PoolKey;
    using CurrencyLibrary for Currency;
    using CurrencySettler for Currency;
    using BalanceDeltaLibrary for BalanceDelta;
    using StateLibrary for IPoolManager;

    uint256 public constant MAX_PAYOUT_WALLETS = 10;
    uint256 public constant MAX_EVER_ASSIGNED = 20;
    address public constant DEAD = 0x000000000000000000000000000000000000dEaD;

    address public immutable factory;
    IPoolManager public immutable poolManager;
    /// @dev The platform's operational gas wallet (the relayer). Receives `gasTankBps` of the protocol's
    /// ETH share as pull-credit; a keeper claims only when the tank runs below its floor. Fee income is
    /// proportional to activity, so the gas budget scales with the platform automatically.
    address public immutable gasTank;
    uint16 public immutable gasTankBps; // slice of the PROTOCOL ETH share routed to the gas tank

    struct Lock {
        bool exists;
        bool graduated; // true once the curve position has been re-ranged to full-range (exactly-once)
        bool initialPayoutSet; // one-shot launch-tx fee split applied (factory-only, pre-first-collect)
        PoolKey key;
        int24 tickLower;
        int24 tickUpper;
        uint128 liquidity;
        address creator;
        address treasury;
        uint16 creatorSplitBps; // FALLBACK creator share of ETH fees (epoch split preferred; see _splitBps)
        address[] payoutWallets;
        uint16[] payoutWeights; // bps, sum == 10000
        // Floor ratchet (change-B parity, now at the money layer): every non-creator recipient ever
        // assigned a positive share is recorded with the highest share it has ever held; any later
        // config must keep each such recipient at or above its floor.
        address[] assignedRecipients;
        mapping(address => uint16) assignedFloor;
    }

    mapping(address => Lock) internal _locks; // launch token => lock

    // Pull-payment ledgers.
    mapping(address => uint256) public withdrawableETH;
    mapping(address => mapping(address => uint256)) public withdrawableToken;

    // Transient unlock context.
    enum Action {
        Lock,
        Collect,
        Graduate
    }

    event PositionLocked(address indexed token, uint128 liquidity, address indexed creator);
    event PositionGraduated(address indexed token, uint128 newLiquidity, uint256 ethToLp, uint256 tokensToLp);
    /// @dev `splitBps` is the creator's realized ETH-share for THIS collection (epoch-weighted).
    event FeesCollected(address indexed token, uint256 tokenAmount, uint256 ethAmount, uint16 splitBps);
    /// @dev ETH sell-side fee pushed by the hook (Option B pools only), distributed at `creatorBps`.
    event SwapFeeCredited(address indexed token, uint256 ethAmount, uint16 creatorBps);
    event CreatorTransferred(address indexed token, address indexed oldCreator, address indexed newCreator);
    event CommunityTakeover(address indexed token, address indexed oldCreator, address indexed newCreator);
    event PayoutConfigSet(address indexed token, address[] wallets, uint16[] weights);
    event ETHWithdrawn(address indexed to, uint256 amount);
    event TokenWithdrawn(address indexed token, address indexed to, uint256 amount);

    error NotFactory();
    error NotPoolManager();
    error NotHook();
    error AlreadyLocked();
    error AlreadyGraduated();
    error UnknownToken();
    error NotCreator();
    error NotTreasury();
    error BadPayoutConfig();
    error FloorViolation();
    error TooManyAssignees();
    error PayoutAlreadyInitialized();
    error NothingToWithdraw();

    constructor(address factory_, address poolManager_, address gasTank_, uint16 gasTankBps_) {
        require(gasTank_ != address(0), "bad gasTank");
        require(gasTankBps_ <= 10000, "bad gasTankBps");
        factory = factory_;
        poolManager = IPoolManager(poolManager_);
        gasTank = gasTank_;
        gasTankBps = gasTankBps_;
    }

    /// @dev Accept ETH from the PoolManager (fee takes / settlement) AND from the pool hook (the
    /// collect-time sell-fee release). Accepting ETH is harmless to the accounting: only amounts
    /// CREDITED to `withdrawableETH` are ever withdrawable, so un-credited ETH sitting here is inert.
    receive() external payable {}

    // ─────────────────────────── locking (custody in) ───────────────────────────

    /// @notice Factory-only. Identical custody semantics to FeeLockerV4.lockLiquidity: places the full
    /// launch supply as the single-sided curve position owned by this contract, forever. Seeds the
    /// payout config to [creator @ 10000].
    function lockLiquidity(
        PoolKey calldata key,
        int24 tickLower,
        int24 tickUpper,
        uint128 liquidity,
        address token,
        address creator,
        address treasury,
        uint16 creatorSplitBps
    ) external {
        if (msg.sender != factory) revert NotFactory();
        if (_locks[token].exists) revert AlreadyLocked();
        if (creatorSplitBps > 10000) revert BadPayoutConfig();

        Lock storage l = _locks[token];
        l.exists = true;
        l.key = key;
        l.tickLower = tickLower;
        l.tickUpper = tickUpper;
        l.liquidity = liquidity;
        l.creator = creator;
        l.treasury = treasury;
        l.creatorSplitBps = creatorSplitBps;
        l.payoutWallets.push(creator);
        l.payoutWeights.push(10000);

        poolManager.unlock(abi.encode(Action.Lock, token));

        // Burn any launch-token dust the position did not absorb (keeps the locker off terminals as a
        // holder). ETH is never left here from a lock (single-sided token position).
        uint256 dust = IERC20(token).balanceOf(address(this));
        if (dust > 0) IERC20(token).safeTransfer(DEAD, dust);

        emit PositionLocked(token, liquidity, creator);
    }

    /// @notice FACTORY-ONLY, ONE-SHOT single-signature fee split, applied in the SAME launch tx (the V4R2
    /// analog of the V4 hook's `setInitialPayout` — now at the money layer). Runs the full validation +
    /// floor-ratchet seeding of `setPayoutConfig`. Reverts once used; after launch only the creator can
    /// change the config.
    function setInitialPayout(address token, address[] calldata wallets, uint16[] calldata weights) external {
        if (msg.sender != factory) revert NotFactory();
        Lock storage l = _locks[token];
        if (!l.exists) revert UnknownToken();
        if (l.initialPayoutSet) revert PayoutAlreadyInitialized();
        l.initialPayoutSet = true;
        _validateAndSet(l, wallets, weights);
        emit PayoutConfigSet(token, wallets, weights);
    }

    // ─────────────────────────── fee collection ───────────────────────────

    /// @notice Permissionless. Pokes `token`'s locked position to realize accrued LP-tier fees, then
    /// credits them per the V4R2 rules: ETH split creator/protocol by the hook's epoch-weighted per-trade
    /// split (protocol slice further split treasury/gas-tank); TOKENS 100% to the creator's payout
    /// wallets. Recipients later pull via withdraw*.
    function collectFees(address token) external nonReentrant returns (uint256 tokenAmount, uint256 ethAmount) {
        Lock storage l = _locks[token];
        if (!l.exists) revert UnknownToken();

        bytes memory res = poolManager.unlock(abi.encode(Action.Collect, token));
        (ethAmount, tokenAmount) = abi.decode(res, (uint256, uint256)); // (currency0=ETH, currency1=token)

        // Settle the hook's per-trade epoch tally: the creator's exact weighted ETH-share for the fees
        // collected in this batch. Falls back to the live-band view, then the fixed split — a hook that
        // reverts or lacks the interface can never brick collection.
        uint16 splitBps = _settleEpochSplit(l);
        _distributeEth(l, splitBps, ethAmount);
        _distributeToken(l, token, tokenAmount);

        // KEEP-THEN-SETTLE: pull the hook's accumulated SELL-side ETH fees. The hook took these to
        // ITSELF during swaps (no swap-path external call → a sell can never be blocked by this locker);
        // here, off the swap path, the hook sends the ETH and returns its per-trade-weighted split. A
        // failure only fails THIS collect (retryable), never a trade. Best-effort so a hook without the
        // interface (e.g. a legacy pool) can never brick collection.
        (bool ok, bytes memory ret) = address(l.key.hooks).call(
            abi.encodeWithSignature("releaseSwapFees(bytes32)", PoolId.unwrap(l.key.toId()))
        );
        if (ok && ret.length >= 64) {
            (uint256 sellEth, uint256 sellBps) = abi.decode(ret, (uint256, uint256));
            if (sellEth > 0 && sellBps <= 10000) _distributeEth(l, uint16(sellBps), sellEth);
        }

        emit FeesCollected(token, tokenAmount, ethAmount, splitBps);
    }

    /// @dev The creator's realized ETH-share for this collection. Primary: the hook's `settleEpoch`
    /// (returns the per-trade weighted bps since the last collection AND resets the tally — the fix for
    /// V4's never-resetting lifetime average). Fallbacks: the hook's live-band `creatorBps` view, then
    /// the fixed `creatorSplitBps` recorded at lock time.
    function _settleEpochSplit(Lock storage l) internal returns (uint16) {
        (bool ok, bytes memory ret) = address(l.key.hooks).call(
            abi.encodeWithSignature("settleEpoch(bytes32)", PoolId.unwrap(l.key.toId()))
        );
        if (ok && ret.length >= 32) {
            uint256 v = abi.decode(ret, (uint256));
            if (v <= 10000) return uint16(v);
        }
        (ok, ret) = address(l.key.hooks).staticcall(
            abi.encodeWithSignature("creatorBps(bytes32)", PoolId.unwrap(l.key.toId()))
        );
        if (ok && ret.length >= 32) {
            uint256 v = abi.decode(ret, (uint256));
            if (v <= 10000) return uint16(v);
        }
        return l.creatorSplitBps;
    }

    // ─────────────────────────── graduation (re-range in place; unchanged from V4) ───────────────────────────

    /// @notice Factory-only, exactly-once. Re-ranges the locked single-sided curve position into a
    /// full-range deep position at the current pool price. Liquidity never leaves custody.
    function graduateRerange(address token, int24 newTickLower, int24 newTickUpper)
        external
        nonReentrant
        returns (uint256 ethToLp, uint256 tokensToLp)
    {
        if (msg.sender != factory) revert NotFactory();
        Lock storage l = _locks[token];
        if (!l.exists) revert UnknownToken();
        if (l.graduated) revert AlreadyGraduated();
        l.graduated = true; // effect-first: exactly-once

        bytes memory res = poolManager.unlock(abi.encode(Action.Graduate, token, newTickLower, newTickUpper));
        uint128 newLiq;
        (ethToLp, tokensToLp, newLiq) = abi.decode(res, (uint256, uint256, uint128));

        l.tickLower = newTickLower;
        l.tickUpper = newTickUpper;
        l.liquidity = newLiq;

        emit PositionGraduated(token, newLiq, ethToLp, tokensToLp);
    }

    /// @notice PoolManager unlock callback. Dispatches Lock (add liquidity), Collect (poke fees) and
    /// Graduate (re-range). Only ever entered via this contract's own `unlock` calls.
    function unlockCallback(bytes calldata data) external returns (bytes memory) {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        Action action = abi.decode(data, (Action));
        if (action == Action.Graduate) return _graduateCallback(data);

        (, address token) = abi.decode(data, (Action, address));
        Lock storage l = _locks[token];

        if (action == Action.Lock) {
            (BalanceDelta delta,) = poolManager.modifyLiquidity(
                l.key,
                ModifyLiquidityParams({
                    tickLower: l.tickLower,
                    tickUpper: l.tickUpper,
                    liquidityDelta: int256(uint256(l.liquidity)),
                    salt: 0
                }),
                ""
            );
            if (delta.amount0() < 0) {
                l.key.currency0.settle(poolManager, address(this), uint256(uint128(-delta.amount0())), false);
            }
            if (delta.amount1() < 0) {
                l.key.currency1.settle(poolManager, address(this), uint256(uint128(-delta.amount1())), false);
            }
            return "";
        }

        // Collect: liquidityDelta 0 pokes the position; callerDelta == fees owed (>= 0). Take both sides.
        (BalanceDelta fees,) = poolManager.modifyLiquidity(
            l.key,
            ModifyLiquidityParams({tickLower: l.tickLower, tickUpper: l.tickUpper, liquidityDelta: 0, salt: 0}),
            ""
        );
        uint256 eth0 = fees.amount0() > 0 ? uint256(uint128(fees.amount0())) : 0;
        uint256 tok1 = fees.amount1() > 0 ? uint256(uint128(fees.amount1())) : 0;
        if (eth0 > 0) poolManager.take(l.key.currency0, address(this), eth0);
        if (tok1 > 0) poolManager.take(l.key.currency1, address(this), tok1);
        return abi.encode(eth0, tok1);
    }

    function _graduateCallback(bytes calldata data) internal returns (bytes memory) {
        (, address token, int24 newLower, int24 newUpper) = abi.decode(data, (Action, address, int24, int24));
        Lock storage l = _locks[token];

        BalanceDelta rm = _modify(l.key, l.tickLower, l.tickUpper, -int256(uint256(l.liquidity)));
        uint128 newLiq = _sizeFullRange(l.key, newLower, newUpper, rm);
        BalanceDelta add = _modify(l.key, newLower, newUpper, int256(uint256(newLiq)));
        _sweepResidual(l, rm, add);

        return abi.encode(_neg0(add), _neg1(add), newLiq);
    }

    function _modify(PoolKey storage key, int24 tickLower, int24 tickUpper, int256 liquidityDelta)
        internal
        returns (BalanceDelta delta)
    {
        (delta,) = poolManager.modifyLiquidity(
            key,
            ModifyLiquidityParams({tickLower: tickLower, tickUpper: tickUpper, liquidityDelta: liquidityDelta, salt: 0}),
            ""
        );
    }

    function _sizeFullRange(PoolKey storage key, int24 newLower, int24 newUpper, BalanceDelta rm)
        internal
        view
        returns (uint128)
    {
        (uint160 sqrtP,,,) = poolManager.getSlot0(key.toId());
        uint256 ethFreed = rm.amount0() > 0 ? uint256(uint128(rm.amount0())) : 0;
        uint256 tokFreed = rm.amount1() > 0 ? uint256(uint128(rm.amount1())) : 0;
        return LiquidityAmounts.getLiquidityForAmounts(
            sqrtP, TickMath.getSqrtPriceAtTick(newLower), TickMath.getSqrtPriceAtTick(newUpper), ethFreed, tokFreed
        );
    }

    /// @dev Graduation residual: ETH dust → treasury CREDIT (pull — not a push transfer), token dust →
    /// DEAD. Touches only this graduation's dust, never held withdrawal balances.
    function _sweepResidual(Lock storage l, BalanceDelta rm, BalanceDelta add) internal {
        int128 net0 = rm.amount0() + add.amount0();
        int128 net1 = rm.amount1() + add.amount1();
        if (net0 > 0) {
            poolManager.take(l.key.currency0, address(this), uint256(uint128(net0)));
            withdrawableETH[l.treasury] += uint256(uint128(net0));
        }
        if (net1 > 0) poolManager.take(l.key.currency1, DEAD, uint256(uint128(net1)));
    }

    function _neg0(BalanceDelta d) internal pure returns (uint256) {
        return d.amount0() < 0 ? uint256(uint128(-d.amount0())) : 0;
    }

    function _neg1(BalanceDelta d) internal pure returns (uint256) {
        return d.amount1() < 0 ? uint256(uint128(-d.amount1())) : 0;
    }

    // ─────────────────────────── V4R2 distribution ───────────────────────────

    /// @dev ETH: creator share (epoch-weighted `splitBps`) fanned across the payout wallets by weight;
    /// the protocol remainder is split gas-tank / treasury by `gasTankBps`. All rounding dust accrues to
    /// the treasury, so credited amounts sum EXACTLY to `amount`.
    function _distributeEth(Lock storage l, uint16 splitBps, uint256 amount) internal {
        if (amount == 0) return;
        uint256 creatorShare = (amount * splitBps) / 10000;
        uint256 protocolShare = amount - creatorShare;

        uint256 distributed;
        uint256 n = l.payoutWallets.length;
        for (uint256 i = 0; i < n; i++) {
            uint256 cut = (creatorShare * l.payoutWeights[i]) / 10000;
            if (cut > 0) withdrawableETH[l.payoutWallets[i]] += cut;
            distributed += cut;
        }
        uint256 gasCut = (protocolShare * gasTankBps) / 10000;
        if (gasCut > 0) withdrawableETH[gasTank] += gasCut;
        uint256 toTreasury = (protocolShare - gasCut) + (creatorShare - distributed);
        if (toTreasury > 0) withdrawableETH[l.treasury] += toTreasury;
    }

    /// @dev TOKENS: 100% to the creator's payout wallets — the treasury NEVER accrues launch tokens
    /// (the platform's take is ETH only). Rounding dust goes to the FIRST payout wallet (never the
    /// treasury), so credited amounts sum EXACTLY to `amount`.
    function _distributeToken(Lock storage l, address token, uint256 amount) internal {
        if (amount == 0) return;
        uint256 distributed;
        uint256 n = l.payoutWallets.length;
        for (uint256 i = 1; i < n; i++) {
            uint256 cut = (amount * l.payoutWeights[i]) / 10000;
            if (cut > 0) withdrawableToken[token][l.payoutWallets[i]] += cut;
            distributed += cut;
        }
        withdrawableToken[token][l.payoutWallets[0]] += amount - distributed; // wallet[0]: its cut + dust
    }

    // ─────────────────────────── withdrawals (pull) ───────────────────────────

    function withdrawETH() external nonReentrant returns (uint256 amount) {
        amount = withdrawableETH[msg.sender];
        if (amount == 0) revert NothingToWithdraw();
        withdrawableETH[msg.sender] = 0;
        (bool ok,) = msg.sender.call{value: amount}("");
        require(ok, "ETH transfer failed");
        emit ETHWithdrawn(msg.sender, amount);
    }

    function withdrawToken(address token) external nonReentrant returns (uint256 amount) {
        amount = withdrawableToken[token][msg.sender];
        if (amount == 0) revert NothingToWithdraw();
        withdrawableToken[token][msg.sender] = 0;
        IERC20(token).safeTransfer(msg.sender, amount);
        emit TokenWithdrawn(token, msg.sender, amount);
    }

    // ─────────────────────────── creator role + payout config (ratchet at the money layer) ───────────────────────────

    /// @notice Creator-only role handoff. V4R2 change vs V4: the payout config and all ratchet floors are
    /// PRESERVED (V4's locker reset them to [newCreator @ 10000], silently wiping dedicated shares —
    /// the exact promise-break the ratchet exists to prevent).
    function transferCreator(address token, address newCreator) external {
        Lock storage l = _locks[token];
        if (!l.exists) revert UnknownToken();
        if (msg.sender != l.creator) revert NotCreator();
        if (newCreator == address(0)) revert BadPayoutConfig();
        address old = l.creator;
        l.creator = newCreator;
        emit CreatorTransferred(token, old, newCreator);
    }

    /// @notice Community takeover: the protocol treasury reassigns the creator role for an abandoned
    /// coin. Payout config + floors preserved; redirects only FUTURE role powers; already-credited
    /// balances are untouchable.
    function communityTakeover(address token, address newCreator) external {
        Lock storage l = _locks[token];
        if (!l.exists) revert UnknownToken();
        if (msg.sender != l.treasury) revert NotTreasury();
        if (newCreator == address(0)) revert BadPayoutConfig();
        address old = l.creator;
        l.creator = newCreator;
        emit CommunityTakeover(token, old, newCreator);
    }

    function setPayoutConfig(address token, address[] calldata wallets, uint16[] calldata weights) external {
        Lock storage l = _locks[token];
        if (!l.exists) revert UnknownToken();
        if (msg.sender != l.creator) revert NotCreator();
        _validateAndSet(l, wallets, weights);
        emit PayoutConfigSet(token, wallets, weights);
    }

    /// @dev Full validation + the floor ratchet, ported from FoundryHookV4Variable._setCreatorPayout:
    /// 1-10 wallets, no zero address, weights sum to 10000; every previously-assigned non-creator
    /// recipient must keep a summed share >= its floor (absent → 0 < floor → revert, blocking removal);
    /// then floors are tracked/ratcheted for the new config's non-creator recipients. Duplicate entries
    /// for one address are SUMMED so a share cannot be split to dodge its floor.
    function _validateAndSet(Lock storage l, address[] calldata wallets, uint16[] calldata weights) internal {
        uint256 n = wallets.length;
        if (n == 0 || n > MAX_PAYOUT_WALLETS || weights.length != n) revert BadPayoutConfig();
        uint256 sum;
        for (uint256 i = 0; i < n; i++) {
            if (wallets[i] == address(0)) revert BadPayoutConfig();
            sum += weights[i];
        }
        if (sum != 10000) revert BadPayoutConfig();

        // Floor enforcement BEFORE mutating state.
        uint256 m = l.assignedRecipients.length;
        for (uint256 i = 0; i < m; i++) {
            address r = l.assignedRecipients[i];
            if (r == l.creator) continue; // creator's own share is never floored
            if (_summedBps(wallets, weights, r) < l.assignedFloor[r]) revert FloorViolation();
        }

        // Track/ratchet floors for the new config's non-creator recipients.
        for (uint256 i = 0; i < n; i++) {
            address w = wallets[i];
            if (w == l.creator) continue;
            uint256 nb = _summedBps(wallets, weights, w);
            if (nb == 0) continue;
            if (l.assignedFloor[w] == 0) {
                if (l.assignedRecipients.length >= MAX_EVER_ASSIGNED) revert TooManyAssignees();
                l.assignedRecipients.push(w);
            }
            if (nb > l.assignedFloor[w]) l.assignedFloor[w] = uint16(nb);
        }

        delete l.payoutWallets;
        delete l.payoutWeights;
        for (uint256 i = 0; i < n; i++) {
            l.payoutWallets.push(wallets[i]);
            l.payoutWeights.push(weights[i]);
        }
    }

    function _summedBps(address[] calldata wallets, uint16[] calldata weights, address a)
        internal
        pure
        returns (uint256 s)
    {
        for (uint256 i = 0; i < wallets.length; i++) {
            if (wallets[i] == a) s += weights[i];
        }
    }

    // ─────────────────────────── views ───────────────────────────

    function getLock(address token)
        external
        view
        returns (
            bool exists,
            uint128 liquidity,
            address creator,
            address treasury,
            uint16 creatorSplitBps,
            address[] memory payoutWallets,
            uint16[] memory payoutWeights
        )
    {
        Lock storage l = _locks[token];
        return (l.exists, l.liquidity, l.creator, l.treasury, l.creatorSplitBps, l.payoutWallets, l.payoutWeights);
    }

    function assignedRecipients(address token) external view returns (address[] memory) {
        return _locks[token].assignedRecipients;
    }

    function assignedFloorOf(address token, address a) external view returns (uint16) {
        return _locks[token].assignedFloor[a];
    }

    function liquidityOf(address token) external view returns (uint128) {
        return _locks[token].liquidity;
    }

    function lockTicks(address token) external view returns (int24 tickLower, int24 tickUpper) {
        Lock storage l = _locks[token];
        return (l.tickLower, l.tickUpper);
    }

    function isGraduated(address token) external view returns (bool) {
        return _locks[token].graduated;
    }
}
Chain explorer7206msChain node85ms