LpFeeCollector

0xea2f0aef181eb38ecacd7d44e918dde0c9f857ab

Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
6,746 bytes
ABI entries
42
16 read · 13 write
License
none

Contract information

Address
0xea2f0aef181eb38ecacd7d44e918dde0c9f857ab
Chain
Robinhood Chain (4663)
Compiler
v0.8.24+commit.e11b9ed9
Optimization
Enabled
Creation tx
0xaa14837df1…7b9b9a19f3

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (16)

DEFAULT_CREATOR_FEE_BPS()uint256
FEE_DENOM()uint256
creatorFeeRecipient(uint256)address
creatorOf(uint256)address
factoryCreatorFeeBps(address)uint256
isFactory(address)bool
isMinter(address)bool
minterCreatorFeeBps(address)uint256
owner()address
pendingCreatorFees(uint256, address)uint256
pendingProtocolFees(address)uint256
positionCreatorFeeBps(uint256)uint256
positionInfo(uint256)address, address, address, address, uint256, uint256
positionManager()address
positionToken0(uint256)address
positionToken1(uint256)address

Events (8)

CreatorFeeRecipientSetCreatorFeesClaimedFactoryUpdatedFeesCollectedMinterUpdatedOwnershipTransferredPositionRegisteredProtocolFeesWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "_positionManager",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "owner",
        "type": "address"
      }
    ],
    "name": "OwnableInvalidOwner",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "account",
        "type": "address"
      }
    ],
    "name": "OwnableUnauthorizedAccount",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "oldRecipient",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "CreatorFeeRecipientSet",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "name": "CreatorFeesClaimed",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "factory",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      }
    ],
    "name": "FactoryUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount0",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount1",
        "type": "uint256"
      }
    ],
    "name": "FeesCollected",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "minter",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      }
    ],
    "name": "MinterUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "previousOwner",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "OwnershipTransferred",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "token0",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "token1",
        "type": "address"
      }
    ],
    "name": "PositionRegistered",
    "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": "ProtocolFeesWithdrawn",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "DEFAULT_CREATOR_FEE_BPS",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "FEE_DENOM",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "claimCreatorFees",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "collectFees",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "creatorFeeRecipient",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "creatorOf",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "factoryCreatorFeeBps",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "isFactory",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "isMinter",
    "outputs": [
      {
        "internalType": "bool",
        "name": "",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "minterCreatorFeeBps",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "minter",
        "type": "address"
      }
    ],
    "name": "onboardMinter",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "owner",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "pendingCreatorFees",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "pendingProtocolFees",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "positionCreatorFeeBps",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "positionInfo",
    "outputs": [
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "recipient",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token0",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token1",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "pendingCreator0",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "pendingCreator1",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "positionManager",
    "outputs": [
      {
        "internalType": "contract INonfungiblePositionManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "positionToken0",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "name": "positionToken1",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      }
    ],
    "name": "registerPosition",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "renounceOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "setCreatorFeeRecipient",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "factory",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      }
    ],
    "name": "setFactory",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "factory",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      },
      {
        "internalType": "uint256",
        "name": "creatorFeeBps",
        "type": "uint256"
      }
    ],
    "name": "setFactory",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "minter",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      },
      {
        "internalType": "uint256",
        "name": "creatorFeeBps",
        "type": "uint256"
      }
    ],
    "name": "setMinter",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "minter",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "allowed",
        "type": "bool"
      }
    ],
    "name": "setMinter",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "newOwner",
        "type": "address"
      }
    ],
    "name": "transferOwnership",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdrawProtocolFees",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address[]",
        "name": "tokens",
        "type": "address[]"
      },
      {
        "internalType": "address",
        "name": "to",
        "type": "address"
      }
    ],
    "name": "withdrawProtocolFeesBatch",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

import "./interfaces/INonfungiblePositionManager.sol";
import "./interfaces/ILpFeeCollector.sol";

