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
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