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
- Creator
- 0x65B9FCCC5F…1DF3c22feC
- 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