CircusLocker

0xa256bc02dbb92ed1cb607a7de7b0bbebca29e930

Verification
Verified
v0.8.35+commit.47b9dedd
Type
Contract
4,662 bytes
ABI entries
23
6 read · 4 write
License
none

Contract information

Address
0xa256bc02dbb92ed1cb607a7de7b0bbebca29e930
Chain
Robinhood Chain (4663)
Compiler
v0.8.35+commit.47b9dedd
Optimization
Enabled
Creation tx
0x62bb914771…c946161f0d

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (6)

launchpad()address
lps(address)uint256, address, address, uint16, uint16, address, address, bool
nfpm()address
onERC721Received(address, address, uint256, bytes)bytes4
owed(address, address)uint256
weth()address

Events (4)

CompoundedHarvestedRegisteredWithdrawn

ABI

[
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "launchpad_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "nfpm_",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "weth_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyRegistered",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NativeSendFailed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NothingOwed",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "OnlyLaunchpad",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ReentrancyGuardReentrantCall",
    "type": "error"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "SafeERC20FailedOperation",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnknownToken",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "wethAmt",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "memeAmt",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint128",
        "name": "liquidityAdded",
        "type": "uint128"
      }
    ],
    "name": "Compounded",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "wethAmt",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "memeAmt",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "creatorSplitBps",
        "type": "uint256"
      }
    ],
    "name": "Harvested",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "address",
        "name": "creator",
        "type": "address"
      }
    ],
    "name": "Registered",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "beneficiary",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "asset",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "bool",
        "name": "unwrapped",
        "type": "bool"
      }
    ],
    "name": "Withdrawn",
    "type": "event"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "harvest",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "unwrap",
        "type": "bool"
      }
    ],
    "name": "harvestAndWithdraw",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "wethOut",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "memeOut",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "launchpad",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "name": "lps",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "protocol",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "splitBps",
        "type": "uint16"
      },
      {
        "internalType": "uint16",
        "name": "compoundBps",
        "type": "uint16"
      },
      {
        "internalType": "address",
        "name": "token0",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token1",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "exists",
        "type": "bool"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "nfpm",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "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": [
      {
        "internalType": "address",
        "name": "beneficiary",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "asset",
        "type": "address"
      }
    ],
    "name": "owed",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "address",
        "name": "creator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "protocol",
        "type": "address"
      },
      {
        "internalType": "uint16",
        "name": "splitBps",
        "type": "uint16"
      },
      {
        "internalType": "uint16",
        "name": "compoundBps",
        "type": "uint16"
      },
      {
        "internalType": "address",
        "name": "token0",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "token1",
        "type": "address"
      }
    ],
    "name": "register",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "weth",
    "outputs": [
      {
        "internalType": "address",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "asset",
        "type": "address"
      },
      {
        "internalType": "bool",
        "name": "unwrap",
        "type": "bool"
      }
    ],
    "name": "withdraw",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "amount",
        "type": "uint256"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "stateMutability": "payable",
    "type": "receive"
  }
]

Source code

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.24;

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

import {IWETH} from "../interfaces/IWETH.sol";
import {INonfungiblePositionManager} from "../interfaces/IUniswapV3.sol";

