LpLocker

0x5247b6f93987543ed1799db6feda4f2d2ed28cfe

Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
3,481 bytes
ABI entries
18
4 read · 4 write
License
none

Contract information

Address
0x5247b6f93987543ed1799db6feda4f2d2ed28cfe
Chain
Robinhood Chain (4663)
Compiler
v0.8.26+commit.8a97fa7a
Optimization
Enabled
Creation tx
0x1aa412bac9…1e1da0de88

Token

Not a token

This contract does not expose ERC-20 metadata.

Read contract (4)

hook()address
lockedPositions(address)uint256, uint256
positionManager()address
registry()address

Events (3)

FeesCollectedPositionLockedPositionsRegistered

ABI

[
  {
    "inputs": [
      {
        "internalType": "contract IPositionManager",
        "name": "positionManager_",
        "type": "address"
      },
      {
        "internalType": "contract FeatherRegistry",
        "name": "registry_",
        "type": "address"
      },
      {
        "internalType": "contract FeatherFeeHook",
        "name": "hook_",
        "type": "address"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "constructor"
  },
  {
    "inputs": [],
    "name": "AlreadyRegistered",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotFactory",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "NotLockedHere",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "UnknownToken",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "WrongPosition",
    "type": "error"
  },
  {
    "inputs": [],
    "name": "ZeroAddress",
    "type": "error"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "caller",
        "type": "address"
      },
      {
        "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": "operator",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "indexed": true,
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      }
    ],
    "name": "PositionLocked",
    "type": "event"
  },
  {
    "anonymous": false,
    "inputs": [
      {
        "indexed": true,
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "curveId",
        "type": "uint256"
      },
      {
        "indexed": false,
        "internalType": "uint256",
        "name": "reserveId",
        "type": "uint256"
      }
    ],
    "name": "PositionsRegistered",
    "type": "event"
  },
  {
    "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": "tokens",
        "type": "address[]"
      }
    ],
    "name": "collectFeesMany",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "hook",
    "outputs": [
      {
        "internalType": "contract FeatherFeeHook",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      }
    ],
    "name": "lockedPositions",
    "outputs": [
      {
        "internalType": "uint256",
        "name": "curveId",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "reserveId",
        "type": "uint256"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "operator",
        "type": "address"
      },
      {
        "internalType": "address",
        "name": "from",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "tokenId",
        "type": "uint256"
      },
      {
        "internalType": "bytes",
        "name": "",
        "type": "bytes"
      }
    ],
    "name": "onERC721Received",
    "outputs": [
      {
        "internalType": "bytes4",
        "name": "",
        "type": "bytes4"
      }
    ],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "positionManager",
    "outputs": [
      {
        "internalType": "contract IPositionManager",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [
      {
        "internalType": "address",
        "name": "token",
        "type": "address"
      },
      {
        "internalType": "uint256",
        "name": "curveId",
        "type": "uint256"
      },
      {
        "internalType": "uint256",
        "name": "reserveId",
        "type": "uint256"
      }
    ],
    "name": "registerPositions",
    "outputs": [],
    "stateMutability": "nonpayable",
    "type": "function"
  },
  {
    "inputs": [],
    "name": "registry",
    "outputs": [
      {
        "internalType": "contract FeatherRegistry",
        "name": "",
        "type": "address"
      }
    ],
    "stateMutability": "view",
    "type": "function"
  }
]

Source code

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;

import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {PoolId} from "@uniswap/v4-core/src/types/PoolId.sol";
import {IPositionManager} from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol";
import {Actions} from "@uniswap/v4-periphery/src/libraries/Actions.sol";
import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";

import {FeatherRegistry} from "./FeatherRegistry.sol";
import {FeatherFeeHook} from "./FeatherFeeHook.sol";

