LockerV2
0xb17f512656e193adb5e19f558b736ab7973c8b5b
Verification
Verified
v0.8.26+commit.8a97fa7a
Type
Contract
9,111 bytes
ABI entries
18
11 read · 3 write
License
none
Contract information
- Address
- 0xb17f512656e193adb5e19f558b736ab7973c8b5b
- Chain
- Robinhood Chain (4663)
- Compiler
- v0.8.26+commit.8a97fa7a
- Optimization
- Enabled
- Creator
- 0x5bB5C36721…2c38244FcA
- Creation tx
- 0xa5c8ff785d…690b43dd26
Token
Not a token
This contract does not expose ERC-20 metadata.
Read contract (11)
LIQUIDITY_SHARE() → uint256
MAX_SELL_IMPACT_BPS() → uint256
distributor() → address
poolFee() → uint24
poolKey() → tuple
poolManager() → address
poolTickSpacing() → int24
positionManager() → address
stateView() → address
token() → address
tokenId() → uint256
Events (2)
HarvestedLocked
ABI
[
{
"inputs": [
{
"internalType": "address",
"name": "_positionManager",
"type": "address"
},
{
"internalType": "address",
"name": "_poolManager",
"type": "address"
},
{
"internalType": "address",
"name": "_stateView",
"type": "address"
},
{
"internalType": "address",
"name": "_permit2",
"type": "address"
},
{
"internalType": "address",
"name": "_token",
"type": "address"
},
{
"internalType": "address",
"name": "_distributor",
"type": "address"
},
{
"internalType": "uint24",
"name": "_poolFee",
"type": "uint24"
},
{
"internalType": "int24",
"name": "_poolTickSpacing",
"type": "int24"
}
],
"stateMutability": "nonpayable",
"type": "constructor"
},
{
"anonymous": false,
"inputs": [
{
"indexed": false,
"internalType": "uint256",
"name": "ethToDividends",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "ethToLiquidity",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokensToLiquidity",
"type": "uint256"
},
{
"indexed": false,
"internalType": "uint256",
"name": "tokensSold",
"type": "uint256"
}
],
"name": "Harvested",
"type": "event"
},
{
"anonymous": false,
"inputs": [
{
"indexed": true,
"internalType": "uint256",
"name": "tokenId",
"type": "uint256"
}
],
"name": "Locked",
"type": "event"
},
{
"inputs": [],
"name": "LIQUIDITY_SHARE",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "MAX_SELL_IMPACT_BPS",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "distributor",
"outputs": [
{
"internalType": "contract IDistributorSink",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "harvest",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{
"internalType": "uint256",
"name": "_tokenId",
"type": "uint256"
}
],
"name": "lock",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [],
"name": "poolFee",
"outputs": [
{
"internalType": "uint24",
"name": "",
"type": "uint24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolKey",
"outputs": [
{
"components": [
{
"internalType": "Currency",
"name": "currency0",
"type": "address"
},
{
"internalType": "Currency",
"name": "currency1",
"type": "address"
},
{
"internalType": "uint24",
"name": "fee",
"type": "uint24"
},
{
"internalType": "int24",
"name": "tickSpacing",
"type": "int24"
},
{
"internalType": "contract IHooks",
"name": "hooks",
"type": "address"
}
],
"internalType": "struct PoolKey",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolManager",
"outputs": [
{
"internalType": "contract IPoolManager",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "poolTickSpacing",
"outputs": [
{
"internalType": "int24",
"name": "",
"type": "int24"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "positionManager",
"outputs": [
{
"internalType": "contract IPositionManagerV2",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "stateView",
"outputs": [
{
"internalType": "contract IStateViewMinimal",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "token",
"outputs": [
{
"internalType": "contract IERC20Lk",
"name": "",
"type": "address"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "tokenId",
"outputs": [
{
"internalType": "uint256",
"name": "",
"type": "uint256"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [
{
"internalType": "bytes",
"name": "data",
"type": "bytes"
}
],
"name": "unlockCallback",
"outputs": [
{
"internalType": "bytes",
"name": "",
"type": "bytes"
}
],
"stateMutability": "nonpayable",
"type": "function"
},
{
"stateMutability": "payable",
"type": "receive"
}
]Source code
/**
SPDX-License-Identifier: Unlicensed
*/
/**
* Permanent liquidity lock + fee harvester for a Uniswap v4 position.
*
* Holds exactly one full-range v4 position NFT forever: there is no owner
* and no function that can move the NFT or remove liquidity. The only
* liquidity actions it can perform are a zero-liquidity decrease (fee
* collection) and INCREASE_LIQUIDITY (compounding) — locked liquidity can
* grow but never shrink.
*
* harvest() — callable by anyone:
* 1. collects accrued swap fees (native + token)
* 2. values everything in native terms at the live pool price
* 3. keeps 1/5 of that value aside as liquidity: half native, half token,
* compounded into the locked position
* 4. sells the remaining token fees through the pool (price-impact
* bounded; unsold remainder rolls to the next harvest)
* 5. pushes the remaining native balance into the dividend distributor
* (skipped while there are no shareholders)
*/
pragma solidity ^0.8.26;
import {IPositionManager} from "v4-periphery/interfaces/IPositionManager.sol";
import {Actions} from "v4-periphery/libraries/Actions.sol";
import {LiquidityAmounts} from "v4-periphery/libraries/LiquidityAmounts.sol";
import {PositionInfo} from "v4-periphery/libraries/PositionInfoLibrary.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {PoolId, PoolIdLibrary} from "@uniswap/v4-core/src/types/PoolId.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {IUnlockCallback} from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol";
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {FullMath} from "@uniswap/v4-core/src/libraries/FullMath.sol";
import {FixedPoint96} from "@uniswap/v4-core/src/libraries/FixedPoint96.sol";
import {IAllowanceTransfer} from "permit2/src/interfaces/IAllowanceTransfer.sol";
interface IPositionManagerV2 is IPositionManager {
function ownerOf(uint256 tokenId) external view returns (address);
}
interface IStateViewMinimal {
function getSlot0(PoolId poolId)
external
view
returns (uint160 sqrtPriceX96, int24 tick, uint24 protocolFee, uint24 lpFee);
}
interface IDistributorSink {
function deposit() external payable;
function totalShares() external view returns (uint256);
}
interface IERC20Lk {
function balanceOf(address account) external view returns (uint256);
function transfer(address recipient, uint256 amount) external returns (bool);
function approve(address spender, uint256 amount) external returns (bool);
}
contract LockerV2 is IUnlockCallback {
using PoolIdLibrary for PoolKey;
IPositionManagerV2 public immutable positionManager;
IPoolManager public immutable poolManager;
IStateViewMinimal public immutable stateView;
IERC20Lk public immutable token; // pool currency1
IDistributorSink public immutable distributor;
uint24 public immutable poolFee;
int24 public immutable poolTickSpacing;
// Of the value collected each harvest, 1/LIQUIDITY_SHARE is compounded
// into the locked position and the rest is distributed as dividends
// (5% pool fee => 4% dividends / 1% liquidity).
uint256 public constant LIQUIDITY_SHARE = 5;
// Maximum price movement the harvest sell may cause, in bps of the
// pre-swap price. If the bound is hit the swap partially fills and the
// unsold remainder simply waits for the next harvest.
uint256 public constant MAX_SELL_IMPACT_BPS = 1000; // 10%
uint256 public tokenId; // 0 until lock() registers the position
bool private inHarvest;
event Locked(uint256 indexed tokenId);
event Harvested(uint256 ethToDividends, uint256 ethToLiquidity, uint256 tokensToLiquidity, uint256 tokensSold);
constructor (
address _positionManager,
address _poolManager,
address _stateView,
address _permit2,
address _token,
address _distributor,
uint24 _poolFee,
int24 _poolTickSpacing
) {
require(_positionManager != address(0), "posm is zero address");
require(_poolManager != address(0), "pm is zero address");
require(_stateView != address(0), "stateView is zero address");
require(_token != address(0), "token is zero address");
require(_distributor != address(0), "distributor is zero address");
positionManager = IPositionManagerV2(_positionManager);
poolManager = IPoolManager(_poolManager);
stateView = IStateViewMinimal(_stateView);
token = IERC20Lk(_token);
distributor = IDistributorSink(_distributor);
poolFee = _poolFee;
poolTickSpacing = _poolTickSpacing;
// standing approvals so INCREASE_LIQUIDITY can settle the token side
IERC20Lk(_token).approve(_permit2, type(uint256).max);
IAllowanceTransfer(_permit2).approve(_token, _positionManager, type(uint160).max, type(uint48).max);
}
function poolKey() public view returns (PoolKey memory) {
return PoolKey({
currency0: Currency.wrap(address(0)),
currency1: Currency.wrap(address(token)),
fee: poolFee,
tickSpacing: poolTickSpacing,
hooks: IHooks(address(0))
});
}
// One-time, trustless registration: the position must already be owned
// by this contract and must be the expected native/token pool.
function lock(uint256 _tokenId) external {
require(tokenId == 0, "already locked");
require(_tokenId != 0, "zero id");
require(positionManager.ownerOf(_tokenId) == address(this), "position not owned");
(PoolKey memory key, ) = positionManager.getPoolAndPositionInfo(_tokenId);
require(Currency.unwrap(key.currency0) == address(0), "currency0 not native");
require(Currency.unwrap(key.currency1) == address(token), "wrong token");
require(key.fee == poolFee, "wrong fee");
require(key.tickSpacing == poolTickSpacing, "wrong spacing");
require(address(key.hooks) == address(0), "hooked pool");
tokenId = _tokenId;
emit Locked(_tokenId);
}
function harvest() external {
uint256 id = tokenId;
require(id != 0, "not locked");
require(!inHarvest, "reentrancy");
_collectFees(id);
uint160 sqrtPriceX96 = _currentSqrtPrice();
uint256 nativeBalance = address(this).balance;
uint256 tokenBalance = token.balanceOf(address(this));
// value the token fees in native terms: amount1 / price, where
// price (token per native) = (sqrtP/Q96)^2
uint256 tokenValueNative = _tokenToNative(tokenBalance, sqrtPriceX96);
uint256 totalValue = nativeBalance + tokenValueNative;
if (totalValue == 0) {
emit Harvested(0, 0, 0, 0);
return;
}
// 1/LIQUIDITY_SHARE of total value goes to liquidity, half per side
uint256 lpSideValue = totalValue / LIQUIDITY_SHARE / 2;
uint256 tokensForLp = _min(tokenBalance, _nativeToToken(lpSideValue, sqrtPriceX96));
// sell everything above the liquidity reservation, impact-bounded
uint256 tokensSold = 0;
uint256 tokensToSell = tokenBalance - tokensForLp;
if (tokensToSell > 0) {
tokensSold = _sellTokens(tokensToSell, sqrtPriceX96);
}
// compound: pair the reserved sides into the locked position
// (price may have moved from the sell; re-read)
uint256 ethForLp = _min(address(this).balance, lpSideValue);
(uint256 ethUsed, uint256 tokensUsed) = _increaseLiquidity(id, ethForLp, tokensForLp);
// Everything else in native terms is dividends. The deposit swaps ETH
// to the reward token via an external DEX, which can fail (dry pair,
// slippage guard). It is wrapped so a failed deposit never bricks the
// harvest: the native balance simply stays in the locker and rolls to
// the next harvest. Skipped entirely while there are no shareholders.
//
// The gas guard closes the try/catch silent-skip hole: a caller could
// otherwise provide just enough gas for the deposit to run out mid-swap
// (caught here) while the harvest still "succeeds" doing nothing. We
// only attempt the deposit with ample gas, so an under-funded harvest
// reverts rather than silently skipping dividends.
uint256 ethToDividends = 0;
uint256 rest = address(this).balance;
if (rest > 0 && distributor.totalShares() > 0) {
require(gasleft() > 400000, "insufficient gas for deposit");
try distributor.deposit{value: rest}() {
ethToDividends = rest;
} catch {}
}
emit Harvested(ethToDividends, ethUsed, tokensUsed, tokensSold);
}
// ---------- internals ----------
function _collectFees(uint256 id) internal {
bytes memory actions = abi.encodePacked(
uint8(Actions.DECREASE_LIQUIDITY),
uint8(Actions.TAKE_PAIR)
);
bytes[] memory params = new bytes[](2);
params[0] = abi.encode(id, uint256(0), uint128(0), uint128(0), bytes(""));
params[1] = abi.encode(Currency.wrap(address(0)), Currency.wrap(address(token)), address(this));
positionManager.modifyLiquidities(abi.encode(actions, params), block.timestamp);
}
function _sellTokens(uint256 amount, uint160 sqrtPriceX96) internal returns (uint256 sold) {
// selling currency1 pushes sqrtPrice up; cap the move at
// ~MAX_SELL_IMPACT_BPS of price (first-order: sqrt moves half as far)
uint160 sqrtLimit = uint160(
uint256(sqrtPriceX96) + FullMath.mulDiv(uint256(sqrtPriceX96), MAX_SELL_IMPACT_BPS, 2 * 10000)
);
inHarvest = true;
bytes memory result = poolManager.unlock(abi.encode(amount, sqrtLimit));
inHarvest = false;
sold = abi.decode(result, (uint256));
}
function unlockCallback(bytes calldata data) external returns (bytes memory) {
require(msg.sender == address(poolManager), "not pool manager");
require(inHarvest, "not harvesting");
(uint256 amount, uint160 sqrtLimit) = abi.decode(data, (uint256, uint160));
BalanceDelta delta = poolManager.swap(
poolKey(),
SwapParams({
zeroForOne: false,
amountSpecified: -int256(amount),
sqrtPriceLimitX96: sqrtLimit
}),
""
);
// amount1 negative = tokens consumed (may be partial at the limit),
// amount0 positive = native received
uint256 sold = uint256(uint128(uint256(-int256(delta.amount1()))));
uint256 received = uint256(uint128(uint256(int256(delta.amount0()))));
if (sold > 0) {
poolManager.sync(Currency.wrap(address(token)));
token.transfer(address(poolManager), sold);
poolManager.settle();
}
if (received > 0) {
poolManager.take(Currency.wrap(address(0)), address(this), received);
}
return abi.encode(sold);
}
function _increaseLiquidity(uint256 id, uint256 ethAmount, uint256 tokenAmount)
internal
returns (uint256 ethUsed, uint256 tokensUsed)
{
if (ethAmount == 0 || tokenAmount == 0) return (0, 0);
uint160 sqrtPriceX96 = _currentSqrtPrice();
int24 tickLower = (TickMath.MIN_TICK / poolTickSpacing) * poolTickSpacing;
int24 tickUpper = (TickMath.MAX_TICK / poolTickSpacing) * poolTickSpacing;
uint128 liquidity = LiquidityAmounts.getLiquidityForAmounts(
sqrtPriceX96,
TickMath.getSqrtPriceAtTick(tickLower),
TickMath.getSqrtPriceAtTick(tickUpper),
ethAmount,
tokenAmount
);
if (liquidity == 0) return (0, 0);
uint256 ethBefore = address(this).balance;
uint256 tokensBefore = token.balanceOf(address(this));
bytes memory actions = abi.encodePacked(
uint8(Actions.INCREASE_LIQUIDITY),
uint8(Actions.SETTLE_PAIR),
uint8(Actions.SWEEP)
);
bytes[] memory params = new bytes[](3);
params[0] = abi.encode(id, uint256(liquidity), uint128(ethAmount), uint128(tokenAmount), bytes(""));
params[1] = abi.encode(Currency.wrap(address(0)), Currency.wrap(address(token)));
params[2] = abi.encode(Currency.wrap(address(0)), address(1)); // sweep native surplus back
positionManager.modifyLiquidities{value: ethAmount}(abi.encode(actions, params), block.timestamp);
ethUsed = ethBefore - address(this).balance;
tokensUsed = tokensBefore - token.balanceOf(address(this));
}
function _currentSqrtPrice() internal view returns (uint160 sqrtPriceX96) {
(sqrtPriceX96,,,) = stateView.getSlot0(poolKey().toId());
require(sqrtPriceX96 > 0, "pool not initialized");
}
function _tokenToNative(uint256 tokenAmount, uint160 sqrtPriceX96) internal pure returns (uint256) {
if (tokenAmount == 0) return 0;
uint256 intermediate = FullMath.mulDiv(tokenAmount, FixedPoint96.Q96, sqrtPriceX96);
return FullMath.mulDiv(intermediate, FixedPoint96.Q96, sqrtPriceX96);
}
function _nativeToToken(uint256 nativeAmount, uint160 sqrtPriceX96) internal pure returns (uint256) {
if (nativeAmount == 0) return 0;
uint256 intermediate = FullMath.mulDiv(nativeAmount, sqrtPriceX96, FixedPoint96.Q96);
return FullMath.mulDiv(intermediate, sqrtPriceX96, FixedPoint96.Q96);
}
function _min(uint256 a, uint256 b) internal pure returns (uint256) {
return a < b ? a : b;
}
// Accepts native currency from the pool manager during collection,
// swaps, and sweep refunds.
receive() external payable { }
}
Chain explorer1591msChain node76ms