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