LpLockerV4
0x5cac3111f082226ca0debb56f13765069238f771
Verification
Verified
v0.8.28+commit.7893614a
Type
Contract
6,928 bytes
ABI entries
33
12 read · 3 write
License
mit
Contract information
- Address
- 0x5cac3111f082226ca0debb56f13765069238f771
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.28+commit.7893614a
- Optimization
- Enabled
- Creator
- 0xc917056e80…09960e0C05
- Creation tx
- 0xb354dd179e…0dfc2f0a38
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (12)
BPS_DENOMINATOR() → uint16
NATIVE() → address
creator() → address
currency0() → address
currency1() → address
onERC721Received(address, address, uint256, bytes) → bytes4
owed(address, address) → uint256
poolManager() → address
positionManager() → address
positionTokenId() → uint256
protocol() → address
protocolShareBps() → uint16
Events (8)
CreatorFeeEscrowedCreatorFeePushedDistributedEthFeeClaimRedeemedFeesCollectedProtocolFeeEscrowedProtocolFeePushedWithdrawn
ABI
[
{
"inputs": [
{
"internalType": "contract IPositionManager",
"name": "positionManager_",
"type": "address"
},
{
"internalType": "contract IPoolManager",
"name": "poolManager_",
"type": "address"
},
{
"internalType": "uint256",
"name": "positionTokenId_",
"type": "uint256"
},
{
"internalType": "Currency",
"name": "currency0_",
"type": "address"
},
{
"internalType": "Currency",
"name": "currency1_",
"type": "address"
},
{
"internalType": "address",
"name": "creator_",
"type": "address"
},
{
"internalType": "address",
"name": "protocol_",
"type": "address"
},
{
"internalType": "uint16",
"name": "protocolShareBps_",
"type": "uint16"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"inputs": [],
"name": "BadShare",
"type": "error"
},
{
"inputs": [],
"name": "EthTransferFailed",
"type": "error"
},
{
"inputs": [],
"name": "NotARecipient",
"type": "error"
},
{
"inputs": [],
"name": "NotPoolManager",
"type": "error"
},
{
"inputs": [],
"name": "NothingOwed",
"type": "error"
},
{
"inputs": [],
"name": "ReentrancyGuardReentrantCall",
"type": "error"
},
{
"inputs": [
{
"internalType": "address",
"name": "token",
"type": "address"
}
],
"name": "SafeERC20FailedOperation",
"type": "error"
},
{
"inputs": [],
"name": "ZeroAddress",
"type": "error"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "CreatorFeeEscrowed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "CreatorFeePushed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "toCreator",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "toProtocol",
"type": "uint256"
}
],
"name": "Distributed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "EthFeeClaimRedeemed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "amount0",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount1",
"type": "uint256"
}
],
"name": "FeesCollected",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "ProtocolFeeEscrowed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "ProtocolFeePushed",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "address",
"name": "who",
"type": "address"
},
{
"indexed": true,
"internalType": "address",
"name": "asset",
"type": "address"
},
{
"indexed": false,
"internalType": "uint256",
"name": "amount",
"type": "uint256"
}
],
"name": "Withdrawn",
"type": "event"
},
{
"inputs": [],
"name": "BPS_DENOMINATOR",
"outputs": [
{
"internalType": "uint16",
"name": "",
"type": "uint16"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "NATIVE",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "collectFees",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "creator",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "currency0",
"outputs": [
{
"internalType": "Currency",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "currency1",
"outputs": [
{
"internalType": "Currency",
"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": "",
"type": "address"
},
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"name": "owed",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolManager",
"outputs": [
{
"internalType": "contract IPoolManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "positionManager",
"outputs": [
{
"internalType": "contract IPositionManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "positionTokenId",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "protocol",
"outputs": [
{
"internalType": "address",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "protocolShareBps",
"outputs": [
{
"internalType": "uint16",
"name": "",
"type": "uint16"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "unlockCallback",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "address",
"name": "asset",
"type": "address"
}
],
"name": "withdraw",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol";
import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
/**
* LpLockerV4 — one per LittleJohn v4 launch. Owns the launch's Uniswap v4
* position NFT FOREVER and, at every `collectFees`, splits the launch's trading
* fees to the creator and the protocol at the immutable 1/3 protocol / 2/3
* creator split.
*
* FEE FLOW (ETH-only, AUTO-PUSH BOTH — operator directive 2026-07-09, Stack v4):
* - The pool's own LP fee is ZERO (LittleJohnHook.beforeSwap overrides it). The
* trading fee is taken by the hook FROM THE ETH LEG of every swap and minted
* to THIS locker as an ERC-6909 native-ETH claim (deferred, because on a BUY
* the trader's ETH has not settled into the PoolManager yet). `collectFees`
* REDEEMS that claim into real ETH (burn + take from the PoolManager) and then
* distributes it.
* - BOTH SIDES ARE AUTO-PUSHED as ETH: the protocol third AND the creator
* two-thirds are sent straight to their immutable wallets the instant they are
* collected — no manual claim, ever. Both are non-reverting sends (a low-level
* ETH call / a try-transfer for a token). If EITHER push fails (a contract
* recipient that reverts, a hostile token), that recipient's share falls back
* to pull-escrow `owed[recipient]`, so a reverting recipient can only ever
* delay their OWN money and can NEVER brick the collect or the other side's
* push. A keeper firing `collectFees` on a schedule is what makes both
* creator and protocol revenue "automatic": no withdraw call is ever needed.
*
* THE CUSTODY INVARIANT (the entire anti-rug claim, enforced by the ABSENCE
* of code, provable in bytecode) — UNCHANGED by the ETH-leg fee path:
* - The ONLY call this contract makes to the PositionManager is `collectFees`:
* a DECREASE_LIQUIDITY with liquidity == 0 (the v4 pattern for collecting
* accrued fees without touching principal) + TAKE_PAIR to itself. There is NO
* function that decreases liquidity by a nonzero amount, burns the position,
* transfers the NFT, or approves any operator on it. The liquidity is locked
* for the life of the chain.
* - The ONLY call this contract makes to the PoolManager is redeeming its OWN
* ERC-6909 native-ETH fee claim (burn + take, inside its own unlock). It moves
* only collected FEES the hook minted to it, never principal, never the NFT.
* - `creator`, `protocol`, and `protocolShareBps` are immutable. Fee recipients
* baked in at launch can never be redirected; the two auto-push targets are
* the immutable `creator` and `protocol` (no arbitrary-address send exists).
* - No fallback function: any unknown selector reverts.
*
* NATIVE ETH: launch pools pair against native ETH (currency0 == address(0)
* on chain 4663). The escrow uses address(0) as the ETH key.
*/
contract LpLockerV4 is IUnlockCallback, ReentrancyGuard {
using SafeERC20 for IERC20;
uint16 public constant BPS_DENOMINATOR = 10_000;
/// Escrow key for native ETH.
address public constant NATIVE = address(0);
/// ERC-6909 id of native ETH == currency0 == toId(address(0)) == 0.
uint256 internal constant ETH_CLAIM_ID = 0;
/// Bound automatic native pushes so a hostile recipient cannot consume the
/// gas needed to persist its escrow fallback.
uint256 internal constant PUSH_GAS_LIMIT = 50_000;
IPositionManager public immutable positionManager;
/// The v4 singleton PoolManager — used ONLY to redeem this locker's own
/// ERC-6909 native-ETH fee claim (the hook mints it here). Never used to
/// touch the position principal (that is the PositionManager's NFT).
IPoolManager public immutable poolManager;
/// The locked v4 position NFT id (minted straight into this contract).
uint256 public immutable positionTokenId;
address public immutable creator;
address public immutable protocol;
uint16 public immutable protocolShareBps; // 3333 = 1/3
/// currency0 is native ETH on every launch pool; currency1 is the token.
Currency public immutable currency0;
Currency public immutable currency1;
/// Per-recipient, per-asset escrow (asset address(0) = native ETH). Normally
/// 0 for BOTH recipients (fees are auto-pushed); non-zero only when a push
/// to that recipient failed and its share fell back to this pull path.
mapping(address => mapping(address => uint256)) public owed;
event FeesCollected(uint256 amount0, uint256 amount1);
event EthFeeClaimRedeemed(uint256 amount);
event Distributed(address indexed asset, uint256 toCreator, uint256 toProtocol);
/// A recipient's share was pushed straight to their immutable wallet.
event ProtocolFeePushed(address indexed asset, uint256 amount);
event CreatorFeePushed(address indexed asset, uint256 amount);
/// A push failed (never for an EOA) — that share fell back to pull-escrow.
event ProtocolFeeEscrowed(address indexed asset, uint256 amount);
event CreatorFeeEscrowed(address indexed asset, uint256 amount);
event Withdrawn(address indexed who, address indexed asset, uint256 amount);
error ZeroAddress();
error BadShare();
error NotARecipient();
error NothingOwed();
error EthTransferFailed();
error NotPoolManager();
constructor(
IPositionManager positionManager_,
IPoolManager poolManager_,
uint256 positionTokenId_,
Currency currency0_,
Currency currency1_,
address creator_,
address protocol_,
uint16 protocolShareBps_
) {
if (
address(positionManager_) == address(0) || address(poolManager_) == address(0) || creator_ == address(0)
|| protocol_ == address(0)
) {
revert ZeroAddress();
}
if (Currency.unwrap(currency1_) == address(0)) revert ZeroAddress(); // token side must be a token
if (protocolShareBps_ == 0 || protocolShareBps_ >= BPS_DENOMINATOR) revert BadShare();
positionManager = positionManager_;
poolManager = poolManager_;
positionTokenId = positionTokenId_;
currency0 = currency0_;
currency1 = currency1_;
creator = creator_;
protocol = protocol_;
protocolShareBps = protocolShareBps_;
}
/**
* Anyone may trigger a collect (permissionless; the split is fixed):
* 1. pull any accrued POOL fees on the locked position via the real
* PositionManager (DECREASE_LIQUIDITY with liquidity = 0 + TAKE_PAIR to
* this contract). With the ETH-leg hook the pool LP fee is 0, so this is
* typically a no-op, kept for robustness (donations / any residual).
* 2. redeem this locker's ERC-6909 native-ETH fee claim (the hook's
* ETH-leg take) into real ETH.
* 3. split every uncredited balance and AUTO-PUSH both the protocol third
* and the creator two-thirds to their immutable wallets.
* A keeper firing this on a schedule is what makes BOTH sides "automatic":
* no withdraw call is ever needed unless a push fails (EOA-impossible).
*/
function collectFees() external nonReentrant {
uint256 bal0Before = _balanceOf(Currency.unwrap(currency0));
uint256 bal1Before = _balanceOf(Currency.unwrap(currency1));
bytes memory actions = abi.encodePacked(uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.TAKE_PAIR));
bytes[] memory params = new bytes[](2);
// liquidity = 0: fee-collect only, principal untouched (custody invariant)
params[0] = abi.encode(positionTokenId, uint256(0), uint128(0), uint128(0), bytes(""));
params[1] = abi.encode(currency0, currency1, address(this));
positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);
emit FeesCollected(
_balanceOf(Currency.unwrap(currency0)) - bal0Before, _balanceOf(Currency.unwrap(currency1)) - bal1Before
);
// redeem the hook's ETH-leg fee (ERC-6909 native-ETH claim) into real ETH
_redeemEthClaim();
_credit(Currency.unwrap(currency0));
_credit(Currency.unwrap(currency1));
}
/// Redeem this locker's ERC-6909 native-ETH claim (minted by the hook on
/// every swap's ETH-leg take) into real ETH held by this contract. Burns the
/// claim (credits this locker inside the PoolManager) and takes the ETH.
function _redeemEthClaim() internal {
uint256 claim = poolManager.balanceOf(address(this), ETH_CLAIM_ID);
if (claim == 0) return;
poolManager.unlock(abi.encode(claim));
emit EthFeeClaimRedeemed(claim);
}
/// PoolManager unlock callback for the ERC-6909 redeem ONLY. Burns the
/// claim and takes the native ETH to this contract. No principal, no NFT,
/// no arbitrary destination — only this locker's own fee claim.
function unlockCallback(bytes calldata data) external returns (bytes memory) {
if (msg.sender != address(poolManager)) revert NotPoolManager();
uint256 amount = abi.decode(data, (uint256));
poolManager.burn(address(this), ETH_CLAIM_ID, amount);
poolManager.take(currency0, address(this), amount); // currency0 == native ETH
return "";
}
/// Split the uncredited balance of `asset`: AUTO-PUSH the protocol third to
/// the immutable `protocol` AND the creator two-thirds to the immutable
/// `creator`, each with a pull-escrow fallback on push failure. Uncredited =
/// balance minus what is already owed (fallback escrow), so repeated calls
/// never double-count.
///
/// Ordering is CEI-safe: `fresh` and both split amounts are computed BEFORE
/// any external call, and `collectFees` is `nonReentrant` (shared guard with
/// `withdraw`), so neither push can reenter a collect/withdraw or be
/// front-run into a double credit. A push that fails cannot revert the
/// collect — it folds that recipient's share into `owed[recipient]`.
function _credit(address asset) internal {
// owed[*][asset] is normally 0 (both sides pushed at collect); non-zero
// only where a prior push fell back to escrow. Counting it keeps `fresh`
// correct across repeated collects.
uint256 alreadyOwed = owed[creator][asset] + owed[protocol][asset];
uint256 bal = _balanceOf(asset);
if (bal <= alreadyOwed) return;
uint256 fresh = bal - alreadyOwed;
uint256 toProtocol = (fresh * protocolShareBps) / BPS_DENOMINATOR;
uint256 toCreator = fresh - toProtocol; // creator gets the remainder, no dust lost
emit Distributed(asset, toCreator, toProtocol);
// PROTOCOL: push the third straight to the immutable operator address.
if (toProtocol > 0) {
bool ok = _tryPush(asset, protocol, toProtocol);
if (ok) {
emit ProtocolFeePushed(asset, toProtocol);
} else {
owed[protocol][asset] += toProtocol;
emit ProtocolFeeEscrowed(asset, toProtocol);
}
}
// CREATOR: push the two-thirds straight to the immutable creator address.
if (toCreator > 0) {
bool ok = _tryPush(asset, creator, toCreator);
if (ok) {
emit CreatorFeePushed(asset, toCreator);
} else {
owed[creator][asset] += toCreator;
emit CreatorFeeEscrowed(asset, toCreator);
}
}
}
/// Non-reverting push of `amount` of `asset` (address(0) = native ETH) to
/// `to`. Native uses a low-level call; a token uses a try-transfer that
/// treats a revert OR a `false` return as failure. A hostile/non-standard
/// token, or a contract recipient that reverts on ETH, falls back to
/// pull-escrow. Production currency1 is the factory-created standard
/// LittleJohnToken; arbitrary hostile ERC20s are outside this locker path.
function _tryPush(address asset, address to, uint256 amount) private returns (bool) {
if (asset == NATIVE) {
(bool okEth,) = to.call{value: amount, gas: PUSH_GAS_LIMIT}("");
return okEth;
}
(bool okTok, bytes memory ret) = asset.call(abi.encodeCall(IERC20.transfer, (to, amount)));
return okTok && (ret.length == 0 || abi.decode(ret, (bool)));
}
/// Pull escrowed fees for `asset` (address(0) = native ETH). Only needed in
/// the (EOA-impossible) fallback case where a push to that recipient failed.
/// A revert here affects ONLY the caller.
function withdraw(address asset) external nonReentrant {
if (msg.sender != creator && msg.sender != protocol) revert NotARecipient();
uint256 amount = owed[msg.sender][asset];
if (amount == 0) revert NothingOwed();
owed[msg.sender][asset] = 0; // effects before interaction (CEI)
if (asset == NATIVE) {
(bool ok,) = msg.sender.call{value: amount}("");
if (!ok) revert EthTransferFailed();
} else {
IERC20(asset).safeTransfer(msg.sender, amount);
}
emit Withdrawn(msg.sender, asset, amount);
}
function _balanceOf(address asset) internal view returns (uint256) {
return asset == NATIVE ? address(this).balance : IERC20(asset).balanceOf(address(this));
}
/// Receive the NFT mint (defensive: posm mints without a receiver check,
/// but a safeTransfer path must not brick the launch).
function onERC721Received(address, address, uint256, bytes calldata) external pure returns (bytes4) {
return this.onERC721Received.selector;
}
/// Accept the native-ETH leg of TAKE_PAIR during collectFees and the ETH
/// taken when redeeming the ERC-6909 fee claim.
receive() external payable {}
// NOTE (deliberate, the custody invariant): there is NO function that
// (a) calls DECREASE_LIQUIDITY with liquidity > 0, BURN_POSITION, or any
// other principal-moving action,
// (b) calls transferFrom/safeTransferFrom/approve/setApprovalForAll or
// subscribe on the PositionManager,
// (c) makes an arbitrary external call — the only value-bearing sends are
// to the IMMUTABLE `creator`/`protocol` (the auto-push) and to
// `msg.sender` in `withdraw` (their own escrow); none takes an
// attacker-chosen destination, and all move only collected FEES, never
// principal. The only PoolManager call redeems this locker's own
// ERC-6909 fee claim, or
// (d) mutates creator/protocol/protocolShareBps.
// No fallback exists, so no proxy/forwarding path can be added later.
}
Chain explorer3205msChain node86ms