/// @title CircusLocker
/// @notice Immutable holder of graduated Uniswap V3 LP positions. There is deliberately **no**
///         `decreaseLiquidity`/withdraw path, so principal liquidity is locked forever — only the swap
///         fees accruing in each position can ever be pulled.
///
///         Fee handling is a two-step **escrow** (pull) model so that no participant can ever block
///         another's payout:
///
///           1. `harvest(token)` — permissionless. Collects the whole position's accrued fees into this
///              contract, optionally reinvests a `compoundBps` slice back into the locked position
///              (compounding — see `_compound`), and credits each remaining side's share to
///              `owed[beneficiary]`. The only external call it makes is a self-directed increaseLiquidity
///              into the canonical position manager (no value leaves to third parties), so it keeps no
///              reentrancy/griefing surface: anyone (creator, protocol, a keeper bot) can top up the
///              escrow / compound at any time.
///           2. `withdraw(asset, unwrap)` — each beneficiary pulls **only their own** accrued balance,
///              to `msg.sender`. The WETH side may be optionally unwrapped to native ETH; that native
///              send is the only value-bearing external call in the contract, and because it can only
///              ever target the caller paying out their own funds, a reverting recipient can at most
///              break their own transaction (retry with `unwrap = false`).
///
///         `harvestAndWithdraw(token, unwrap)` folds the two for the common one-click creator flow.
///
///         Escrow (rather than atomic push payouts) is deliberate: a push design could be bricked by
///         any one recipient that reverts on ETH receipt. Fees are split creator/protocol per the
///         per-token `splitBps` baked in at migration. Both assets ever moved — canonical WETH and
///         the launch token itself (a plain, non-fee-on-transfer ERC20) — are benign, so `collect`'s
///         returned amounts are exact and safe to credit directly.
///
///         Immutable by design (no proxy, no owner, no privileged functions): "locked forever" must not
///         be revocable by a later upgrade, unlike the launchpad which is admin-upgradeable.
contract CircusLocker is ReentrancyGuard, IERC721Receiver {
    using SafeERC20 for IERC20;

    uint256 internal constant BPS = 10_000;

    address public immutable launchpad;
    address public immutable nfpm;
    address public immutable weth;

    struct LP {
        uint256 tokenId;
        address creator;
        address protocol;
        uint16 splitBps; // creator's share of collected fees, AFTER the compounding cut is taken
        uint16 compoundBps; // share of collected fees reinvested into the locked position (compounding)
        address token0;
        address token1;
        bool exists;
    }

    mapping(address => LP) public lps; // launch token => its locked position

    /// @notice Accrued, unclaimed fees per beneficiary per asset. `asset` is `weth` or a launch token.
    ///         WETH balances aggregate across every launch a beneficiary is owed on, so one
    ///         `withdraw(weth, ...)` sweeps all of them.
    mapping(address beneficiary => mapping(address asset => uint256 amount)) public owed;

    event Registered(address indexed token, uint256 indexed tokenId, address creator);
    /// @param wethAmt / memeAmt the amounts credited to the creator/protocol escrow (i.e. AFTER the
    ///        compounding cut was reinvested); the reinvested portion is reported by `Compounded`.
    event Harvested(address indexed token, uint256 wethAmt, uint256 memeAmt, uint256 creatorSplitBps);
    /// @notice Emitted when a slice of harvested fees is reinvested into the locked position.
    /// @param wethAmt / memeAmt the amounts actually consumed by increaseLiquidity (leftover, if any,
    ///        falls through to the creator/protocol split and is reflected in `Harvested`).
    event Compounded(address indexed token, uint256 wethAmt, uint256 memeAmt, uint128 liquidityAdded);
    event Withdrawn(address indexed beneficiary, address indexed asset, uint256 amount, bool unwrapped);

    error OnlyLaunchpad();
    error AlreadyRegistered();
    error UnknownToken();
    error NothingOwed();
    error NativeSendFailed();

    constructor(address launchpad_, address nfpm_, address weth_) {
        launchpad = launchpad_;
        nfpm = nfpm_;
        weth = weth_;
    }

    /// @notice Called by the launchpad at migration to record a freshly-minted, locked position.
    function register(
        address token,
        uint256 tokenId,
        address creator,
        address protocol,
        uint16 splitBps,
        uint16 compoundBps,
        address token0,
        address token1
    ) external {
        if (msg.sender != launchpad) revert OnlyLaunchpad();
        if (lps[token].exists) revert AlreadyRegistered();
        lps[token] = LP(tokenId, creator, protocol, splitBps, compoundBps, token0, token1, true);
        emit Registered(token, tokenId, creator);
    }

    /// @notice Collect the position's accrued fees into escrow and credit creator/protocol. Permissionless
    ///         and pure-accounting: no value leaves the contract here, so it cannot be used to grief.
    function harvest(address token) external nonReentrant {
        _harvest(token);
    }

    /// @notice Withdraw the caller's own accrued balance of `asset`, to the caller. If `asset == weth`
    ///         and `unwrap` is set, the WETH is unwrapped and sent as native ETH; otherwise the ERC20 is
    ///         transferred as-is. The only place a beneficiary is ever paid.
    function withdraw(address asset, bool unwrap) external nonReentrant returns (uint256 amount) {
        return _withdraw(asset, unwrap);
    }

    /// @notice Convenience one-click flow: harvest `token`, then withdraw the caller's WETH (optionally as
    ///         native ETH) and their meme-side balance for that token.
    function harvestAndWithdraw(address token, bool unwrap)
        external
        nonReentrant
        returns (uint256 wethOut, uint256 memeOut)
    {
        _harvest(token);
        wethOut = _withdrawIfAny(weth, unwrap);
        // the meme-side asset is the launch token itself
        memeOut = _withdrawIfAny(token, false);
    }

    function _harvest(address token) internal {
        LP memory lp = lps[token];
        if (!lp.exists) revert UnknownToken();

        (uint256 a0, uint256 a1) = INonfungiblePositionManager(nfpm)
            .collect(
                INonfungiblePositionManager.CollectParams({
                    tokenId: lp.tokenId,
                    recipient: address(this),
                    amount0Max: type(uint128).max,
                    amount1Max: type(uint128).max
                })
            );

        // Reinvest `compoundBps` of each side back into the locked position. Whatever increaseLiquidity
        // can't consume at the current price ratio stays here and is distributed below, so nothing
        // strands and compoundBps == 0 is byte-for-byte the pre-compounding behavior.
        (uint256 used0, uint256 used1) = _compound(token, lp, a0, a1);

        // Distribute the remainder (uncompounded fees + un-consumed compound dust) creator/protocol,
        // split into WETH-side and meme-side (meme == the launch token).
        (uint256 wethAmt, uint256 memeAmt) =
            lp.token0 == weth ? (a0 - used0, a1 - used1) : (a1 - used1, a0 - used0);

        if (wethAmt > 0) {
            uint256 cCut = wethAmt * lp.splitBps / BPS;
            owed[lp.creator][weth] += cCut;
            owed[lp.protocol][weth] += wethAmt - cCut; // remainder → no dust leak
        }
        if (memeAmt > 0) {
            uint256 cCut = memeAmt * lp.splitBps / BPS;
            owed[lp.creator][token] += cCut;
            owed[lp.protocol][token] += memeAmt - cCut;
        }

        emit Harvested(token, wethAmt, memeAmt, lp.splitBps);
    }

    /// @dev Reinvest `lp.compoundBps` of the just-collected fees (`a0`/`a1`, in token0/token1 order)
    ///      into the locked full-range position via increaseLiquidity, returning the amounts actually
    ///      consumed. Both sides are needed for an in-range add, so a one-sided harvest skips
    ///      compounding. Wrapped in try/catch: harvest is the only way fees ever leave, so compounding
    ///      must never be able to revert it — on any failure we fall back to full distribution.
    ///      Mins are 0 deliberately: both the added liquidity and any leftover remain locked in this
    ///      contract, so a manipulated price ratio can only shift the compound/distribute split, never
    ///      remove value — and sandwiching a harvest means paying the pool's own 1% fee both ways.
    function _compound(address token, LP memory lp, uint256 a0, uint256 a1)
        internal
        returns (uint256 used0, uint256 used1)
    {
        uint16 bps = lp.compoundBps;
        if (bps == 0) return (0, 0);
        uint256 c0 = a0 * bps / BPS;
        uint256 c1 = a1 * bps / BPS;
        if (c0 == 0 || c1 == 0) return (0, 0);

        IERC20(lp.token0).forceApprove(nfpm, c0);
        IERC20(lp.token1).forceApprove(nfpm, c1);

        try INonfungiblePositionManager(nfpm)
            .increaseLiquidity(
                INonfungiblePositionManager.IncreaseLiquidityParams({
                    tokenId: lp.tokenId,
                    amount0Desired: c0,
                    amount1Desired: c1,
                    amount0Min: 0,
                    amount1Min: 0,
                    deadline: block.timestamp
                })
            ) returns (
            uint128 liq, uint256 amt0, uint256 amt1
        ) {
            (used0, used1) = (amt0, amt1);
            (uint256 wethUsed, uint256 memeUsed) = lp.token0 == weth ? (amt0, amt1) : (amt1, amt0);
            emit Compounded(token, wethUsed, memeUsed, liq);
        } catch {
            (used0, used1) = (0, 0);
        }

        // Clear any residual allowance (defensive; both assets are benign, canonical WETH + a plain
        // non-fee-on-transfer launch token, and nfpm is the canonical position manager).
        IERC20(lp.token0).forceApprove(nfpm, 0);
        IERC20(lp.token1).forceApprove(nfpm, 0);
    }

    function _withdraw(address asset, bool unwrap) internal returns (uint256 amount) {
        amount = owed[msg.sender][asset];
        if (amount == 0) revert NothingOwed();
        _payout(asset, amount, unwrap);
    }

    /// @dev Like `_withdraw` but a zero balance is a no-op instead of a revert (used by the batched flow
    ///      so that a harvest which credited only one side doesn't revert the whole call).
    function _withdrawIfAny(address asset, bool unwrap) internal returns (uint256 amount) {
        amount = owed[msg.sender][asset];
        if (amount == 0) return 0;
        _payout(asset, amount, unwrap);
    }

    function _payout(address asset, uint256 amount, bool unwrap) internal {
        owed[msg.sender][asset] = 0; // effects before interaction (CEI)
        if (unwrap && asset == weth) {
            IWETH(weth).withdraw(amount);
            _sendEth(msg.sender, amount); // only ever the caller's own funds, to the caller
        } else {
            IERC20(asset).safeTransfer(msg.sender, amount);
        }
        emit Withdrawn(msg.sender, asset, amount, unwrap && asset == weth);
    }

    function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
        return this.onERC721Received.selector;
    }

    receive() external payable {} // WETH.withdraw sends native ETH here

    function _sendEth(address to, uint256 amount) internal {
        (bool ok,) = to.call{value: amount}("");
        if (!ok) revert NativeSendFailed();
    }
}
Chain explorer7562msChain node78ms