LaunchHoodV3Locker

0x25cf5b90e2b141142f868fb4e52ec66466287a75

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
5,244 bytes
ABI entries
29
9 read · 5 write
License
none

Contract information

Address
0x25cf5b90e2b141142f868fb4e52ec66466287a75
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x6ee57622f0…471fad0523

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (9)

FACTORY()address
POSITION_MANAGER()address
PROTOCOL_TREASURY()address
defaultCreatorShareBps()uint16
onERC721Received(address, address, uint256, bytes)bytes4
positionMeta(uint256)address, address, address, uint16
positionOf(address)uint256
rewardRecipient(uint256)address
rewardRecipientOverride(uint256)address

Events (4)

DefaultCreatorShareUpdatedFeesCollectedRegisteredRewardRecipientChanged

ABI

[
  {
    "inputs": [
      {
        "internalType": "contract INonfungiblePositionManagerMinimal",
        "name": "positionManager_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "treasury_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "factory_",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "initialCreatorShareBps_",
        "type": "uint16"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyRegistered",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "InvalidShare",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "OnlyAdmin",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "OnlyFactory",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "OnlyPositionManager",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnexpectedETH",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnknownPosition",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "creatorShareBps",
        "type": "uint16"
      }
    ],
    "name": "DefaultCreatorShareUpdated",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "meme",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalEth",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "totalMeme",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "creatorEth",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "creatorMeme",
        "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": "meme",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint16",
        "name": "creatorShareBps",
        "type": "uint16"
      }
    ],
    "name": "Registered",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "meme",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "RewardRecipientChanged",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "FACTORY",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "POSITION_MANAGER",
    "outputs": [
      {
        "internalType": "contract INonfungiblePositionManagerMinimal",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "PROTOCOL_TREASURY",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "collect",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256[]",
        "name": "tokenIds",
        "type": "uint256[]"
      }
    ],
    "name": "collectMany",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "defaultCreatorShareBps",
    "outputs": [
      {
        "internalType": "uint16",
        "name": "",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "name": "onERC721Received",
    "outputs": [
      {
        "internalType": "bytes4",
        "name": "",
        "type": "bytes4"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "positionMeta",
    "outputs": [
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "meme",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "pairToken",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "creatorShareBps",
        "type": "uint16"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "meme",
        "type": "address"
      }
    ],
    "name": "positionOf",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "meme",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "pairToken",
        "type": "address"
      }
    ],
    "name": "register",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "rewardRecipient",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "rewardRecipientOverride",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint16",
        "name": "newShareBps",
        "type": "uint16"
      }
    ],
    "name": "setDefaultCreatorShareBps",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "newRecipient",
        "type": "address"
      }
    ],
    "name": "setRewardRecipient",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

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

// =============================================================================
//   launchhood.fun — memecoin launchpad on Ethereum
//   https://launchhood.fun   ·   https://x.com/LaunchHoodfun
// =============================================================================

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

import {INonfungiblePositionManagerMinimal, IWETH9Minimal} from "./interfaces/IUniswapV3.sol";