/**
 * @title LpFeeCollector
 * @notice Holds every Uniswap V3 LP position minted by RobinLaunch — both bonding-curve
 *         graduations (Factory/BondingCurve) and instant Direct Pool launches (DirectFactory)
 *         — replacing the previous "burn the LP NFT to 0xdead" pattern. Trading fees that
 *         accrue on these positions after launch are now collectible and split between the
 *         token creator and the protocol, instead of being permanently unreachable.
 *
 * Security model — read this before touching anything:
 *   • This contract can NEVER move the underlying liquidity. There is no `decreaseLiquidity`,
 *     no `burn`, no NFT transfer function of any kind, and it never approves the NFT to anyone.
 *     Liquidity stays exactly as permanently locked as it was when sent to 0xdead — the only
 *     thing that changed is that swap fees are now reachable.
 *   • `registerPosition` can only succeed once per tokenId (enforced by `creatorOf[tokenId] ==
 *     address(0)`), so a position's creator can never be overwritten or hijacked later — not
 *     even by the contract owner.
 *   • Creator and protocol balances are accounted for separately, per token, exactly like
 *     `BondingCurve.pendingCreatorFees` / `Factory.pendingFees` today. The owner can only ever
 *     withdraw the protocol share; it can never touch a creator's pending balance.
 *   • Two independent, owner-controlled allow-lists gate write access:
 *       – `isFactory`: contracts allowed to onboard *new* minters (the bonding-curve Factory —
 *         it self-registers each BondingCurve it deploys, atomically, in the same transaction).
 *       – `isMinter`: contracts allowed to call `registerPosition` directly (every onboarded
 *         BondingCurve, plus the DirectFactory, which registers on its own behalf).
 *
 * Fee split: set per minter at allow-list time (bonding Factory → typically 70% creator /
 * 30% protocol; DirectFactory → typically 90% / 10%). Frozen per position at registration.
 */