/// @title LpLocker
/// @notice Permanent vault for the launch position NFTs. Liquidity is
///         locked by construction: no code path decreases liquidity,
///         transfers a position out, or approves a spender. Its only call
///         into the PositionManager is `collectFees`, a zero-liquidity
///         decrease (the delta is the literal constant 0) that sweeps
///         accrued LP fees — the fee ladder's LP column — to the treasury.
///         Anyone may trigger the sweep.
///
///         The contract is not a proxy and has no owner, admin or upgrade
///         path; once deployed, nobody can change its code or release the
///         principal. The treasury address is read from the fee hook at
///         sweep time, so rotating the role there moves this payout path as
///         well.
contract LpLocker {
    IPositionManager public immutable positionManager;
    FeatherRegistry public immutable registry;
    FeatherFeeHook public immutable hook;

    struct LockedPositions {
        uint256 curveId;
        uint256 reserveId;
    }

    /// @notice The two locked position NFTs backing a feather token's pool,
    ///         recorded at launch by the (registry-whitelisted) factory.
    mapping(address token => LockedPositions) public lockedPositions;

    event PositionLocked(address indexed operator, address indexed from, uint256 indexed tokenId);
    event PositionsRegistered(address indexed token, uint256 curveId, uint256 reserveId);
    event FeesCollected(address indexed token, address indexed caller, uint256 amount0, uint256 amount1);

    error ZeroAddress();
    error NotFactory();
    error AlreadyRegistered();
    error NotLockedHere();
    error WrongPosition();
    error UnknownToken();

    constructor(IPositionManager positionManager_, FeatherRegistry registry_, FeatherFeeHook hook_) {
        if (address(positionManager_) == address(0) || address(registry_) == address(0) || address(hook_) == address(0))
        {
            revert ZeroAddress();
        }
        positionManager = positionManager_;
        registry = registry_;
        hook = hook_;
    }

    /// @notice Accepts position NFTs; anything received is held forever.
    ///         `PositionLocked` is emitted only for PositionManager NFTs so
    ///         the event cannot be spoofed by other collections or direct
    ///         calls; other NFTs are accepted silently (and are stuck).
    function onERC721Received(address operator, address from, uint256 tokenId, bytes calldata)
        external
        returns (bytes4)
    {
        if (msg.sender == address(positionManager)) {
            emit PositionLocked(operator, from, tokenId);
        }
        return this.onERC721Received.selector;
    }

    /// @notice Records the two locked positions backing `token` so
    ///         `collectFees` knows what to sweep. Callable only by a
    ///         registry-whitelisted factory, exactly once per token. Both
    ///         positions must already be owned by the locker and live in
    ///         `token`'s canonical pool: their full PoolKey must hash to the
    ///         PoolId the registry recorded at launch, so even a faulty
    ///         future factory cannot register positions from another pool.
    function registerPositions(address token, uint256 curveId, uint256 reserveId) external {
        // Aderyn FPs: these are view STATICCALLs to immutable trusted protocol
        // dependencies; they cannot reenter the state write below.
        // aderyn-fp-next-line(reentrancy-state-change)
        if (!registry.isFactory(msg.sender)) revert NotFactory();
        if (lockedPositions[token].curveId != 0) revert AlreadyRegistered();
        if (curveId == 0 || reserveId == 0 || curveId == reserveId) revert WrongPosition();

        IERC721 nft = IERC721(address(positionManager));
        // aderyn-fp-next-line(reentrancy-state-change)
        if (nft.ownerOf(curveId) != address(this) || nft.ownerOf(reserveId) != address(this)) revert NotLockedHere();
        // aderyn-fp-next-line(reentrancy-state-change)
        bytes32 canonical = registry.canonicalPool(token);
        // aderyn-fp-next-line(reentrancy-state-change)
        (PoolKey memory curveKey,) = positionManager.getPoolAndPositionInfo(curveId);
        // aderyn-fp-next-line(reentrancy-state-change)
        (PoolKey memory reserveKey,) = positionManager.getPoolAndPositionInfo(reserveId);
        if (
            canonical == bytes32(0) || PoolId.unwrap(curveKey.toId()) != canonical
                || PoolId.unwrap(reserveKey.toId()) != canonical
        ) revert WrongPosition();

        lockedPositions[token] = LockedPositions({curveId: curveId, reserveId: reserveId});
        emit PositionsRegistered(token, curveId, reserveId);
    }

    /// @notice Collects the accrued LP fees of `token`'s two locked
    ///         positions and pays them to the treasury. Anyone may call.
    ///         Principal cannot leave: the DECREASE_LIQUIDITY delta is the
    ///         literal constant 0, so the PoolManager pays out only the
    ///         positions' accumulated `feeGrowthInside`. Calling with
    ///         nothing accrued is a no-op.
    function collectFees(address token) public returns (uint256 amount0, uint256 amount1) {
        LockedPositions memory locked = lockedPositions[token];
        if (locked.curveId == 0) revert UnknownToken();

        address treasury = hook.treasury();
        (PoolKey memory key,) = positionManager.getPoolAndPositionInfo(locked.curveId);

        // Fees-only sweep: two zero-liquidity decreases, then take both
        // currencies' full (fee) deltas straight to the treasury.
        bytes memory actions = abi.encodePacked(
            uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.DECREASE_LIQUIDITY), uint8(Actions.TAKE_PAIR)
        );
        bytes[] memory params = new bytes[](3);
        params[0] = abi.encode(locked.curveId, uint256(0), uint128(0), uint128(0), bytes(""));
        params[1] = abi.encode(locked.reserveId, uint256(0), uint128(0), uint128(0), bytes(""));
        params[2] = abi.encode(key.currency0, key.currency1, treasury);

        uint256 balance0Before = treasury.balance; // currency0 is native ETH
        uint256 balance1Before = IERC20(token).balanceOf(treasury);

        positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);

        // The balance deltas are event-only (TAKE_PAIR already paid the
        // treasury). Floored at zero so a treasury that spends ETH inside
        // its own receive() cannot underflow-revert the sweep.
        uint256 balance0After = treasury.balance;
        uint256 balance1After = IERC20(token).balanceOf(treasury);
        amount0 = balance0After > balance0Before ? balance0After - balance0Before : 0;
        amount1 = balance1After > balance1Before ? balance1After - balance1Before : 0;
        emit FeesCollected(token, msg.sender, amount0, amount1);
    }

    /// @notice Batch form of `collectFees`: sweeps many tokens' accrued LP
    ///         fees to the treasury in one transaction. Anyone may call; an
    ///         unknown token reverts the batch.
    function collectFeesMany(address[] calldata tokens) external {
        for (uint256 i = 0; i < tokens.length; i++) {
            collectFees(tokens[i]);
        }
    }
}
Chain explorer5322msChain node80ms