FeeManager
0x68f3349c93c43794c43870825ff508ce414a4851
Verification
Verified
v0.8.24+commit.e11b9ed9
Type
Contract
4,725 bytes
ABI entries
35
8 read · 5 write
License
none
Contract information
- Address
- 0x68f3349c93c43794c43870825ff508ce414a4851
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.24+commit.e11b9ed9
- Optimization
- Enabled
- Creator
- 0x0Af3CB0Efc…381b8BE9be
- Creation tx
- 0x5a6ac11b0d…6fa709bcb6
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (8)
BPS() → uint256
creatorCredited(uint256, address) → uint256
factory() → address
npm() → address
owed(uint256, address, address) → uint256
pendingCreator(uint256) → address
positions(uint256) → address, address, uint16, address, address, bool
totalCollected(uint256, address) → uint256
Events (5)
CreatorRecipientUpdatedCreatorTransferProposedFeesCollectedLockedWithdrawn
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "npm_",
"type": "address"
},
{
"internalType": "address",
"name": "factory_",
"type": "address"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "AlreadyLocked",
"type": "error"
},
{
"inputs": [],
"name": "CreatorBpsOOB",
"type": "error"
},
{
"inputs": [],
"name": "CreatorZero",
"type": "error"
},
{
"inputs": [],
"name": "FactoryZero",
"type": "error"
},
{
"inputs": [],
"name": "NoLiquidity",
"type": "error"
},
{
"inputs": [],
"name": "NotCreator",
"type": "error"
},
{
"inputs": [],
"name": "NotFactory",
"type": "error"
},
{
"inputs": [],
"name": "NotLocked",
"type": "error"
},
{
"inputs": [],
"name": "NotNpm",
"type": "error"
},
{
"inputs": [],
"name": "NotPendingCreator",
"type": "error"
},
{
"inputs": [],
"name": "NothingOwed",
"type": "error"
},
{
"inputs": [],
"name": "NpmZero",
"type": "error"
},
{
"inputs": [],
"name": "PlatformZero",
"type": "error"
},
{
"inputs": [],
"name": "RecipientIsSelf",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"inputs": [],
"name": "UnknownToken",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": true,
"internalType": "address",
"name": "oldCreator",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newCreator",
"type": "address"
}
],
"name": "CreatorRecipientUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": true,
"internalType": "address",
"name": "currentCreator",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "proposedCreator",
"type": "address"
}
],
"name": "CreatorTransferProposed",
"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"
},
{
"indexed": false,
"internalType": "uint256",
"name": "creator0",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "creator1",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "platform0",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "platform1",
"type": "uint256"
}
],
"name": "FeesCollected",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": true,
"internalType": "address",
"name": "creator",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "platform",
"type": "address"
},
{
"indexed": false,
"internalType": "uint16",
"name": "creatorBps",
"type": "uint16"
},
{
"indexed": false,
"internalType": "address",
"name": "token0",
"type": "address"
},
{
"indexed": false,
"internalType": "address",
"name": "token1",
"type": "address"
}
],
"name": "Locked",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "Withdrawn",
"type": "event"
},
{
"inputs": [],
"name": "BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "acceptCreatorRole",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "collect",
"outputs": [
{
"internalType": "uint256",
"name": "amount0",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amount1",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
},
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "creatorCredited",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "factory",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "npm",
"outputs": [
{
"internalType": "contract INonfungiblePositionManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "operator",
"type": "address"
},
{
"internalType": "address",
"name": "from",
"type": "address"
},
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "onERC721Received",
"outputs": [
{
"internalType": "bytes4",
"name": "",
"type": "bytes4"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
},
{
"internalType": "address",
"name": "",
"type": "address"
},
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "owed",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "pendingCreator",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"name": "positions",
"outputs": [
{
"internalType": "address",
"name": "creator",
"type": "address"
},
{
"internalType": "address",
"name": "platform",
"type": "address"
},
{
"internalType": "uint16",
"name": "creatorBps",
"type": "uint16"
},
{
"internalType": "address",
"name": "token0",
"type": "address"
},
{
"internalType": "address",
"name": "token1",
"type": "address"
},
{
"internalType": "bool",
"name": "locked",
"type": "bool"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "newCreator",
"type": "address"
}
],
"name": "proposeCreatorRecipient",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
},
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "totalCollected",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
},
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "withdraw",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {INonfungiblePositionManager as INPM} from "./interfaces/INonfungiblePositionManager.sol";
/// @notice Immutable, ownerless singleton locker + fee-splitter for fairlaunch V3 positions.
/// @dev Permanent lock (no liquidity-reduction / NFT-out surface). Pull-payment fee split. NEVER implement
/// ERC-1271 isValidSignature or a return-data fallback — the NPM's permit() would then hand an attacker
/// NFT approval and let them drain the principal. See docs/superpowers/specs/2026-07-14-feemanager-design.md.
contract FeeManager is IERC721Receiver {
using SafeERC20 for IERC20;
uint256 public constant BPS = 10_000;
INPM public immutable npm;
address public immutable factory;
struct Position {
address creator;
address platform;
uint16 creatorBps; // platform gets BPS - creatorBps
address token0;
address token1;
bool locked;
}
mapping(uint256 => Position) public positions;
mapping(uint256 => mapping(address => uint256)) public totalCollected; // [tokenId][token]
mapping(uint256 => mapping(address => uint256)) public creatorCredited; // [tokenId][token]
mapping(uint256 => mapping(address => mapping(address => uint256))) public owed; // [tokenId][token][recipient]
mapping(uint256 => address) public pendingCreator; // proposed next creator; 0 = none
error NpmZero();
error FactoryZero();
error NotNpm();
error NotFactory();
error AlreadyLocked();
error CreatorZero();
error PlatformZero();
error RecipientIsSelf();
error CreatorBpsOOB();
error NoLiquidity();
error NotLocked();
error UnknownToken();
error NothingOwed();
error NotCreator();
error NotPendingCreator();
event Locked(
uint256 indexed tokenId,
address indexed creator,
address indexed platform,
uint16 creatorBps,
address token0,
address token1
);
event FeesCollected(
uint256 indexed tokenId,
uint256 amount0,
uint256 amount1,
uint256 creator0,
uint256 creator1,
uint256 platform0,
uint256 platform1
);
event Withdrawn(address indexed recipient, uint256 indexed tokenId, address indexed token, uint256 amount);
event CreatorTransferProposed(
uint256 indexed tokenId, address indexed currentCreator, address indexed proposedCreator
);
event CreatorRecipientUpdated(uint256 indexed tokenId, address indexed oldCreator, address indexed newCreator);
constructor(address npm_, address factory_) {
if (npm_ == address(0)) revert NpmZero();
if (factory_ == address(0)) revert FactoryZero();
npm = INPM(npm_);
factory = factory_;
}
/// @notice Register a position on receipt. Only the trusted factory (via the NPM safe transfer) may lock.
/// @dev No token calls here; the only external call is the NPM view `positions()`. Store exactly once.
function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata data)
external
returns (bytes4)
{
if (msg.sender != address(npm)) revert NotNpm();
if (operator != factory || from != factory) revert NotFactory();
if (positions[tokenId].locked) revert AlreadyLocked();
(address creator, address platform, uint16 creatorBps) = abi.decode(data, (address, address, uint16));
if (creator == address(0)) revert CreatorZero();
if (platform == address(0)) revert PlatformZero();
if (creator == address(this) || platform == address(this)) revert RecipientIsSelf();
if (creatorBps > BPS) revert CreatorBpsOOB();
(,, address token0, address token1,,,, uint128 liquidity,,,,) = npm.positions(tokenId);
if (liquidity == 0) revert NoLiquidity();
positions[tokenId] = Position({
creator: creator,
platform: platform,
creatorBps: creatorBps,
token0: token0,
token1: token1,
locked: true
});
emit Locked(tokenId, creator, platform, creatorBps, token0, token1);
return IERC721Receiver.onERC721Received.selector;
}
/// @notice Pull the position's accrued V3 fees into internal balances. Permissionless: it only credits the
/// current recipients, never an arbitrary address, and makes no outbound transfer.
function collect(uint256 tokenId) external returns (uint256 amount0, uint256 amount1) {
return _collect(tokenId);
}
/// @dev The collect body, reused by `acceptCreatorRole` (auto-collect). Keeps its own `NotLocked` check so the
/// external `collect` wrapper's ABI/revert behavior is bit-identical.
function _collect(uint256 tokenId) internal returns (uint256 amount0, uint256 amount1) {
Position storage p = positions[tokenId];
if (!p.locked) revert NotLocked();
(amount0, amount1) = npm.collect(
INPM.CollectParams({
tokenId: tokenId,
recipient: address(this),
amount0Max: type(uint128).max,
amount1Max: type(uint128).max
})
);
if (amount0 == 0 && amount1 == 0) return (0, 0); // no-op: no credit, no event
(uint256 c0, uint256 pl0) = _credit(tokenId, p, p.token0, amount0);
(uint256 c1, uint256 pl1) = _credit(tokenId, p, p.token1, amount1);
emit FeesCollected(tokenId, amount0, amount1, c0, c1, pl0, pl1);
}
/// @dev Credit `amount` of `token` using CUMULATIVE entitlement so the split is independent of how often /
/// by whom collect() is called. creatorDelta is monotonic and <= amount, so platformDelta never underflows.
function _credit(uint256 tokenId, Position storage p, address token, uint256 amount)
internal
returns (uint256 creatorDelta, uint256 platformDelta)
{
if (amount == 0) return (0, 0);
uint256 newTotal = totalCollected[tokenId][token] + amount;
totalCollected[tokenId][token] = newTotal;
uint256 creatorTarget = (newTotal * p.creatorBps) / BPS; // cumulative floor
creatorDelta = creatorTarget - creatorCredited[tokenId][token];
creatorCredited[tokenId][token] = creatorTarget;
platformDelta = amount - creatorDelta;
owed[tokenId][token][p.creator] += creatorDelta;
owed[tokenId][token][p.platform] += platformDelta; // if creator == platform, both land on one key
}
/// @notice Withdraw the caller's owed balance of one token for one position. Pull-payment.
/// @dev Zero-before-transfer: reentrancy-safe (checks-effects) AND retryable (a reverting transfer — e.g. the
/// launch token to a non-exempt recipient at maxWallet during the window — rolls back the zeroing, so the
/// claim stays intact). Each call is one (tokenId, token), so a restricted token never blocks another.
function withdraw(uint256 tokenId, address token) external {
Position storage p = positions[tokenId];
if (!p.locked) revert NotLocked();
if (token != p.token0 && token != p.token1) revert UnknownToken();
uint256 amount = owed[tokenId][token][msg.sender];
if (amount == 0) revert NothingOwed();
owed[tokenId][token][msg.sender] = 0;
IERC20(token).safeTransfer(msg.sender, amount);
emit Withdrawn(msg.sender, tokenId, token, amount);
}
/// @notice Propose a new recipient for THIS position's 80% creator stream. Only the current creator; the transfer
/// takes effect only when `newCreator` calls `acceptCreatorRole` (two-step, so a typo/dead address can
/// never strand the stream). `newCreator == 0` cancels a pending proposal; re-proposing overwrites.
function proposeCreatorRecipient(uint256 tokenId, address newCreator) external {
Position storage p = positions[tokenId];
if (!p.locked) revert NotLocked();
if (msg.sender != p.creator) revert NotCreator();
if (newCreator == address(this)) revert RecipientIsSelf();
pendingCreator[tokenId] = newCreator;
emit CreatorTransferProposed(tokenId, p.creator, newCreator);
}
/// @notice Accept a proposed creator-recipient transfer for THIS position. Only the pending recipient. Auto-
/// collects first so the OUTGOING creator's accrued-but-uncollected share is crystallized to their
/// `owed` balance at the handoff boundary; everything after accrues to the new creator.
function acceptCreatorRole(uint256 tokenId) external {
Position storage p = positions[tokenId];
if (!p.locked) revert NotLocked();
address pend = pendingCreator[tokenId];
if (pend == address(0) || msg.sender != pend) revert NotPendingCreator();
_collect(tokenId); // crystallize the outgoing creator's share (reads p.creator, still the old creator)
address old = p.creator; // cache BEFORE the flip: correct event + one fewer SLOAD
p.creator = pend;
delete pendingCreator[tokenId];
emit CreatorRecipientUpdated(tokenId, old, pend);
}
}
Chain explorer15321msChain node90ms