// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import { IStankV3Factory, IStankV3Pool, IStankV3PositionManager, IERC721Receiver } from "./interfaces/IStankV3.sol"; import {IERC20Minimal} from "./interfaces/IERC20Minimal.sol"; import {SafeTransferLib} from "./libraries/SafeTransferLib.sol"; import {LaunchRewardVault} from "./LaunchRewardVault.sol"; import {StankLaunchToken} from "./StankLaunchToken.sol"; import {ReentrancyGuard} from "./utils/ReentrancyGuard.sol"; /// @title STANK Permanent Liquidity Locker (PancakeSwap V3) /// @notice Holds one V3 position NFT forever. Paired-asset trading fees are forwarded to the reward /// vault and split; launch-token trading fees are permanently BURNED (deflationary). /// @dev No NFT approval/transfer, liquidity decrease, arbitrary swap, token rescue, or self-destruct /// path exists, so the locked liquidity can never be pulled. Runs on stock PancakeSwap V3, so it /// tolerates whatever pool protocol fee PancakeSwap governance may set (it simply splits the LP /// fees it actually collects). contract PermanentLiquidityLocker is IERC721Receiver, ReentrancyGuard { using SafeTransferLib for IERC20Minimal; IStankV3PositionManager public immutable positionManager; IStankV3Factory public immutable v3Factory; LaunchRewardVault public immutable rewardVault; address public immutable factory; address public immutable launchToken; address public immutable pairedAsset; address public immutable token0; address public immutable token1; uint24 public immutable poolFee; int24 public immutable tickLower; int24 public immutable tickUpper; address public pool; uint256 public positionId; uint256 public permanentlyLockedDust; bool public positionBound; struct LockerConfig { address factory; address launchToken; address pairedAsset; address token0; address token1; uint24 poolFee; int24 tickLower; int24 tickUpper; } error NotFactory(); error InvalidConfiguration(); error InvalidPositionManager(); error PositionAlreadyBound(); error PositionNotBound(); error InvalidPosition(); event PositionPermanentlyBound( uint256 indexed positionId, address indexed pool, uint128 liquidity, uint256 lockedDust ); event FeesCollected(uint256 indexed positionId, uint256 pairedAssetForwarded, uint256 launchTokenBurned); constructor(IStankV3PositionManager positionManager_, LaunchRewardVault rewardVault_, LockerConfig memory config) { if ( config.factory == address(0) || address(positionManager_) == address(0) || address(rewardVault_) == address(0) || config.launchToken == address(0) || config.pairedAsset == address(0) ) revert InvalidConfiguration(); address v3Factory_ = positionManager_.factory(); if (v3Factory_ == address(0)) revert InvalidConfiguration(); factory = config.factory; positionManager = positionManager_; v3Factory = IStankV3Factory(v3Factory_); rewardVault = rewardVault_; launchToken = config.launchToken; pairedAsset = config.pairedAsset; token0 = config.token0; token1 = config.token1; poolFee = config.poolFee; tickLower = config.tickLower; tickUpper = config.tickUpper; } /// @notice Permanently associates the launch position and token dust with this locker. function bindPosition( uint256 positionId_, address pool_, uint128 expectedLiquidity, uint256 expectedLockedDust ) external { if (msg.sender != factory) revert NotFactory(); if (positionBound) revert PositionAlreadyBound(); if ( pool_ == address(0) || positionManager.ownerOf(positionId_) != address(this) || v3Factory.getPool(token0, token1, poolFee) != pool_ ) revert InvalidPosition(); (,, address actualToken0, address actualToken1, uint24 actualFee, int24 actualLower, int24 actualUpper, uint128 actualLiquidity,,,,) = positionManager.positions(positionId_); if ( actualToken0 != token0 || actualToken1 != token1 || actualFee != poolFee || actualLower != tickLower || actualUpper != tickUpper || actualLiquidity == 0 || actualLiquidity != expectedLiquidity || IERC20Minimal(launchToken).balanceOf(address(this)) < expectedLockedDust ) revert InvalidPosition(); pool = pool_; positionId = positionId_; permanentlyLockedDust = expectedLockedDust; positionBound = true; emit PositionPermanentlyBound(positionId_, pool_, actualLiquidity, expectedLockedDust); } /// @notice Collects trading fees: paired-asset fees are split through the reward vault, launch-token /// fees are burned. Permissionless — anyone can trigger a collection. /// @dev Only `collect` is ever called on the position manager; principal liquidity is never touched. function collectRewards() external nonReentrant returns (uint256 pairedForwarded, uint256 launchBurned) { if (!positionBound) revert PositionNotBound(); positionManager.collect( IStankV3PositionManager.CollectParams({ tokenId: positionId, recipient: address(this), amount0Max: type(uint128).max, amount1Max: type(uint128).max }) ); uint256 pairedBalance = IERC20Minimal(pairedAsset).balanceOf(address(this)); if (pairedBalance != 0) { IERC20Minimal(pairedAsset).safeTransfer(address(rewardVault), pairedBalance); rewardVault.checkpoint(); pairedForwarded = pairedBalance; } // Burn only collected launch-token fees, never the permanent mint remainder. uint256 launchBalance = IERC20Minimal(launchToken).balanceOf(address(this)); uint256 dust = permanentlyLockedDust; if (launchBalance > dust) { launchBurned = launchBalance - dust; StankLaunchToken(launchToken).burn(launchBurned); } emit FeesCollected(positionId, pairedForwarded, launchBurned); } /// @dev Accepts the single launch NFT only from the configured position manager before binding. function onERC721Received(address, address, uint256, bytes calldata) external view returns (bytes4) { if (msg.sender != address(positionManager)) revert InvalidPositionManager(); if (positionBound) revert PositionAlreadyBound(); return IERC721Receiver.onERC721Received.selector; } }