contract LpFeeCollector is Ownable, ReentrancyGuard, ILpFeeCollector {
    using SafeERC20 for IERC20;

    // ─── Constants ────────────────────────────────────────────────────────────

    uint256 public constant FEE_DENOM = 10_000;
    /// Fallback creator share if a minter was allow-listed without an explicit BPS.
    /// Aligned with bonding-curve launches (70 % creator / 30 % protocol).
    uint256 public constant DEFAULT_CREATOR_FEE_BPS = 7_000;

    // ─── Immutables ───────────────────────────────────────────────────────────

    INonfungiblePositionManager public immutable positionManager;

    // ─── State ────────────────────────────────────────────────────────────────

    /// Contracts allowed to onboard new minters (i.e. the bonding-curve Factory).
    mapping(address => bool) public isFactory;

    /// Creator fee BPS applied when this factory calls `onboardMinter`.
    mapping(address => uint256) public factoryCreatorFeeBps;

    /// Contracts allowed to call registerPosition (onboarded BondingCurves + DirectFactory).
    mapping(address => bool) public isMinter;

    /// Creator fee BPS for positions registered by this minter.
    mapping(address => uint256) public minterCreatorFeeBps;

    /// tokenId → original creator. Set exactly once at registration, never overwritten.
    mapping(uint256 => address) public creatorOf;

    /// tokenId → creator share of Uniswap fees (BPS of FEE_DENOM), frozen at registration.
    mapping(uint256 => uint256) public positionCreatorFeeBps;

    /// tokenId → payout address for creator fees. Defaults to creator, changeable by creator.
    mapping(uint256 => address) public creatorFeeRecipient;

    /// tokenId → pool tokens, resolved once from the position manager at registration time.
    mapping(uint256 => address) public positionToken0;
    mapping(uint256 => address) public positionToken1;

    /// tokenId → token → creator fees collected but not yet claimed.
    mapping(uint256 => mapping(address => uint256)) public pendingCreatorFees;

    /// token → protocol fees collected but not yet withdrawn (aggregated across all positions).
    mapping(address => uint256) public pendingProtocolFees;

    // ─── Events ───────────────────────────────────────────────────────────────

    event FactoryUpdated(address indexed factory, bool allowed);
    event MinterUpdated(address indexed minter, bool allowed);
    event PositionRegistered(uint256 indexed tokenId, address indexed creator, address token0, address token1);
    event CreatorFeeRecipientSet(uint256 indexed tokenId, address indexed oldRecipient, address indexed newRecipient);
    event FeesCollected(uint256 indexed tokenId, uint256 amount0, uint256 amount1);
    event CreatorFeesClaimed(uint256 indexed tokenId, address indexed recipient, address token, uint256 amount);
    event ProtocolFeesWithdrawn(address indexed token, address indexed to, uint256 amount);

    // ─── Constructor ──────────────────────────────────────────────────────────

    constructor(address _positionManager) Ownable(msg.sender) {
        require(_positionManager != address(0), "LpFeeCollector: zero positionManager");
        positionManager = INonfungiblePositionManager(_positionManager);
    }

    // ─── Owner setup (one-time, right after deployment) ──────────────────────

    /**
     * @notice Allow/revoke a bonding-curve Factory to onboard new minters
     *         (i.e. to vouch for each BondingCurve it deploys).
     *         Uses DEFAULT_CREATOR_FEE_BPS (70%) for onboarded curves.
     */
    function setFactory(address factory, bool allowed) external onlyOwner {
        _setFactory(factory, allowed, DEFAULT_CREATOR_FEE_BPS);
    }

    /**
     * @notice Same as `setFactory(factory, allowed)` with an explicit creator fee BPS
     *         (e.g. 7000 = 70% creator / 30% protocol on post-graduation Uniswap fees).
     */
    function setFactory(address factory, bool allowed, uint256 creatorFeeBps) external onlyOwner {
        _setFactory(factory, allowed, creatorFeeBps);
    }

    function _setFactory(address factory, bool allowed, uint256 creatorFeeBps) internal {
        require(factory != address(0), "LpFeeCollector: zero factory");
        if (allowed) {
            require(creatorFeeBps > 0 && creatorFeeBps <= FEE_DENOM, "LpFeeCollector: bad fee bps");
            factoryCreatorFeeBps[factory] = creatorFeeBps;
        }
        isFactory[factory] = allowed;
        emit FactoryUpdated(factory, allowed);
    }

    /**
     * @notice Directly allow/revoke a minter (used for DirectFactory).
     *         Uses DEFAULT_CREATOR_FEE_BPS unless the 3-arg overload is used.
     */
    function setMinter(address minter, bool allowed) external onlyOwner {
        _setMinter(minter, allowed, DEFAULT_CREATOR_FEE_BPS);
    }

    /**
     * @notice Allow/revoke a minter with an explicit creator fee BPS
     *         (e.g. DirectFactory → 9000 = 90% creator / 10% protocol).
     */
    function setMinter(address minter, bool allowed, uint256 creatorFeeBps) external onlyOwner {
        _setMinter(minter, allowed, creatorFeeBps);
    }

    function _setMinter(address minter, bool allowed, uint256 creatorFeeBps) internal {
        require(minter != address(0), "LpFeeCollector: zero minter");
        if (allowed) {
            require(creatorFeeBps > 0 && creatorFeeBps <= FEE_DENOM, "LpFeeCollector: bad fee bps");
            minterCreatorFeeBps[minter] = creatorFeeBps;
        }
        isMinter[minter] = allowed;
        emit MinterUpdated(minter, allowed);
    }

    // ─── Factory-controlled onboarding ────────────────────────────────────────

    /**
     * @notice Called by an allow-listed Factory, in the same transaction as it deploys a
     *         new BondingCurve, so that curve can register its own position when it
     *         eventually graduates — with no per-token owner intervention required.
     *         Inherits the factory's configured creator fee BPS.
     */
    function onboardMinter(address minter) external override {
        require(isFactory[msg.sender], "LpFeeCollector: not factory");
        require(minter != address(0), "LpFeeCollector: zero minter");
        uint256 bps = factoryCreatorFeeBps[msg.sender];
        if (bps == 0) bps = DEFAULT_CREATOR_FEE_BPS;
        isMinter[minter] = true;
        minterCreatorFeeBps[minter] = bps;
        emit MinterUpdated(minter, true);
    }

    // ─── Registration (called once, right after mint) ────────────────────────

    /**
     * @notice Record the creator of a freshly-minted LP position owned by this contract.
     *         Must be called in the same transaction as the mint, by the minter itself.
     *         Freezes the minter's creator fee BPS onto this tokenId forever.
     */
    function registerPosition(uint256 tokenId, address creator) external override {
        require(isMinter[msg.sender], "LpFeeCollector: not minter");
        require(creator != address(0), "LpFeeCollector: zero creator");
        require(creatorOf[tokenId] == address(0), "LpFeeCollector: already registered");

        (, , address token0, address token1, , , , , , , ,) = positionManager.positions(tokenId);

        uint256 bps = minterCreatorFeeBps[msg.sender];
        if (bps == 0) bps = DEFAULT_CREATOR_FEE_BPS;

        creatorOf[tokenId]           = creator;
        creatorFeeRecipient[tokenId] = creator;
        positionToken0[tokenId]      = token0;
        positionToken1[tokenId]      = token1;
        positionCreatorFeeBps[tokenId] = bps;

        emit PositionRegistered(tokenId, creator, token0, token1);
    }

    // ─── Creator fee recipient management ─────────────────────────────────────

    /**
     * @notice Change the address that receives this position's creator fee payouts.
     *         Only callable by the original creator recorded at registration.
     */
    function setCreatorFeeRecipient(uint256 tokenId, address newRecipient) external {
        require(msg.sender == creatorOf[tokenId], "LpFeeCollector: not creator");
        require(newRecipient != address(0), "LpFeeCollector: zero address");
        address old = creatorFeeRecipient[tokenId];
        creatorFeeRecipient[tokenId] = newRecipient;
        emit CreatorFeeRecipientSet(tokenId, old, newRecipient);
    }

    // ─── Harvest ───────────────────────────────────────────────────────────────

    /**
     * @notice Pull all Uniswap V3 swap fees accrued on `tokenId` into this contract,
     *         then book the creator/protocol split frozen on this position. Permissionless — anyone can
     *         trigger a harvest (the "Claim" button, a keeper, or a curious observer).
     */
    function collectFees(uint256 tokenId) external nonReentrant {
        require(creatorOf[tokenId] != address(0), "LpFeeCollector: not registered");

        (uint256 amount0, uint256 amount1) = positionManager.collect(
            INonfungiblePositionManager.CollectParams({
                tokenId:    tokenId,
                recipient:  address(this),
                amount0Max: type(uint128).max,
                amount1Max: type(uint128).max
            })
        );

        if (amount0 > 0) _bookFees(tokenId, positionToken0[tokenId], amount0);
        if (amount1 > 0) _bookFees(tokenId, positionToken1[tokenId], amount1);

        emit FeesCollected(tokenId, amount0, amount1);
    }

    function _bookFees(uint256 tokenId, address token, uint256 amount) internal {
        uint256 bps = positionCreatorFeeBps[tokenId];
        if (bps == 0) bps = DEFAULT_CREATOR_FEE_BPS;
        uint256 creatorShare  = amount * bps / FEE_DENOM;
        uint256 protocolShare = amount - creatorShare;
        pendingCreatorFees[tokenId][token] += creatorShare;
        pendingProtocolFees[token]         += protocolShare;
    }

    // ─── Claim ─────────────────────────────────────────────────────────────────

    /**
     * @notice Claim the creator's pending balance (both pool tokens at once) for `tokenId`.
     *         Callable by anyone — funds always go to creatorFeeRecipient. Call collectFees()
     *         first to make sure the freshest fees are included.
     */
    function claimCreatorFees(uint256 tokenId) external nonReentrant {
        address creator = creatorOf[tokenId];
        require(creator != address(0), "LpFeeCollector: not registered");

        address recipient = creatorFeeRecipient[tokenId];
        address t0 = positionToken0[tokenId];
        address t1 = positionToken1[tokenId];
        uint256 amount0 = pendingCreatorFees[tokenId][t0];
        uint256 amount1 = pendingCreatorFees[tokenId][t1];
        require(amount0 > 0 || amount1 > 0, "LpFeeCollector: nothing to claim");

        if (amount0 > 0) {
            pendingCreatorFees[tokenId][t0] = 0;
            IERC20(t0).safeTransfer(recipient, amount0);
            emit CreatorFeesClaimed(tokenId, recipient, t0, amount0);
        }
        if (amount1 > 0) {
            pendingCreatorFees[tokenId][t1] = 0;
            IERC20(t1).safeTransfer(recipient, amount1);
            emit CreatorFeesClaimed(tokenId, recipient, t1, amount1);
        }
    }

    // ─── Protocol fee management ───────────────────────────────────────────────

    /**
     * @notice Withdraw all accumulated protocol fees of `token` to `to`.
     */
    function withdrawProtocolFees(address token, address to) external onlyOwner nonReentrant {
        require(to != address(0), "LpFeeCollector: zero recipient");
        uint256 amount = pendingProtocolFees[token];
        require(amount > 0, "LpFeeCollector: no fees");
        pendingProtocolFees[token] = 0;
        IERC20(token).safeTransfer(to, amount);
        emit ProtocolFeesWithdrawn(token, to, amount);
    }

    /**
     * @notice Convenience batch withdrawal across several tokens in one call.
     */
    function withdrawProtocolFeesBatch(address[] calldata tokens, address to) external onlyOwner nonReentrant {
        require(to != address(0), "LpFeeCollector: zero recipient");
        for (uint256 i = 0; i < tokens.length; i++) {
            address token = tokens[i];
            uint256 amount = pendingProtocolFees[token];
            if (amount == 0) continue;
            pendingProtocolFees[token] = 0;
            IERC20(token).safeTransfer(to, amount);
            emit ProtocolFeesWithdrawn(token, to, amount);
        }
    }

    // ─── View helpers ──────────────────────────────────────────────────────────

    /**
     * @notice One-call snapshot of a position for the frontend: creator, payout
     *         address, both pool tokens, and both pending creator balances.
     */
    function positionInfo(uint256 tokenId)
        external
        view
        returns (
            address creator,
            address recipient,
            address token0,
            address token1,
            uint256 pendingCreator0,
            uint256 pendingCreator1
        )
    {
        creator         = creatorOf[tokenId];
        recipient       = creatorFeeRecipient[tokenId];
        token0          = positionToken0[tokenId];
        token1          = positionToken1[tokenId];
        pendingCreator0 = pendingCreatorFees[tokenId][token0];
        pendingCreator1 = pendingCreatorFees[tokenId][token1];
    }
}
Chain explorer1187msChain node79ms