// SPDX-License-Identifier: MIT pragma solidity ^0.8.24; import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol"; /// @dev The minimal Uniswap V3 NonfungiblePositionManager surface this locker /// uses. `collect` harvests ACCRUED FEES ONLY: internally the manager pokes /// the pool (a zero-liquidity burn) to realise fees into `tokensOwed`, then /// pays them out — it never reduces the position's `liquidity`. Principal is /// only ever reduced by `decreaseLiquidity`, which this contract NEVER calls. interface INonfungiblePositionManager { struct CollectParams { uint256 tokenId; address recipient; uint128 amount0Max; uint128 amount1Max; } function collect(CollectParams calldata params) external payable returns (uint256 amount0, uint256 amount1); function positions(uint256 tokenId) external view returns ( uint96 nonce, address operator, address token0, address token1, uint24 fee, int24 tickLower, int24 tickUpper, uint128 liquidity, uint256 feeGrowthInside0LastX128, uint256 feeGrowthInside1LastX128, uint128 tokensOwed0, uint128 tokensOwed1); function safeTransferFrom(address from, address to, uint256 tokenId, bytes calldata data) external; } /// @title FledgeFeeLocker /// @notice Locks a Uniswap V3 liquidity position FOREVER while letting a fixed /// recipient keep collecting its trading fees. Once a position NFT is /// deposited it can NEVER leave, and its principal liquidity can NEVER /// be reduced — the principal is locked exactly as hard as a burn. The /// difference from a burn is that the swap fees flow to the creator /// instead of being stranded. This is the "fees-locked LP" model: /// creators earn from real trading volume with liquidity they provably /// cannot pull. /// @dev Trust properties, enforced by CONSTRUCTION (the guarantees are the /// ABSENCE of code, which is why this contract is deliberately tiny): /// - NO owner, NO admin, NO upgrade, NO pause. /// - NO function calls `decreaseLiquidity` → principal can never shrink. /// - NO function transfers a held NFT out → a locked position never leaves. /// - The fee recipient is set once at lock time and is IMMUTABLE — there is /// no setter, so not even the caller of `collectFees` can redirect fees. /// - `collectFees` is permissionless (a keeper can trigger it), but funds /// can only ever be paid to the recorded recipient. /// Chirp's LP-lock analysis may treat NFTs held here as LOCKED once this /// address is source-reviewed and added to its locker allowlist. contract FledgeFeeLocker is ReentrancyGuard, IERC721Receiver { /// @notice The Uniswap V3 position manager whose NFTs this locker holds. INonfungiblePositionManager public immutable npm; struct LockInfo { address feeRecipient; // immutable per position — the sole fee destination bool locked; } /// @notice tokenId -> lock record. `locked` is one-way: it can never revert /// to false, and the recipient can never be changed. mapping(uint256 => LockInfo) public lockOf; event PositionLocked(uint256 indexed tokenId, address indexed feeRecipient, address indexed depositor); event FeesCollected(uint256 indexed tokenId, address indexed feeRecipient, uint256 amount0, uint256 amount1); constructor(address npm_) { require(npm_ != address(0), "zero npm"); npm = INonfungiblePositionManager(npm_); } /// @notice Convenience lock: pull `tokenId` from the caller (who must have /// approved this contract) and record `feeRecipient`. PERMANENT. /// The actual recording happens in `onERC721Received`. function lock(uint256 tokenId, address feeRecipient) external nonReentrant { require(feeRecipient != address(0), "zero recipient"); npm.safeTransferFrom(msg.sender, address(this), tokenId, abi.encode(feeRecipient)); } /// @notice ERC-721 receive hook — the single place a lock is recorded. Only /// accepts Uniswap V3 position NFTs (from `npm`). The fee recipient is /// the address ABI-encoded in `data`, or the sender when `data` is /// empty. A position sent here via a plain (unsafe) `transferFrom` /// will NOT be recorded and its fees become uncollectable — always /// use `safeTransferFrom` or `lock()`. function onERC721Received(address, address from, uint256 tokenId, bytes calldata data) external override returns (bytes4) { require(msg.sender == address(npm), "only V3 positions"); require(!lockOf[tokenId].locked, "already locked"); address feeRecipient = data.length == 32 ? abi.decode(data, (address)) : from; require(feeRecipient != address(0), "zero recipient"); lockOf[tokenId] = LockInfo({feeRecipient: feeRecipient, locked: true}); emit PositionLocked(tokenId, feeRecipient, from); return IERC721Receiver.onERC721Received.selector; } /// @notice Collect the accrued swap fees of a locked position to its immutable /// recipient. Permissionless — anyone / a keeper may call it, and the /// funds can ONLY go to the recorded recipient. Fees only: the contract /// never calls `decreaseLiquidity`, so `collect` cannot touch principal. function collectFees(uint256 tokenId) external nonReentrant returns (uint256 amount0, uint256 amount1) { LockInfo memory info = lockOf[tokenId]; require(info.locked, "not locked"); (amount0, amount1) = npm.collect(INonfungiblePositionManager.CollectParams({ tokenId: tokenId, recipient: info.feeRecipient, amount0Max: type(uint128).max, amount1Max: type(uint128).max })); emit FeesCollected(tokenId, info.feeRecipient, amount0, amount1); } // ── Views for transparency / trust badges ── /// @notice The locked principal liquidity of a position (0 if the manager has /// no such position). This contract can only ever leave it unchanged. function lockedLiquidity(uint256 tokenId) external view returns (uint128 liquidity) { (, , , , , , , liquidity, , , , ) = npm.positions(tokenId); } /// @notice The immutable fee recipient for a locked position (zero if unlocked). function feeRecipientOf(uint256 tokenId) external view returns (address) { return lockOf[tokenId].feeRecipient; } // By deliberate design there is NO decreaseLiquidity, NO NFT withdrawal, NO // fee-recipient setter, and NO owner. Their absence IS the lock. }