CashCatLiquidityLocker
0x8c4b82d15a82f5f56e04a927de5a8c861f9a9d0e
Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
9,167 bytes
ABI entries
62
20 read · 11 write
License
none
Contract information
- Address
- 0x8c4b82d15a82f5f56e04a927de5a8c861f9a9d0e
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.26+commit.8a97fa7a
- Optimization
- Enabled
- Creator
- 0xf29fEF9e4B…dCeD85d09E
- Creation tx
- 0x91f840401a…4fb5478010
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (20)
BPS_DENOMINATOR() → uint256
MAX_PROTOCOL_FEE_BPS() → uint256
deployerTokenCount(address) → uint256
deployerTokensAt(address, uint256, uint256) → address[]
feeRecipientTokenCount(address) → uint256
feeRecipientTokensAt(address, uint256, uint256) → address[]
feeRedirects(address) → address
launchpad() → address
lockedTokens(address) → bool
onERC721Received(address, address, uint256, bytes) → bytes4
owner() → address
pendingFees(address, address) → uint256
pendingOwner() → address
poolManager() → address
positionManager() → address
positionToken(uint256) → address
protocolFeeBps() → uint256
protocolFeeRecipient() → address
renounceOwnership()
tokenProtocolFeeBps(address) → uint256
Events (11)
FeeRedirectUpdatedFeesCollectedLaunchpadSetOwnershipTransferStartedOwnershipTransferredPayoutDeferredPendingFeesReassignedPendingFeesWithdrawnPositionLockedProtocolFeeBpsUpdatedProtocolFeeRecipientUpdated
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "initialOwner",
"type": "address"
},
{
"internalType": "contract IPositionManager",
"name": "positionManager_",
"type": "address"
},
{
"internalType": "address",
"name": "poolManager_",
"type": "address"
},
{
"internalType": "address",
"name": "initialProtocolFeeRecipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "initialProtocolFeeBps",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "InvalidFeeRecipient",
"type": "error"
},
{
"inputs": [],
"name": "InvalidProtocolFee",
"type": "error"
},
{
"inputs": [],
"name": "LaunchpadAlreadySet",
"type": "error"
},
{
"inputs": [],
"name": "NativeTransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "NoFeesToCollect",
"type": "error"
},
{
"inputs": [],
"name": "NotAPositionNft",
"type": "error"
},
{
"inputs": [],
"name": "NotAuthorized",
"type": "error"
},
{
"inputs": [],
"name": "NotLaunchpad",
"type": "error"
},
{
"inputs": [],
"name": "NothingToWithdraw",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "owner",
"type": "address"
}
],
"name": "OwnableInvalidOwner",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "account",
"type": "address"
}
],
"name": "OwnableUnauthorizedAccount",
"type": "error"
},
{
"inputs": [],
"name": "OwnershipCannotBeRenounced",
"type": "error"
},
{
"inputs": [],
"name": "PositionAlreadyLocked",
"type": "error"
},
{
"inputs": [],
"name": "PositionNotHeld",
"type": "error"
},
{
"inputs": [],
"name": "ReentrancyGuardReentrantCall",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"inputs": [],
"name": "TokenNotFound",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAddress",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newFeeWallet",
"type": "address"
}
],
"name": "FeeRedirectUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "caller",
"type": "address"
},
{
"indexed": false,
"internalType": "address",
"name": "creatorRecipient",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount0",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount1",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "protocolAmount0",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "protocolAmount1",
"type": "uint256"
}
],
"name": "FeesCollected",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "launchpad",
"type": "address"
}
],
"name": "LaunchpadSet",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "previousOwner",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "OwnershipTransferStarted",
"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": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "currency",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "PayoutDeferred",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "from",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "to",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "currency",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "PendingFeesReassigned",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "currency",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "PendingFeesWithdrawn",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "token",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "deployer",
"type": "address"
},
{
"indexed": true,
"internalType": "uint256",
"name": "positionId",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "protocolFeeBps",
"type": "uint256"
}
],
"name": "PositionLocked",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "bps",
"type": "uint256"
}
],
"name": "ProtocolFeeBpsUpdated",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "recipient",
"type": "address"
}
],
"name": "ProtocolFeeRecipientUpdated",
"type": "event"
},
{
"inputs": [],
"name": "BPS_DENOMINATOR",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_PROTOCOL_FEE_BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "acceptOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "collectFees",
"outputs": [
{
"internalType": "uint256",
"name": "amount0",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "amount1",
"type": "uint256"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "deployer",
"type": "address"
}
],
"name": "deployerTokenCount",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "deployer",
"type": "address"
},
{
"internalType": "uint256",
"name": "start",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "count",
"type": "uint256"
}
],
"name": "deployerTokensAt",
"outputs": [
{
"internalType": "address[]",
"name": "page",
"type": "address[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "to",
"type": "address"
}
],
"name": "escrowLaunchFee",
"outputs": [],
"stateMutability": "payable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
}
],
"name": "feeRecipientTokenCount",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "uint256",
"name": "start",
"type": "uint256"
},
{
"internalType": "uint256",
"name": "count",
"type": "uint256"
}
],
"name": "feeRecipientTokensAt",
"outputs": [
{
"internalType": "address[]",
"name": "page",
"type": "address[]"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "feeRedirects",
"outputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "launchpad",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "lockPosition",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "lockedTokens",
"outputs": [
{
"internalType": "bool",
"name": "locked",
"type": "bool"
}
],
"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": "pure",
"type": "function"
},
{
"inputs": [],
"name": "owner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
},
{
"internalType": "address",
"name": "currency",
"type": "address"
}
],
"name": "pendingFees",
"outputs": [
{
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "pendingOwner",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolManager",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "positionManager",
"outputs": [
{
"internalType": "contract IPositionManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "positionId",
"type": "uint256"
}
],
"name": "positionToken",
"outputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "protocolFeeBps",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "protocolFeeRecipient",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "currency",
"type": "address"
},
{
"internalType": "address",
"name": "newRecipient",
"type": "address"
}
],
"name": "reassignPending",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "renounceOwnership",
"outputs": [],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
},
{
"internalType": "address",
"name": "newFeeWallet",
"type": "address"
}
],
"name": "setFeeRedirect",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "launchpad_",
"type": "address"
}
],
"name": "setLaunchpad",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "bps",
"type": "uint256"
}
],
"name": "setProtocolFeeBps",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "recipient",
"type": "address"
}
],
"name": "setProtocolFeeRecipient",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "tokenProtocolFeeBps",
"outputs": [
{
"internalType": "uint256",
"name": "bps",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "newOwner",
"type": "address"
}
],
"name": "transferOwnership",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "currency",
"type": "address"
}
],
"name": "withdrawPending",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Ownable2Step} from "@openzeppelin/contracts/access/Ownable2Step.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol";
import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol";
import {ICashCatLaunchpad, ICashCatLiquidityLocker} from "./interfaces/ICashCat.sol";
/**
* @title CashCatLiquidityLocker
* @notice Permanently holds the Uniswap v4 position NFTs of every CashCat
* launch and streams the accrued swap fees to the token creator and the
* protocol.
*
* The permanence guarantee is structural: this contract exposes no function
* that decreases position liquidity, burns a position, transfers a position
* NFT or performs an arbitrary external call. Fee collection uses a
* zero-liquidity decrease, which by construction can only realize accrued
* fees, never principal.
*
* Fee payouts push funds to the recipients; when a push fails (reverting
* receiver, token-level block) the amount is escrowed in `pendingFees` and can
* be pulled later with {withdrawPending}, so one unreachable recipient can
* never block the other side's revenue.
*/
contract CashCatLiquidityLocker is Ownable2Step, ReentrancyGuard, IERC721Receiver, ICashCatLiquidityLocker {
using SafeERC20 for IERC20;
uint256 public constant BPS_DENOMINATOR = 10_000;
/// @notice Hard cap on the protocol's share of LP fees (50%).
uint256 public constant MAX_PROTOCOL_FEE_BPS = 5000;
/// @notice Gas forwarded to an untrusted native recipient on a fee push.
/// Enough for an EOA or a plain receive()/multisig, capped so a gas-burning
/// recipient cannot deny the co-recipient's payout in the same collection;
/// a recipient needing more pulls via withdrawPending instead.
uint256 private constant NATIVE_PUSH_GAS = 30_000;
error NotLaunchpad();
error NotAuthorized();
error LaunchpadAlreadySet();
error TokenNotFound();
error PositionNotHeld();
error PositionAlreadyLocked();
error NoFeesToCollect();
error InvalidProtocolFee();
error InvalidFeeRecipient();
error NothingToWithdraw();
error NativeTransferFailed();
error NotAPositionNft();
error OwnershipCannotBeRenounced();
error ZeroAddress();
event LaunchpadSet(address indexed launchpad);
event PositionLocked(
address indexed token, address indexed deployer, uint256 indexed positionId, uint256 protocolFeeBps
);
event FeesCollected(
address indexed token,
address indexed caller,
address creatorRecipient,
uint256 amount0,
uint256 amount1,
uint256 protocolAmount0,
uint256 protocolAmount1
);
event FeeRedirectUpdated(address indexed token, address indexed newFeeWallet);
event ProtocolFeeRecipientUpdated(address indexed recipient);
event ProtocolFeeBpsUpdated(uint256 bps);
event PayoutDeferred(address indexed recipient, address indexed currency, uint256 amount);
event PendingFeesWithdrawn(address indexed recipient, address indexed currency, uint256 amount);
event PendingFeesReassigned(
address indexed from, address indexed to, address indexed currency, uint256 amount
);
/// @notice Uniswap v4 PositionManager holding the locked position NFTs.
IPositionManager public immutable positionManager;
/// @notice Uniswap v4 PoolManager; the only address allowed to send native ETH here.
address public immutable poolManager;
/// @notice The launchpad bound to this locker (one-shot set by the owner).
address public launchpad;
/// @notice Recipient of the protocol's fee share and of launch fees.
address public override protocolFeeRecipient;
/// @notice Protocol share (in bps) snapshotted by each future launch.
uint256 public protocolFeeBps;
/// @notice Protocol fee share fixed for a given token at launch time.
mapping(address token => uint256 bps) public tokenProtocolFeeBps;
/// @notice Tokens whose position is registered and permanently locked here.
mapping(address token => bool locked) public lockedTokens;
/// @notice Reverse index enforcing one launch position ↔ one token, so the
/// locker validates the exact NFT it was handed rather than merely that it
/// owns *some* position (which is always true).
mapping(uint256 positionId => address token) public positionToken;
/// @notice Optional creator-fee redirect per token (defaults to the deployer).
mapping(address token => address recipient) public feeRedirects;
/// @notice Escrowed payouts that could not be pushed; `currency == address(0)` is native ETH.
mapping(address recipient => mapping(address currency => uint256 amount)) public pendingFees;
mapping(address deployer => address[] tokens) private _deployerTokens;
mapping(address recipient => address[] tokens) private _feeRecipientTokens;
mapping(address token => uint256 indexPlusOne) private _feeRecipientTokenIndex;
constructor(
address initialOwner,
IPositionManager positionManager_,
address poolManager_,
address initialProtocolFeeRecipient,
uint256 initialProtocolFeeBps
) Ownable(initialOwner) {
if (address(positionManager_) == address(0) || poolManager_ == address(0)) {
revert ZeroAddress();
}
if (initialProtocolFeeRecipient == address(0)) revert ZeroAddress();
if (initialProtocolFeeBps > MAX_PROTOCOL_FEE_BPS) revert InvalidProtocolFee();
positionManager = positionManager_;
poolManager = poolManager_;
protocolFeeRecipient = initialProtocolFeeRecipient;
protocolFeeBps = initialProtocolFeeBps;
}
modifier onlyLaunchpad() {
if (msg.sender != launchpad) revert NotLaunchpad();
_;
}
/// @dev Native fee proceeds arrive from the PoolManager during collection;
/// all other senders are rejected so ETH cannot get stranded here.
receive() external payable {
if (msg.sender != poolManager) revert NotAuthorized();
}
/**
* @notice ERC-721 receiver hook. Rejects every incoming transfer.
* @dev The v4 PositionManager mints launch positions with a plain `_mint`
* (verified: no receiver callback), so this hook is NEVER invoked by a
* CashCat launch — the position is minted with the locker already set as
* owner. There is no registration path for a position arriving any other
* way: `lockPosition` is `onlyLaunchpad` and cross-checks the launch record,
* and the locker exposes no NFT-transfer or arbitrary-call surface, so an
* accepted stray position would be stranded forever. Reverting makes an
* accidental `safeTransferFrom` fail loudly instead of silently eating the NFT.
*/
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
revert NotAPositionNft();
}
/// @notice Disabled. The protocol fee stream, recipient, and per-token fee
/// bookkeeping are all owner-administered; renouncing would strand fee
/// revenue in escrow with no way to re-point it. Ownership hand-off still
/// uses the 2-step flow.
function renounceOwnership() public view override onlyOwner {
revert OwnershipCannotBeRenounced();
}
/**
* @notice Escrows a launch fee that the launchpad could not push directly
* to the protocol recipient, crediting it for pull withdrawal.
* @dev Lets the launchpad keep launches live even if `protocolFeeRecipient`
* is temporarily unable to receive ETH, instead of reverting every launch.
*/
function escrowLaunchFee(address to) external payable override onlyLaunchpad {
if (to == address(0)) revert ZeroAddress();
pendingFees[to][address(0)] += msg.value;
emit PayoutDeferred(to, address(0), msg.value);
}
/**
* @notice Binds the locker to one launchpad. One-shot.
*/
function setLaunchpad(address launchpad_) external onlyOwner {
if (launchpad != address(0)) revert LaunchpadAlreadySet();
if (launchpad_ == address(0)) revert ZeroAddress();
// Verify the reverse binding before committing this one-shot pointer,
// matching the launchpad's own setTokenDeployer check so a mis-wired
// deployment reverts instead of locking in permanently.
if (address(ICashCatLaunchpad(launchpad_).locker()) != address(this)) revert NotAuthorized();
launchpad = launchpad_;
emit LaunchpadSet(launchpad_);
}
/**
* @notice Registers permanent custody of a freshly minted launch position.
* @dev Called by the launchpad in the launch transaction after minting the
* position NFT directly to this contract.
*/
function lockPosition(address token) external override onlyLaunchpad {
if (lockedTokens[token]) revert PositionAlreadyLocked();
ICashCatLaunchpad.LaunchedToken memory launched = ICashCatLaunchpad(launchpad).getLaunchedToken(token);
if (!launched.exists || launched.token != token) revert TokenNotFound();
if (IERC721(address(positionManager)).ownerOf(launched.positionId) != address(this)) {
revert PositionNotHeld();
}
// Enforce one-position ↔ one-token from the locker's own state. The
// ownerOf check only proves the locker owns SOME position (always true);
// this proves the id has not already been claimed by another token.
if (positionToken[launched.positionId] != address(0)) revert PositionAlreadyLocked();
lockedTokens[token] = true;
positionToken[launched.positionId] = token;
uint256 snapshotBps = protocolFeeBps;
tokenProtocolFeeBps[token] = snapshotBps;
_deployerTokens[launched.deployer].push(token);
emit PositionLocked(token, launched.deployer, launched.positionId, snapshotBps);
}
/**
* @notice Realizes accrued swap fees for a launch token and distributes
* them between the protocol and the creator (or their redirect).
* @dev Callable by anyone: recipients are fixed by configuration, so open
* collection is keeper-friendly and grants callers nothing.
*/
function collectFees(address token) external nonReentrant returns (uint256 amount0, uint256 amount1) {
ICashCatLaunchpad.LaunchedToken memory launched = ICashCatLaunchpad(launchpad).getLaunchedToken(token);
if (!launched.exists || !lockedTokens[token]) revert TokenNotFound();
(amount0, amount1) = _realizeFees(launched);
if (amount0 == 0 && amount1 == 0) revert NoFeesToCollect();
address creator = feeRedirects[token];
if (creator == address(0)) creator = launched.deployer;
uint256 shareBps = tokenProtocolFeeBps[token];
uint256 protocolAmount0 = (amount0 * shareBps) / BPS_DENOMINATOR;
uint256 protocolAmount1 = (amount1 * shareBps) / BPS_DENOMINATOR;
address currency0 = Currency.unwrap(launched.poolKey.currency0);
address currency1 = Currency.unwrap(launched.poolKey.currency1);
_pay(currency0, protocolFeeRecipient, protocolAmount0);
_pay(currency1, protocolFeeRecipient, protocolAmount1);
_pay(currency0, creator, amount0 - protocolAmount0);
_pay(currency1, creator, amount1 - protocolAmount1);
emit FeesCollected(token, msg.sender, creator, amount0, amount1, protocolAmount0, protocolAmount1);
}
/**
* @dev Realizes accrued fees for a locked position: a zero-liquidity
* decrease (which by construction cannot touch principal) followed by
* TAKE_PAIR to this contract. Fee amounts cannot be sandwiched, so the
* decrease minimums are 0. Returns amounts measured by balance change.
*/
function _realizeFees(ICashCatLaunchpad.LaunchedToken memory launched)
private
returns (uint256 amount0, uint256 amount1)
{
address currency0 = Currency.unwrap(launched.poolKey.currency0);
address currency1 = Currency.unwrap(launched.poolKey.currency1);
uint256 balance0Before = _selfBalance(currency0);
uint256 balance1Before = _selfBalance(currency1);
bytes memory actions = abi.encodePacked(uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.TAKE_PAIR));
bytes[] memory params = new bytes[](2);
params[0] = abi.encode(launched.positionId, uint256(0), uint128(0), uint128(0), bytes(""));
params[1] = abi.encode(launched.poolKey.currency0, launched.poolKey.currency1, address(this));
positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);
amount0 = _selfBalance(currency0) - balance0Before;
amount1 = _selfBalance(currency1) - balance1Before;
}
/**
* @notice Withdraws payouts that were escrowed after a failed push.
* @param currency Token address, or address(0) for native ETH.
*/
function withdrawPending(address currency) external nonReentrant {
uint256 amount = pendingFees[msg.sender][currency];
if (amount == 0) revert NothingToWithdraw();
pendingFees[msg.sender][currency] = 0;
if (currency == address(0)) {
(bool ok,) = payable(msg.sender).call{value: amount}("");
if (!ok) revert NativeTransferFailed();
} else {
IERC20(currency).safeTransfer(msg.sender, amount);
}
emit PendingFeesWithdrawn(msg.sender, currency, amount);
}
/**
* @notice Reassigns the caller's escrowed balance in `currency` to another
* address, giving the escrow a terminal recovery path.
* @dev Escapes the case where the credited address cannot execute
* {withdrawPending} (a contract with no payable fallback, or a recipient the
* token later blocklisted): a blocklisted or fallback-less party can still
* sign this call and forward its balance to an address that can withdraw.
* Pure internal accounting — no external call, no NFT transfer, no liquidity
* change — so every structural guarantee of the locker is preserved.
*/
function reassignPending(address currency, address newRecipient) external nonReentrant {
// Reject in-system sinks that cannot withdraw, matching the fee setters —
// reassigning there would strand the caller's own escrow. (Self-grief only,
// but the guard removes the footgun and keeps the invariant consistent.)
if (
newRecipient == address(0) || newRecipient == address(this) || newRecipient == launchpad
|| newRecipient == address(positionManager) || newRecipient == poolManager
) revert InvalidFeeRecipient();
uint256 amount = pendingFees[msg.sender][currency];
if (amount == 0) revert NothingToWithdraw();
pendingFees[msg.sender][currency] = 0;
pendingFees[newRecipient][currency] += amount;
emit PendingFeesReassigned(msg.sender, newRecipient, currency, amount);
}
/**
* @notice Redirects the creator fee share for one token.
* @dev Callable by the token's deployer, or by the launchpad during launch.
*/
function setFeeRedirect(address token, address newFeeWallet) external override {
ICashCatLaunchpad.LaunchedToken memory launched = ICashCatLaunchpad(launchpad).getLaunchedToken(token);
if (!launched.exists) revert TokenNotFound();
if (msg.sender != launched.deployer && msg.sender != launchpad) revert NotAuthorized();
_setFeeRedirect(token, newFeeWallet);
}
/**
* @notice Changes the protocol fee recipient.
*/
function setProtocolFeeRecipient(address recipient) external onlyOwner {
// Reject in-system addresses that silently strand fees: the launchpad
// has no withdrawPending caller and no ERC20 sweep, and the locker's own
// receive() rejects the launchpad's push. address(0) is rejected too.
if (
recipient == address(0) || recipient == address(this) || recipient == launchpad
|| recipient == address(positionManager) || recipient == poolManager
) revert InvalidFeeRecipient();
protocolFeeRecipient = recipient;
emit ProtocolFeeRecipientUpdated(recipient);
}
/**
* @notice Changes the fee share snapshotted by future launches. Live
* tokens keep the share fixed at their launch.
*/
function setProtocolFeeBps(uint256 bps) external onlyOwner {
if (bps > MAX_PROTOCOL_FEE_BPS) revert InvalidProtocolFee();
protocolFeeBps = bps;
emit ProtocolFeeBpsUpdated(bps);
}
/**
* @notice Number of tokens launched by a deployer.
*/
function deployerTokenCount(address deployer) external view returns (uint256) {
return _deployerTokens[deployer].length;
}
/**
* @notice Page of tokens launched by a deployer.
*/
function deployerTokensAt(address deployer, uint256 start, uint256 count)
external
view
returns (address[] memory page)
{
return _page(_deployerTokens[deployer], start, count);
}
/**
* @notice Number of tokens whose creator fees are redirected to a recipient.
*/
function feeRecipientTokenCount(address recipient) external view returns (uint256) {
return _feeRecipientTokens[recipient].length;
}
/**
* @notice Page of tokens whose creator fees are redirected to a recipient.
* @dev Membership is attacker-influenceable (anyone can redirect their own
* token's fees to any wallet), so it MUST NOT be treated as an authorization
* or association signal. For the authentic set of a wallet's launches, read
* `deployerTokensAt`, which is written only from the recorded deployer.
*/
function feeRecipientTokensAt(address recipient, uint256 start, uint256 count)
external
view
returns (address[] memory page)
{
return _page(_feeRecipientTokens[recipient], start, count);
}
/**
* @dev Stores a creator-fee redirect and keeps the per-recipient token
* index consistent (swap-and-pop removal from the previous recipient).
*/
function _setFeeRedirect(address token, address newFeeWallet) private {
// address(0) means "default to the deployer"; any other in-system
// address would silently strand the creator's fee share, so reject it.
if (
newFeeWallet == address(this) || newFeeWallet == launchpad
|| newFeeWallet == address(positionManager) || newFeeWallet == poolManager
) revert InvalidFeeRecipient();
address previousFeeWallet = feeRedirects[token];
if (previousFeeWallet == newFeeWallet) return;
uint256 previousIndexPlusOne = _feeRecipientTokenIndex[token];
if (previousFeeWallet != address(0) && previousIndexPlusOne != 0) {
address[] storage previousTokens = _feeRecipientTokens[previousFeeWallet];
uint256 previousIndex = previousIndexPlusOne - 1;
uint256 lastIndex = previousTokens.length - 1;
if (previousIndex != lastIndex) {
address movedToken = previousTokens[lastIndex];
previousTokens[previousIndex] = movedToken;
_feeRecipientTokenIndex[movedToken] = previousIndex + 1;
}
previousTokens.pop();
delete _feeRecipientTokenIndex[token];
}
feeRedirects[token] = newFeeWallet;
if (newFeeWallet != address(0)) {
_feeRecipientTokens[newFeeWallet].push(token);
_feeRecipientTokenIndex[token] = _feeRecipientTokens[newFeeWallet].length;
}
emit FeeRedirectUpdated(token, newFeeWallet);
}
/**
* @dev Pushes a payout; on failure escrows it for {withdrawPending} so a
* broken recipient can never block collection for everyone else.
*/
function _pay(address currency, address to, uint256 amount) private {
if (amount == 0) return;
bool ok;
if (currency == address(0)) {
// Cap gas so a gas-burning recipient cannot exhaust the transaction
// and deny the co-recipient's leg; a recipient needing more pulls it
// via withdrawPending, which forwards all gas.
(ok,) = payable(to).call{value: amount, gas: NATIVE_PUSH_GAS}("");
} else {
ok = IERC20(currency).trySafeTransfer(to, amount);
}
if (!ok) {
pendingFees[to][currency] += amount;
emit PayoutDeferred(to, currency, amount);
}
}
function _selfBalance(address currency) private view returns (uint256) {
return currency == address(0) ? address(this).balance : IERC20(currency).balanceOf(address(this));
}
function _page(address[] storage list, uint256 start, uint256 count) private view returns (address[] memory page) {
uint256 length = list.length;
if (start >= length) return new address[](0);
// Overflow-safe clamp: length - start cannot underflow after the check above.
uint256 end = count > length - start ? length : start + count;
page = new address[](end - start);
for (uint256 i = start; i < end; ++i) {
page[i - start] = list[i];
}
}
}
Chain explorer1658msChain node95ms