/// @title LaunchHoodV3Locker
/// @notice Holds every V3-direct launch's position NFT forever. Principal
///         liquidity is structurally non-withdrawable — only fee collection
///         is exposed. Fees split by a single creator/protocol share that is
///         SNAPSHOTTED into each position at launch and frozen there: the
///         protocol admin (factory owner) can retune the default for future
///         launches, but existing positions keep the split they launched
///         with, and creators can never influence it.
///
///         V3 pools accrue fees in both pool currencies. collect() harvests
///         both; the WETH side is unwrapped and paid out as ETH, the token
///         side is transferred as-is.
///
///         The creator's share goes to `rewardRecipient` — the creator by
///         default, but the protocol admin (factory owner) can redirect it,
///         e.g. to a community-lead address.
contract LaunchHoodV3Locker is ReentrancyGuard, IERC721Receiver {
    using SafeERC20 for IERC20;

    INonfungiblePositionManagerMinimal public immutable POSITION_MANAGER;
    address public immutable PROTOCOL_TREASURY;
    address public immutable FACTORY;

    struct PositionMeta {
        address creator;
        address meme;
        address pairToken; // WETH — unwrapped to ETH on payout
        uint16 creatorShareBps; // frozen at registration
    }

    /// @notice Creator share (bps) applied to NEW launches. Snapshotted into
    ///         PositionMeta at register() — changing it never touches
    ///         already-locked positions.
    uint16 public defaultCreatorShareBps;

    mapping(uint256 tokenId => PositionMeta) public positionMeta;
    /// @notice Position id for a launched token (one lock per launch).
    mapping(address meme => uint256) public positionOf;

    /// @notice Admin-set override of who receives the creator share.
    ///         address(0) = no override (the original creator earns).
    mapping(uint256 tokenId => address) public rewardRecipientOverride;

    error OnlyPositionManager();
    error OnlyFactory();
    error OnlyAdmin();
    error AlreadyRegistered();
    error UnknownPosition();
    error InvalidShare();
    error UnexpectedETH();

    event Registered(
        uint256 indexed tokenId, address indexed creator, address indexed meme, uint16 creatorShareBps
    );
    event DefaultCreatorShareUpdated(uint16 creatorShareBps);
    event RewardRecipientChanged(uint256 indexed tokenId, address indexed meme, address indexed newRecipient);
    event FeesCollected(
        uint256 indexed tokenId,
        address indexed meme,
        uint256 totalEth,
        uint256 totalMeme,
        uint256 creatorEth,
        uint256 creatorMeme
    );

    constructor(
        INonfungiblePositionManagerMinimal positionManager_,
        address treasury_,
        address factory_,
        uint16 initialCreatorShareBps_
    ) {
        if (initialCreatorShareBps_ > 10_000) revert InvalidShare();
        POSITION_MANAGER = positionManager_;
        PROTOCOL_TREASURY = treasury_;
        FACTORY = factory_;
        defaultCreatorShareBps = initialCreatorShareBps_;
    }

    /// @notice Protocol admin (= factory owner) sets the split for FUTURE
    ///         launches. Already-locked positions keep their frozen share.
    function setDefaultCreatorShareBps(uint16 newShareBps) external {
        if (msg.sender != Ownable(FACTORY).owner()) revert OnlyAdmin();
        if (newShareBps > 10_000) revert InvalidShare();
        defaultCreatorShareBps = newShareBps;
        emit DefaultCreatorShareUpdated(newShareBps);
    }

    /// @notice Called by FACTORY exactly once per launch, right after the
    ///         position NFT is minted straight to this contract. The split is
    ///         snapshotted from `defaultCreatorShareBps` — neither the caller
    ///         nor the creator can pass a custom one.
    function register(uint256 tokenId, address creator, address meme, address pairToken) external {
        if (msg.sender != FACTORY) revert OnlyFactory();
        if (positionMeta[tokenId].creator != address(0)) revert AlreadyRegistered();
        uint16 share = defaultCreatorShareBps;
        positionMeta[tokenId] = PositionMeta(creator, meme, pairToken, share);
        positionOf[meme] = tokenId;
        emit Registered(tokenId, creator, meme, share);
    }

    /// @notice Protocol admin (= factory owner) may redirect the creator
    ///         share of a position's fees, e.g. to a community lead. Set
    ///         address(0) to restore the original creator.
    function setRewardRecipient(uint256 tokenId, address newRecipient) external {
        if (msg.sender != Ownable(FACTORY).owner()) revert OnlyAdmin();
        PositionMeta memory pm = positionMeta[tokenId];
        if (pm.creator == address(0)) revert UnknownPosition();
        rewardRecipientOverride[tokenId] = newRecipient;
        emit RewardRecipientChanged(
            tokenId, pm.meme, newRecipient == address(0) ? pm.creator : newRecipient
        );
    }

    /// @notice Effective receiver of the creator share for a position.
    function rewardRecipient(uint256 tokenId) public view returns (address) {
        address r = rewardRecipientOverride[tokenId];
        return r == address(0) ? positionMeta[tokenId].creator : r;
    }

    function collect(uint256 tokenId) external nonReentrant {
        _collect(tokenId);
    }

    function collectMany(uint256[] calldata tokenIds) external nonReentrant {
        uint256 len = tokenIds.length;
        for (uint256 i; i < len; ++i) {
            _collect(tokenIds[i]);
        }
    }

    function _collect(uint256 tokenId) internal {
        PositionMeta memory pm = positionMeta[tokenId];
        if (pm.creator == address(0)) revert UnknownPosition();

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

        (uint256 pairAmount, uint256 memeAmount) =
            pm.pairToken < pm.meme ? (amount0, amount1) : (amount1, amount0);

        // Pay the pair side out as native ETH.
        if (pairAmount > 0) {
            IWETH9Minimal(pm.pairToken).withdraw(pairAmount);
        }

        address recipient = rewardRecipient(tokenId);

        // Odd wei rounds toward the protocol.
        uint256 creatorEth = (pairAmount * pm.creatorShareBps) / 10_000;
        uint256 creatorMeme = (memeAmount * pm.creatorShareBps) / 10_000;
        uint256 protoEth = pairAmount - creatorEth;
        uint256 protoMeme = memeAmount - creatorMeme;

        // A hostile recipient (contract rejecting ETH) can't brick collect —
        // their ETH share falls through to the protocol.
        if (creatorEth > 0) {
            (bool ok,) = recipient.call{value: creatorEth}("");
            if (!ok) protoEth += creatorEth;
        }
        if (creatorMeme > 0) {
            IERC20(pm.meme).safeTransfer(recipient, creatorMeme);
        }

        if (protoEth > 0) {
            (bool ok,) = PROTOCOL_TREASURY.call{value: protoEth}("");
            require(ok, "treasury ETH send failed");
        }
        if (protoMeme > 0) {
            IERC20(pm.meme).safeTransfer(PROTOCOL_TREASURY, protoMeme);
        }

        emit FeesCollected(tokenId, pm.meme, pairAmount, memeAmount, creatorEth, creatorMeme);
    }

    function onERC721Received(address, address, uint256, bytes calldata)
        external
        view
        override
        returns (bytes4)
    {
        if (msg.sender != address(POSITION_MANAGER)) revert OnlyPositionManager();
        return IERC721Receiver.onERC721Received.selector;
    }

    /// @dev Accept ETH only from WETH.withdraw during _collect.
    receive() external payable {
        // pm.pairToken varies per position, so allow any contract sender —
        // but plain EOA transfers are rejected to avoid stray deposits.
        if (msg.sender.code.length == 0) revert UnexpectedETH();
    }
}
Chain explorer4211msChain node97ms