LpFeeCollector
0xa510a8d9a9a767289b3b6e646eb09aad956627a5
Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
11,139 bytes
ABI entries
51
20 read · 15 write
License
none
Contract information
- Address
- 0xa510a8d9a9a767289b3b6e646eb09aad956627a5
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.24+commit.e11b9ed9
- Optimization
- Enabled
- Creator
- 0x76053CcE1B…c8949D3385
- Creation tx
- 0xff9653580d…245f31568d
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (20)
DEFAULT_CREATOR_FEE_BPS() → uint256
DEFAULT_POOL_FEE() → uint24
FEE_DENOM() → uint256
WETH() → address
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
positionFee(uint256) → uint24
positionInfo(uint256) → address, address, address, address, uint256, uint256
positionManager() → address
positionToken0(uint256) → address
positionToken1(uint256) → address
swapRouter() → address
Events (10)
CreatorFeeRecipientSetCreatorFeesClaimedCreatorFeesClaimedAsEthFactoryUpdatedFeesCollectedMinterUpdatedOwnershipTransferredPositionRegisteredProtocolFeesWithdrawnProtocolFeesWithdrawnAsEth
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_positionManager",
"type": "address"
},
{
"internalType": "address",
"name": "_weth",
"type": "address"
},
{
"internalType": "address",
"name": "_swapRouter",
"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": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethAmount",
"type": "uint256"
}
],
"name": "CreatorFeesClaimedAsEth",
"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"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethAmount",
"type": "uint256"
}
],
"name": "ProtocolFeesWithdrawnAsEth",
"type": "event"
},
{
"inputs": [],
"name": "DEFAULT_CREATOR_FEE_BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "DEFAULT_POOL_FEE",
"outputs": [
{
"internalType": "uint24",
"name": "",
"type": "uint24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "FEE_DENOM",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "WETH",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"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"
},
{
"internalType": "uint256",
"name": "amountOutMinimum",
"type": "uint256"
}
],
"name": "claimCreatorFeesAsEth",
"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": "",
"type": "uint256"
}
],
"name": "positionFee",
"outputs": [
{
"internalType": "uint24",
"name": "",
"type": "uint24"
}
],
"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": [],
"name": "swapRouter",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"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": "token",
"type": "address"
},
{
"internalType": "address",
"name": "to",
"type": "address"
},
{
"internalType": "uint256",
"name": "amountOutMinimum",
"type": "uint256"
}
],
"name": "withdrawProtocolFeesAsEth",
"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"
},
{
"stateMutability": "payable",
"type": "receive"
}
]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";
import "./interfaces/IWETH.sol";
import "./interfaces/IUniswapV3SwapRouter.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).
* • `claimCreatorFeesAsEth` / `withdrawProtocolFeesAsEth` only unwrap the exact WETH amount
* booked for that claim (plus swap output), never the whole contract balance — so other
* creators' / protocol pending WETH cannot be drained.
*
* 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.
*
* v2 additions: optional ETH payout via Uniswap V3 swap of the meme leg + WETH unwrap.
*/
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;
/// Robinlaunch pools use the Uniswap V3 1% tier; used if positions() reports fee=0.
uint24 public constant DEFAULT_POOL_FEE = 10_000;
// ─── Immutables ───────────────────────────────────────────────────────────
INonfungiblePositionManager public immutable positionManager;
address public immutable WETH;
address public immutable swapRouter;
// ─── 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 → Uniswap V3 fee tier, frozen at registration.
mapping(uint256 => uint24) public positionFee;
/// 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 CreatorFeesClaimedAsEth(uint256 indexed tokenId, address indexed recipient, uint256 ethAmount);
event ProtocolFeesWithdrawn(address indexed token, address indexed to, uint256 amount);
event ProtocolFeesWithdrawnAsEth(address indexed to, uint256 ethAmount);
// ─── Constructor ──────────────────────────────────────────────────────────
constructor(address _positionManager, address _weth, address _swapRouter) Ownable(msg.sender) {
require(_positionManager != address(0), "LpFeeCollector: zero positionManager");
require(_weth != address(0), "LpFeeCollector: zero weth");
require(_swapRouter != address(0), "LpFeeCollector: zero swapRouter");
positionManager = INonfungiblePositionManager(_positionManager);
WETH = _weth;
swapRouter = _swapRouter;
}
receive() external payable {}
// ─── 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, uint24 fee, , , , , , ,) = positionManager.positions(tokenId);
uint256 bps = minterCreatorFeeBps[msg.sender];
if (bps == 0) bps = DEFAULT_CREATOR_FEE_BPS;
if (fee == 0) fee = DEFAULT_POOL_FEE;
creatorOf[tokenId] = creator;
creatorFeeRecipient[tokenId] = creator;
positionToken0[tokenId] = token0;
positionToken1[tokenId] = token1;
positionFee[tokenId] = fee;
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);
}
}
/**
* @notice Claim creator fees as native ETH: swap the non-WETH leg via Uniswap V3,
* unwrap WETH, send ETH to creatorFeeRecipient. Call collectFees() first.
* @param tokenId Position id.
* @param amountOutMinimum Min WETH from the meme→WETH swap (0 if no meme leg / no slippage guard).
*/
function claimCreatorFeesAsEth(uint256 tokenId, uint256 amountOutMinimum)
external
nonReentrant
{
address creator = creatorOf[tokenId];
require(creator != address(0), "LpFeeCollector: not registered");
address recipient = creatorFeeRecipient[tokenId];
(address meme, uint256 memeAmount, uint256 wethAmount) = _splitCreatorPending(tokenId);
require(memeAmount > 0 || wethAmount > 0, "LpFeeCollector: nothing to claim");
if (memeAmount > 0) {
pendingCreatorFees[tokenId][meme] = 0;
}
if (wethAmount > 0) {
pendingCreatorFees[tokenId][WETH] = 0;
}
uint256 wethBefore = IERC20(WETH).balanceOf(address(this));
if (memeAmount > 0) {
uint24 fee = positionFee[tokenId];
if (fee == 0) fee = DEFAULT_POOL_FEE;
IERC20(meme).forceApprove(swapRouter, memeAmount);
IUniswapV3SwapRouter(swapRouter).exactInputSingle(
IUniswapV3SwapRouter.ExactInputSingleParams({
tokenIn: meme,
tokenOut: WETH,
fee: fee,
recipient: address(this),
amountIn: memeAmount,
amountOutMinimum: amountOutMinimum,
sqrtPriceLimitX96: 0
})
);
IERC20(meme).forceApprove(swapRouter, 0);
}
uint256 wethFromSwap = IERC20(WETH).balanceOf(address(this)) - wethBefore;
uint256 totalWeth = wethAmount + wethFromSwap;
require(totalWeth > 0, "LpFeeCollector: zero eth out");
IWETH(WETH).withdraw(totalWeth);
(bool ok, ) = recipient.call{value: totalWeth}("");
require(ok, "LpFeeCollector: eth transfer failed");
emit CreatorFeesClaimedAsEth(tokenId, recipient, totalWeth);
if (memeAmount > 0) {
emit CreatorFeesClaimed(tokenId, recipient, meme, memeAmount);
}
if (wethAmount > 0) {
emit CreatorFeesClaimed(tokenId, recipient, WETH, wethAmount);
}
}
function _splitCreatorPending(uint256 tokenId)
internal
view
returns (address meme, uint256 memeAmount, uint256 wethAmount)
{
address t0 = positionToken0[tokenId];
address t1 = positionToken1[tokenId];
require(t0 == WETH || t1 == WETH, "LpFeeCollector: no weth pool");
meme = t0 == WETH ? t1 : t0;
memeAmount = pendingCreatorFees[tokenId][meme];
wethAmount = pendingCreatorFees[tokenId][WETH];
}
// ─── 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);
}
}
/**
* @notice Withdraw protocol WETH as native ETH. For meme protocol balances, swap first
* via `withdrawProtocolFeesAsEth(meme, to, amountOutMinimum)`.
*/
function withdrawProtocolFeesAsEth(address token, address to, uint256 amountOutMinimum)
external
onlyOwner
nonReentrant
{
require(to != address(0), "LpFeeCollector: zero recipient");
uint256 amount = pendingProtocolFees[token];
require(amount > 0, "LpFeeCollector: no fees");
pendingProtocolFees[token] = 0;
uint256 totalWeth;
if (token == WETH) {
totalWeth = amount;
} else {
uint256 wethBefore = IERC20(WETH).balanceOf(address(this));
IERC20(token).forceApprove(swapRouter, amount);
// Fee tier unknown for aggregated protocol balances — use default 1%.
IUniswapV3SwapRouter(swapRouter).exactInputSingle(
IUniswapV3SwapRouter.ExactInputSingleParams({
tokenIn: token,
tokenOut: WETH,
fee: DEFAULT_POOL_FEE,
recipient: address(this),
amountIn: amount,
amountOutMinimum: amountOutMinimum,
sqrtPriceLimitX96: 0
})
);
IERC20(token).forceApprove(swapRouter, 0);
totalWeth = IERC20(WETH).balanceOf(address(this)) - wethBefore;
}
require(totalWeth > 0, "LpFeeCollector: zero eth out");
IWETH(WETH).withdraw(totalWeth);
(bool ok, ) = to.call{value: totalWeth}("");
require(ok, "LpFeeCollector: eth transfer failed");
emit ProtocolFeesWithdrawnAsEth(to, totalWeth);
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 explorer4929msChain node81ms