{"language":"Solidity","sources":{"src/LazyGraduator.sol":{"content":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\ninterface IERC20Min {\n    function approve(address spender, uint256 amount) external returns (bool);\n    function balanceOf(address a) external view returns (uint256);\n    function transfer(address to, uint256 amount) external returns (bool);\n}\n\n\ninterface IFeeVaultRoute {\n    function splits(address token) external view returns (uint16 creatorBps, uint16 holdersBps, uint16 burnBps);\n    function deposit(address token) external payable;\n    function depositTokens(address token, uint256 amount) external;\n}\n\ninterface IPermit2Min {\n    function approve(address token, address spender, uint160 amount, uint48 expiration) external;\n}\n\ninterface ICurveMin {\n    function migrate(address target) external;\n    function token() external view returns (address);\n    function creator() external view returns (address);\n    function graduated() external view returns (bool);\n    function migrated() external view returns (bool);\n    function tokenReserve() external view returns (uint256);\n}\n\nstruct PoolKey {\n    address currency0;\n    address currency1;\n    uint24 fee;\n    int24 tickSpacing;\n    address hooks;\n}\n\ninterface IPositionManager {\n    function initializePool(PoolKey memory key, uint160 sqrtPriceX96) external returns (int24);\n    function modifyLiquidities(bytes calldata unlockData, uint256 deadline) external payable;\n    function nextTokenId() external view returns (uint256);\n}\n\n/// @notice Moves a graduated LazyCurve into a permanently locked full-range Uniswap v4\n///         USDC pool on Arc. LP fees are claimable and split 0.8% creator / 0.2% platform\n///         (i.e. 80/20 of the 1% pool fee).\ncontract LazyGraduator {\n    uint8 constant INCREASE_LIQUIDITY = 0x00;\n    uint8 constant MINT_POSITION = 0x02;\n    uint8 constant SETTLE_PAIR = 0x0d;\n    uint8 constant DECREASE_LIQUIDITY = 0x01;\n    uint8 constant TAKE_PAIR = 0x11;\n\n    uint24 public constant POOL_FEE = 10_000; // 1%\n    int24 public constant TICK_SPACING = 200;\n    int24 public constant MIN_TICK = -887_200;\n    int24 public constant MAX_TICK = 887_200;\n    uint256 public constant CREATOR_BPS = 8_000; // 80% of collected LP fees\n\n    address public platform;\n    IPositionManager public immutable posm;\n    IPermit2Min public immutable permit2;\n    address public immutable feeVault;\n\n    struct Position {\n        uint256 tokenId;\n        address token;\n        address creator;\n    }\n\n    mapping(address => Position) public positionOfCurve; // curve => locked LP position\n    address[] public graduatedCurves;\n\n    /// Payouts that could not be pushed (recipient reverted); withdrawable any time.\n    mapping(address => uint256) public owed;\n\n    uint256 private _lock = 1;\n\n    modifier nonReentrant() {\n        require(_lock == 1, \"REENTRANT\");\n        _lock = 2;\n        _;\n        _lock = 1;\n    }\n\n    event Graduated(address indexed curve, address indexed token, uint256 tokenId, uint256 quoteAmount, uint256 tokenAmount);\n    event FeesCollected(address indexed curve, uint256 quoteFees, uint256 tokenFees);\n    event PayoutDeferred(address indexed to, uint256 amount);\n    event PlatformUpdated(address platform);\n    event DustSwept(address indexed curve, uint256 quoteAmount, uint256 tokenAmount);\n    event RemainderAdded(address indexed curve, uint256 quoteAmount, uint256 tokenAmount);\n\n    modifier onlyPlatform() {\n        require(msg.sender == platform, \"NOT_PLATFORM\");\n        _;\n    }\n\n    constructor(address _platform, address _posm, address _permit2, address _feeVault) {\n        require(_platform != address(0), \"ZERO_PLATFORM\");\n        platform = _platform;\n        posm = IPositionManager(_posm);\n        permit2 = IPermit2Min(_permit2);\n        feeVault = _feeVault;\n    }\n\n    function graduatedCount() external view returns (uint256) {\n        return graduatedCurves.length;\n    }\n\n    /// @notice Pulls a graduated curve's liquidity and locks it in a full-range v4 pool.\n    /// @dev Callable by the platform keeper or by the curve itself, so a buy that\n    ///      crosses the graduation target seeds the Uniswap v4 pool atomically.\n    function graduate(address curve) external nonReentrant returns (uint256 tokenId) {\n        require(msg.sender == platform || msg.sender == curve, \"NOT_PLATFORM\");\n        ICurveMin c = ICurveMin(curve);\n        require(c.graduated(), \"NOT_GRADUATED\");\n        require(!c.migrated(), \"MIGRATED\");\n        require(positionOfCurve[curve].tokenId == 0, \"DONE\");\n\n        address token = c.token();\n        address creator = c.creator();\n\n        // Pull the liquidity ourselves and seed the pool with exactly what this curve\n        // sent — never with this contract's raw balance, which anyone can donate to.\n        uint256 quoteBefore = address(this).balance;\n        uint256 tokenBefore = IERC20Min(token).balanceOf(address(this));\n        c.migrate(address(this));\n\n        uint256 amount0 = address(this).balance - quoteBefore; // native USDC on Arc\n        uint256 amount1 = IERC20Min(token).balanceOf(address(this)) - tokenBefore;\n        require(amount0 > 0 && amount1 > 0, \"NO_LIQUIDITY\");\n        require(amount0 <= type(uint128).max && amount1 <= type(uint128).max, \"OVERFLOW\");\n\n        PoolKey memory key = PoolKey({\n            currency0: address(0),\n            currency1: token,\n            fee: POOL_FEE,\n            tickSpacing: TICK_SPACING,\n            hooks: address(0)\n        });\n\n        uint160 sqrtPriceX96 = _sqrtPriceX96(amount0, amount1);\n        posm.initializePool(key, sqrtPriceX96);\n\n        // approve token through permit2 -> position manager\n        IERC20Min(token).approve(address(permit2), type(uint256).max);\n        permit2.approve(token, address(posm), type(uint160).max, type(uint48).max);\n\n        uint256 liquidity = (_sqrt(amount0 * amount1) * 99) / 100;\n        require(liquidity > 0, \"LIQ\");\n\n        tokenId = posm.nextTokenId();\n\n        bytes memory actions = abi.encodePacked(MINT_POSITION, SETTLE_PAIR);\n        bytes[] memory params = new bytes[](2);\n        params[0] = abi.encode(\n            key,\n            MIN_TICK,\n            MAX_TICK,\n            liquidity,\n            uint128(amount0),\n            uint128(amount1),\n            address(this),\n            bytes(\"\")\n        );\n        params[1] = abi.encode(key.currency0, key.currency1);\n\n        posm.modifyLiquidities{value: amount0}(abi.encode(actions, params), block.timestamp + 600);\n\n        positionOfCurve[curve] = Position({tokenId: tokenId, token: token, creator: creator});\n        graduatedCurves.push(curve);\n\n        emit Graduated(curve, token, tokenId, amount0, amount1);\n\n        // The 1% mint safety margin is not kept: it is added straight back into the\n        // same locked LP position. Only if that top-up cannot execute is the remainder\n        // swept to the platform, so nothing can ever stay stranded in this contract.\n        // Baselines keep pre-existing balances (donations, deferred payouts) untouched.\n        _absorbRemainder(curve, token, tokenId, key, quoteBefore, tokenBefore);\n    }\n\n    function _absorbRemainder(\n        address curve,\n        address token,\n        uint256 tokenId,\n        PoolKey memory key,\n        uint256 quoteBefore,\n        uint256 tokenBefore\n    ) internal {\n        uint256 leftQuote = address(this).balance > quoteBefore ? address(this).balance - quoteBefore : 0;\n        uint256 tokenNow = IERC20Min(token).balanceOf(address(this));\n        uint256 leftToken = tokenNow > tokenBefore ? tokenNow - tokenBefore : 0;\n        if (leftQuote == 0 || leftToken == 0) {\n            _sweepRemainder(curve, token, leftQuote, leftToken);\n            return;\n        }\n        if (leftQuote > type(uint128).max || leftToken > type(uint128).max) {\n            _sweepRemainder(curve, token, leftQuote, leftToken);\n            return;\n        }\n\n        uint256 addLiquidity = (_sqrt(leftQuote * leftToken) * 99) / 100;\n        if (addLiquidity == 0) {\n            _sweepRemainder(curve, token, leftQuote, leftToken);\n            return;\n        }\n\n        bytes memory actions = abi.encodePacked(INCREASE_LIQUIDITY, SETTLE_PAIR);\n        bytes[] memory params = new bytes[](2);\n        params[0] = abi.encode(tokenId, addLiquidity, uint128(leftQuote), uint128(leftToken), bytes(\"\"));\n        params[1] = abi.encode(key.currency0, key.currency1);\n\n        try posm.modifyLiquidities{value: leftQuote}(abi.encode(actions, params), block.timestamp + 600) {\n            emit RemainderAdded(curve, leftQuote, leftToken);\n        } catch {\n            _sweepRemainder(curve, token, leftQuote, leftToken);\n            return;\n        }\n\n        // Whatever the pool did not consume (true rounding dust) goes to the platform.\n        uint256 dustQuote = address(this).balance > quoteBefore ? address(this).balance - quoteBefore : 0;\n        uint256 nowToken = IERC20Min(token).balanceOf(address(this));\n        uint256 dustToken = nowToken > tokenBefore ? nowToken - tokenBefore : 0;\n        _sweepRemainder(curve, token, dustQuote, dustToken);\n    }\n\n    function _sweepRemainder(address curve, address token, uint256 quoteAmount, uint256 tokenAmount) internal {\n        if (quoteAmount == 0 && tokenAmount == 0) return;\n        if (quoteAmount > 0) _send(platform, quoteAmount);\n        if (tokenAmount > 0) IERC20Min(token).transfer(platform, tokenAmount);\n        emit DustSwept(curve, quoteAmount, tokenAmount);\n    }\n\n    /// @notice Collects accrued LP fees for a graduated pool and splits them 80/20.\n    function collectFees(address curve) external nonReentrant {\n        Position memory p = positionOfCurve[curve];\n        require(p.tokenId != 0, \"UNKNOWN\");\n\n        PoolKey memory key = PoolKey({\n            currency0: address(0),\n            currency1: p.token,\n            fee: POOL_FEE,\n            tickSpacing: TICK_SPACING,\n            hooks: address(0)\n        });\n\n        bytes memory actions = abi.encodePacked(DECREASE_LIQUIDITY, TAKE_PAIR);\n        bytes[] memory params = new bytes[](2);\n        params[0] = abi.encode(p.tokenId, uint256(0), uint128(0), uint128(0), bytes(\"\"));\n        params[1] = abi.encode(key.currency0, key.currency1, address(this));\n\n        uint256 quoteBefore = address(this).balance;\n        uint256 tokenBefore = IERC20Min(p.token).balanceOf(address(this));\n\n        posm.modifyLiquidities(abi.encode(actions, params), block.timestamp + 600);\n\n        uint256 quoteFees = address(this).balance - quoteBefore;\n        uint256 tokenFees = IERC20Min(p.token).balanceOf(address(this)) - tokenBefore;\n\n        (, uint16 hBps, uint16 bBps) = IFeeVaultRoute(feeVault).splits(p.token);\n        uint256 vaultBps = uint256(hBps) + uint256(bBps);\n\n        if (quoteFees > 0) {\n            uint256 creatorCut = (quoteFees * CREATOR_BPS) / 10_000;\n            uint256 toVault = (creatorCut * vaultBps) / 10_000;\n            if (toVault > 0) IFeeVaultRoute(feeVault).deposit{value: toVault}(p.token);\n            // A recipient that rejects payment can never block fee collection.\n            _send(p.creator, creatorCut - toVault);\n            _send(platform, quoteFees - creatorCut);\n        }\n        if (tokenFees > 0) {\n            uint256 creatorCut = (tokenFees * CREATOR_BPS) / 10_000;\n            uint256 toVault = (creatorCut * vaultBps) / 10_000;\n            require(IERC20Min(p.token).transfer(p.creator, creatorCut - toVault), \"PAY_T\");\n            require(IERC20Min(p.token).transfer(platform, tokenFees - creatorCut), \"PAY_T\");\n            if (toVault > 0) {\n                require(IERC20Min(p.token).transfer(feeVault, toVault), \"PAY_T\");\n                IFeeVaultRoute(feeVault).depositTokens(p.token, toVault);\n            }\n        }\n\n        emit FeesCollected(curve, quoteFees, tokenFees);\n    }\n\n    /// @dev Push payment with a gas cap; credited for later withdrawal if it fails.\n    function _send(address to, uint256 amount) internal {\n        if (amount == 0) return;\n        (bool ok, ) = to.call{value: amount, gas: 30_000}(\"\");\n        if (!ok) {\n            owed[to] += amount;\n            emit PayoutDeferred(to, amount);\n        }\n    }\n\n    /// @notice Withdraw payouts that could not be pushed at collection time.\n    function withdrawOwed() external nonReentrant {\n        uint256 amount = owed[msg.sender];\n        require(amount > 0, \"NOTHING\");\n        owed[msg.sender] = 0;\n        (bool ok, ) = msg.sender.call{value: amount}(\"\");\n        require(ok, \"PAY\");\n    }\n\n    function setPlatform(address _platform) external onlyPlatform {\n        require(_platform != address(0), \"ZERO\");\n        platform = _platform;\n        emit PlatformUpdated(_platform);\n    }\n\n    function _sqrtPriceX96(uint256 amount0, uint256 amount1) internal pure returns (uint160) {\n        // price = amount1 / amount0 ; sqrtPriceX96 = sqrt(price) * 2^96\n        uint256 ratio = (amount1 << 96) / amount0; // Q96 price\n        uint256 sqrtQ48 = _sqrt(ratio); // sqrt(price) * 2^48\n        uint256 result = sqrtQ48 << 48;\n        require(result <= type(uint160).max, \"PRICE\");\n        return uint160(result);\n    }\n\n    function _sqrt(uint256 x) internal pure returns (uint256 y) {\n        if (x == 0) return 0;\n        uint256 z = (x + 1) / 2;\n        y = x;\n        while (z < y) {\n            y = z;\n            z = (x / z + z) / 2;\n        }\n    }\n\n    receive() external payable {}\n}\n"}},"settings":{"remappings":["forge-std/=lib/forge-std/src/"],"optimizer":{"enabled":true,"runs":200},"metadata":{"useLiteralContent":false,"bytecodeHash":"ipfs","appendCBOR":true},"outputSelection":{"*":{"*":["abi","evm.bytecode.object","evm.bytecode.sourceMap","evm.bytecode.linkReferences","evm.deployedBytecode.object","evm.deployedBytecode.sourceMap","evm.deployedBytecode.linkReferences","evm.deployedBytecode.immutableReferences","evm.methodIdentifiers","metadata"]}},"evmVersion":"cancun","viaIR":true,"libraries":{}}}
