FeeLockerV4

0xc98182af3f81ec8a3e336ce6526bfa25b9ecc610

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
14,192 bytes
ABI entries
40
10 read · 9 write
License
none

Contract information

Address
0xc98182af3f81ec8a3e336ce6526bfa25b9ecc610
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0xe3813e5415…c8535cb455

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (10)

DEAD()address
MAX_PAYOUT_WALLETS()uint256
factory()address
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 (8)

CommunityTakeoverCreatorTransferredETHWithdrawnFeesCollectedPayoutConfigSetPositionGraduatedPositionLockedTokenWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "factory_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "poolManager_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyGraduated",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "AlreadyLocked",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "BadPayoutConfig",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotCreator",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotFactory",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotPoolManager",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotTreasury",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingToWithdraw",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "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"
      }
    ],
    "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": 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_PAYOUT_WALLETS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "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": [
      {
        "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": "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";

interface ILaunchFactoryV4 {
    function isPool(address token) external view returns (bool);
}

/// @title FeeLockerV4
/// @notice Holds every Foundry launch's Uniswap V4 liquidity position FOREVER. The full 1B supply is
/// placed as a single-sided position OWNED BY THIS CONTRACT (positions in V4 are keyed by the address
/// that calls `modifyLiquidity`, so the locker itself is the owner). There is deliberately NO function
/// that can remove, transfer or burn the liquidity — the only thing the locker can do with a position
/// is `collectFees`, which pokes it (`modifyLiquidity` with liquidityDelta 0) and takes the accrued
/// LP-tier fees. That permanent, withdraw-less custody is both the "liquidity locked forever" claim and
/// the security model — the analog of the V3 `FeeLocker` holding the LP NFT forever.
///
/// @dev The pool's own LP-fee-tier fees (SEPARATE from the 1% policy fee the FoundryHookV4 skims on
/// every swap) accrue to this locked position and are split creator/protocol 50/50 by `collectFees`,
/// with the creator half fanned across up to 10 payout wallets (pump-parity). All payouts are pull
/// (credit + withdraw). Rounding dust accrues to the protocol treasury. The native-ETH side is taken
/// directly as ETH (no WETH unwrap needed — currency0 is address(0)). This mirrors the V3 FeeLocker's
/// post-graduation behaviour exactly.
contract FeeLockerV4 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;
    address public constant DEAD = 0x000000000000000000000000000000000000dEaD;

    address public immutable factory;
    IPoolManager public immutable poolManager;

    struct Lock {
        bool exists;
        bool graduated; // true once the curve position has been re-ranged to full-range (exactly-once)
        PoolKey key;
        int24 tickLower;
        int24 tickUpper;
        uint128 liquidity;
        address creator;
        address treasury;
        uint16 creatorSplitBps; // creator's share of collected LP fees (5000 = 50%)
        address[] payoutWallets;
        uint16[] payoutWeights; // bps, sum == 10000
    }

    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);
    /// @dev The curve position was re-ranged into a full-range, deep, still-locked position.
    event PositionGraduated(address indexed token, uint128 newLiquidity, uint256 ethToLp, uint256 tokensToLp);
    event FeesCollected(address indexed token, uint256 tokenAmount, uint256 ethAmount);
    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 AlreadyLocked();
    error AlreadyGraduated();
    error UnknownToken();
    error NotCreator();
    error NotTreasury();
    error BadPayoutConfig();
    error NothingToWithdraw();

    constructor(address factory_, address poolManager_) {
        factory = factory_;
        poolManager = IPoolManager(poolManager_);
    }

    /// @dev Only accept ETH from the PoolManager (fee takes / liquidity settlement).
    receive() external payable {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
    }

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

    /// @notice Factory-only. Places `liquidity` for `token` as the single-sided position and records the
    /// lock. The factory must have already transferred the launch tokens to this locker; the locker
    /// settles what the position needs and burns any dust to DEAD. There is no matching exit — once
    /// locked, the liquidity can never leave.
    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();

        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 (mirrors V3's burn-leftover; keeps the
        // locker off terminals as a holder). ETH is never left here from a lock (single-sided token).
        uint256 dust = IERC20(token).balanceOf(address(this));
        if (dust > 0) IERC20(token).safeTransfer(DEAD, dust);

        emit PositionLocked(token, liquidity, creator);
    }

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

    /// @notice Permissionless. Pokes `token`'s locked position to realize its accrued LP-tier fees, then
    /// credits them (native ETH + the launch token) to the creator's payout wallets and the treasury per
    /// the immutable 50/50 split. 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)

        // Creator share of THIS collection: the hook's tracked MC-weighted split (variable) when it
        // exposes pendingSplitBps, else the fixed creatorSplitBps set at lock time (back-compat).
        uint16 splitBps = _splitBps(l);
        _distribute(l, splitBps, token, tokenAmount, false);
        _distribute(l, splitBps, token, ethAmount, true);

        emit FeesCollected(token, tokenAmount, ethAmount);
    }

    /// @dev The creator's bps share for a collection: prefer the hook's tracked variable split
    /// (pendingSplitBps), fall back to the fixed creatorSplitBps on any read failure. staticcall so a
    /// hook without the view (or that reverts) can never brick fee collection.
    function _splitBps(Lock storage l) internal view returns (uint16) {
        (bool ok, bytes memory ret) = address(l.key.hooks).staticcall(
            abi.encodeWithSignature("pendingSplitBps(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) ───────────────────────────

    /// @notice Factory-only, exactly-once. Re-ranges the locked single-sided CURVE position into a
    /// full-range [newTickLower, newTickUpper] deep position at the current pool price. The liquidity
    /// NEVER leaves the locker's custody — it is removed from the curve range and immediately re-added
    /// full-range in one unlock, so the "locked forever" guarantee is preserved (there is still no path
    /// that withdraws liquidity to anyone). Any side-dust the full-range add cannot absorb is burned
    /// (token → DEAD) / swept (ETH → treasury), mirroring the V3 graduation. Returns the amounts placed.
    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 the Lock (add liquidity), Collect (poke fees) and
    /// Graduate (re-range) actions. 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
                }),
                ""
            );
            // Single-sided token1 position at current tick == tickUpper: only currency1 (token) is owed.
            // Settle whatever each side owes (defensive; currency0 owed is expected to be 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); // native ETH to locker
        if (tok1 > 0) poolManager.take(l.key.currency1, address(this), tok1);
        return abi.encode(eth0, tok1);
    }

    /// @dev Graduate action: remove the whole curve position and re-add it full-range at the current
    /// price, in one unlock. Never withdraws to any external party — the freed ETH+token go straight back
    /// into the new locked position; only the unavoidable single-side dust is swept (ETH → treasury) /
    /// burned (token → DEAD). Returns (ethPlaced, tokenPlaced, newLiquidity).
    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];

        // 1. Remove ALL curve liquidity (also realizes accrued LP fees) → positive deltas owed to us.
        BalanceDelta rm = _modify(l.key, l.tickLower, l.tickUpper, -int256(uint256(l.liquidity)));

        // 2. Size + add a full-range position from the freed amounts at the live price. getLiquidityFor-
        //    Amounts guarantees the add never needs more than `freed` on either side (net residual >= 0).
        uint128 newLiq = _sizeFullRange(l.key, newLower, newUpper, rm);
        BalanceDelta add = _modify(l.key, newLower, newUpper, int256(uint256(newLiq)));

        // 3. Zero the net deltas: take the residual dust and dispose of it (ETH → treasury real value;
        //    token → DEAD so the locker is never a phantom holder). Touches ONLY this graduation's dust —
        //    never the locker's held withdrawal balances.
        _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
        );
    }

    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, 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;
    }

    /// @dev Split `amount` of one asset (ETH if `isEth`, else the launch `token`) 50/50 (per
    /// creatorSplitBps) between the creator's payout wallets and the treasury. All rounding dust accrues
    /// to the treasury, so credited amounts sum EXACTLY to `amount` (identical to the V3 FeeLocker).
    function _distribute(Lock storage l, uint16 splitBps, address token, uint256 amount, bool isEth) 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) _credit(l.payoutWallets[i], token, cut, isEth);
            distributed += cut;
        }
        uint256 toTreasury = protocolShare + (creatorShare - distributed);
        if (toTreasury > 0) _credit(l.treasury, token, toTreasury, isEth);
    }

    function _credit(address to, address token, uint256 amount, bool isEth) internal {
        if (isEth) {
            withdrawableETH[to] += amount;
        } else {
            withdrawableToken[token][to] += amount;
        }
    }

    // ─────────────────────────── 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 ───────────────────────────

    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;
        _resetPayout(l, newCreator);
        emit CreatorTransferred(token, old, newCreator);
    }

    /// @notice Community takeover: the protocol treasury (multisig) reassigns the creator role for an
    /// abandoned coin. Redirects only FUTURE creator fees; 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;
        _resetPayout(l, 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);
    }

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

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

    function _resetPayout(Lock storage l, address newCreator) internal {
        delete l.payoutWallets;
        delete l.payoutWeights;
        l.payoutWallets.push(newCreator);
        l.payoutWeights.push(10000);
    }

    // ─────────────────────────── 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 liquidityOf(address token) external view returns (uint128) {
        return _locks[token].liquidity;
    }

    /// @notice The lock's current tick range (curve range pre-graduation, full range after).
    function lockTicks(address token) external view returns (int24 tickLower, int24 tickUpper) {
        Lock storage l = _locks[token];
        return (l.tickLower, l.tickUpper);
    }

    /// @notice Whether the position has been re-ranged to full-range (graduated).
    function isGraduated(address token) external view returns (bool) {
        return _locks[token].graduated;
    }
}
Chain explorer2712msChain node79ms