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
- Creator
- 0x90Ae1f7Ded…1f05FA0681
- 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