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
- Creator
- 0xFc2007F0B9…2681EEdA5B
- 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