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