{"language":"Solidity","sources":{"src/LazyFeeVault.sol":{"content":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.26;\n\ninterface IERC20Min {\n    function balanceOf(address a) external view returns (uint256);\n    function transfer(address to, uint256 amount) external returns (bool);\n    function totalSupply() external view returns (uint256);\n}\n\ninterface ICurveMin {\n    function buy(uint256 minTokensOut) external payable;\n    function graduated() external view returns (bool);\n    function quoteBuy(uint256 quoteIn) external view returns (uint256 tokensOut, uint256 fee);\n}\n\ninterface IV4Quoter {\n    struct QuoteExactSingleParams {\n        PoolKey poolKey;\n        bool zeroForOne;\n        uint128 exactAmount;\n        bytes hookData;\n    }\n\n    function quoteExactInputSingle(QuoteExactSingleParams memory params)\n        external\n        returns (uint256 amountOut, uint256 gasEstimate);\n}\n\nstruct PoolKey {\n    address currency0;\n    address currency1;\n    uint24 fee;\n    int24 tickSpacing;\n    address hooks;\n}\n\nstruct ExactInputSingleParams {\n    PoolKey poolKey;\n    bool zeroForOne;\n    uint128 amountIn;\n    uint128 amountOutMinimum;\n    bytes hookData;\n}\n\ninterface IUniversalRouter {\n    function execute(bytes calldata commands, bytes[] calldata inputs, uint256 deadline) external payable;\n}\n\n/// @notice Holds and settles the holder-reward and burn slices of every LazyMemes launch.\n///\n///         Fee routing:\n///           - Every trade pays 1%: 0.2% platform, 0.8% creator allocation.\n///           - The creator's Keep slice is paid out instantly by the curve / direct launcher.\n///           - The Holders + Burn slices are sent here as quote currency and pool up.\n///\n///         Settlement (permissionless, at most once every 24h per token):\n///           - The caller is reimbursed a 1% keeper fee out of the pot, so anyone\n///             pressing Claim can trigger it and still come out ahead of gas.\n///           - The rest is swapped into the token (bonding curve, or Uniswap v4 once\n///             the token trades there).\n///           - The burn share of those tokens is sent to the dead address, permanently\n///             reducing supply. The holder share is credited pro-rata to holders.\n///\n///         Holders pull their rewards with `claim` — no snapshots, no platform gas.\ncontract LazyFeeVault {\n    uint256 public constant KEEPER_BPS = 100; // 1% of the settled pot\n    uint256 public constant SETTLE_INTERVAL = 24 hours;\n    uint256 private constant ACC_PRECISION = 1e18;\n    address public constant DEAD = 0x000000000000000000000000000000000000dEaD;\n\n    uint24 public constant POOL_FEE = 10_000; // 1%\n    int24 public constant TICK_SPACING = 200;\n\n    address public platform;\n    address public factory;\n    IUniversalRouter public immutable router;\n    address public immutable poolManager;\n    /// @notice Uniswap v4 quoter, used to price the settlement swap on-chain.\n    address public quoter;\n    /// @notice Worst price the settlement swap may accept versus the live quote.\n    uint16 public maxSlippageBps = 500; // 5%\n    /// @notice How far below the last settled price the swap may print. Stops a caller\n    ///         from pumping the price, settling against their own manipulated quote and\n    ///         selling back — the live quote alone is not a safe floor.\n    uint16 public maxDriftBps = 1_000; // 10%\n    /// @notice Largest slice of the pot a single settlement may swap. The rest rolls\n    ///         over to the next day, so no single swap is big enough for a sandwich\n    ///         to be worth the attacker's capital.\n    uint256 public maxSettleSpend = 200 ether;\n    /// @notice Tokens per 1e18 wei observed at the previous settlement, per token.\n    mapping(address => uint256) public refPrice;\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    struct Info {\n        bool registered;\n        address curve; // address(0) for direct-to-market launches\n        address creator;\n        uint16 creatorBps; // splits of the 0.8% creator allocation, sum 10_000\n        uint16 holdersBps;\n        uint16 burnBps;\n        uint64 lastSettle;\n        uint256 pendingQuote; // holders + burn currency waiting to be swapped\n        uint256 accPerToken; // 1e18-scaled reward tokens per eligible token\n        uint256 rewardBalance; // reward tokens held here for holders\n        uint256 trackedSupply; // supply held by reward-eligible addresses\n        uint256 totalBurned;\n        uint256 totalRewarded;\n    }\n\n    mapping(address => Info) public info;\n    mapping(address => mapping(address => bool)) public excluded;\n    mapping(address => mapping(address => uint256)) public userAcc;\n    mapping(address => mapping(address => uint256)) public pendingOf;\n\n    event Registered(address indexed token, address curve, address creator, uint16 creatorBps, uint16 holdersBps, uint16 burnBps);\n    event Deposited(address indexed token, uint256 amount);\n    event Settled(address indexed token, uint256 pot, uint256 keeperFee, uint256 tokensOut, uint256 burned, uint256 rewarded);\n    event Claimed(address indexed token, address indexed holder, uint256 amount);\n    event ExcludedSet(address indexed token, address indexed account, bool value);\n    event FactoryUpdated(address factory);\n    event QuoterUpdated(address quoter);\n    event SlippageUpdated(uint16 bps);\n    event DriftUpdated(uint16 bps);\n    event MaxSettleSpendUpdated(uint256 amount);\n    event RefPriceSet(address indexed token, uint256 tokensPerEther);\n    event PlatformUpdated(address platform);\n\n    modifier onlyPlatform() {\n        require(msg.sender == platform, \"NOT_PLATFORM\");\n        _;\n    }\n\n    constructor(address _platform, address _router, address _poolManager, address _quoter) {\n        require(_platform != address(0), \"ZERO_PLATFORM\");\n        platform = _platform;\n        router = IUniversalRouter(_router);\n        poolManager = _poolManager;\n        quoter = _quoter;\n    }\n\n    function setFactory(address _factory) external onlyPlatform {\n        factory = _factory;\n        emit FactoryUpdated(_factory);\n    }\n\n    function setQuoter(address _quoter) external onlyPlatform {\n        quoter = _quoter;\n        emit QuoterUpdated(_quoter);\n    }\n\n    function setMaxSlippageBps(uint16 bps) external onlyPlatform {\n        require(bps <= 2_000, \"TOO_LOOSE\");\n        maxSlippageBps = bps;\n        emit SlippageUpdated(bps);\n    }\n\n    function setMaxSettleSpend(uint256 amount) external onlyPlatform {\n        require(amount > 0, \"ZERO\");\n        maxSettleSpend = amount;\n        emit MaxSettleSpendUpdated(amount);\n    }\n\n    function setMaxDriftBps(uint16 bps) external onlyPlatform {\n        require(bps <= 5_000, \"TOO_LOOSE\");\n        maxDriftBps = bps;\n        emit DriftUpdated(bps);\n    }\n\n    /// @notice Reset the reference price when the market has genuinely moved further than\n    ///         `maxDriftBps` in a day and settlement is stuck. Cannot move funds.\n    function setRefPrice(address token, uint256 tokensPerEther) external onlyPlatform {\n        refPrice[token] = tokensPerEther;\n        emit RefPriceSet(token, tokensPerEther);\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    // ---------------------------------------------------------------- registry\n\n    /// @notice Called by the factory right after the vanity token is deployed and\n    ///         before any supply is moved, so holder tracking starts from zero.\n    function register(\n        address token,\n        address curve,\n        address creator,\n        uint16 creatorBps,\n        uint16 holdersBps,\n        uint16 burnBps,\n        address[] calldata excludedAccounts\n    ) external {\n        require(msg.sender == factory && factory != address(0), \"NOT_FACTORY\");\n        Info storage i = info[token];\n        require(!i.registered, \"REGISTERED\");\n        require(uint256(creatorBps) + holdersBps + burnBps == 10_000, \"SPLIT\");\n\n        i.registered = true;\n        i.curve = curve;\n        i.creator = creator;\n        i.creatorBps = creatorBps;\n        i.holdersBps = holdersBps;\n        i.burnBps = burnBps;\n        i.lastSettle = uint64(block.timestamp);\n\n        excluded[token][address(this)] = true;\n        excluded[token][DEAD] = true;\n        excluded[token][poolManager] = true;\n        excluded[token][msg.sender] = true;\n        if (curve != address(0)) excluded[token][curve] = true;\n        for (uint256 n = 0; n < excludedAccounts.length; n++) {\n            excluded[token][excludedAccounts[n]] = true;\n        }\n\n        emit Registered(token, curve, creator, creatorBps, holdersBps, burnBps);\n    }\n\n    /// @notice Splits of the 0.8% creator allocation, read by the curve and direct launcher.\n    function splits(address token) external view returns (uint16 creatorBps, uint16 holdersBps, uint16 burnBps) {\n        Info storage i = info[token];\n        return (i.creatorBps, i.holdersBps, i.burnBps);\n    }\n\n    /// @dev Only contracts (pools, routers, launch infrastructure) can be excluded, so the\n    ///      platform can never cut an ordinary holder out of a distribution.\n    function setExcluded(address token, address account, bool value) external onlyPlatform {\n        require(account.code.length > 0, \"EOA\");\n        Info storage i = info[token];\n        if (excluded[token][account] == value) return;\n        uint256 bal = IERC20Min(token).balanceOf(account);\n        if (value) {\n            _sync(token, account);\n            if (bal > 0 && i.trackedSupply >= bal) i.trackedSupply -= bal;\n        } else {\n            userAcc[token][account] = i.accPerToken;\n            i.trackedSupply += bal;\n        }\n        excluded[token][account] = value;\n        emit ExcludedSet(token, account, value);\n    }\n\n    // ---------------------------------------------------------------- accrual\n\n    /// @notice Called by LazyToken before every balance change.\n    function onTransfer(address from, address to, uint256 value) external {\n        Info storage i = info[msg.sender];\n        if (!i.registered) return;\n        address token = msg.sender;\n\n        _sync(token, from);\n        _sync(token, to);\n\n        bool ef = excluded[token][from];\n        bool et = excluded[token][to];\n        if (ef && !et) {\n            i.trackedSupply += value;\n        } else if (!ef && et) {\n            i.trackedSupply = i.trackedSupply >= value ? i.trackedSupply - value : 0;\n        }\n    }\n\n    function _sync(address token, address account) internal {\n        Info storage i = info[token];\n        uint256 acc = i.accPerToken;\n        if (!excluded[token][account]) {\n            uint256 bal = IERC20Min(token).balanceOf(account);\n            uint256 last = userAcc[token][account];\n            if (bal > 0 && acc > last) {\n                pendingOf[token][account] += (bal * (acc - last)) / ACC_PRECISION;\n            }\n        }\n        userAcc[token][account] = acc;\n    }\n\n    /// @notice Reward tokens claimable by `account`, including unsynced accrual.\n    function pendingRewards(address token, address account) external view returns (uint256) {\n        Info storage i = info[token];\n        uint256 owed = pendingOf[token][account];\n        if (!excluded[token][account]) {\n            uint256 bal = IERC20Min(token).balanceOf(account);\n            uint256 last = userAcc[token][account];\n            if (bal > 0 && i.accPerToken > last) {\n                owed += (bal * (i.accPerToken - last)) / ACC_PRECISION;\n            }\n        }\n        return owed;\n    }\n\n    // ---------------------------------------------------------------- deposits\n\n    /// @notice Currency deposit of the holders + burn slice, from the curve or direct launcher.\n    function deposit(address token) external payable {\n        Info storage i = info[token];\n        require(i.registered, \"UNKNOWN\");\n        if (msg.value == 0) return;\n        i.pendingQuote += msg.value;\n        emit Deposited(token, msg.value);\n    }\n\n    /// @notice Token-denominated deposit (Uniswap LP fees already paid in the token).\n    ///         Credited immediately — no swap and no waiting period needed.\n    function depositTokens(address token, uint256 amount) external nonReentrant {\n        Info storage i = info[token];\n        require(i.registered, \"UNKNOWN\");\n        if (amount == 0) return;\n        require(IERC20Min(token).balanceOf(address(this)) >= amount + i.rewardBalance, \"NOT_FUNDED\");\n        _distribute(token, amount);\n    }\n\n    // ---------------------------------------------------------------- settlement\n\n    function settleDue(address token) public view returns (bool) {\n        Info storage i = info[token];\n        return i.registered && i.pendingQuote > 0 && block.timestamp >= uint256(i.lastSettle) + SETTLE_INTERVAL;\n    }\n\n    function nextSettleAt(address token) external view returns (uint256) {\n        return uint256(info[token].lastSettle) + SETTLE_INTERVAL;\n    }\n\n    /// @notice Permissionless. Swaps the pooled currency into the token, burns the burn\n    ///         share and credits the holder share. Caller keeps a 1% keeper fee.\n    function settle(address token) public returns (uint256 tokensOut) {\n        return settle(token, 0);\n    }\n\n    /// @param minTokensOut caller-supplied floor. The vault always enforces its own\n    ///        quote-based floor too, so the swap can never be sandwiched for free.\n    function settle(address token, uint256 minTokensOut) public nonReentrant returns (uint256 tokensOut) {\n        require(settleDue(token), \"NOT_DUE\");\n        Info storage i = info[token];\n\n        uint256 pot = i.pendingQuote;\n        // Cap how much a single settlement swaps; the remainder stays pending and\n        // settles on a later day. Bounds the value any price manipulation can touch.\n        uint256 cap = maxSettleSpend;\n        if (pot > cap) pot = cap;\n        i.pendingQuote -= pot;\n        i.lastSettle = uint64(block.timestamp);\n\n        uint256 keeperFee = (pot * KEEPER_BPS) / 10_000;\n        uint256 spend = pot - keeperFee;\n        require(spend <= type(uint128).max, \"OVERFLOW\");\n\n        uint256 floor = _minOut(token, i.curve, spend);\n        if (floor > minTokensOut) minTokensOut = floor;\n\n        uint256 before = IERC20Min(token).balanceOf(address(this));\n        _swapToToken(token, i.curve, spend, minTokensOut);\n        tokensOut = IERC20Min(token).balanceOf(address(this)) - before;\n        require(tokensOut >= minTokensOut && tokensOut > 0, \"SLIPPAGE\");\n\n        // Smooth the reference instead of jumping to the last print, so a single\n        // manipulated settlement cannot move the floor that guards the next one.\n        uint256 printed = (tokensOut * 1e18) / spend;\n        uint256 prev = refPrice[token];\n        refPrice[token] = prev == 0 ? printed : (prev * 3 + printed) / 4;\n\n        (uint256 burned, uint256 rewarded) = _distribute(token, tokensOut);\n\n        if (keeperFee > 0) {\n            (bool ok, ) = msg.sender.call{value: keeperFee}(\"\");\n            require(ok, \"KEEPER\");\n        }\n\n        emit Settled(token, pot, keeperFee, tokensOut, burned, rewarded);\n    }\n\n    function _distribute(address token, uint256 amount) internal returns (uint256 burned, uint256 rewarded) {\n        Info storage i = info[token];\n        if (amount == 0) return (0, 0);\n\n        uint256 share = uint256(i.holdersBps) + uint256(i.burnBps);\n        rewarded = share == 0 ? 0 : (amount * i.holdersBps) / share;\n        burned = amount - rewarded;\n\n        // Nobody eligible yet — burn the holder share rather than stranding it.\n        if (rewarded > 0 && i.trackedSupply == 0) {\n            burned += rewarded;\n            rewarded = 0;\n        }\n\n        if (burned > 0) {\n            i.totalBurned += burned;\n            require(IERC20Min(token).transfer(DEAD, burned), \"BURN\");\n        }\n        if (rewarded > 0) {\n            i.accPerToken += (rewarded * ACC_PRECISION) / i.trackedSupply;\n            i.rewardBalance += rewarded;\n            i.totalRewarded += rewarded;\n        }\n    }\n\n    /// @notice Price floor for the settlement swap: the live quote minus `maxSlippageBps`.\n    ///         Curve launches price exactly; pool launches use the Uniswap v4 quoter.\n    function _minOut(address token, address curve, uint256 spend) internal returns (uint256) {\n        if (spend == 0) return 0;\n\n        uint256 expected;\n        if (curve != address(0) && !ICurveMin(curve).graduated()) {\n            (expected, ) = ICurveMin(curve).quoteBuy(spend);\n        } else {\n            require(quoter != address(0), \"NO_QUOTER\");\n            IV4Quoter.QuoteExactSingleParams memory q = IV4Quoter.QuoteExactSingleParams({\n                poolKey: PoolKey({\n                    currency0: address(0),\n                    currency1: token,\n                    fee: POOL_FEE,\n                    tickSpacing: TICK_SPACING,\n                    hooks: address(0)\n                }),\n                zeroForOne: true,\n                exactAmount: uint128(spend),\n                hookData: bytes(\"\")\n            });\n            (expected, ) = IV4Quoter(quoter).quoteExactInputSingle(q);\n        }\n\n        require(expected > 0, \"NO_QUOTE\");\n        uint256 floor = (expected * (10_000 - maxSlippageBps)) / 10_000;\n\n        // The live quote alone is manipulable in the same transaction, so never accept a\n        // price more than `maxDriftBps` below the last settled one. If the market really\n        // moved that far, settlement waits until the platform resets the reference.\n        uint256 ref = refPrice[token];\n        if (ref > 0) {\n            uint256 refFloor = (((spend * ref) / 1e18) * (10_000 - maxDriftBps)) / 10_000;\n            if (refFloor > floor) floor = refFloor;\n        }\n        return floor;\n    }\n\n    function _swapToToken(address token, address curve, uint256 spend, uint256 minTokensOut) internal {\n        if (spend == 0) return;\n\n        if (curve != address(0) && !ICurveMin(curve).graduated()) {\n            ICurveMin(curve).buy{value: spend}(minTokensOut);\n            return;\n        }\n\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        bytes memory actions = abi.encodePacked(uint8(0x06), uint8(0x0c), uint8(0x0f)); // SWAP_EXACT_IN_SINGLE, SETTLE_ALL, TAKE_ALL\n        bytes[] memory params = new bytes[](3);\n        params[0] = abi.encode(\n            ExactInputSingleParams({\n                poolKey: key,\n                zeroForOne: true,\n                amountIn: uint128(spend),\n                amountOutMinimum: uint128(minTokensOut),\n                hookData: bytes(\"\")\n            })\n        );\n        params[1] = abi.encode(key.currency0, spend);\n        params[2] = abi.encode(key.currency1, uint256(0));\n\n        bytes memory commands = abi.encodePacked(uint8(0x10)); // V4_SWAP\n        bytes[] memory inputs = new bytes[](1);\n        inputs[0] = abi.encode(actions, params);\n\n        router.execute{value: spend}(commands, inputs, block.timestamp + 600);\n    }\n\n    // ---------------------------------------------------------------- claiming\n\n    /// @notice Settles first when the 24h window is open, then pays the caller's rewards.\n    function claim(address token) external returns (uint256 amount) {\n        // settle() takes the reentrancy lock itself, so it runs before the payout below.\n        if (settleDue(token)) settle(token, 0);\n        _sync(token, msg.sender);\n\n        amount = pendingOf[token][msg.sender];\n        if (amount == 0) return 0;\n\n        Info storage i = info[token];\n        // Guard against rounding drift so the last claimer can never be blocked.\n        uint256 held = IERC20Min(token).balanceOf(address(this));\n        if (amount > held) amount = held;\n        if (amount == 0) return 0;\n        pendingOf[token][msg.sender] = 0;\n        i.rewardBalance = i.rewardBalance >= amount ? i.rewardBalance - amount : 0;\n        require(IERC20Min(token).transfer(msg.sender, amount), \"PAY\");\n        emit Claimed(token, msg.sender, amount);\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":{}}}
