From 2c2938a5fc6220f40da584a01271941a6df324dd Mon Sep 17 00:00:00 2001 From: hazarxyz <258789013+hazarxyz@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:17:39 +0200 Subject: [PATCH 1/2] Publish configurable Classic contracts --- script/DeployClassicV3InfrastructureV1.s.sol | 454 +++++++++++++ src/ClassicCtoAuthorityV1.sol | 67 ++ ...lassicInitialBuyVestingWalletFactoryV1.sol | 128 ++++ src/ClassicInitialBuyVestingWalletV1.sol | 181 +++++ src/ClassicLaunchPolicyV1.sol | 97 +++ src/ClassicRewardVaultFactoryV1.sol | 114 ++++ src/ClassicRewardVaultV1.sol | 318 +++++++++ src/EthCreatorFeeHookFactoryV3.sol | 100 +++ src/EthCreatorFeeHookV3.sol | 512 ++++++++++++++ src/FeeSplitVaultFactoryV1.sol | 74 ++ src/FeeSplitVaultV1.sol | 182 +++++ src/MemeLaunchV2.sol | 643 ++++++++++++++++++ src/interfaces/IClassicCtoVaultV1.sol | 8 + src/interfaces/IClassicFeeHookV3.sol | 13 + test/ClassicInitialBuyVestingWalletV1.t.sol | 187 +++++ test/ClassicLaunchPolicyV1.t.sol | 164 +++++ test/ClassicRewardVaultV1.t.sol | 430 ++++++++++++ test/EthCreatorFeeHookV3.t.sol | 419 ++++++++++++ test/MemeLaunchV2.t.sol | 451 ++++++++++++ .../ClassicRewardVaultV1Invariant.t.sol | 249 +++++++ .../ClassicV3FeeAccountingInvariant.t.sol | 305 +++++++++ 21 files changed, 5096 insertions(+) create mode 100644 script/DeployClassicV3InfrastructureV1.s.sol create mode 100644 src/ClassicCtoAuthorityV1.sol create mode 100644 src/ClassicInitialBuyVestingWalletFactoryV1.sol create mode 100644 src/ClassicInitialBuyVestingWalletV1.sol create mode 100644 src/ClassicLaunchPolicyV1.sol create mode 100644 src/ClassicRewardVaultFactoryV1.sol create mode 100644 src/ClassicRewardVaultV1.sol create mode 100644 src/EthCreatorFeeHookFactoryV3.sol create mode 100644 src/EthCreatorFeeHookV3.sol create mode 100644 src/FeeSplitVaultFactoryV1.sol create mode 100644 src/FeeSplitVaultV1.sol create mode 100644 src/MemeLaunchV2.sol create mode 100644 src/interfaces/IClassicCtoVaultV1.sol create mode 100644 src/interfaces/IClassicFeeHookV3.sol create mode 100644 test/ClassicInitialBuyVestingWalletV1.t.sol create mode 100644 test/ClassicLaunchPolicyV1.t.sol create mode 100644 test/ClassicRewardVaultV1.t.sol create mode 100644 test/EthCreatorFeeHookV3.t.sol create mode 100644 test/MemeLaunchV2.t.sol create mode 100644 test/invariant/ClassicRewardVaultV1Invariant.t.sol create mode 100644 test/invariant/ClassicV3FeeAccountingInvariant.t.sol diff --git a/script/DeployClassicV3InfrastructureV1.s.sol b/script/DeployClassicV3InfrastructureV1.s.sol new file mode 100644 index 00000000..4f830802 --- /dev/null +++ b/script/DeployClassicV3InfrastructureV1.s.sol @@ -0,0 +1,454 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Script } from "forge-std/Script.sol"; + +import { UERC20Factory } from "@uniswap/uerc20-factory/src/factories/UERC20Factory.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { IPositionManager } from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ClassicCtoAuthorityV1 } from "../src/ClassicCtoAuthorityV1.sol"; +import { ClassicInitialBuyVestingWalletFactoryV1 } from "../src/ClassicInitialBuyVestingWalletFactoryV1.sol"; +import { ClassicLaunchPolicyV1 } from "../src/ClassicLaunchPolicyV1.sol"; +import { ClassicRewardVaultFactoryV1 } from "../src/ClassicRewardVaultFactoryV1.sol"; +import { EthCreatorFeeHookFactoryV3 } from "../src/EthCreatorFeeHookFactoryV3.sol"; +import { EthCreatorFeeHookV3 } from "../src/EthCreatorFeeHookV3.sol"; +import { FeeSplitVaultFactoryV1 } from "../src/FeeSplitVaultFactoryV1.sol"; +import { LockedPositionFeeForwarderFactoryV1 } from "../src/LockedPositionFeeForwarderFactoryV1.sol"; +import { MemeLaunchV2 } from "../src/MemeLaunchV2.sol"; + +/// @title DeployClassicV3InfrastructureV1 +/// @notice Deterministic, fail-closed deployment path for the configurable Classic stack. +/// @dev Supports Ethereum Mainnet and Sepolia. It never reads a private key and does not broadcast unless Forge +/// receives an explicit `--broadcast` flag. The reviewed sequence is exactly seven transactions. +contract DeployClassicV3InfrastructureV1 is Script { + uint256 internal constant MAINNET_CHAIN_ID = 1; + uint256 internal constant SEPOLIA_CHAIN_ID = 11_155_111; + uint256 internal constant MAX_LAUNCHER_RUNTIME_BYTES = 23_000; + + address public constant LAUNCHER_TREASURY = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address public constant INITIAL_CTO_AUTHORITY = 0x2Bb333d48DFAF1596D9036671d2E43168994249E; + uint160 public constant REQUIRED_HOOK_FLAGS = uint160( + Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG + ); + + struct Dependencies { + address poolManager; + address positionManager; + address stateView; + address v4Quoter; + address uerc20Factory; + address permit2; + address universalRouter; + address positionForwarderFactory; + bytes32 poolManagerCodeHash; + bytes32 positionManagerCodeHash; + bytes32 stateViewCodeHash; + bytes32 v4QuoterCodeHash; + bytes32 uerc20FactoryCodeHash; + bytes32 permit2CodeHash; + bytes32 universalRouterCodeHash; + bytes32 positionForwarderFactoryCodeHash; + } + + struct DeploymentPlan { + uint256 chainId; + address broadcaster; + uint64 startingNonce; + address ctoAuthority; + address rewardVaultFactory; + address initialBuyVestingWalletFactory; + address launchPolicy; + address hookFactory; + address feeHook; + address launcher; + bytes32 hookSalt; + bytes32 sourceCommitment; + } + + struct DeploymentResult { + ClassicCtoAuthorityV1 ctoAuthority; + ClassicRewardVaultFactoryV1 rewardVaultFactory; + ClassicInitialBuyVestingWalletFactoryV1 initialBuyVestingWalletFactory; + ClassicLaunchPolicyV1 launchPolicy; + EthCreatorFeeHookFactoryV3 hookFactory; + EthCreatorFeeHookV3 feeHook; + MemeLaunchV2 launcher; + bytes32 hookSalt; + bytes32 sourceCommitment; + uint64 startingNonce; + } + + error DeploymentAddressOccupied(address target); + error InvalidBroadcaster(address broadcaster); + error UnexpectedAddress(bytes32 field, address actual, address expected); + error UnexpectedChain(uint256 actual); + error UnexpectedCodeHash(address target, bytes32 actual, bytes32 expected); + error UnexpectedHookFlags(uint160 actual, uint160 expected); + error UnexpectedNonce(address broadcaster, uint64 actual, uint64 expected); + error UnexpectedTreasury(address actual, address expected); + error UnexpectedValue(bytes32 field, uint256 actual, uint256 expected); + + /// @notice Simulates or broadcasts the reviewed seven-transaction sequence. + /// @dev Required environment: CLASSIC_V3_DEPLOYER, CLASSIC_V3_START_NONCE and CLASSIC_V3_TREASURY. + function run() external returns (DeploymentResult memory result) { + address broadcaster = vm.envAddress("CLASSIC_V3_DEPLOYER"); + address configuredTreasury = vm.envAddress("CLASSIC_V3_TREASURY"); + uint256 configuredNonce = vm.envUint("CLASSIC_V3_START_NONCE"); + if (configuredNonce > type(uint64).max) { + revert UnexpectedValue(keccak256("startingNonce"), configuredNonce, type(uint64).max); + } + + // The explicit bound above makes this narrowing conversion lossless. + // forge-lint: disable-next-line(unsafe-typecast) + return deployReviewed(broadcaster, uint64(configuredNonce), configuredTreasury); + } + + function deployReviewed(address broadcaster, uint64 startingNonce, address configuredTreasury) + public + returns (DeploymentResult memory result) + { + Dependencies memory dependencies = validateOfficialDependencies(); + if (broadcaster == address(0)) revert InvalidBroadcaster(broadcaster); + if (configuredTreasury != LAUNCHER_TREASURY) { + revert UnexpectedTreasury(configuredTreasury, LAUNCHER_TREASURY); + } + + uint64 actualNonce = vm.getNonce(broadcaster); + if (actualNonce != startingNonce) revert UnexpectedNonce(broadcaster, actualNonce, startingNonce); + + DeploymentPlan memory plan = deploymentPlan(broadcaster, startingNonce); + _assertVacant(plan.ctoAuthority); + _assertVacant(plan.rewardVaultFactory); + _assertVacant(plan.initialBuyVestingWalletFactory); + _assertVacant(plan.launchPolicy); + _assertVacant(plan.hookFactory); + _assertVacant(plan.feeHook); + _assertVacant(plan.launcher); + + vm.startBroadcast(broadcaster); + result.ctoAuthority = new ClassicCtoAuthorityV1(INITIAL_CTO_AUTHORITY); + result.rewardVaultFactory = new ClassicRewardVaultFactoryV1(result.ctoAuthority); + result.initialBuyVestingWalletFactory = new ClassicInitialBuyVestingWalletFactoryV1(); + result.launchPolicy = new ClassicLaunchPolicyV1(); + result.hookFactory = new EthCreatorFeeHookFactoryV3(); + result.feeHook = result.hookFactory + .deploy( + plan.hookSalt, + IPoolManager(dependencies.poolManager), + configuredTreasury, + FeeSplitVaultFactoryV1(address(result.rewardVaultFactory)) + ); + result.launcher = new MemeLaunchV2( + IPoolManager(dependencies.poolManager), + IPositionManager(dependencies.positionManager), + UERC20Factory(dependencies.uerc20Factory), + result.feeHook, + result.rewardVaultFactory, + result.initialBuyVestingWalletFactory, + result.launchPolicy, + LockedPositionFeeForwarderFactoryV1(dependencies.positionForwarderFactory) + ); + vm.stopBroadcast(); + + _assertAddress(keccak256("ctoAuthority"), address(result.ctoAuthority), plan.ctoAuthority); + _assertAddress(keccak256("rewardVaultFactory"), address(result.rewardVaultFactory), plan.rewardVaultFactory); + _assertAddress( + keccak256("initialBuyVestingWalletFactory"), + address(result.initialBuyVestingWalletFactory), + plan.initialBuyVestingWalletFactory + ); + _assertAddress(keccak256("launchPolicy"), address(result.launchPolicy), plan.launchPolicy); + _assertAddress(keccak256("hookFactory"), address(result.hookFactory), plan.hookFactory); + _assertAddress(keccak256("feeHook"), address(result.feeHook), plan.feeHook); + _assertAddress(keccak256("launcher"), address(result.launcher), plan.launcher); + + result.hookSalt = plan.hookSalt; + result.sourceCommitment = plan.sourceCommitment; + result.startingNonce = startingNonce; + _validateDeployedStack(result, dependencies); + + uint64 finalNonce = vm.getNonce(broadcaster); + if (finalNonce != startingNonce + 7) { + revert UnexpectedNonce(broadcaster, finalNonce, startingNonce + 7); + } + } + + function deploymentPlan(address broadcaster, uint64 startingNonce) + public + view + returns (DeploymentPlan memory plan) + { + if (broadcaster == address(0)) revert InvalidBroadcaster(broadcaster); + Dependencies memory dependencies = _dependencies(); + + plan.chainId = block.chainid; + plan.broadcaster = broadcaster; + plan.startingNonce = startingNonce; + plan.ctoAuthority = vm.computeCreateAddress(broadcaster, startingNonce); + plan.rewardVaultFactory = vm.computeCreateAddress(broadcaster, uint256(startingNonce) + 1); + plan.initialBuyVestingWalletFactory = vm.computeCreateAddress(broadcaster, uint256(startingNonce) + 2); + plan.launchPolicy = vm.computeCreateAddress(broadcaster, uint256(startingNonce) + 3); + plan.hookFactory = vm.computeCreateAddress(broadcaster, uint256(startingNonce) + 4); + (plan.feeHook, plan.hookSalt) = HookMiner.find( + plan.hookFactory, + REQUIRED_HOOK_FLAGS, + type(EthCreatorFeeHookV3).creationCode, + abi.encode( + IPoolManager(dependencies.poolManager), + LAUNCHER_TREASURY, + FeeSplitVaultFactoryV1(plan.rewardVaultFactory) + ) + ); + plan.launcher = vm.computeCreateAddress(broadcaster, uint256(startingNonce) + 6); + plan.sourceCommitment = deploymentSourceCommitment(); + } + + function predictHook(address hookFactory, address rewardVaultFactory, bytes32 hookSalt) + public + view + returns (address) + { + Dependencies memory dependencies = _dependencies(); + bytes32 initCodeHash = keccak256( + abi.encodePacked( + type(EthCreatorFeeHookV3).creationCode, + abi.encode( + IPoolManager(dependencies.poolManager), + LAUNCHER_TREASURY, + FeeSplitVaultFactoryV1(rewardVaultFactory) + ) + ) + ); + return address(uint160(uint256(keccak256(abi.encodePacked(bytes1(0xff), hookFactory, hookSalt, initCodeHash))))); + } + + /// @notice Checks every official dependency needed for deployment and the signed launch lifecycle. + function validateOfficialDependencies() public view returns (Dependencies memory dependencies) { + dependencies = _dependencies(); + _assertCodeHash(dependencies.poolManager, dependencies.poolManagerCodeHash); + _assertCodeHash(dependencies.positionManager, dependencies.positionManagerCodeHash); + _assertCodeHash(dependencies.stateView, dependencies.stateViewCodeHash); + _assertCodeHash(dependencies.v4Quoter, dependencies.v4QuoterCodeHash); + _assertCodeHash(dependencies.uerc20Factory, dependencies.uerc20FactoryCodeHash); + _assertCodeHash(dependencies.permit2, dependencies.permit2CodeHash); + _assertCodeHash(dependencies.universalRouter, dependencies.universalRouterCodeHash); + _assertCodeHash(dependencies.positionForwarderFactory, dependencies.positionForwarderFactoryCodeHash); + } + + function deploymentSourceCommitment() public view returns (bytes32) { + Dependencies memory dependencies = _dependencies(); + bytes32 bytecodeCommitment = keccak256( + abi.encode( + keccak256(type(ClassicCtoAuthorityV1).creationCode), + keccak256(type(ClassicRewardVaultFactoryV1).creationCode), + keccak256(type(ClassicInitialBuyVestingWalletFactoryV1).creationCode), + keccak256(type(ClassicLaunchPolicyV1).creationCode), + keccak256(type(EthCreatorFeeHookFactoryV3).creationCode), + keccak256(type(EthCreatorFeeHookV3).creationCode), + keccak256(type(MemeLaunchV2).creationCode) + ) + ); + bytes32 dependencyCommitment = keccak256( + abi.encode( + block.chainid, + dependencies.poolManager, + dependencies.positionManager, + dependencies.stateView, + dependencies.v4Quoter, + dependencies.uerc20Factory, + dependencies.permit2, + dependencies.universalRouter, + dependencies.positionForwarderFactory, + LAUNCHER_TREASURY, + INITIAL_CTO_AUTHORITY + ) + ); + bytes32 feeCommitment = keccak256( + abi.encode( + uint256(10), + uint256(100), + uint256(1000), + uint256(100), + uint256(0), + int256(200), + keccak256("immutable-directional-buy-and-sell-fees") + ) + ); + bytes32 rewardCommitment = keccak256( + abi.encode( + uint256(5), + uint256(10_000), + keccak256("beneficiary-owned-historic-rewards"), + keccak256("prospective-payout-wallet-change"), + keccak256("programmable-approved-prospective-cto") + ) + ); + bytes32 launchCommitment = keccak256( + abi.encode( + uint256(0.0006 ether), + uint256(1_000_000_000 ether), + uint256(1), + uint256(3650), + keccak256("unlocked-fixed-lock-linear-and-cliff-linear-initial-buy-custody"), + keccak256("immutable-initial-buy-beneficiary"), + keccak256("one-sided-permanently-locked-official-v4-position") + ) + ); + bytes32 economicsCommitment = keccak256(abi.encode(feeCommitment, rewardCommitment, launchCommitment)); + return keccak256( + abi.encode( + keccak256("programmable.classic.infrastructure.v3.ethereum"), + bytecodeCommitment, + dependencyCommitment, + economicsCommitment + ) + ); + } + + function _validateDeployedStack(DeploymentResult memory result, Dependencies memory dependencies) private view { + _assertCodeHash(address(result.ctoAuthority), keccak256(type(ClassicCtoAuthorityV1).runtimeCode)); + if (address(result.rewardVaultFactory).code.length == 0) { + revert UnexpectedValue(keccak256("rewardVaultFactory.runtimeBytes"), 0, 1); + } + _assertCodeHash( + address(result.initialBuyVestingWalletFactory), + keccak256(type(ClassicInitialBuyVestingWalletFactoryV1).runtimeCode) + ); + _assertCodeHash(address(result.launchPolicy), keccak256(type(ClassicLaunchPolicyV1).runtimeCode)); + _assertCodeHash(address(result.hookFactory), keccak256(type(EthCreatorFeeHookFactoryV3).runtimeCode)); + if (address(result.launcher).code.length > MAX_LAUNCHER_RUNTIME_BYTES) { + revert UnexpectedValue( + keccak256("launcher.runtimeBytes"), address(result.launcher).code.length, MAX_LAUNCHER_RUNTIME_BYTES + ); + } + + _assertAddress(keccak256("hook.poolManager"), address(result.feeHook.poolManager()), dependencies.poolManager); + _assertAddress(keccak256("hook.launcherFeeRecipient"), result.feeHook.launcherFeeRecipient(), LAUNCHER_TREASURY); + _assertAddress( + keccak256("hook.feeSplitVaultFactory"), + address(result.feeHook.feeSplitVaultFactory()), + address(result.rewardVaultFactory) + ); + uint160 actualFlags = uint160(address(result.feeHook)) & result.hookFactory.ALL_HOOK_MASK(); + if (actualFlags != REQUIRED_HOOK_FLAGS) revert UnexpectedHookFlags(actualFlags, REQUIRED_HOOK_FLAGS); + if (!result.hookFactory.isFactoryHook(address(result.feeHook))) { + revert UnexpectedAddress(keccak256("hookFactory.provenance"), address(0), address(result.feeHook)); + } + + _assertAddress( + keccak256("launcher.poolManager"), address(result.launcher.poolManager()), dependencies.poolManager + ); + _assertAddress( + keccak256("launcher.positionManager"), + address(result.launcher.positionManager()), + dependencies.positionManager + ); + _assertAddress( + keccak256("launcher.tokenFactory"), address(result.launcher.tokenFactory()), dependencies.uerc20Factory + ); + _assertAddress(keccak256("launcher.feeHook"), address(result.launcher.feeHook()), address(result.feeHook)); + _assertAddress( + keccak256("launcher.rewardVaultFactory"), + address(result.launcher.rewardVaultFactory()), + address(result.rewardVaultFactory) + ); + _assertAddress( + keccak256("launcher.initialBuyVestingWalletFactory"), + address(result.launcher.initialBuyVestingWalletFactory()), + address(result.initialBuyVestingWalletFactory) + ); + _assertAddress( + keccak256("launcher.launchPolicy"), address(result.launcher.launchPolicy()), address(result.launchPolicy) + ); + _assertAddress( + keccak256("rewardVaultFactory.ctoAuthority"), + address(result.rewardVaultFactory.ctoAuthority()), + address(result.ctoAuthority) + ); + _assertAddress(keccak256("ctoAuthority.authority"), result.ctoAuthority.authority(), INITIAL_CTO_AUTHORITY); + _assertAddress( + keccak256("launcher.positionForwarderFactory"), + address(result.launcher.positionForwarderFactory()), + dependencies.positionForwarderFactory + ); + _assertValue(keccak256("hook.launcherFeeBps"), result.feeHook.LAUNCHER_FEE_BPS(), 10); + _assertValue(keccak256("hook.minimumFeeBps"), result.feeHook.MIN_TOTAL_SWAP_FEE_BPS(), 100); + _assertValue(keccak256("hook.maximumFeeBps"), result.feeHook.MAX_TOTAL_SWAP_FEE_BPS(), 1000); + _assertValue(keccak256("hook.feeStepBps"), result.feeHook.TOTAL_SWAP_FEE_STEP_BPS(), 100); + _assertValue(keccak256("hook.transferTaxBps"), result.feeHook.TRANSFER_TAX_BPS(), 0); + _assertValue(keccak256("hook.lpFeePips"), result.feeHook.LP_FEE_PIPS(), 0); + _assertValue(keccak256("hook.tickSpacing"), uint24(result.feeHook.TICK_SPACING()), 200); + _assertValue(keccak256("launcher.minimumInitialBuyWei"), result.launcher.MIN_INITIAL_BUY_WEI(), 0.0006 ether); + _assertValue( + keccak256("custody.minimumDurationDays"), result.initialBuyVestingWalletFactory.MIN_DURATION_DAYS(), 1 + ); + _assertValue( + keccak256("custody.maximumDurationDays"), result.initialBuyVestingWalletFactory.MAX_DURATION_DAYS(), 3650 + ); + } + + function _dependencies() private view returns (Dependencies memory dependencies) { + if (block.chainid == MAINNET_CHAIN_ID) { + return Dependencies({ + poolManager: 0x000000000004444c5dc75cB358380D2e3dE08A90, + positionManager: 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e, + stateView: 0x7fFE42C4a5DEeA5b0feC41C94C136Cf115597227, + v4Quoter: 0x52F0E24D1c21C8A0cB1e5a5dD6198556BD9E1203, + uerc20Factory: 0x000000e200088D55C39a11F609E5F667729ad49b, + permit2: 0x000000000022D473030F116dDEE9F6B43aC78BA3, + universalRouter: 0xd92A36B0000531EF3063dEd4De20A0783308446C, + positionForwarderFactory: 0x291a9ff1059d225d02B1659430804486404dB507, + poolManagerCodeHash: 0x785f1014552b7ce7d5fb7d0c970ca60edee94fd00425d7ca21609acac7ce1293, + positionManagerCodeHash: 0x77e36c08b19959a30dde46dec9abe6208e371ff2f56884a56fe1e1a53615528b, + stateViewCodeHash: 0xd7947778589cf4aac9a092a4451292a2056380941635ab7006d3c691d8dfd878, + v4QuoterCodeHash: 0x06de58fa119c5deaa7a667fb92d3894e25d9160e62fb82c8d86d43b47eefe441, + uerc20FactoryCodeHash: 0x9f042af1533641f048ced56b55898d9e87b2ccb0ec6854292e2cd8ea733e6aeb, + permit2CodeHash: 0xc67d1657868aa5146eaf24fb879fb1fdec3d2d493b3683a61c9c2f4fb2851131, + universalRouterCodeHash: 0x41ccd905c8e4de29ce9536ff49233b79e3085a0987d490664e703ee1e7b1dc49, + positionForwarderFactoryCodeHash: 0xcefd10b60f990984bb60c98eb53e66048bfd36da9b48200e8535f5ca39d58fb2 + }); + } + if (block.chainid == SEPOLIA_CHAIN_ID) { + return Dependencies({ + poolManager: 0xE03A1074c86CFeDd5C142C4F04F1a1536e203543, + positionManager: 0x429ba70129df741B2Ca2a85BC3A2a3328e5c09b4, + stateView: 0xE1Dd9c3fA50EDB962E442f60DfBc432e24537E4C, + v4Quoter: 0x61B3f2011A92d183C7dbaDBdA940a7555Ccf9227, + uerc20Factory: 0x000000e200088D55C39a11F609E5F667729ad49b, + permit2: 0x000000000022D473030F116dDEE9F6B43aC78BA3, + universalRouter: 0x470FFC67b1feEEC31D16C46AC7545C98716a194c, + positionForwarderFactory: 0xaE3C324B742a7576863A546120c4280b7c9E8448, + poolManagerCodeHash: 0x09930125a49f5b95caf8052991cc14d1240dca8b43f42b899115b86867e4bce1, + positionManagerCodeHash: 0xcffd746f78c2b50aafd19076bbe9c48f14446e5248fc5d76b9b4896610e51aab, + stateViewCodeHash: 0xaaed3db8eb8ebde8014ce4c8a3938496687f4c6374e17a7d735288f6c65ceb9e, + v4QuoterCodeHash: 0xf481a751ac453d40c46d12360b85b05472028c1b113ab63749d69a5f8b0e47d1, + uerc20FactoryCodeHash: 0x9f042af1533641f048ced56b55898d9e87b2ccb0ec6854292e2cd8ea733e6aeb, + permit2CodeHash: 0x96d9f5c3f0fb0423426b7f970186235b7347027f4e5c19c40c412b7d97fc3751, + universalRouterCodeHash: 0x14b733fce7cfcca643ef884ed59d2cb2d23b3fead8692613dcee311d65555caf, + positionForwarderFactoryCodeHash: 0x49e040806b0664b2fa4f41c5abc11241cdb8f847c538c13d6874c32804b74ebc + }); + } + revert UnexpectedChain(block.chainid); + } + + function _assertVacant(address target) private view { + if (target.code.length != 0) revert DeploymentAddressOccupied(target); + } + + function _assertCodeHash(address target, bytes32 expected) private view { + bytes32 actual = target.codehash; + if (actual != expected) revert UnexpectedCodeHash(target, actual, expected); + } + + function _assertAddress(bytes32 field, address actual, address expected) private pure { + if (actual != expected) revert UnexpectedAddress(field, actual, expected); + } + + function _assertValue(bytes32 field, uint256 actual, uint256 expected) private pure { + if (actual != expected) revert UnexpectedValue(field, actual, expected); + } +} diff --git a/src/ClassicCtoAuthorityV1.sol b/src/ClassicCtoAuthorityV1.sol new file mode 100644 index 00000000..2b7f4676 --- /dev/null +++ b/src/ClassicCtoAuthorityV1.sol @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; + +import { IClassicCtoVaultV1 } from "./interfaces/IClassicCtoVaultV1.sol"; + +/// @title ClassicCtoAuthorityV1 +/// @notice Executes disclosed Community Takeover reward changes and supports a two-step authority transfer. +/// @dev The authority can replace only a vault's future creator-reward configuration. Each vault checkpoints all +/// rewards accrued before accepting the replacement. +contract ClassicCtoAuthorityV1 is ReentrancyGuardTransient { + address public authority; + address public pendingAuthority; + + error InvalidAuthority(address authority); + error InvalidVault(address vault); + error UnauthorizedAuthority(address caller, address expected); + error UnauthorizedPendingAuthority(address caller, address expected); + + event AuthorityTransferProposed(address indexed authority, address indexed pendingAuthority); + event AuthorityTransferred(address indexed previousAuthority, address indexed newAuthority); + event CtoExecuted(address indexed vault, bytes32 indexed approvalReference, address indexed authority); + + constructor(address initialAuthority) { + if (initialAuthority == address(0)) revert InvalidAuthority(initialAuthority); + authority = initialAuthority; + emit AuthorityTransferred(address(0), initialAuthority); + } + + function proposeAuthority(address newAuthority) external { + address currentAuthority = authority; + if (msg.sender != currentAuthority) revert UnauthorizedAuthority(msg.sender, currentAuthority); + if (newAuthority == address(0)) revert InvalidAuthority(newAuthority); + + pendingAuthority = newAuthority; + emit AuthorityTransferProposed(currentAuthority, newAuthority); + } + + function acceptAuthority() external { + address proposedAuthority = pendingAuthority; + if (msg.sender != proposedAuthority) { + revert UnauthorizedPendingAuthority(msg.sender, proposedAuthority); + } + + address previousAuthority = authority; + authority = proposedAuthority; + pendingAuthority = address(0); + emit AuthorityTransferred(previousAuthority, proposedAuthority); + } + + function executeCto( + IClassicCtoVaultV1 vault, + address[] calldata beneficiaries, + uint16[] calldata sharesBps, + bytes32 approvalReference + ) external nonReentrant { + address currentAuthority = authority; + if (msg.sender != currentAuthority) revert UnauthorizedAuthority(msg.sender, currentAuthority); + if (address(vault).code.length == 0) revert InvalidVault(address(vault)); + + vault.executeCto(beneficiaries, sharesBps, approvalReference); + // The transient guard covers the complete call; Slither does not model it. + // slither-disable-next-line reentrancy-events + emit CtoExecuted(address(vault), approvalReference, currentAuthority); + } +} diff --git a/src/ClassicInitialBuyVestingWalletFactoryV1.sol b/src/ClassicInitialBuyVestingWalletFactoryV1.sol new file mode 100644 index 00000000..337c1e04 --- /dev/null +++ b/src/ClassicInitialBuyVestingWalletFactoryV1.sol @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { + ClassicInitialBuyCustodyConfig, + ClassicInitialBuyCustodyMode, + ClassicInitialBuyScheduleV1, + ClassicInitialBuyVestingWalletV1 +} from "./ClassicInitialBuyVestingWalletV1.sol"; + +/// @title ClassicInitialBuyVestingWalletFactoryV1 +/// @notice Deterministically deploys authenticated, non-transferable Initial Buy custody wallets. +contract ClassicInitialBuyVestingWalletFactoryV1 { + uint16 public constant MIN_DURATION_DAYS = 1; + uint16 public constant MAX_DURATION_DAYS = 3650; + + mapping(address wallet => bytes32 configurationHash) public configurationHashOf; + + error CustodyNotRequired(); + error DeploymentAddressMismatch(address actual, address predicted); + error UnrecognizedFactoryDeployment(address deployment); + error WalletAlreadyDeployed(address wallet); + + event ClassicInitialBuyVestingWalletDeployed( + address indexed wallet, + address indexed token, + address indexed beneficiary, + bytes32 salt, + bytes32 configurationHash + ); + + function validateConfig(ClassicInitialBuyCustodyConfig memory config) public pure { + ClassicInitialBuyScheduleV1.validate(config); + } + + function deploy( + bytes32 salt, + IERC20 token, + address beneficiary, + uint64 launchTimestamp, + ClassicInitialBuyCustodyConfig memory config + ) external returns (ClassicInitialBuyVestingWalletV1 wallet) { + if (config.mode == ClassicInitialBuyCustodyMode.Unlocked) { + revert CustodyNotRequired(); + } + bytes memory code = initCode(token, beneficiary, launchTimestamp, config); + address predicted = Create2.computeAddress(salt, keccak256(code)); + if (predicted.code.length != 0) revert WalletAlreadyDeployed(predicted); + + address deployed = Create2.deploy(0, salt, code); + if (deployed != predicted) revert DeploymentAddressMismatch(deployed, predicted); + wallet = ClassicInitialBuyVestingWalletV1(payable(deployed)); + + bytes32 configurationHash = wallet.configurationHash(); + configurationHashOf[deployed] = configurationHash; + emit ClassicInitialBuyVestingWalletDeployed(deployed, address(token), beneficiary, salt, configurationHash); + } + + /// @notice Deploys the configured custody or returns the same authenticated counterfactual wallet if it exists. + /// @dev This makes a launch resistant to third parties predeploying its publicly predictable CREATE2 custody. + function deployOrGet( + bytes32 salt, + IERC20 token, + address beneficiary, + uint64 launchTimestamp, + ClassicInitialBuyCustodyConfig memory config + ) external returns (ClassicInitialBuyVestingWalletV1 wallet) { + if (config.mode == ClassicInitialBuyCustodyMode.Unlocked) { + revert CustodyNotRequired(); + } + address predicted = Create2.computeAddress(salt, initCodeHash(token, beneficiary, launchTimestamp, config)); + if (predicted.code.length == 0) { + bytes memory code = initCode(token, beneficiary, launchTimestamp, config); + address deployed = Create2.deploy(0, salt, code); + if (deployed != predicted) revert DeploymentAddressMismatch(deployed, predicted); + wallet = ClassicInitialBuyVestingWalletV1(payable(deployed)); + + bytes32 configurationHash = wallet.configurationHash(); + configurationHashOf[deployed] = configurationHash; + emit ClassicInitialBuyVestingWalletDeployed(deployed, address(token), beneficiary, salt, configurationHash); + return wallet; + } + if (configurationHashOf[predicted] == bytes32(0)) revert UnrecognizedFactoryDeployment(predicted); + return ClassicInitialBuyVestingWalletV1(payable(predicted)); + } + + function predict( + bytes32 salt, + IERC20 token, + address beneficiary, + uint64 launchTimestamp, + ClassicInitialBuyCustodyConfig memory config + ) external view returns (address) { + if (config.mode == ClassicInitialBuyCustodyMode.Unlocked) revert CustodyNotRequired(); + return Create2.computeAddress(salt, initCodeHash(token, beneficiary, launchTimestamp, config)); + } + + function initCode( + IERC20 token, + address beneficiary, + uint64 launchTimestamp, + ClassicInitialBuyCustodyConfig memory config + ) public pure returns (bytes memory) { + ClassicInitialBuyScheduleV1.validate(config); + if (config.mode == ClassicInitialBuyCustodyMode.Unlocked) revert CustodyNotRequired(); + // Slither mistakes the creation-code expression for a long numeric literal. + // slither-disable-next-line too-many-digits + return abi.encodePacked( + type(ClassicInitialBuyVestingWalletV1).creationCode, abi.encode(token, beneficiary, launchTimestamp, config) + ); + } + + function initCodeHash( + IERC20 token, + address beneficiary, + uint64 launchTimestamp, + ClassicInitialBuyCustodyConfig memory config + ) public pure returns (bytes32) { + return keccak256(initCode(token, beneficiary, launchTimestamp, config)); + } + + function isFactoryWallet(address wallet) external view returns (bool) { + return configurationHashOf[wallet] != bytes32(0); + } +} diff --git a/src/ClassicInitialBuyVestingWalletV1.sol b/src/ClassicInitialBuyVestingWalletV1.sol new file mode 100644 index 00000000..5846cadc --- /dev/null +++ b/src/ClassicInitialBuyVestingWalletV1.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { VestingWallet } from "@openzeppelin/contracts/finance/VestingWallet.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; + +enum ClassicInitialBuyCustodyMode { + Unlocked, + FixedLock, + LinearVesting, + CliffLinearVesting +} + +struct ClassicInitialBuyCustodyConfig { + ClassicInitialBuyCustodyMode mode; + uint16 durationDays; + uint16 cliffDays; +} + +library ClassicInitialBuyScheduleV1 { + using SafeCast for uint256; + + uint16 internal constant MIN_DURATION_DAYS = 1; + uint16 internal constant MAX_DURATION_DAYS = 3650; + + error CliffMustLeaveFullVestingDay(uint16 durationDays, uint16 cliffDays); + error InvalidCliffDays(ClassicInitialBuyCustodyMode mode, uint16 cliffDays); + error InvalidDurationDays(uint16 durationDays); + error InvalidUnlockedSchedule(uint16 durationDays, uint16 cliffDays); + + function validate(ClassicInitialBuyCustodyConfig memory config) internal pure { + if (config.mode == ClassicInitialBuyCustodyMode.Unlocked) { + if (config.durationDays != 0 || config.cliffDays != 0) { + revert InvalidUnlockedSchedule(config.durationDays, config.cliffDays); + } + return; + } + + if (config.durationDays < MIN_DURATION_DAYS || config.durationDays > MAX_DURATION_DAYS) { + revert InvalidDurationDays(config.durationDays); + } + + if (config.mode == ClassicInitialBuyCustodyMode.CliffLinearVesting) { + if (config.cliffDays < MIN_DURATION_DAYS || config.cliffDays >= config.durationDays) { + revert InvalidCliffDays(config.mode, config.cliffDays); + } + if (config.durationDays - config.cliffDays < MIN_DURATION_DAYS) { + revert CliffMustLeaveFullVestingDay(config.durationDays, config.cliffDays); + } + return; + } + + if (config.cliffDays != 0) revert InvalidCliffDays(config.mode, config.cliffDays); + } + + function vestingStart(uint64 launchTimestamp, ClassicInitialBuyCustodyConfig memory config) + internal + pure + returns (uint64) + { + validate(config); + uint256 offsetDays = config.mode == ClassicInitialBuyCustodyMode.LinearVesting ? 0 : config.durationDays; + if (config.mode == ClassicInitialBuyCustodyMode.CliffLinearVesting) { + offsetDays = config.cliffDays; + } + return (uint256(launchTimestamp) + offsetDays * 1 days).toUint64(); + } + + function vestingDuration(ClassicInitialBuyCustodyConfig memory config) internal pure returns (uint64) { + validate(config); + if (config.mode == ClassicInitialBuyCustodyMode.FixedLock) return 0; + if (config.mode == ClassicInitialBuyCustodyMode.LinearVesting) { + return (uint256(config.durationDays) * 1 days).toUint64(); + } + if (config.mode == ClassicInitialBuyCustodyMode.CliffLinearVesting) { + return (uint256(config.durationDays - config.cliffDays) * 1 days).toUint64(); + } + return 0; + } + + function cliffTimestamp(uint64 launchTimestamp, ClassicInitialBuyCustodyConfig memory config) + internal + pure + returns (uint64) + { + validate(config); + if (config.mode == ClassicInitialBuyCustodyMode.FixedLock) { + return (uint256(launchTimestamp) + uint256(config.durationDays) * 1 days).toUint64(); + } + if (config.mode == ClassicInitialBuyCustodyMode.CliffLinearVesting) { + return (uint256(launchTimestamp) + uint256(config.cliffDays) * 1 days).toUint64(); + } + return launchTimestamp; + } + + function releaseTimestamp(uint64 launchTimestamp, ClassicInitialBuyCustodyConfig memory config) + internal + pure + returns (uint64) + { + validate(config); + return (uint256(launchTimestamp) + uint256(config.durationDays) * 1 days).toUint64(); + } +} + +/// @title ClassicInitialBuyVestingWalletV1 +/// @notice Holds one Classic launch's Initial Buy tokens for an immutable launch-wallet beneficiary. +/// @dev Uses OpenZeppelin VestingWallet for token accounting. Fixed lock uses a zero-duration schedule beginning on the +/// release day. Cliff plus linear vesting begins at zero on the cliff day and reaches 100% on the final day. +contract ClassicInitialBuyVestingWalletV1 is VestingWallet { + IERC20 public immutable initialBuyToken; + ClassicInitialBuyCustodyMode public immutable custodyMode; + uint64 public immutable launchTimestamp; + uint64 public immutable cliffTimestamp; + uint64 public immutable releaseTimestamp; + uint16 public immutable durationDays; + uint16 public immutable cliffDays; + bytes32 public immutable configurationHash; + + error CustodyNotRequired(); + error ImmutableBeneficiary(); + error InvalidInitialBuyToken(address token); + + constructor( + IERC20 initialBuyToken_, + address beneficiary_, + uint64 launchTimestamp_, + ClassicInitialBuyCustodyConfig memory config_ + ) + VestingWallet( + beneficiary_, + ClassicInitialBuyScheduleV1.vestingStart(launchTimestamp_, config_), + ClassicInitialBuyScheduleV1.vestingDuration(config_) + ) + { + if (config_.mode == ClassicInitialBuyCustodyMode.Unlocked) revert CustodyNotRequired(); + address tokenAddress = address(initialBuyToken_); + if (tokenAddress == address(0) || tokenAddress.code.length == 0) { + revert InvalidInitialBuyToken(tokenAddress); + } + + initialBuyToken = initialBuyToken_; + custodyMode = config_.mode; + launchTimestamp = launchTimestamp_; + cliffTimestamp = ClassicInitialBuyScheduleV1.cliffTimestamp(launchTimestamp_, config_); + releaseTimestamp = ClassicInitialBuyScheduleV1.releaseTimestamp(launchTimestamp_, config_); + durationDays = config_.durationDays; + cliffDays = config_.cliffDays; + configurationHash = keccak256( + abi.encode( + block.chainid, + address(this), + tokenAddress, + beneficiary_, + config_.mode, + launchTimestamp_, + config_.durationDays, + config_.cliffDays + ) + ); + } + + /// @notice Only the immutable beneficiary can release vested native currency. + function release() public override onlyOwner { + super.release(); + } + + /// @notice Only the immutable beneficiary can release vested ERC-20 tokens. + function release(address token) public override onlyOwner { + super.release(token); + } + + function transferOwnership(address) public pure override { + revert ImmutableBeneficiary(); + } + + function renounceOwnership() public pure override { + revert ImmutableBeneficiary(); + } +} diff --git a/src/ClassicLaunchPolicyV1.sol b/src/ClassicLaunchPolicyV1.sol new file mode 100644 index 00000000..420356c8 --- /dev/null +++ b/src/ClassicLaunchPolicyV1.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { UERC20Metadata } from "@uniswap/uerc20-factory/src/libraries/UERC20MetadataLibrary.sol"; + +/// @title ClassicLaunchPolicyV1 +/// @notice Immutable input policy shared by every launch through the configurable Classic launcher. +/// @dev Keeping bounded metadata and reward-allocation validation in a dedicated contract leaves the launcher below +/// Ethereum's runtime bytecode limit without weakening atomic launch validation. +contract ClassicLaunchPolicyV1 { + uint256 public constant MAX_TOKEN_NAME_BYTES = 48; + uint256 public constant MAX_TOKEN_SYMBOL_BYTES = 12; + uint256 public constant MAX_TOKEN_DESCRIPTION_BYTES = 280; + uint256 public constant MAX_METADATA_URL_BYTES = 2048; + uint256 public constant MAX_SOCIAL_EXTRA_DATA_BYTES = 1200; + uint256 public constant MAX_REWARD_BENEFICIARIES = 5; + uint16 public constant REWARD_SHARE_BASIS_POINTS = 10_000; + + error DuplicateRewardBeneficiary(address beneficiary); + error EmptyName(); + error EmptySymbol(); + error InvalidBeneficiaryCount(uint256 count); + error InvalidRewardBeneficiary(address beneficiary); + error InvalidRewardShare(address beneficiary, uint16 shareBps); + error InvalidRewardShareTotal(uint256 totalShareBps); + error MetadataExtraDataTooLong(uint256 actualBytes, uint256 maximumBytes); + error MetadataImageTooLong(uint256 actualBytes, uint256 maximumBytes); + error MetadataWebsiteTooLong(uint256 actualBytes, uint256 maximumBytes); + error TokenDescriptionTooLong(uint256 actualBytes, uint256 maximumBytes); + error TokenNameTooLong(uint256 actualBytes, uint256 maximumBytes); + error TokenSymbolTooLong(uint256 actualBytes, uint256 maximumBytes); + + function validate( + string calldata name, + string calldata symbol, + UERC20Metadata calldata metadata, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external pure { + _validateMetadata(name, symbol, metadata); + _validateRewardConfiguration(beneficiaries, sharesBps); + } + + function _validateMetadata(string calldata name, string calldata symbol, UERC20Metadata calldata metadata) + private + pure + { + uint256 nameBytes = bytes(name).length; + uint256 symbolBytes = bytes(symbol).length; + uint256 descriptionBytes = bytes(metadata.description).length; + uint256 websiteBytes = bytes(metadata.website).length; + uint256 imageBytes = bytes(metadata.image).length; + uint256 extraDataBytes = metadata.extraData.length; + + if (nameBytes == 0) revert EmptyName(); + if (symbolBytes == 0) revert EmptySymbol(); + if (nameBytes > MAX_TOKEN_NAME_BYTES) revert TokenNameTooLong(nameBytes, MAX_TOKEN_NAME_BYTES); + if (symbolBytes > MAX_TOKEN_SYMBOL_BYTES) { + revert TokenSymbolTooLong(symbolBytes, MAX_TOKEN_SYMBOL_BYTES); + } + if (descriptionBytes > MAX_TOKEN_DESCRIPTION_BYTES) { + revert TokenDescriptionTooLong(descriptionBytes, MAX_TOKEN_DESCRIPTION_BYTES); + } + if (websiteBytes > MAX_METADATA_URL_BYTES) { + revert MetadataWebsiteTooLong(websiteBytes, MAX_METADATA_URL_BYTES); + } + if (imageBytes > MAX_METADATA_URL_BYTES) { + revert MetadataImageTooLong(imageBytes, MAX_METADATA_URL_BYTES); + } + if (extraDataBytes > MAX_SOCIAL_EXTRA_DATA_BYTES) { + revert MetadataExtraDataTooLong(extraDataBytes, MAX_SOCIAL_EXTRA_DATA_BYTES); + } + } + + function _validateRewardConfiguration(address[] calldata beneficiaries, uint16[] calldata sharesBps) private pure { + uint256 count = beneficiaries.length; + if (count == 0 || count > MAX_REWARD_BENEFICIARIES || sharesBps.length != count) { + revert InvalidBeneficiaryCount(count); + } + uint256 totalShareBps = 0; + for (uint256 index; index < count; index++) { + address beneficiary = beneficiaries[index]; + uint16 shareBps = sharesBps[index]; + if (beneficiary == address(0)) revert InvalidRewardBeneficiary(beneficiary); + if (shareBps == 0) revert InvalidRewardShare(beneficiary, shareBps); + for (uint256 prior; prior < index; prior++) { + if (beneficiaries[prior] == beneficiary) { + revert DuplicateRewardBeneficiary(beneficiary); + } + } + totalShareBps += shareBps; + } + if (totalShareBps != REWARD_SHARE_BASIS_POINTS) { + revert InvalidRewardShareTotal(totalShareBps); + } + } +} diff --git a/src/ClassicRewardVaultFactoryV1.sol b/src/ClassicRewardVaultFactoryV1.sol new file mode 100644 index 00000000..7d5f624b --- /dev/null +++ b/src/ClassicRewardVaultFactoryV1.sol @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; + +import { ClassicCtoAuthorityV1 } from "./ClassicCtoAuthorityV1.sol"; +import { ClassicRewardVaultV1 } from "./ClassicRewardVaultV1.sol"; +import { IClassicFeeHookV3 } from "./interfaces/IClassicFeeHookV3.sol"; + +/// @title ClassicRewardVaultFactoryV1 +/// @notice Deterministically deploys authenticated Classic reward vaults governed by one disclosed CTO authority. +contract ClassicRewardVaultFactoryV1 { + ClassicCtoAuthorityV1 public immutable ctoAuthority; + + mapping(address vault => bytes32 configurationHash) public configurationHashOf; + + error DeploymentAddressMismatch(address actual, address predicted); + error InvalidCtoAuthority(address authority); + error UnrecognizedFactoryDeployment(address deployment); + error VaultAlreadyDeployed(address vault); + + event ClassicRewardVaultDeployed( + address indexed vault, bytes32 indexed poolId, address indexed feeHook, bytes32 salt, bytes32 configurationHash + ); + + constructor(ClassicCtoAuthorityV1 ctoAuthority_) { + address authorityAddress = address(ctoAuthority_); + if (authorityAddress == address(0) || authorityAddress.code.length == 0) { + revert InvalidCtoAuthority(authorityAddress); + } + ctoAuthority = ctoAuthority_; + } + + function deploy( + bytes32 salt, + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external returns (ClassicRewardVaultV1 vault) { + bytes memory code = initCode(feeHook, poolId, beneficiaries, sharesBps); + address predicted = Create2.computeAddress(salt, keccak256(code)); + if (predicted.code.length != 0) revert VaultAlreadyDeployed(predicted); + + address deployed = Create2.deploy(0, salt, code); + if (deployed != predicted) revert DeploymentAddressMismatch(deployed, predicted); + vault = ClassicRewardVaultV1(payable(deployed)); + + bytes32 configurationHash = vault.configurationHash(); + configurationHashOf[deployed] = configurationHash; + emit ClassicRewardVaultDeployed(deployed, poolId, address(feeHook), salt, configurationHash); + } + + /// @notice Deploys the configured vault or returns the same authenticated counterfactual vault if it already + /// exists. @dev This makes a launch resistant to third parties predeploying its publicly predictable CREATE2 vault. + function deployOrGet( + bytes32 salt, + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external returns (ClassicRewardVaultV1 vault) { + address predicted = Create2.computeAddress(salt, initCodeHash(feeHook, poolId, beneficiaries, sharesBps)); + if (predicted.code.length == 0) { + bytes memory code = initCode(feeHook, poolId, beneficiaries, sharesBps); + address deployed = Create2.deploy(0, salt, code); + if (deployed != predicted) revert DeploymentAddressMismatch(deployed, predicted); + vault = ClassicRewardVaultV1(payable(deployed)); + + bytes32 configurationHash = vault.configurationHash(); + configurationHashOf[deployed] = configurationHash; + emit ClassicRewardVaultDeployed(deployed, poolId, address(feeHook), salt, configurationHash); + return vault; + } + if (configurationHashOf[predicted] == bytes32(0)) revert UnrecognizedFactoryDeployment(predicted); + return ClassicRewardVaultV1(payable(predicted)); + } + + function predict( + bytes32 salt, + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external view returns (address) { + return Create2.computeAddress(salt, initCodeHash(feeHook, poolId, beneficiaries, sharesBps)); + } + + function initCode( + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) public view returns (bytes memory) { + // Slither mistakes the creation-code expression for a long numeric literal. + // slither-disable-next-line too-many-digits + return abi.encodePacked( + type(ClassicRewardVaultV1).creationCode, abi.encode(feeHook, poolId, ctoAuthority, beneficiaries, sharesBps) + ); + } + + function initCodeHash( + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) public view returns (bytes32) { + return keccak256(initCode(feeHook, poolId, beneficiaries, sharesBps)); + } + + function isFactoryVault(address vault) external view returns (bool) { + return configurationHashOf[vault] != bytes32(0); + } +} diff --git a/src/ClassicRewardVaultV1.sol b/src/ClassicRewardVaultV1.sol new file mode 100644 index 00000000..c2b0ee90 --- /dev/null +++ b/src/ClassicRewardVaultV1.sol @@ -0,0 +1,318 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { FullMath } from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; + +import { ClassicCtoAuthorityV1 } from "./ClassicCtoAuthorityV1.sol"; +import { IClassicCtoVaultV1 } from "./interfaces/IClassicCtoVaultV1.sol"; +import { IClassicFeeHookV3 } from "./interfaces/IClassicFeeHookV3.sol"; + +/// @title ClassicRewardVaultV1 +/// @notice Distributes one Classic pool's creator rewards across at most five current payout wallets. +/// @dev Every payout-wallet change and CTO first checkpoints all creator fees accrued under the prior configuration. +/// Historic claim balances remain owned by the wallets that earned them; only future accrual follows the new +/// configuration. +contract ClassicRewardVaultV1 is IClassicCtoVaultV1, ReentrancyGuardTransient { + using Address for address payable; + + uint16 public constant BASIS_POINTS = 10_000; + uint256 public constant MAX_BENEFICIARIES = 5; + + IClassicFeeHookV3 public immutable feeHook; + IPoolManager public immutable poolManager; + ClassicCtoAuthorityV1 public immutable ctoAuthority; + bytes32 public immutable poolId; + + /// @notice Factory-authenticated commitment to this vault's immutable dependencies and initial allocation. + bytes32 public immutable configurationHash; + + /// @notice Monotonic version of the active future-reward configuration. The initial configuration is epoch one. + uint64 public configurationEpoch; + + /// @notice Commitment to the current future-reward payout wallets, shares and epoch. + bytes32 public activeConfigurationHash; + + address[] private _beneficiaries; + uint16[] private _sharesBps; + + mapping(address beneficiary => uint256 amount) private _claimableBy; + mapping(address beneficiary => uint256 claimed) public claimedBy; + + /// @notice Creator fees pulled from the registered hook. Forced ETH is intentionally excluded. + uint256 public totalCreatorFeesReceived; + + /// @notice Creator fees paid to current or historic payout wallets. + uint256 public totalCreatorFeesClaimed; + + error DuplicateBeneficiary(address beneficiary); + error FeeReceiptMismatch(uint256 actual, uint256 expected); + error InvalidAllocationIndex(uint256 index, uint256 count); + error InvalidBeneficiary(address beneficiary); + error InvalidBeneficiaryCount(uint256 count); + error InvalidCtoApprovalReference(); + error InvalidCtoAuthority(address authority); + error InvalidHook(address hook); + error InvalidShare(address beneficiary, uint16 shareBps); + error InvalidShareTotal(uint256 totalShareBps); + error NoFeesToClaim(address beneficiary); + error PayoutWalletUnchanged(address payoutWallet); + error UnauthorizedAllocationOwner(address caller, uint256 allocationIndex, address expected); + error UnauthorizedCtoAuthority(address caller, address expected); + error UnauthorizedNativeSender(address caller); + + event CreatorFeesCheckpointed( + bytes32 indexed poolId, uint64 indexed configurationEpoch, uint256 amount, uint256 totalCreatorFeesReceived + ); + event BeneficiaryFeesClaimed( + address indexed beneficiary, uint256 amount, uint256 beneficiaryTotalClaimed, uint256 vaultTotalReceived + ); + event PayoutWalletChanged( + bytes32 indexed poolId, + uint256 indexed allocationIndex, + address indexed previousPayoutWallet, + address newPayoutWallet, + uint16 shareBps, + uint64 configurationEpoch, + bytes32 activeConfigurationHash, + uint256 effectiveTotalCreatorFeesReceived + ); + event CtoRewardConfigurationActivated( + bytes32 indexed poolId, + bytes32 indexed approvalReference, + uint64 indexed configurationEpoch, + bytes32 previousConfigurationHash, + bytes32 newConfigurationHash, + address[] beneficiaries, + uint16[] sharesBps, + uint256 effectiveTotalCreatorFeesReceived + ); + + constructor( + IClassicFeeHookV3 feeHook_, + bytes32 poolId_, + ClassicCtoAuthorityV1 ctoAuthority_, + address[] memory beneficiaries_, + uint16[] memory sharesBps_ + ) { + address hookAddress = address(feeHook_); + if (hookAddress == address(0) || hookAddress.code.length == 0) revert InvalidHook(hookAddress); + + address authorityAddress = address(ctoAuthority_); + if (authorityAddress == address(0) || authorityAddress.code.length == 0) { + revert InvalidCtoAuthority(authorityAddress); + } + + IPoolManager poolManager_ = feeHook_.poolManager(); + if (address(poolManager_) == address(0) || address(poolManager_).code.length == 0) { + revert InvalidHook(hookAddress); + } + + _validateConfiguration(beneficiaries_, sharesBps_); + + feeHook = feeHook_; + poolManager = poolManager_; + ctoAuthority = ctoAuthority_; + poolId = poolId_; + _replaceConfiguration(beneficiaries_, sharesBps_); + + configurationEpoch = 1; + configurationHash = keccak256( + abi.encode( + block.chainid, + address(this), + hookAddress, + address(poolManager_), + authorityAddress, + poolId_, + beneficiaries_, + sharesBps_ + ) + ); + activeConfigurationHash = _configurationHash(beneficiaries_, sharesBps_, configurationEpoch); + } + + function beneficiaryCount() external view returns (uint256) { + return _beneficiaries.length; + } + + function beneficiaryAt(uint256 index) external view returns (address) { + return _beneficiaries[index]; + } + + function shareBpsAt(uint256 index) external view returns (uint16) { + return _sharesBps[index]; + } + + /// @notice Returns the sum of all active allocation shares currently owned by `beneficiary`. + function shareBpsOf(address beneficiary) public view returns (uint16 totalShareBps) { + uint256 count = _beneficiaries.length; + for (uint256 index; index < count; index++) { + if (_beneficiaries[index] == beneficiary) totalShareBps += _sharesBps[index]; + } + } + + /// @notice Returns checkpointed creator rewards owned by `beneficiary`. + function claimable(address beneficiary) public view returns (uint256) { + return _claimableBy[beneficiary]; + } + + /// @notice Changes only where one allocation's future rewards accrue, without requiring acceptance. + /// @dev Rewards accrued before this transaction stay claimable by the previous payout wallet. They are never moved + /// by this function. + // ReentrancyGuardTransient protects the entire update; Slither does not model its transient lock. + // slither-disable-next-line reentrancy-no-eth,reentrancy-benign + function changePayoutWallet(uint256 allocationIndex, address newPayoutWallet) external nonReentrant { + uint256 count = _beneficiaries.length; + if (allocationIndex >= count) revert InvalidAllocationIndex(allocationIndex, count); + + address previousPayoutWallet = _beneficiaries[allocationIndex]; + if (msg.sender != previousPayoutWallet) { + revert UnauthorizedAllocationOwner(msg.sender, allocationIndex, previousPayoutWallet); + } + if (newPayoutWallet == address(0)) revert InvalidBeneficiary(newPayoutWallet); + if (newPayoutWallet == previousPayoutWallet) revert PayoutWalletUnchanged(newPayoutWallet); + + _checkpoint(); + _beneficiaries[allocationIndex] = newPayoutWallet; + uint64 newEpoch = configurationEpoch + 1; + configurationEpoch = newEpoch; + bytes32 newConfigurationHash = _configurationHash(_beneficiaries, _sharesBps, newEpoch); + activeConfigurationHash = newConfigurationHash; + + emit PayoutWalletChanged( + poolId, + allocationIndex, + previousPayoutWallet, + newPayoutWallet, + _sharesBps[allocationIndex], + newEpoch, + newConfigurationHash, + totalCreatorFeesReceived + ); + } + + /// @notice Replaces the complete future creator-reward configuration after a Programmable-approved CTO. + /// @dev The shared CTO contract is the sole caller. The new wallets receive no reward accrued before this call. + // ReentrancyGuardTransient protects the complete replacement; Slither does not model its transient lock. + // slither-disable-next-line reentrancy-no-eth,reentrancy-benign + function executeCto(address[] calldata beneficiaries, uint16[] calldata sharesBps, bytes32 approvalReference) + external + override + nonReentrant + { + address expectedAuthority = address(ctoAuthority); + if (msg.sender != expectedAuthority) revert UnauthorizedCtoAuthority(msg.sender, expectedAuthority); + if (approvalReference == bytes32(0)) revert InvalidCtoApprovalReference(); + _validateConfiguration(beneficiaries, sharesBps); + + _checkpoint(); + bytes32 previousConfigurationHash = activeConfigurationHash; + _replaceConfiguration(beneficiaries, sharesBps); + + uint64 newEpoch = configurationEpoch + 1; + configurationEpoch = newEpoch; + bytes32 newConfigurationHash = _configurationHash(beneficiaries, sharesBps, newEpoch); + activeConfigurationHash = newConfigurationHash; + + emit CtoRewardConfigurationActivated( + poolId, + approvalReference, + newEpoch, + previousConfigurationHash, + newConfigurationHash, + beneficiaries, + sharesBps, + totalCreatorFeesReceived + ); + } + + /// @notice Pulls newly accrued fees and pays all checkpointed rewards owned by the caller. + // ReentrancyGuardTransient protects checkpointing and payment; Slither does not model its transient lock. + // slither-disable-next-line reentrancy-no-eth,reentrancy-benign + function claim() external nonReentrant returns (uint256 amount) { + address beneficiary = msg.sender; + _checkpoint(); + + amount = _claimableBy[beneficiary]; + if (amount == 0) revert NoFeesToClaim(beneficiary); + _claimableBy[beneficiary] = 0; + claimedBy[beneficiary] += amount; + totalCreatorFeesClaimed += amount; + + payable(beneficiary).sendValue(amount); + emit BeneficiaryFeesClaimed(beneficiary, amount, claimedBy[beneficiary], totalCreatorFeesReceived); + } + + /// @dev Native ETH is received only while the registered hook redeems a PoolManager claim. + receive() external payable { + if (msg.sender != address(poolManager)) revert UnauthorizedNativeSender(msg.sender); + } + + // Every caller holds ReentrancyGuardTransient across the full checkpoint and its following state transition. + // slither-disable-next-line reentrancy-benign + function _checkpoint() private returns (uint256 received) { + uint256 balanceBefore = address(this).balance; + uint256 pulled = feeHook.claimCreatorFees(poolId); + received = address(this).balance - balanceBefore; + if (received != pulled) revert FeeReceiptMismatch(received, pulled); + // Zero is a no-op sentinel, not a price, balance threshold or authorization decision. + // slither-disable-next-line incorrect-equality + if (received == 0) return 0; + + totalCreatorFeesReceived += received; + _allocate(received); + emit CreatorFeesCheckpointed(poolId, configurationEpoch, received, totalCreatorFeesReceived); + } + + function _allocate(uint256 amount) private { + uint256 count = _beneficiaries.length; + uint256 allocated = 0; + for (uint256 index; index + 1 < count; index++) { + uint256 share = FullMath.mulDiv(amount, _sharesBps[index], BASIS_POINTS); + _claimableBy[_beneficiaries[index]] += share; + allocated += share; + } + _claimableBy[_beneficiaries[count - 1]] += amount - allocated; + } + + function _replaceConfiguration(address[] memory beneficiaries, uint16[] memory sharesBps) private { + delete _beneficiaries; + delete _sharesBps; + uint256 count = beneficiaries.length; + for (uint256 index; index < count; index++) { + _beneficiaries.push(beneficiaries[index]); + _sharesBps.push(sharesBps[index]); + } + } + + function _validateConfiguration(address[] memory beneficiaries, uint16[] memory sharesBps) private pure { + uint256 count = beneficiaries.length; + if (count == 0 || count > MAX_BENEFICIARIES || sharesBps.length != count) { + revert InvalidBeneficiaryCount(count); + } + + uint256 totalShareBps = 0; + for (uint256 index; index < count; index++) { + address beneficiary = beneficiaries[index]; + uint16 shareBps = sharesBps[index]; + if (beneficiary == address(0)) revert InvalidBeneficiary(beneficiary); + if (shareBps == 0) revert InvalidShare(beneficiary, shareBps); + for (uint256 prior; prior < index; prior++) { + if (beneficiaries[prior] == beneficiary) revert DuplicateBeneficiary(beneficiary); + } + totalShareBps += shareBps; + } + if (totalShareBps != BASIS_POINTS) revert InvalidShareTotal(totalShareBps); + } + + function _configurationHash(address[] memory beneficiaries, uint16[] memory sharesBps, uint64 epoch) + private + view + returns (bytes32) + { + return keccak256(abi.encode(block.chainid, address(this), configurationHash, epoch, beneficiaries, sharesBps)); + } +} diff --git a/src/EthCreatorFeeHookFactoryV3.sol b/src/EthCreatorFeeHookFactoryV3.sol new file mode 100644 index 00000000..e5bba98a --- /dev/null +++ b/src/EthCreatorFeeHookFactoryV3.sol @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; + +import { EthCreatorFeeHookV3 } from "./EthCreatorFeeHookV3.sol"; +import { FeeSplitVaultFactoryV1 } from "./FeeSplitVaultFactoryV1.sol"; + +/// @title EthCreatorFeeHookFactoryV3 +/// @notice Deterministically deploys the Classic V3 shared fee hook at a valid v4 hook address. +contract EthCreatorFeeHookFactoryV3 { + uint160 public constant ALL_HOOK_MASK = uint160((1 << 14) - 1); + uint160 public constant REQUIRED_HOOK_FLAGS = uint160( + Hooks.BEFORE_INITIALIZE_FLAG | Hooks.BEFORE_SWAP_FLAG | Hooks.AFTER_SWAP_FLAG + | Hooks.BEFORE_SWAP_RETURNS_DELTA_FLAG | Hooks.AFTER_SWAP_RETURNS_DELTA_FLAG + ); + + mapping(address hook => bytes32 configurationHash) public configurationHashOf; + + error DeploymentAddressMismatch(address actual, address predicted); + error HookAlreadyDeployed(address hook); + error InvalidHookAddress(address hook, uint160 actualFlags, uint160 requiredFlags); + + event EthCreatorFeeHookDeployed( + address indexed hook, + address indexed poolManager, + address indexed launcherFeeRecipient, + address feeSplitVaultFactory, + bytes32 salt, + bytes32 configurationHash + ); + + function deploy( + bytes32 salt, + IPoolManager poolManager, + address launcherFeeRecipient, + FeeSplitVaultFactoryV1 feeSplitVaultFactory + ) external returns (EthCreatorFeeHookV3 hook) { + bytes memory code = initCode(poolManager, launcherFeeRecipient, feeSplitVaultFactory); + address predicted = Create2.computeAddress(salt, keccak256(code)); + uint160 actualFlags = uint160(predicted) & ALL_HOOK_MASK; + if (actualFlags != REQUIRED_HOOK_FLAGS) { + revert InvalidHookAddress(predicted, actualFlags, REQUIRED_HOOK_FLAGS); + } + if (predicted.code.length != 0) revert HookAlreadyDeployed(predicted); + + address deployed = Create2.deploy(0, salt, code); + if (deployed != predicted) revert DeploymentAddressMismatch(deployed, predicted); + hook = EthCreatorFeeHookV3(deployed); + + bytes32 configurationHash = keccak256( + abi.encode( + block.chainid, + address(this), + deployed, + address(poolManager), + launcherFeeRecipient, + address(feeSplitVaultFactory) + ) + ); + configurationHashOf[deployed] = configurationHash; + emit EthCreatorFeeHookDeployed( + deployed, address(poolManager), launcherFeeRecipient, address(feeSplitVaultFactory), salt, configurationHash + ); + } + + function predict( + bytes32 salt, + IPoolManager poolManager, + address launcherFeeRecipient, + FeeSplitVaultFactoryV1 feeSplitVaultFactory + ) external view returns (address) { + return Create2.computeAddress(salt, initCodeHash(poolManager, launcherFeeRecipient, feeSplitVaultFactory)); + } + + function initCode( + IPoolManager poolManager, + address launcherFeeRecipient, + FeeSplitVaultFactoryV1 feeSplitVaultFactory + ) public pure returns (bytes memory) { + // slither-disable-next-line too-many-digits + return abi.encodePacked( + type(EthCreatorFeeHookV3).creationCode, abi.encode(poolManager, launcherFeeRecipient, feeSplitVaultFactory) + ); + } + + function initCodeHash( + IPoolManager poolManager, + address launcherFeeRecipient, + FeeSplitVaultFactoryV1 feeSplitVaultFactory + ) public pure returns (bytes32) { + return keccak256(initCode(poolManager, launcherFeeRecipient, feeSplitVaultFactory)); + } + + function isFactoryHook(address hook) external view returns (bool) { + return configurationHashOf[hook] != bytes32(0); + } +} diff --git a/src/EthCreatorFeeHookV3.sol b/src/EthCreatorFeeHookV3.sol new file mode 100644 index 00000000..c58b4377 --- /dev/null +++ b/src/EthCreatorFeeHookV3.sol @@ -0,0 +1,512 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { BaseHook } from "@openzeppelin/uniswap-hooks/src/base/BaseHook.sol"; +import { IHookEvents } from "@openzeppelin/uniswap-hooks/src/interfaces/IHookEvents.sol"; +import { CurrencySettler } from "@openzeppelin/uniswap-hooks/src/utils/CurrencySettler.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 { FullMath } from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { + BeforeSwapDelta, + BeforeSwapDeltaLibrary, + toBeforeSwapDelta +} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import { Currency } from "@uniswap/v4-core/src/types/Currency.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; + +import { FeeSplitVaultFactoryV1 } from "./FeeSplitVaultFactoryV1.sol"; +import { FeeSplitVaultV1 } from "./FeeSplitVaultV1.sol"; +import { IHookSwapEvents } from "./interfaces/IHookSwapEvents.sol"; + +interface IClassicCreatorToken { + function creator() external view returns (address); +} + +/// @title EthCreatorFeeHookV3 +/// @notice Charges immutable, independently configured buy and sell fees on native ETH/token Classic pools. +/// @dev Creator rewards accrue only to a factory-authenticated split vault. The fixed 0.10 percentage-point +/// Programmable share is deducted from the directional total fee and never added on top. ERC-20 transfers are +/// untaxed. The hook is non-upgradeable and has no administrative controls. +contract EthCreatorFeeHookV3 is BaseHook, IUnlockCallback, ReentrancyGuardTransient, IHookEvents, IHookSwapEvents { + using BeforeSwapDeltaLibrary for BeforeSwapDelta; + using CurrencySettler for Currency; + using SafeCast for *; + + uint16 public constant BASIS_POINTS = 10_000; + uint16 public constant LAUNCHER_FEE_BPS = 10; + uint16 public constant MIN_TOTAL_SWAP_FEE_BPS = 100; + uint16 public constant MAX_TOTAL_SWAP_FEE_BPS = 1000; + uint16 public constant TOTAL_SWAP_FEE_STEP_BPS = 100; + uint16 public constant TRANSFER_TAX_BPS = 0; + uint24 public constant LP_FEE_PIPS = 0; + int24 public constant TICK_SPACING = 200; + + Currency private constant NATIVE = Currency.wrap(address(0)); + + struct PoolFeeConfig { + address rewardVault; + address registrar; + uint16 buySwapFeeBps; + uint16 sellSwapFeeBps; + bool registered; + uint256 creatorFeesAccrued; + } + + /// @notice The immutable address that receives Programmable's fixed 0.10 percentage-point share. + address public immutable launcherFeeRecipient; + + /// @notice The only vault factory accepted during pool registration. + FeeSplitVaultFactoryV1 public immutable feeSplitVaultFactory; + + mapping(bytes32 poolId => PoolFeeConfig config) public poolFeeConfig; + + uint256 public launcherFeesAccrued; + uint256 public totalNativeFeesAccrued; + + error AlreadyRegistered(bytes32 poolId); + error InvalidCurrencyOrder(address currency0, address currency1); + error InvalidHook(address actual, address expected); + error InvalidLpFee(uint24 actual, uint24 expected); + error InvalidRegistrar(address caller, address recordedCreator); + error InvalidRewardVault(address rewardVault); + error InvalidTickSpacing(int24 actual, int24 expected); + error InvalidTotalSwapFee(uint16 totalSwapFeeBps); + error NoFeesToClaim(); + error PartialFillUnsupported(uint256 expectedNativePoolAmount, uint256 actualNativePoolAmount); + error PoolNotRegistered(bytes32 poolId); + error UnauthorizedCreatorClaim(address caller, address expectedVault); + error UnauthorizedFeeRedirect(address caller, address expected); + error UnauthorizedInitializer(address caller, address expected); + error UnexpectedUnlockResult(); + error UnrecognizedToken(address token); + error ZeroAddress(); + + event PoolRegistered( + bytes32 indexed poolId, + address indexed token, + address indexed rewardVault, + address registrar, + uint16 buySwapFeeBps, + uint16 sellSwapFeeBps, + bytes32 rewardConfigurationHash + ); + event PoolFeeDisclosure( + bytes32 indexed poolId, + address indexed token, + address indexed rewardVault, + uint16 buySwapFeeBps, + uint16 sellSwapFeeBps, + uint16 buyCreatorFeeBps, + uint16 sellCreatorFeeBps, + uint16 launcherFeeBps, + uint16 transferTaxBps, + uint24 lpFeePips + ); + event NativeSwapFeesAccrued( + bytes32 indexed poolId, + address indexed swapSender, + bool indexed isBuy, + uint16 appliedTotalSwapFeeBps, + uint256 grossNativeAmount, + uint256 creatorFee, + uint256 launcherFee + ); + event CreatorFeesClaimed( + bytes32 indexed poolId, address indexed rewardVault, address indexed caller, uint256 amount + ); + event LauncherFeesClaimed( + address indexed treasury, address indexed recipient, address indexed caller, uint256 amount + ); + + constructor(IPoolManager poolManager_, address launcherFeeRecipient_, FeeSplitVaultFactoryV1 feeSplitVaultFactory_) + BaseHook(poolManager_) + { + if ( + address(poolManager_) == address(0) || launcherFeeRecipient_ == address(0) + || address(feeSplitVaultFactory_) == address(0) || address(feeSplitVaultFactory_).code.length == 0 + ) { + revert ZeroAddress(); + } + launcherFeeRecipient = launcherFeeRecipient_; + feeSplitVaultFactory = feeSplitVaultFactory_; + } + + /// @notice Registers one native ETH/token pool with immutable directional fees and reward vault. + function registerPool(PoolKey calldata key, address rewardVault, uint16 buySwapFeeBps, uint16 sellSwapFeeBps) + external + returns (bytes32 poolId) + { + _validatePoolShape(key); + _validateTotalSwapFee(buySwapFeeBps); + _validateTotalSwapFee(sellSwapFeeBps); + + address token = Currency.unwrap(key.currency1); + address recordedCreator = _recordedTokenCreator(token); + if (recordedCreator != msg.sender) revert InvalidRegistrar(msg.sender, recordedCreator); + + poolId = PoolId.unwrap(key.toId()); + if (poolFeeConfig[poolId].registered) revert AlreadyRegistered(poolId); + bytes32 rewardConfigurationHash = _validateRewardVault(rewardVault, poolId); + + poolFeeConfig[poolId] = PoolFeeConfig({ + rewardVault: rewardVault, + registrar: msg.sender, + buySwapFeeBps: buySwapFeeBps, + sellSwapFeeBps: sellSwapFeeBps, + registered: true, + creatorFeesAccrued: 0 + }); + + emit PoolRegistered( + poolId, token, rewardVault, msg.sender, buySwapFeeBps, sellSwapFeeBps, rewardConfigurationHash + ); + emit PoolFeeDisclosure( + poolId, + token, + rewardVault, + buySwapFeeBps, + sellSwapFeeBps, + buySwapFeeBps - LAUNCHER_FEE_BPS, + sellSwapFeeBps - LAUNCHER_FEE_BPS, + LAUNCHER_FEE_BPS, + TRANSFER_TAX_BPS, + LP_FEE_PIPS + ); + } + + /// @notice Returns all immutable fee and reward routing information for `poolId`. + function feeDisclosure(bytes32 poolId) + external + view + returns ( + uint16 buySwapFeeBps, + uint16 sellSwapFeeBps, + uint16 buyCreatorFeeBps, + uint16 sellCreatorFeeBps, + uint16 launcherFeeBps, + uint16 transferTaxBps, + uint24 lpFeePips, + address rewardVault + ) + { + PoolFeeConfig storage config = poolFeeConfig[poolId]; + if (!config.registered) revert PoolNotRegistered(poolId); + + buySwapFeeBps = config.buySwapFeeBps; + sellSwapFeeBps = config.sellSwapFeeBps; + buyCreatorFeeBps = buySwapFeeBps - LAUNCHER_FEE_BPS; + sellCreatorFeeBps = sellSwapFeeBps - LAUNCHER_FEE_BPS; + launcherFeeBps = LAUNCHER_FEE_BPS; + transferTaxBps = TRANSFER_TAX_BPS; + lpFeePips = LP_FEE_PIPS; + rewardVault = config.rewardVault; + } + + function totalSwapFeeBpsFor(bytes32 poolId, bool isBuy) public view returns (uint16) { + PoolFeeConfig storage config = poolFeeConfig[poolId]; + if (!config.registered) revert PoolNotRegistered(poolId); + return isBuy ? config.buySwapFeeBps : config.sellSwapFeeBps; + } + + function quoteGrossFees(uint256 grossNativeAmount, uint16 totalSwapFeeBps) + external + pure + returns (uint256 creatorFee, uint256 launcherFee) + { + _validateTotalSwapFee(totalSwapFeeBps); + return _feesForGross(grossNativeAmount, totalSwapFeeBps); + } + + function quoteExactOutputFees(uint256 netNativeAmount, uint16 totalSwapFeeBps) + external + pure + returns (uint256 creatorFee, uint256 launcherFee) + { + _validateTotalSwapFee(totalSwapFeeBps); + return _feesForNet(netNativeAmount, totalSwapFeeBps); + } + + /// @notice Redeems all currently accrued creator fees to the registered vault. + /// @dev Only that vault can initiate the claim. Returning zero lets another beneficiary claim already-pulled fees. + function claimCreatorFees(bytes32 poolId) external nonReentrant returns (uint256 amount) { + PoolFeeConfig storage config = poolFeeConfig[poolId]; + if (!config.registered) revert PoolNotRegistered(poolId); + if (msg.sender != config.rewardVault) revert UnauthorizedCreatorClaim(msg.sender, config.rewardVault); + + amount = config.creatorFeesAccrued; + if (amount == 0) return 0; + config.creatorFeesAccrued = 0; + totalNativeFeesAccrued -= amount; + _redeemNative(config.rewardVault, amount); + + emit CreatorFeesClaimed(poolId, config.rewardVault, msg.sender, amount); + } + + /// @notice Redeems Programmable fees to the immutable treasury. + function claimLauncherFees() external nonReentrant returns (uint256 amount) { + if (msg.sender != launcherFeeRecipient) { + revert UnauthorizedFeeRedirect(msg.sender, launcherFeeRecipient); + } + return _claimLauncherFees(launcherFeeRecipient); + } + + /// @notice Lets only the immutable treasury choose an alternative payout destination for this claim. + function claimLauncherFeesTo(address recipient) external nonReentrant returns (uint256 amount) { + if (msg.sender != launcherFeeRecipient) { + revert UnauthorizedFeeRedirect(msg.sender, launcherFeeRecipient); + } + if (recipient == address(0)) revert ZeroAddress(); + return _claimLauncherFees(recipient); + } + + function _claimLauncherFees(address recipient) private returns (uint256 amount) { + amount = launcherFeesAccrued; + if (amount == 0) revert NoFeesToClaim(); + + launcherFeesAccrued = 0; + totalNativeFeesAccrued -= amount; + _redeemNative(recipient, amount); + // Every external entry point holds ReentrancyGuardTransient and all accounting effects precede the unlock. + // This event is observational and cannot expose partially updated state. + // slither-disable-next-line reentrancy-events + emit LauncherFeesClaimed(launcherFeeRecipient, recipient, msg.sender, amount); + } + + /// @inheritdoc BaseHook + function getHookPermissions() public pure override returns (Hooks.Permissions memory permissions) { + return Hooks.Permissions({ + beforeInitialize: true, + afterInitialize: false, + beforeAddLiquidity: false, + afterAddLiquidity: false, + beforeRemoveLiquidity: false, + afterRemoveLiquidity: false, + beforeSwap: true, + afterSwap: true, + beforeDonate: false, + afterDonate: false, + beforeSwapReturnDelta: true, + afterSwapReturnDelta: true, + afterAddLiquidityReturnDelta: false, + afterRemoveLiquidityReturnDelta: false + }); + } + + function _beforeInitialize(address sender, PoolKey calldata key, uint160) internal view override returns (bytes4) { + PoolFeeConfig storage config = _registeredConfig(key); + if (sender != config.registrar) revert UnauthorizedInitializer(sender, config.registrar); + return IHooks.beforeInitialize.selector; + } + + function _beforeSwap(address sender, PoolKey calldata key, SwapParams calldata params, bytes calldata) + internal + override + returns (bytes4, BeforeSwapDelta, uint24) + { + bytes32 poolId = _registeredPoolId(key); + bool nativeIsSpecified = params.zeroForOne == (params.amountSpecified < 0); + if (!nativeIsSpecified) { + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + uint256 nativeAmount = _absolute(params.amountSpecified); + uint256 totalFee = _chargeNative(poolId, sender, nativeAmount, params.amountSpecified > 0, params.zeroForOne); + if (totalFee == 0) { + return (IHooks.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + return (IHooks.beforeSwap.selector, toBeforeSwapDelta(totalFee.toInt256().toInt128(), 0), 0); + } + + function _afterSwap( + address sender, + PoolKey calldata key, + SwapParams calldata params, + BalanceDelta delta, + bytes calldata + ) internal override returns (bytes4, int128) { + bytes32 poolId = _registeredPoolId(key); + uint16 appliedFeeBps = totalSwapFeeBpsFor(poolId, params.zeroForOne); + bool nativeIsSpecified = params.zeroForOne == (params.amountSpecified < 0); + if (nativeIsSpecified) { + uint256 requestedNativeAmount = _absolute(params.amountSpecified); + (uint256 creatorFee, uint256 launcherFee) = params.amountSpecified > 0 + ? _feesForNet(requestedNativeAmount, appliedFeeBps) + : _feesForGross(requestedNativeAmount, appliedFeeBps); + uint256 expectedTotalFee = creatorFee + launcherFee; + uint256 expectedNativePoolAmount = params.amountSpecified > 0 + ? requestedNativeAmount + expectedTotalFee + : requestedNativeAmount - expectedTotalFee; + uint256 actualNativePoolAmount = _absolute(int256(delta.amount0())); + if (actualNativePoolAmount != expectedNativePoolAmount) { + revert PartialFillUnsupported(expectedNativePoolAmount, actualNativePoolAmount); + } + return (IHooks.afterSwap.selector, 0); + } + + uint256 nativeAmount = _absolute(int256(delta.amount0())); + uint256 totalFee = _chargeNative(poolId, sender, nativeAmount, params.amountSpecified > 0, params.zeroForOne); + if (totalFee == 0) return (IHooks.afterSwap.selector, 0); + return (IHooks.afterSwap.selector, totalFee.toInt256().toInt128()); + } + + function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) { + (address recipient, uint256 amount) = abi.decode(data, (address, uint256)); + NATIVE.settle(poolManager, address(this), amount, true); + NATIVE.take(poolManager, recipient, amount, false); + return ""; + } + + function _accrue( + bytes32 poolId, + PoolFeeConfig storage config, + address sender, + bool isBuy, + uint16 appliedFeeBps, + uint256 grossNativeAmount, + uint256 creatorFee, + uint256 launcherFee + ) private { + uint256 totalFee = creatorFee + launcherFee; + config.creatorFeesAccrued += creatorFee; + launcherFeesAccrued += launcherFee; + totalNativeFeesAccrued += totalFee; + + emit HookFee(poolId, sender, totalFee.toUint128(), 0); + emit HookSwap(PoolId.wrap(poolId), sender, -totalFee.toInt256().toInt128(), 0, uint24(appliedFeeBps) * 100); + emit NativeSwapFeesAccrued(poolId, sender, isBuy, appliedFeeBps, grossNativeAmount, creatorFee, launcherFee); + } + + function _chargeNative(bytes32 poolId, address sender, uint256 nativeAmount, bool amountIsNet, bool isBuy) + private + returns (uint256 totalFee) + { + PoolFeeConfig storage config = poolFeeConfig[poolId]; + uint16 appliedFeeBps = isBuy ? config.buySwapFeeBps : config.sellSwapFeeBps; + (uint256 creatorFee, uint256 launcherFee) = + amountIsNet ? _feesForNet(nativeAmount, appliedFeeBps) : _feesForGross(nativeAmount, appliedFeeBps); + totalFee = creatorFee + launcherFee; + if (totalFee == 0) return 0; + + _accrue( + poolId, + config, + sender, + isBuy, + appliedFeeBps, + nativeAmount + (amountIsNet ? totalFee : 0), + creatorFee, + launcherFee + ); + NATIVE.take(poolManager, address(this), totalFee, true); + } + + function _redeemNative(address recipient, uint256 amount) private { + bytes memory result = poolManager.unlock(abi.encode(recipient, amount)); + if (result.length != 0) revert UnexpectedUnlockResult(); + } + + function _registeredConfig(PoolKey calldata key) private view returns (PoolFeeConfig storage config) { + _validatePoolShape(key); + bytes32 poolId = PoolId.unwrap(key.toId()); + config = poolFeeConfig[poolId]; + if (!config.registered) revert PoolNotRegistered(poolId); + } + + function _registeredPoolId(PoolKey calldata key) private view returns (bytes32 poolId) { + _validatePoolShape(key); + poolId = PoolId.unwrap(key.toId()); + if (!poolFeeConfig[poolId].registered) revert PoolNotRegistered(poolId); + } + + function _validatePoolShape(PoolKey calldata key) private view { + address currency0 = Currency.unwrap(key.currency0); + address currency1 = Currency.unwrap(key.currency1); + if (currency0 != address(0) || currency1 == address(0)) { + revert InvalidCurrencyOrder(currency0, currency1); + } + if (address(key.hooks) != address(this)) revert InvalidHook(address(key.hooks), address(this)); + if (key.fee != LP_FEE_PIPS) revert InvalidLpFee(key.fee, LP_FEE_PIPS); + if (key.tickSpacing != TICK_SPACING) revert InvalidTickSpacing(key.tickSpacing, TICK_SPACING); + } + + function _validateRewardVault(address rewardVault, bytes32 expectedPoolId) + private + view + returns (bytes32 configurationHash) + { + if ( + rewardVault == address(0) || rewardVault.code.length == 0 + || feeSplitVaultFactory.configurationHashOf(rewardVault) == bytes32(0) + ) { + revert InvalidRewardVault(rewardVault); + } + + FeeSplitVaultV1 vault = FeeSplitVaultV1(payable(rewardVault)); + if ( + address(vault.feeHook()) != address(this) || address(vault.poolManager()) != address(poolManager) + || vault.poolId() != expectedPoolId + ) { + revert InvalidRewardVault(rewardVault); + } + configurationHash = vault.configurationHash(); + if (configurationHash != feeSplitVaultFactory.configurationHashOf(rewardVault)) { + revert InvalidRewardVault(rewardVault); + } + } + + function _recordedTokenCreator(address token) private view returns (address recordedCreator) { + if (token.code.length == 0) revert UnrecognizedToken(token); + try IClassicCreatorToken(token).creator() returns (address creator) { + recordedCreator = creator; + } catch { + revert UnrecognizedToken(token); + } + if (recordedCreator == address(0)) revert UnrecognizedToken(token); + } + + function _feesForGross(uint256 grossNativeAmount, uint16 totalSwapFeeBps) + private + pure + returns (uint256 creatorFee, uint256 launcherFee) + { + uint256 totalFee = FullMath.mulDiv(grossNativeAmount, totalSwapFeeBps, BASIS_POINTS); + launcherFee = FullMath.mulDiv(grossNativeAmount, LAUNCHER_FEE_BPS, BASIS_POINTS); + if (launcherFee > totalFee) launcherFee = totalFee; + creatorFee = totalFee - launcherFee; + } + + function _feesForNet(uint256 netNativeAmount, uint16 totalSwapFeeBps) + private + pure + returns (uint256 creatorFee, uint256 launcherFee) + { + uint256 grossNativeAmount = + FullMath.mulDivRoundingUp(netNativeAmount, BASIS_POINTS, BASIS_POINTS - totalSwapFeeBps); + uint256 totalFee = grossNativeAmount - netNativeAmount; + launcherFee = FullMath.mulDiv(grossNativeAmount, LAUNCHER_FEE_BPS, BASIS_POINTS); + if (launcherFee > totalFee) launcherFee = totalFee; + creatorFee = totalFee - launcherFee; + } + + function _validateTotalSwapFee(uint16 totalSwapFeeBps) private pure { + if ( + totalSwapFeeBps < MIN_TOTAL_SWAP_FEE_BPS || totalSwapFeeBps > MAX_TOTAL_SWAP_FEE_BPS + || totalSwapFeeBps % TOTAL_SWAP_FEE_STEP_BPS != 0 + ) { + revert InvalidTotalSwapFee(totalSwapFeeBps); + } + } + + function _absolute(int256 value) private pure returns (uint256) { + if (value >= 0) return value.toUint256(); + return (-(value + 1)).toUint256() + 1; + } +} diff --git a/src/FeeSplitVaultFactoryV1.sol b/src/FeeSplitVaultFactoryV1.sol new file mode 100644 index 00000000..49de9437 --- /dev/null +++ b/src/FeeSplitVaultFactoryV1.sol @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; + +import { FeeSplitVaultV1 } from "./FeeSplitVaultV1.sol"; +import { IClassicFeeHookV3 } from "./interfaces/IClassicFeeHookV3.sol"; + +/// @title FeeSplitVaultFactoryV1 +/// @notice Deterministically deploys immutable Classic creator-reward vaults. +contract FeeSplitVaultFactoryV1 { + mapping(address vault => bytes32 configurationHash) public configurationHashOf; + + error DeploymentAddressMismatch(address actual, address predicted); + error VaultAlreadyDeployed(address vault); + + event FeeSplitVaultDeployed( + address indexed vault, address indexed feeHook, bytes32 indexed poolId, bytes32 salt, bytes32 configurationHash + ); + + function deploy( + bytes32 salt, + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external returns (FeeSplitVaultV1 vault) { + bytes memory code = initCode(feeHook, poolId, beneficiaries, sharesBps); + address predicted = Create2.computeAddress(salt, keccak256(code)); + if (predicted.code.length != 0) revert VaultAlreadyDeployed(predicted); + + address deployed = Create2.deploy(0, salt, code); + if (deployed != predicted) revert DeploymentAddressMismatch(deployed, predicted); + vault = FeeSplitVaultV1(payable(deployed)); + + bytes32 configurationHash = vault.configurationHash(); + configurationHashOf[deployed] = configurationHash; + emit FeeSplitVaultDeployed(deployed, address(feeHook), poolId, salt, configurationHash); + } + + function predict( + bytes32 salt, + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external view returns (address) { + return Create2.computeAddress(salt, initCodeHash(feeHook, poolId, beneficiaries, sharesBps)); + } + + function initCode( + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) public pure returns (bytes memory) { + // slither-disable-next-line too-many-digits + return + abi.encodePacked(type(FeeSplitVaultV1).creationCode, abi.encode(feeHook, poolId, beneficiaries, sharesBps)); + } + + function initCodeHash( + IClassicFeeHookV3 feeHook, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) public pure returns (bytes32) { + return keccak256(initCode(feeHook, poolId, beneficiaries, sharesBps)); + } + + function isFactoryVault(address vault) external view returns (bool) { + return configurationHashOf[vault] != bytes32(0); + } +} diff --git a/src/FeeSplitVaultV1.sol b/src/FeeSplitVaultV1.sol new file mode 100644 index 00000000..161a209f --- /dev/null +++ b/src/FeeSplitVaultV1.sol @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Address } from "@openzeppelin/contracts/utils/Address.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { FullMath } from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; + +import { IClassicFeeHookV3 } from "./interfaces/IClassicFeeHookV3.sol"; + +/// @title FeeSplitVaultV1 +/// @notice Holds one Classic pool's creator rewards under an immutable beneficiary split. +/// @dev Beneficiary identities and shares never change. Each beneficiary alone controls its claim and may update only +/// its payout address. The payout address has no claim or configuration authority. +contract FeeSplitVaultV1 is ReentrancyGuardTransient { + using Address for address payable; + + uint16 public constant BASIS_POINTS = 10_000; + uint256 public constant MAX_BENEFICIARIES = 8; + + IClassicFeeHookV3 public immutable feeHook; + IPoolManager public immutable poolManager; + bytes32 public immutable poolId; + bytes32 public immutable configurationHash; + uint256 public immutable beneficiaryCount; + + address[] private _beneficiaries; + + mapping(address beneficiary => uint16 shareBps) public shareBpsOf; + mapping(address beneficiary => address payoutAddress) public payoutAddressOf; + mapping(address beneficiary => uint256 claimed) public claimedBy; + + /// @notice Creator fees pulled from the registered hook. Forced ETH is intentionally excluded. + uint256 public totalCreatorFeesReceived; + + /// @notice Creator fees paid to beneficiaries. + uint256 public totalCreatorFeesClaimed; + + error DuplicateBeneficiary(address beneficiary); + error FeeReceiptMismatch(uint256 actual, uint256 expected); + error InvalidBeneficiary(address beneficiary); + error InvalidBeneficiaryCount(uint256 count); + error InvalidHook(address hook); + error InvalidPayoutAddress(address payoutAddress); + error InvalidShare(address beneficiary, uint16 shareBps); + error InvalidShareTotal(uint256 totalShareBps); + error NoFeesToClaim(address beneficiary); + error UnauthorizedBeneficiary(address caller); + error UnauthorizedNativeSender(address caller); + + event PayoutAddressUpdated( + address indexed beneficiary, address indexed previousPayoutAddress, address indexed newPayoutAddress + ); + event BeneficiaryFeesClaimed( + address indexed beneficiary, + address indexed payoutAddress, + uint256 amount, + uint256 beneficiaryTotalClaimed, + uint256 vaultTotalReceived + ); + + constructor( + IClassicFeeHookV3 feeHook_, + bytes32 poolId_, + address[] memory beneficiaries_, + uint16[] memory sharesBps_ + ) { + address hookAddress = address(feeHook_); + if (hookAddress == address(0) || hookAddress.code.length == 0) revert InvalidHook(hookAddress); + + uint256 count = beneficiaries_.length; + if (count == 0 || count > MAX_BENEFICIARIES || sharesBps_.length != count) { + revert InvalidBeneficiaryCount(count); + } + + IPoolManager poolManager_ = feeHook_.poolManager(); + if (address(poolManager_) == address(0) || address(poolManager_).code.length == 0) { + revert InvalidHook(hookAddress); + } + + uint256 totalShareBps = 0; + for (uint256 index; index < count; index++) { + address beneficiary = beneficiaries_[index]; + uint16 shareBps = sharesBps_[index]; + if (beneficiary == address(0)) revert InvalidBeneficiary(beneficiary); + if (shareBps == 0) revert InvalidShare(beneficiary, shareBps); + + for (uint256 prior; prior < index; prior++) { + if (beneficiaries_[prior] == beneficiary) revert DuplicateBeneficiary(beneficiary); + } + + _beneficiaries.push(beneficiary); + shareBpsOf[beneficiary] = shareBps; + payoutAddressOf[beneficiary] = beneficiary; + totalShareBps += shareBps; + } + if (totalShareBps != BASIS_POINTS) revert InvalidShareTotal(totalShareBps); + + feeHook = feeHook_; + poolManager = poolManager_; + poolId = poolId_; + beneficiaryCount = count; + configurationHash = keccak256( + abi.encode( + block.chainid, address(this), hookAddress, address(poolManager_), poolId_, beneficiaries_, sharesBps_ + ) + ); + } + + /// @notice Returns the immutable beneficiary at `index`. + function beneficiaryAt(uint256 index) external view returns (address) { + return _beneficiaries[index]; + } + + /// @notice Returns creator rewards currently claimable by `beneficiary`. + function claimable(address beneficiary) public view returns (uint256 amount) { + uint16 shareBps = shareBpsOf[beneficiary]; + if (shareBps == 0) return 0; + + uint256 entitlement = _entitlement(beneficiary, totalCreatorFeesReceived); + uint256 alreadyClaimed = claimedBy[beneficiary]; + return entitlement > alreadyClaimed ? entitlement - alreadyClaimed : 0; + } + + /// @notice Changes only the caller's payout destination in one transaction. + /// @dev Claim authority remains with `msg.sender`; the new payout address receives no control rights. + function setPayoutAddress(address newPayoutAddress) external nonReentrant { + if (shareBpsOf[msg.sender] == 0) revert UnauthorizedBeneficiary(msg.sender); + if (newPayoutAddress == address(0)) revert InvalidPayoutAddress(newPayoutAddress); + + address previousPayoutAddress = payoutAddressOf[msg.sender]; + payoutAddressOf[msg.sender] = newPayoutAddress; + emit PayoutAddressUpdated(msg.sender, previousPayoutAddress, newPayoutAddress); + } + + /// @notice Pulls newly accrued creator fees and pays all of the caller's current entitlement. + /// @dev Only an immutable beneficiary can call. A reverting payout affects only that beneficiary's transaction. + function claim() external nonReentrant returns (uint256 amount) { + address beneficiary = msg.sender; + if (shareBpsOf[beneficiary] == 0) revert UnauthorizedBeneficiary(beneficiary); + + uint256 balanceBefore = address(this).balance; + // The transient guard covers the hook pull. Effects precede the only untrusted payout call below. + // slither-disable-next-line reentrancy-benign + uint256 pulled = feeHook.claimCreatorFees(poolId); + uint256 received = address(this).balance - balanceBefore; + if (received != pulled) revert FeeReceiptMismatch(received, pulled); + totalCreatorFeesReceived += received; + + uint256 entitlement = _entitlement(beneficiary, totalCreatorFeesReceived); + uint256 alreadyClaimed = claimedBy[beneficiary]; + if (entitlement <= alreadyClaimed) revert NoFeesToClaim(beneficiary); + amount = entitlement - alreadyClaimed; + + claimedBy[beneficiary] = entitlement; + totalCreatorFeesClaimed += amount; + + address payoutAddress = payoutAddressOf[beneficiary]; + payable(payoutAddress).sendValue(amount); + emit BeneficiaryFeesClaimed(beneficiary, payoutAddress, amount, entitlement, totalCreatorFeesReceived); + } + + /// @dev Native ETH is received only while the registered hook redeems a PoolManager claim. + receive() external payable { + if (msg.sender != address(poolManager)) revert UnauthorizedNativeSender(msg.sender); + } + + function _entitlement(address beneficiary, uint256 totalReceived) private view returns (uint256 amount) { + uint256 count = beneficiaryCount; + if (beneficiary != _beneficiaries[count - 1]) { + return FullMath.mulDiv(totalReceived, shareBpsOf[beneficiary], BASIS_POINTS); + } + + // The final immutable beneficiary receives all deterministic division remainders. + uint256 allocatedBeforeRemainder = 0; + for (uint256 index; index + 1 < count; index++) { + address priorBeneficiary = _beneficiaries[index]; + allocatedBeforeRemainder += FullMath.mulDiv(totalReceived, shareBpsOf[priorBeneficiary], BASIS_POINTS); + } + amount = totalReceived - allocatedBeforeRemainder; + } +} diff --git a/src/MemeLaunchV2.sol b/src/MemeLaunchV2.sol new file mode 100644 index 00000000..677d235e --- /dev/null +++ b/src/MemeLaunchV2.sol @@ -0,0 +1,643 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ReentrancyGuardTransient } from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { CurrencySettler } from "@openzeppelin/uniswap-hooks/src/utils/CurrencySettler.sol"; +import { PositionPlanner } from "@uniswap/liquidity-launcher/src/libraries/PositionPlanner.sol"; +import { PositionFeesForwarder } from "@uniswap/liquidity-launcher/src/periphery/PositionFeesForwarder.sol"; +import { + CurrencyAmounts, + Plan, + Position, + PositionDefinition +} from "@uniswap/liquidity-launcher/src/types/PositionPlannerTypes.sol"; +import { UERC20Factory } from "@uniswap/uerc20-factory/src/factories/UERC20Factory.sol"; +import { UERC20Metadata } from "@uniswap/uerc20-factory/src/libraries/UERC20MetadataLibrary.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { Currency } from "@uniswap/v4-core/src/types/Currency.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IPositionManager } from "@uniswap/v4-periphery/src/interfaces/IPositionManager.sol"; + +import { ClassicInitialBuyCustodyConfig, ClassicInitialBuyCustodyMode } from "./ClassicInitialBuyVestingWalletV1.sol"; +import { ClassicInitialBuyVestingWalletFactoryV1 } from "./ClassicInitialBuyVestingWalletFactoryV1.sol"; +import { ClassicLaunchPolicyV1 } from "./ClassicLaunchPolicyV1.sol"; +import { ClassicRewardVaultFactoryV1 } from "./ClassicRewardVaultFactoryV1.sol"; +import { EthCreatorFeeHookV3 } from "./EthCreatorFeeHookV3.sol"; +import { LockedPositionFeeForwarderFactoryV1 } from "./LockedPositionFeeForwarderFactoryV1.sol"; +import { IClassicFeeHookV3 } from "./interfaces/IClassicFeeHookV3.sol"; + +/// @title MemeLaunchV2 +/// @notice Launches a fixed-supply Classic token with immutable directional fees and beneficiary-owned rewards. +/// @dev Preserves Classic's UERC20, pool, locked one-sided position and initial-buy mechanics. Creator rewards use one +/// authenticated vault whose future payout configuration can change only through its disclosed rules. +contract MemeLaunchV2 is IUnlockCallback, ReentrancyGuardTransient { + using CurrencySettler for Currency; + using SafeCast for *; + + uint8 public constant TOKEN_DECIMALS = 18; + uint256 public constant TOKEN_SUPPLY = 1_000_000_000 ether; + uint256 public constant MIN_INITIAL_BUY_WEI = 0.0006 ether; + uint256 public constant MAX_REWARD_BENEFICIARIES = 5; + uint16 public constant REWARD_SHARE_BASIS_POINTS = 10_000; + int24 public constant INITIAL_TICK = 204_200; + int24 public constant TICK_SPACING = 200; + uint24 public constant LP_FEE_PIPS = 0; + uint24 private constant POSITION_WEIGHT = 10_000_000; + // Slither 0.11.5 cannot build IR for unlockCallback and consequently misses the native settlement use. + // slither-disable-next-line unused-state + Currency private constant NATIVE = Currency.wrap(address(0)); + + IPoolManager public immutable poolManager; + IPositionManager public immutable positionManager; + UERC20Factory public immutable tokenFactory; + EthCreatorFeeHookV3 public immutable feeHook; + ClassicRewardVaultFactoryV1 public immutable rewardVaultFactory; + ClassicInitialBuyVestingWalletFactoryV1 public immutable initialBuyVestingWalletFactory; + ClassicLaunchPolicyV1 public immutable launchPolicy; + LockedPositionFeeForwarderFactoryV1 public immutable positionForwarderFactory; + + mapping(address token => bytes32 launchHash) public launchHashOf; + mapping(address token => address rewardVault) public rewardVaultOf; + mapping(address token => address custody) public initialBuyCustodyOf; + + struct LaunchParameters { + string name; + string symbol; + uint16 buySwapFeeBps; + uint16 sellSwapFeeBps; + bytes32 creatorSalt; + UERC20Metadata metadata; + address[] rewardBeneficiaries; + uint16[] rewardSharesBps; + ClassicInitialBuyCustodyConfig initialBuyCustody; + } + + struct LaunchResult { + address token; + address rewardVault; + address positionRecipient; + uint256 positionTokenId; + uint256 tokenLiquidityAmount; + uint256 lockedTokenDust; + uint256 initialBuyNativeAmount; + uint256 initialBuyTokenAmount; + address initialBuyCustody; + bytes32 poolId; + bytes32 launchHash; + } + + struct InitialBuyCallbackData { + PoolKey key; + address recipient; + uint256 nativeAmount; + } + + error InitialBuyBelowMinimum(uint256 actual, uint256 minimum); + error InvalidDependency(address dependency); + error InvalidInitialBuyDelta(int128 nativeDelta, int128 tokenDelta); + error InvalidInitialBuyRecipientBalance(uint256 actual, uint256 expected); + error InvalidInitialBuyResult(uint256 tokenAmount, uint256 residualNativeBalance); + error InvalidInitialBuySettlement(uint256 actual, uint256 expected); + error InvalidInitialTick(int24 actual, int24 expected); + error InvalidPosition(uint256 count, uint256 amount0, int24 tickLower, int24 tickUpper); + error InvalidPositionManager(address expectedPoolManager, address actualPoolManager); + error InvalidPositionManagerFactory(address expectedPositionManager, address actualPositionManager); + error InvalidSharedHook(address expectedPoolManager, uint24 lpFeePips, int24 tickSpacing); + error InvalidVaultFactory(address expected, address actual); + error TokenAddressMismatch(address actual, address predicted); + error TokenAlreadyExists(address token); + error TokenCustodyMismatch(uint256 launcherBalance, uint256 positionManagerBalance); + error UnauthorizedUnlockCallback(address caller); + error UnrecognizedFactoryDeployment(address deployment); + + event MemeTokenLaunchedV2( + address indexed deployer, + address indexed token, + bytes32 indexed poolId, + address feeHook, + address rewardVault, + address positionRecipient, + uint256 positionTokenId, + uint16 buySwapFeeBps, + uint16 sellSwapFeeBps, + bytes32 rewardConfigurationHash, + bytes32 launchHash + ); + event MemeLiquidityConfiguredV2( + address indexed token, + uint256 totalSupply, + uint256 tokenLiquidityAmount, + uint256 lockedTokenDust, + int24 initialTick, + int24 tickLower, + int24 tickUpper, + uint24 lpFeePips, + bytes32 launchHash + ); + event MemeCreatorInitialBuyV2( + address indexed deployer, + address indexed token, + bytes32 indexed poolId, + uint256 nativeAmount, + uint256 tokenAmount, + bytes32 launchHash + ); + event MemeCreatorInitialBuyCustodyV2( + address indexed deployer, + address indexed token, + address indexed custody, + ClassicInitialBuyCustodyMode mode, + uint16 durationDays, + uint16 cliffDays, + bytes32 configurationHash, + bytes32 launchHash + ); + + constructor( + IPoolManager poolManager_, + IPositionManager positionManager_, + UERC20Factory tokenFactory_, + EthCreatorFeeHookV3 feeHook_, + ClassicRewardVaultFactoryV1 rewardVaultFactory_, + ClassicInitialBuyVestingWalletFactoryV1 initialBuyVestingWalletFactory_, + ClassicLaunchPolicyV1 launchPolicy_, + LockedPositionFeeForwarderFactoryV1 positionForwarderFactory_ + ) { + _requireContract(address(poolManager_)); + _requireContract(address(positionManager_)); + _requireContract(address(tokenFactory_)); + _requireContract(address(feeHook_)); + _requireContract(address(rewardVaultFactory_)); + _requireContract(address(initialBuyVestingWalletFactory_)); + _requireContract(address(launchPolicy_)); + _requireContract(address(positionForwarderFactory_)); + + address positionManagerPoolManager = address(positionManager_.poolManager()); + if (positionManagerPoolManager != address(poolManager_)) { + revert InvalidPositionManager(address(poolManager_), positionManagerPoolManager); + } + address factoryPositionManager = address(positionForwarderFactory_.positionManager()); + if (factoryPositionManager != address(positionManager_)) { + revert InvalidPositionManagerFactory(address(positionManager_), factoryPositionManager); + } + if ( + address(feeHook_.poolManager()) != address(poolManager_) || feeHook_.LP_FEE_PIPS() != LP_FEE_PIPS + || feeHook_.TICK_SPACING() != TICK_SPACING + ) { + revert InvalidSharedHook(address(poolManager_), feeHook_.LP_FEE_PIPS(), feeHook_.TICK_SPACING()); + } + address configuredVaultFactory = address(feeHook_.feeSplitVaultFactory()); + if (configuredVaultFactory != address(rewardVaultFactory_)) { + revert InvalidVaultFactory(address(rewardVaultFactory_), configuredVaultFactory); + } + + poolManager = poolManager_; + positionManager = positionManager_; + tokenFactory = tokenFactory_; + feeHook = feeHook_; + rewardVaultFactory = rewardVaultFactory_; + initialBuyVestingWalletFactory = initialBuyVestingWalletFactory_; + launchPolicy = launchPolicy_; + positionForwarderFactory = positionForwarderFactory_; + } + + function predictTokenAddress(string calldata name, string calldata symbol, address deployer, bytes32 creatorSalt) + external + view + returns (address token, bytes32 effectiveGraffiti) + { + effectiveGraffiti = _effectiveGraffiti(deployer, creatorSalt); + token = tokenFactory.getUERC20Address(name, symbol, TOKEN_DECIMALS, address(this), effectiveGraffiti); + } + + function predictRewardVault( + address token, + address deployer, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) external view returns (address) { + PoolKey memory key = _poolKey(token); + bytes32 poolId = PoolId.unwrap(key.toId()); + return rewardVaultFactory.predict( + _rewardVaultSalt(token, deployer), IClassicFeeHookV3(address(feeHook)), poolId, beneficiaries, sharesBps + ); + } + + /// @notice Creates, registers, initializes and permanently positions a Classic launch atomically. + function launch(LaunchParameters calldata parameters) + external + payable + nonReentrant + returns (LaunchResult memory result) + { + _validateLaunch(parameters); + if (msg.value < MIN_INITIAL_BUY_WEI) { + revert InitialBuyBelowMinimum(msg.value, MIN_INITIAL_BUY_WEI); + } + result.initialBuyNativeAmount = msg.value; + + bytes32 effectiveGraffiti = _effectiveGraffiti(msg.sender, parameters.creatorSalt); + result.token = tokenFactory.getUERC20Address( + parameters.name, parameters.symbol, TOKEN_DECIMALS, address(this), effectiveGraffiti + ); + if (result.token.code.length != 0) revert TokenAlreadyExists(result.token); + + PoolKey memory key = _poolKey(result.token); + result.poolId = PoolId.unwrap(key.toId()); + result.rewardVault = _deployOrReuseRewardVault( + result.token, msg.sender, result.poolId, parameters.rewardBeneficiaries, parameters.rewardSharesBps + ); + result.positionRecipient = _deployOrReusePositionRecipient(result.token, msg.sender); + _createToken(parameters, effectiveGraffiti, result.token); + + bytes32 registeredPoolId = + feeHook.registerPool(key, result.rewardVault, parameters.buySwapFeeBps, parameters.sellSwapFeeBps); + assert(registeredPoolId == result.poolId); + + uint160 initialSqrtPriceX96 = TickMath.getSqrtPriceAtTick(INITIAL_TICK); + int24 initializedTick = poolManager.initialize(key, initialSqrtPriceX96); + if (initializedTick != INITIAL_TICK) revert InvalidInitialTick(initializedTick, INITIAL_TICK); + + (Plan memory plan, Position memory position, uint256 lockedTokenDust) = + _buildOneSidedPlan(key, result.positionRecipient, initialSqrtPriceX96); + result.positionTokenId = positionManager.nextTokenId(); + result.tokenLiquidityAmount = position.amount1; + result.lockedTokenDust = lockedTokenDust; + + Currency.wrap(result.token).transfer(address(positionManager), TOKEN_SUPPLY); + positionManager.modifyLiquidities(abi.encode(plan.actions, plan.params), block.timestamp); + + uint256 launcherTokenBalance = IERC20(result.token).balanceOf(address(this)); + uint256 positionManagerTokenBalance = IERC20(result.token).balanceOf(address(positionManager)); + if (launcherTokenBalance != 0 || positionManagerTokenBalance != 0) { + revert TokenCustodyMismatch(launcherTokenBalance, positionManagerTokenBalance); + } + + result.initialBuyCustody = + _deployOrReuseInitialBuyCustody(result.token, msg.sender, parameters.initialBuyCustody); + address initialBuyRecipient = result.initialBuyCustody == address(0) ? msg.sender : result.initialBuyCustody; + result.initialBuyTokenAmount = _executeInitialBuy(key, initialBuyRecipient, result.initialBuyNativeAmount); + // ReentrancyGuardTransient protects the complete launch; Slither does not model its transient lock. + // slither-disable-next-line reentrancy-benign + result.launchHash = _recordLaunch(parameters, result, position, msg.sender); + } + + function unlockCallback(bytes calldata data) external returns (bytes memory) { + if (msg.sender != address(poolManager)) revert UnauthorizedUnlockCallback(msg.sender); + + InitialBuyCallbackData memory callback = abi.decode(data, (InitialBuyCallbackData)); + BalanceDelta delta = poolManager.swap( + callback.key, + SwapParams({ + zeroForOne: true, + amountSpecified: -callback.nativeAmount.toInt256(), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + "" + ); + + int128 nativeDelta = delta.amount0(); + int128 tokenDelta = delta.amount1(); + if (nativeDelta >= 0 || tokenDelta <= 0) revert InvalidInitialBuyDelta(nativeDelta, tokenDelta); + + uint256 nativeSettlement = (-int256(nativeDelta)).toUint256(); + if (nativeSettlement != callback.nativeAmount) { + revert InvalidInitialBuySettlement(nativeSettlement, callback.nativeAmount); + } + uint256 tokenAmount = int256(tokenDelta).toUint256(); + + NATIVE.settle(poolManager, address(this), nativeSettlement, false); + callback.key.currency1.take(poolManager, callback.recipient, tokenAmount, false); + return abi.encode(tokenAmount); + } + + function poolKey(address token) external view returns (PoolKey memory) { + return _poolKey(token); + } + + function _executeInitialBuy(PoolKey memory key, address recipient, uint256 nativeAmount) + private + returns (uint256 tokenAmount) + { + // Native ETH can be forced into any contract. Preserve that unrelated balance instead of allowing it to + // permanently block launches, while still proving that this launch spent exactly `nativeAmount`. + uint256 residualNativeBalance = address(this).balance - nativeAmount; + address token = Currency.unwrap(key.currency1); + uint256 recipientBalanceBefore = IERC20(token).balanceOf(recipient); + bytes memory result = poolManager.unlock( + abi.encode(InitialBuyCallbackData({ key: key, recipient: recipient, nativeAmount: nativeAmount })) + ); + tokenAmount = abi.decode(result, (uint256)); + if (tokenAmount == 0 || address(this).balance != residualNativeBalance) { + revert InvalidInitialBuyResult(tokenAmount, address(this).balance); + } + uint256 recipientBalanceIncrease = IERC20(token).balanceOf(recipient) - recipientBalanceBefore; + if (recipientBalanceIncrease != tokenAmount) { + revert InvalidInitialBuyRecipientBalance(recipientBalanceIncrease, tokenAmount); + } + } + + function _buildOneSidedPlan(PoolKey memory key, address positionRecipient, uint160 initialSqrtPriceX96) + private + pure + returns (Plan memory plan, Position memory position, uint256 lockedTokenDust) + { + int24 minUsableTick = TickMath.minUsableTick(TICK_SPACING); + PositionDefinition[] memory definitions = new PositionDefinition[](1); + definitions[0] = PositionDefinition({ + offsetLower: minUsableTick - INITIAL_TICK, + offsetUpper: 0, + weight: POSITION_WEIGHT, + overridePositionRecipient: positionRecipient + }); + + CurrencyAmounts memory available = CurrencyAmounts({ amount0: 0, amount1: TOKEN_SUPPLY }); + (Position[] memory positions, CurrencyAmounts memory remaining) = + PositionPlanner.resolve(definitions, initialSqrtPriceX96, TICK_SPACING, available, positionRecipient); + if ( + positions.length != 1 || positions[0].amount0 != 0 || positions[0].tickLower != minUsableTick + || positions[0].tickUpper != INITIAL_TICK + ) { + uint256 amount0 = positions.length == 0 ? 0 : positions[0].amount0; + int24 tickLower = positions.length == 0 ? int24(0) : positions[0].tickLower; + int24 tickUpper = positions.length == 0 ? int24(0) : positions[0].tickUpper; + revert InvalidPosition(positions.length, amount0, tickLower, tickUpper); + } + + position = positions[0]; + lockedTokenDust = remaining.amount1; + plan = PositionPlanner.toPlan(positions, key, positionRecipient); + } + + function _recordLaunch( + LaunchParameters calldata parameters, + LaunchResult memory result, + Position memory position, + address deployer + ) private returns (bytes32 launchHash) { + bytes32 rewardConfigurationHash = rewardVaultFactory.configurationHashOf(result.rewardVault); + bytes32 custodyConfigurationHash = _initialBuyCustodyConfigurationHash(parameters, result, deployer); + bytes32 infrastructureHash = + _infrastructureHash(result, deployer, rewardConfigurationHash, custodyConfigurationHash); + bytes32 economicsHash = _economicsHash(parameters, result, position, custodyConfigurationHash); + launchHash = keccak256(abi.encode(block.chainid, address(this), infrastructureHash, economicsHash)); + launchHashOf[result.token] = launchHash; + rewardVaultOf[result.token] = result.rewardVault; + initialBuyCustodyOf[result.token] = result.initialBuyCustody; + + _emitLaunchEvents( + parameters, result, position, deployer, rewardConfigurationHash, custodyConfigurationHash, launchHash + ); + } + + function _emitLaunchEvents( + LaunchParameters calldata parameters, + LaunchResult memory result, + Position memory position, + address deployer, + bytes32 rewardConfigurationHash, + bytes32 custodyConfigurationHash, + bytes32 launchHash + ) private { + _emitTokenLaunched(parameters, result, deployer, rewardConfigurationHash, launchHash); + emit MemeLiquidityConfiguredV2( + result.token, + TOKEN_SUPPLY, + result.tokenLiquidityAmount, + result.lockedTokenDust, + INITIAL_TICK, + position.tickLower, + position.tickUpper, + LP_FEE_PIPS, + launchHash + ); + emit MemeCreatorInitialBuyV2( + deployer, + result.token, + result.poolId, + result.initialBuyNativeAmount, + result.initialBuyTokenAmount, + launchHash + ); + emit MemeCreatorInitialBuyCustodyV2( + deployer, + result.token, + result.initialBuyCustody, + parameters.initialBuyCustody.mode, + parameters.initialBuyCustody.durationDays, + parameters.initialBuyCustody.cliffDays, + custodyConfigurationHash, + launchHash + ); + } + + function _emitTokenLaunched( + LaunchParameters calldata parameters, + LaunchResult memory result, + address deployer, + bytes32 rewardConfigurationHash, + bytes32 launchHash + ) private { + emit MemeTokenLaunchedV2( + deployer, + result.token, + result.poolId, + address(feeHook), + result.rewardVault, + result.positionRecipient, + result.positionTokenId, + parameters.buySwapFeeBps, + parameters.sellSwapFeeBps, + rewardConfigurationHash, + launchHash + ); + } + + function _infrastructureHash( + LaunchResult memory result, + address deployer, + bytes32 rewardConfigurationHash, + bytes32 custodyConfigurationHash + ) private view returns (bytes32) { + return keccak256( + abi.encode( + deployer, + result.token, + address(feeHook), + result.rewardVault, + rewardConfigurationHash, + address(initialBuyVestingWalletFactory), + result.initialBuyCustody, + custodyConfigurationHash, + result.positionRecipient, + result.positionTokenId, + result.poolId + ) + ); + } + + function _economicsHash( + LaunchParameters calldata parameters, + LaunchResult memory result, + Position memory position, + bytes32 custodyConfigurationHash + ) private view returns (bytes32) { + bytes32 liquidityHash = keccak256( + abi.encode( + TOKEN_SUPPLY, + result.tokenLiquidityAmount, + result.lockedTokenDust, + INITIAL_TICK, + position.tickLower, + position.tickUpper, + LP_FEE_PIPS + ) + ); + bytes32 tradeHash = keccak256( + abi.encode( + MIN_INITIAL_BUY_WEI, + result.initialBuyNativeAmount, + result.initialBuyTokenAmount, + parameters.buySwapFeeBps, + parameters.sellSwapFeeBps, + feeHook.LAUNCHER_FEE_BPS(), + parameters.initialBuyCustody.mode, + parameters.initialBuyCustody.durationDays, + parameters.initialBuyCustody.cliffDays, + custodyConfigurationHash + ) + ); + return keccak256(abi.encode(liquidityHash, tradeHash)); + } + + function _deployOrReusePositionRecipient(address token, address deployer) private returns (address recipient) { + recipient = positionForwarderFactory.predict(_positionSalt(token, deployer), deployer); + if (recipient.code.length == 0) { + return address(positionForwarderFactory.deploy(_positionSalt(token, deployer), deployer)); + } + + PositionFeesForwarder forwarder = PositionFeesForwarder(payable(recipient)); + if ( + positionForwarderFactory.configurationHashOf(recipient) == bytes32(0) + || address(forwarder.positionManager()) != address(positionManager) + || forwarder.operator() != address(0) || forwarder.timelockBlockNumber() != type(uint256).max + || forwarder.feeRecipient() != deployer + ) { + revert UnrecognizedFactoryDeployment(recipient); + } + } + + function _deployOrReuseInitialBuyCustody( + address token, + address deployer, + ClassicInitialBuyCustodyConfig calldata config + ) private returns (address custody) { + initialBuyVestingWalletFactory.validateConfig(config); + if (config.mode == ClassicInitialBuyCustodyMode.Unlocked) return address(0); + return address( + initialBuyVestingWalletFactory.deployOrGet( + _initialBuyCustodySalt(token, deployer), IERC20(token), deployer, block.timestamp.toUint64(), config + ) + ); + } + + function _deployOrReuseRewardVault( + address token, + address deployer, + bytes32 poolId, + address[] calldata beneficiaries, + uint16[] calldata sharesBps + ) private returns (address rewardVault) { + return address( + rewardVaultFactory.deployOrGet( + _rewardVaultSalt(token, deployer), IClassicFeeHookV3(address(feeHook)), poolId, beneficiaries, sharesBps + ) + ); + } + + function _initialBuyCustodyConfigurationHash( + LaunchParameters calldata parameters, + LaunchResult memory result, + address deployer + ) private view returns (bytes32) { + if (result.initialBuyCustody != address(0)) { + return initialBuyVestingWalletFactory.configurationHashOf(result.initialBuyCustody); + } + return keccak256( + abi.encode( + block.chainid, + address(this), + result.token, + deployer, + parameters.initialBuyCustody.mode, + parameters.initialBuyCustody.durationDays, + parameters.initialBuyCustody.cliffDays + ) + ); + } + + function _createToken(LaunchParameters calldata parameters, bytes32 effectiveGraffiti, address predictedToken) + private + { + address token = tokenFactory.createToken( + parameters.name, + parameters.symbol, + TOKEN_DECIMALS, + TOKEN_SUPPLY, + address(this), + abi.encode(parameters.metadata), + effectiveGraffiti + ); + if (token != predictedToken) revert TokenAddressMismatch(token, predictedToken); + } + + function _poolKey(address token) private view returns (PoolKey memory) { + return PoolKey({ + currency0: Currency.wrap(address(0)), + currency1: Currency.wrap(token), + fee: LP_FEE_PIPS, + tickSpacing: TICK_SPACING, + hooks: feeHook + }); + } + + function _validateLaunch(LaunchParameters calldata parameters) private view { + launchPolicy.validate( + parameters.name, + parameters.symbol, + parameters.metadata, + parameters.rewardBeneficiaries, + parameters.rewardSharesBps + ); + initialBuyVestingWalletFactory.validateConfig(parameters.initialBuyCustody); + } + + function _effectiveGraffiti(address deployer, bytes32 creatorSalt) private pure returns (bytes32) { + return keccak256(abi.encode(deployer, creatorSalt)); + } + + function _positionSalt(address token, address deployer) private pure returns (bytes32) { + return keccak256(abi.encode("launcher.meme-position.v1", token, deployer)); + } + + function _rewardVaultSalt(address token, address deployer) private pure returns (bytes32) { + return keccak256(abi.encode("programmable.classic-reward-vault.v1", token, deployer)); + } + + // Slither cannot build IR for the caller and therefore misses this use. + // slither-disable-next-line dead-code + function _initialBuyCustodySalt(address token, address deployer) private pure returns (bytes32) { + return keccak256(abi.encode("programmable.classic-initial-buy-custody.v1", token, deployer)); + } + + function _requireContract(address dependency) private view { + if (dependency == address(0) || dependency.code.length == 0) revert InvalidDependency(dependency); + } +} diff --git a/src/interfaces/IClassicCtoVaultV1.sol b/src/interfaces/IClassicCtoVaultV1.sol new file mode 100644 index 00000000..1f62848f --- /dev/null +++ b/src/interfaces/IClassicCtoVaultV1.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +/// @notice Minimal interface used by the shared Classic CTO authority. +interface IClassicCtoVaultV1 { + function executeCto(address[] calldata beneficiaries, uint16[] calldata sharesBps, bytes32 approvalReference) + external; +} diff --git a/src/interfaces/IClassicFeeHookV3.sol b/src/interfaces/IClassicFeeHookV3.sol new file mode 100644 index 00000000..ea15b4e7 --- /dev/null +++ b/src/interfaces/IClassicFeeHookV3.sol @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; + +/// @notice Minimal interface used by a Classic V3 reward vault. +interface IClassicFeeHookV3 { + function poolManager() external view returns (IPoolManager); + + /// @notice Redeems all currently accrued creator fees for `poolId` to its registered reward vault. + /// @dev The registered vault must be the caller. Returns zero when no new fees have accrued. + function claimCreatorFees(bytes32 poolId) external returns (uint256 amount); +} diff --git a/test/ClassicInitialBuyVestingWalletV1.t.sol b/test/ClassicInitialBuyVestingWalletV1.t.sol new file mode 100644 index 00000000..966a5f19 --- /dev/null +++ b/test/ClassicInitialBuyVestingWalletV1.t.sol @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Test } from "forge-std/Test.sol"; +import { MockERC20 } from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import { + ClassicInitialBuyCustodyConfig, + ClassicInitialBuyCustodyMode, + ClassicInitialBuyVestingWalletV1 +} from "../src/ClassicInitialBuyVestingWalletV1.sol"; +import { ClassicInitialBuyVestingWalletFactoryV1 } from "../src/ClassicInitialBuyVestingWalletFactoryV1.sol"; + +contract ClassicInitialBuyVestingWalletV1Test is Test { + uint256 internal constant ALLOCATION = 1_000_000 ether; + + ClassicInitialBuyVestingWalletFactoryV1 internal factory; + MockERC20 internal token; + address internal beneficiary; + address internal attacker; + + function setUp() public { + factory = new ClassicInitialBuyVestingWalletFactoryV1(); + token = new MockERC20("Classic Initial Buy", "CIB", 18); + beneficiary = makeAddr("beneficiary"); + attacker = makeAddr("attacker"); + vm.warp(1_800_000_000); + } + + function test_fixedLockReleasesEverythingOnlyAfterTheReleaseDay() public { + ClassicInitialBuyCustodyConfig memory config = _config(ClassicInitialBuyCustodyMode.FixedLock, 30, 0); + ClassicInitialBuyVestingWalletV1 wallet = _deploy(bytes32("fixed"), config); + token.mint(address(wallet), ALLOCATION); + + assertEq(wallet.owner(), beneficiary); + assertEq(wallet.start(), block.timestamp + 30 days); + assertEq(wallet.end(), block.timestamp + 30 days); + assertEq(wallet.releasable(address(token)), 0); + + vm.warp(block.timestamp + 30 days - 1); + assertEq(wallet.releasable(address(token)), 0); + vm.warp(block.timestamp + 1); + assertEq(wallet.releasable(address(token)), ALLOCATION); + + vm.prank(beneficiary); + wallet.release(address(token)); + assertEq(token.balanceOf(beneficiary), ALLOCATION); + assertEq(token.balanceOf(address(wallet)), 0); + } + + function test_linearVestingReleasesProRataFromLaunchUntilEnd() public { + uint64 launchTimestamp = uint64(block.timestamp); + ClassicInitialBuyCustodyConfig memory config = _config(ClassicInitialBuyCustodyMode.LinearVesting, 100, 0); + ClassicInitialBuyVestingWalletV1 wallet = _deploy(bytes32("linear"), config); + token.mint(address(wallet), ALLOCATION); + + vm.warp(uint256(launchTimestamp) + 25 days); + assertEq(wallet.releasable(address(token)), ALLOCATION / 4); + vm.prank(beneficiary); + wallet.release(address(token)); + assertEq(token.balanceOf(beneficiary), ALLOCATION / 4); + + vm.warp(uint256(launchTimestamp) + 100 days); + vm.prank(beneficiary); + wallet.release(address(token)); + assertEq(token.balanceOf(beneficiary), ALLOCATION); + } + + function test_cliffThenLinearStartsAtZeroAndReachesFullAllocationAtEnd() public { + uint64 launchTimestamp = uint64(block.timestamp); + ClassicInitialBuyCustodyConfig memory config = _config(ClassicInitialBuyCustodyMode.CliffLinearVesting, 100, 20); + ClassicInitialBuyVestingWalletV1 wallet = _deploy(bytes32("cliff"), config); + token.mint(address(wallet), ALLOCATION); + + assertEq(wallet.start(), uint256(launchTimestamp) + 20 days); + assertEq(wallet.end(), uint256(launchTimestamp) + 100 days); + vm.warp(uint256(launchTimestamp) + 20 days); + assertEq(wallet.releasable(address(token)), 0); + + vm.warp(uint256(launchTimestamp) + 60 days); + assertEq(wallet.releasable(address(token)), ALLOCATION / 2); + vm.prank(beneficiary); + wallet.release(address(token)); + assertEq(token.balanceOf(beneficiary), ALLOCATION / 2); + + vm.warp(uint256(launchTimestamp) + 100 days); + vm.prank(beneficiary); + wallet.release(address(token)); + assertEq(token.balanceOf(beneficiary), ALLOCATION); + } + + function test_onlyImmutableBeneficiaryCanReleaseOrAttemptOwnershipChanges() public { + ClassicInitialBuyVestingWalletV1 wallet = + _deploy(bytes32("immutable"), _config(ClassicInitialBuyCustodyMode.LinearVesting, 30, 0)); + token.mint(address(wallet), ALLOCATION); + vm.warp(block.timestamp + 30 days); + + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, attacker)); + wallet.release(address(token)); + + vm.prank(beneficiary); + vm.expectRevert(ClassicInitialBuyVestingWalletV1.ImmutableBeneficiary.selector); + wallet.transferOwnership(attacker); + + vm.prank(beneficiary); + vm.expectRevert(ClassicInitialBuyVestingWalletV1.ImmutableBeneficiary.selector); + wallet.renounceOwnership(); + + assertEq(wallet.owner(), beneficiary); + assertEq(token.balanceOf(address(wallet)), ALLOCATION); + } + + function test_factoryAddressAndConfigurationAreDeterministicAndAuthenticated() public { + ClassicInitialBuyCustodyConfig memory config = + _config(ClassicInitialBuyCustodyMode.CliffLinearVesting, 3650, 365); + bytes32 salt = keccak256("deterministic"); + uint64 launchTimestamp = uint64(block.timestamp); + address predicted = factory.predict(salt, IERC20(address(token)), beneficiary, launchTimestamp, config); + ClassicInitialBuyVestingWalletV1 wallet = + factory.deploy(salt, IERC20(address(token)), beneficiary, launchTimestamp, config); + ClassicInitialBuyVestingWalletV1 reused = + factory.deployOrGet(salt, IERC20(address(token)), beneficiary, launchTimestamp, config); + + assertEq(address(wallet), predicted); + assertEq(address(reused), predicted); + assertTrue(factory.isFactoryWallet(predicted)); + assertEq(factory.configurationHashOf(predicted), wallet.configurationHash()); + assertEq(address(wallet.initialBuyToken()), address(token)); + assertEq(uint8(wallet.custodyMode()), uint8(ClassicInitialBuyCustodyMode.CliffLinearVesting)); + assertEq(wallet.launchTimestamp(), launchTimestamp); + assertEq(wallet.durationDays(), 3650); + assertEq(wallet.cliffDays(), 365); + } + + function test_deployOrGetRejectsCodeThatWasNotAuthenticatedByTheFactory() public { + ClassicInitialBuyCustodyConfig memory config = _config(ClassicInitialBuyCustodyMode.FixedLock, 30, 0); + bytes32 salt = keccak256("impostor"); + uint64 launchTimestamp = uint64(block.timestamp); + address predicted = factory.predict(salt, IERC20(address(token)), beneficiary, launchTimestamp, config); + vm.etch(predicted, hex"00"); + + vm.expectRevert( + abi.encodeWithSelector( + ClassicInitialBuyVestingWalletFactoryV1.UnrecognizedFactoryDeployment.selector, predicted + ) + ); + factory.deployOrGet(salt, IERC20(address(token)), beneficiary, launchTimestamp, config); + } + + function test_rejectsInvalidSchedulesAndUnlockedDeployment() public { + _expectInvalid(_config(ClassicInitialBuyCustodyMode.Unlocked, 1, 0)); + _expectInvalid(_config(ClassicInitialBuyCustodyMode.FixedLock, 0, 0)); + _expectInvalid(_config(ClassicInitialBuyCustodyMode.FixedLock, 3651, 0)); + _expectInvalid(_config(ClassicInitialBuyCustodyMode.FixedLock, 30, 1)); + _expectInvalid(_config(ClassicInitialBuyCustodyMode.LinearVesting, 30, 1)); + _expectInvalid(_config(ClassicInitialBuyCustodyMode.CliffLinearVesting, 30, 0)); + _expectInvalid(_config(ClassicInitialBuyCustodyMode.CliffLinearVesting, 30, 30)); + + ClassicInitialBuyCustodyConfig memory unlocked = _config(ClassicInitialBuyCustodyMode.Unlocked, 0, 0); + factory.validateConfig(unlocked); + vm.expectRevert(ClassicInitialBuyVestingWalletFactoryV1.CustodyNotRequired.selector); + factory.deploy(bytes32("unlocked"), IERC20(address(token)), beneficiary, uint64(block.timestamp), unlocked); + } + + function _expectInvalid(ClassicInitialBuyCustodyConfig memory config) private { + vm.expectRevert(); + factory.validateConfig(config); + } + + function _deploy(bytes32 salt, ClassicInitialBuyCustodyConfig memory config) + private + returns (ClassicInitialBuyVestingWalletV1) + { + return factory.deploy(salt, IERC20(address(token)), beneficiary, uint64(block.timestamp), config); + } + + function _config(ClassicInitialBuyCustodyMode mode, uint16 durationDays, uint16 cliffDays) + private + pure + returns (ClassicInitialBuyCustodyConfig memory) + { + return ClassicInitialBuyCustodyConfig({ mode: mode, durationDays: durationDays, cliffDays: cliffDays }); + } +} diff --git a/test/ClassicLaunchPolicyV1.t.sol b/test/ClassicLaunchPolicyV1.t.sol new file mode 100644 index 00000000..8687d8d2 --- /dev/null +++ b/test/ClassicLaunchPolicyV1.t.sol @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; +import { UERC20Metadata } from "@uniswap/uerc20-factory/src/libraries/UERC20MetadataLibrary.sol"; + +import { ClassicLaunchPolicyV1 } from "../src/ClassicLaunchPolicyV1.sol"; + +contract ClassicLaunchPolicyV1Test is Test { + ClassicLaunchPolicyV1 internal policy; + + function setUp() public { + policy = new ClassicLaunchPolicyV1(); + } + + function test_acceptsEveryPublishedBoundaryAndFiveUnequalAllocations() public view { + address[] memory beneficiaries = new address[](5); + uint16[] memory sharesBps = new uint16[](5); + for (uint256 index; index < 5; index++) { + beneficiaries[index] = address(uint160(index + 1)); + } + sharesBps[0] = 5000; + sharesBps[1] = 2000; + sharesBps[2] = 1500; + sharesBps[3] = 1000; + sharesBps[4] = 500; + + policy.validate( + _string(policy.MAX_TOKEN_NAME_BYTES(), bytes1("N")), + _string(policy.MAX_TOKEN_SYMBOL_BYTES(), bytes1("S")), + UERC20Metadata({ + description: _string(policy.MAX_TOKEN_DESCRIPTION_BYTES(), bytes1("D")), + website: _string(policy.MAX_METADATA_URL_BYTES(), bytes1("W")), + image: _string(policy.MAX_METADATA_URL_BYTES(), bytes1("I")), + extraData: bytes(_string(policy.MAX_SOCIAL_EXTRA_DATA_BYTES(), bytes1("X"))) + }), + beneficiaries, + sharesBps + ); + } + + function test_rejectsEmptyNameAndSymbol() public { + (address[] memory beneficiaries, uint16[] memory sharesBps) = _singleAllocation(); + UERC20Metadata memory metadata = _metadata(); + + vm.expectRevert(ClassicLaunchPolicyV1.EmptyName.selector); + policy.validate("", "TOKEN", metadata, beneficiaries, sharesBps); + + vm.expectRevert(ClassicLaunchPolicyV1.EmptySymbol.selector); + policy.validate("Token", "", metadata, beneficiaries, sharesBps); + } + + function test_rejectsEachMetadataFieldAboveItsPublishedLimit() public { + (address[] memory beneficiaries, uint16[] memory sharesBps) = _singleAllocation(); + UERC20Metadata memory metadata = _metadata(); + uint256 maxNameBytes = policy.MAX_TOKEN_NAME_BYTES(); + uint256 maxSymbolBytes = policy.MAX_TOKEN_SYMBOL_BYTES(); + uint256 maxDescriptionBytes = policy.MAX_TOKEN_DESCRIPTION_BYTES(); + uint256 maxUrlBytes = policy.MAX_METADATA_URL_BYTES(); + uint256 maxExtraDataBytes = policy.MAX_SOCIAL_EXTRA_DATA_BYTES(); + + vm.expectRevert( + abi.encodeWithSelector(ClassicLaunchPolicyV1.TokenNameTooLong.selector, maxNameBytes + 1, maxNameBytes) + ); + policy.validate(_string(maxNameBytes + 1, bytes1("N")), "TOKEN", metadata, beneficiaries, sharesBps); + + vm.expectRevert( + abi.encodeWithSelector( + ClassicLaunchPolicyV1.TokenSymbolTooLong.selector, maxSymbolBytes + 1, maxSymbolBytes + ) + ); + policy.validate("Token", _string(maxSymbolBytes + 1, bytes1("S")), metadata, beneficiaries, sharesBps); + + metadata.description = _string(maxDescriptionBytes + 1, bytes1("D")); + vm.expectRevert( + abi.encodeWithSelector( + ClassicLaunchPolicyV1.TokenDescriptionTooLong.selector, maxDescriptionBytes + 1, maxDescriptionBytes + ) + ); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + metadata = _metadata(); + metadata.website = _string(maxUrlBytes + 1, bytes1("W")); + vm.expectRevert( + abi.encodeWithSelector(ClassicLaunchPolicyV1.MetadataWebsiteTooLong.selector, maxUrlBytes + 1, maxUrlBytes) + ); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + metadata = _metadata(); + metadata.image = _string(maxUrlBytes + 1, bytes1("I")); + vm.expectRevert( + abi.encodeWithSelector(ClassicLaunchPolicyV1.MetadataImageTooLong.selector, maxUrlBytes + 1, maxUrlBytes) + ); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + metadata = _metadata(); + metadata.extraData = bytes(_string(maxExtraDataBytes + 1, bytes1("X"))); + vm.expectRevert( + abi.encodeWithSelector( + ClassicLaunchPolicyV1.MetadataExtraDataTooLong.selector, maxExtraDataBytes + 1, maxExtraDataBytes + ) + ); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + } + + function test_rejectsInvalidRewardCountsWalletsSharesAndTotals() public { + UERC20Metadata memory metadata = _metadata(); + address[] memory beneficiaries = new address[](0); + uint16[] memory sharesBps = new uint16[](0); + vm.expectRevert(abi.encodeWithSelector(ClassicLaunchPolicyV1.InvalidBeneficiaryCount.selector, 0)); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + beneficiaries = new address[](2); + beneficiaries[0] = address(1); + beneficiaries[1] = address(1); + sharesBps = new uint16[](2); + sharesBps[0] = 5000; + sharesBps[1] = 5000; + vm.expectRevert(abi.encodeWithSelector(ClassicLaunchPolicyV1.DuplicateRewardBeneficiary.selector, address(1))); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + beneficiaries[1] = address(2); + beneficiaries[0] = address(0); + vm.expectRevert(abi.encodeWithSelector(ClassicLaunchPolicyV1.InvalidRewardBeneficiary.selector, address(0))); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + beneficiaries[0] = address(1); + sharesBps[0] = 0; + sharesBps[1] = 10_000; + vm.expectRevert( + abi.encodeWithSelector(ClassicLaunchPolicyV1.InvalidRewardShare.selector, address(1), uint16(0)) + ); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + + sharesBps[0] = 4000; + sharesBps[1] = 5000; + vm.expectRevert(abi.encodeWithSelector(ClassicLaunchPolicyV1.InvalidRewardShareTotal.selector, 9000)); + policy.validate("Token", "TOKEN", metadata, beneficiaries, sharesBps); + } + + function _metadata() private pure returns (UERC20Metadata memory) { + return UERC20Metadata({ + description: "Classic token", + website: "https://programmable.family", + image: "ipfs://classic", + extraData: bytes("") + }); + } + + function _singleAllocation() private pure returns (address[] memory beneficiaries, uint16[] memory sharesBps) { + beneficiaries = new address[](1); + beneficiaries[0] = address(1); + sharesBps = new uint16[](1); + sharesBps[0] = 10_000; + } + + function _string(uint256 length, bytes1 character) private pure returns (string memory value) { + bytes memory output = new bytes(length); + for (uint256 index; index < length; index++) { + output[index] = character; + } + return string(output); + } +} diff --git a/test/ClassicRewardVaultV1.t.sol b/test/ClassicRewardVaultV1.t.sol new file mode 100644 index 00000000..3358a5ee --- /dev/null +++ b/test/ClassicRewardVaultV1.t.sol @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { Test } from "forge-std/Test.sol"; + +import { ClassicCtoAuthorityV1 } from "../src/ClassicCtoAuthorityV1.sol"; +import { ClassicRewardVaultFactoryV1 } from "../src/ClassicRewardVaultFactoryV1.sol"; +import { ClassicRewardVaultV1 } from "../src/ClassicRewardVaultV1.sol"; +import { IClassicFeeHookV3 } from "../src/interfaces/IClassicFeeHookV3.sol"; + +contract VaultMockPoolManager { + function pay(address recipient, uint256 amount) external { + (bool success,) = recipient.call{ value: amount }(""); + require(success); + } + + receive() external payable { + // Test pool manager accepts native settlement. + } +} + +contract VaultMockHook is IClassicFeeHookV3 { + IPoolManager public immutable override poolManager; + mapping(bytes32 poolId => uint256 amount) public accrued; + + constructor(IPoolManager poolManager_) { + poolManager = poolManager_; + } + + function setAccrued(bytes32 poolId, uint256 amount) external { + accrued[poolId] = amount; + } + + function creatorFeesAccrued(bytes32 poolId) external view returns (uint256) { + return accrued[poolId]; + } + + function claimCreatorFees(bytes32 poolId) external returns (uint256 amount) { + amount = accrued[poolId]; + accrued[poolId] = 0; + VaultMockPoolManager(payable(address(poolManager))).pay(msg.sender, amount); + } +} + + contract ClassicRewardVaultV1Test is Test { + bytes32 internal constant POOL_ID = keccak256("classic-reward-pool"); + + ClassicCtoAuthorityV1 internal ctoAuthority; + ClassicRewardVaultFactoryV1 internal factory; + VaultMockPoolManager internal manager; + VaultMockHook internal hook; + address internal ctoAdmin; + + function setUp() public { + ctoAdmin = makeAddr("ctoAdmin"); + ctoAuthority = new ClassicCtoAuthorityV1(ctoAdmin); + factory = new ClassicRewardVaultFactoryV1(ctoAuthority); + manager = new VaultMockPoolManager(); + hook = new VaultMockHook(IPoolManager(address(manager))); + vm.deal(address(manager), 100 ether); + } + + function test_factoryDeploysAtPredictedAddressAndCommitsConfiguration() public { + address[] memory beneficiaries = _beneficiaries(3); + uint16[] memory shares = new uint16[](3); + shares[0] = 2500; + shares[1] = 2500; + shares[2] = 5000; + bytes32 salt = keccak256("vault"); + + address predicted = factory.predict(salt, hook, POOL_ID, beneficiaries, shares); + ClassicRewardVaultV1 vault = factory.deploy(salt, hook, POOL_ID, beneficiaries, shares); + ClassicRewardVaultV1 reused = factory.deployOrGet(salt, hook, POOL_ID, beneficiaries, shares); + + assertEq(address(vault), predicted); + assertEq(address(reused), predicted); + assertTrue(factory.isFactoryVault(predicted)); + assertEq(factory.configurationHashOf(predicted), vault.configurationHash()); + assertEq(address(vault.ctoAuthority()), address(ctoAuthority)); + assertEq(vault.beneficiaryCount(), 3); + assertEq(vault.configurationEpoch(), 1); + } + + function test_supportsFiveUnequalRewardAllocations() public { + address[] memory beneficiaries = _beneficiaries(5); + uint16[] memory shares = new uint16[](5); + shares[0] = 5000; + shares[1] = 2000; + shares[2] = 1500; + shares[3] = 1000; + shares[4] = 500; + + ClassicRewardVaultV1 vault = factory.deploy(bytes32("five"), hook, POOL_ID, beneficiaries, shares); + assertEq(vault.beneficiaryCount(), 5); + for (uint256 index; index < 5; index++) { + assertEq(vault.beneficiaryAt(index), beneficiaries[index]); + assertEq(vault.shareBpsAt(index), shares[index]); + } + } + + function test_rejectsZeroAndMoreThanFiveBeneficiaries() public { + address[] memory none = new address[](0); + uint16[] memory noShares = new uint16[](0); + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.InvalidBeneficiaryCount.selector, 0)); + factory.deploy(bytes32("none"), hook, POOL_ID, none, noShares); + + address[] memory six = _beneficiaries(6); + uint16[] memory shares = new uint16[](6); + for (uint256 index; index < 5; index++) { + shares[index] = 1500; + } + shares[5] = 2500; + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.InvalidBeneficiaryCount.selector, 6)); + factory.deploy(bytes32("six"), hook, POOL_ID, six, shares); + } + + function test_rejectsZeroDuplicateAndZeroShareBeneficiaries() public { + address[] memory beneficiaries = _beneficiaries(2); + uint16[] memory shares = _shares2(5000, 5000); + + beneficiaries[0] = address(0); + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.InvalidBeneficiary.selector, address(0))); + factory.deploy(bytes32("zero"), hook, POOL_ID, beneficiaries, shares); + + beneficiaries = _beneficiaries(2); + beneficiaries[1] = beneficiaries[0]; + vm.expectRevert( + abi.encodeWithSelector(ClassicRewardVaultV1.DuplicateBeneficiary.selector, beneficiaries[0]) + ); + factory.deploy(bytes32("duplicate"), hook, POOL_ID, beneficiaries, shares); + + beneficiaries = _beneficiaries(2); + shares[0] = 0; + shares[1] = 10_000; + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.InvalidShare.selector, beneficiaries[0], 0)); + factory.deploy(bytes32("zero-share"), hook, POOL_ID, beneficiaries, shares); + } + + function test_rejectsShareTotalOtherThanTenThousand() public { + address[] memory beneficiaries = _beneficiaries(2); + uint16[] memory shares = _shares2(4000, 5000); + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.InvalidShareTotal.selector, 9000)); + factory.deploy(bytes32("bad-total"), hook, POOL_ID, beneficiaries, shares); + } + + function test_acceptsSmartAndCounterfactualWalletBeneficiaries() public { + address smartWallet = address(new VaultMockPoolManager()); + address counterfactualWallet = address(0x1234567890123456789012345678901234567890); + ClassicRewardVaultV1 vault = factory.deploy( + bytes32("wallet-types"), + hook, + POOL_ID, + _addresses2(smartWallet, counterfactualWallet), + _shares2(5000, 5000) + ); + assertEq(vault.beneficiaryAt(0), smartWallet); + assertEq(vault.beneficiaryAt(1), counterfactualWallet); + } + + function test_roundingRemainderGoesToFinalBeneficiaryWithoutStrandingCreatorFees() public { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + ClassicRewardVaultV1 vault = + factory.deploy(bytes32("rounding"), hook, POOL_ID, _addresses2(alice, bob), _shares2(5000, 5000)); + hook.setAccrued(POOL_ID, 3); + + vm.prank(alice); + assertEq(vault.claim(), 1); + vm.prank(bob); + assertEq(vault.claim(), 2); + + assertEq(vault.totalCreatorFeesReceived(), 3); + assertEq(vault.totalCreatorFeesClaimed(), 3); + assertEq(address(vault).balance, 0); + } + + /// forge-config: default.fuzz.runs = 10000 + function testFuzz_splitConservationLeavesNoCreatorFeeStranded(uint96 rawAmount, uint16 rawShare) public { + uint256 amount = bound(uint256(rawAmount), 10_000, 10 ether); + uint16 firstShare = uint16(bound(uint256(rawShare), 1, 9999)); + address alice = makeAddr("fuzzAlice"); + address bob = makeAddr("fuzzBob"); + bytes32 poolId = keccak256(abi.encode(amount, firstShare)); + ClassicRewardVaultV1 vault = + factory.deploy(poolId, hook, poolId, _addresses2(alice, bob), _shares2(firstShare, 10_000 - firstShare)); + hook.setAccrued(poolId, amount); + + vm.prank(alice); + uint256 aliceClaim = vault.claim(); + vm.prank(bob); + uint256 bobClaim = vault.claim(); + + assertEq(aliceClaim + bobClaim, amount); + assertEq(vault.totalCreatorFeesClaimed(), amount); + assertEq(address(vault).balance, 0); + } + + function test_payoutWalletChangeMovesOnlyFutureRewardsAndNeedsNoAcceptance() public { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address replacement = makeAddr("replacement"); + ClassicRewardVaultV1 vault = + factory.deploy(bytes32("prospective"), hook, POOL_ID, _addresses2(alice, bob), _shares2(4000, 6000)); + + hook.setAccrued(POOL_ID, 10 ether); + vm.prank(alice); + vault.changePayoutWallet(0, replacement); + + assertEq(vault.beneficiaryAt(0), replacement); + assertEq(vault.shareBpsAt(0), 4000); + assertEq(vault.claimable(alice), 4 ether); + assertEq(vault.claimable(replacement), 0); + assertEq(vault.configurationEpoch(), 2); + + hook.setAccrued(POOL_ID, 5 ether); + vm.prank(replacement); + assertEq(vault.claim(), 2 ether); + vm.prank(alice); + assertEq(vault.claim(), 4 ether); + vm.prank(bob); + assertEq(vault.claim(), 9 ether); + + assertEq(replacement.balance, 2 ether); + assertEq(alice.balance, 4 ether); + assertEq(bob.balance, 9 ether); + assertEq(vault.totalCreatorFeesClaimed(), 15 ether); + } + + function test_onlyCurrentPayoutWalletCanChangeItself() public { + address owner = makeAddr("owner"); + address attacker = makeAddr("attacker"); + address replacement = makeAddr("replacement"); + ClassicRewardVaultV1 vault = + factory.deploy(bytes32("authority"), hook, POOL_ID, _addresses1(owner), _shares1(10_000)); + + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector( + ClassicRewardVaultV1.UnauthorizedAllocationOwner.selector, attacker, uint256(0), owner + ) + ); + vault.changePayoutWallet(0, replacement); + + vm.prank(owner); + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.InvalidBeneficiary.selector, address(0))); + vault.changePayoutWallet(0, address(0)); + } + + function test_payoutWalletCanConsolidateWithAnExistingPayoutWallet() public { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + ClassicRewardVaultV1 vault = + factory.deploy(bytes32("consolidate"), hook, POOL_ID, _addresses2(alice, bob), _shares2(4000, 6000)); + + vm.prank(alice); + vault.changePayoutWallet(0, bob); + assertEq(vault.beneficiaryAt(0), bob); + assertEq(vault.beneficiaryAt(1), bob); + assertEq(vault.shareBpsOf(bob), 10_000); + + hook.setAccrued(POOL_ID, 3 ether); + vm.prank(bob); + assertEq(vault.claim(), 3 ether); + } + + function test_ctoReplacesTheCompleteFutureConfigurationWithoutTakingOldRewards() public { + address alice = makeAddr("alice"); + address bob = makeAddr("bob"); + address carol = makeAddr("carol"); + address ctoWallet = makeAddr("ctoWallet"); + ClassicRewardVaultV1 vault = + factory.deploy( + bytes32("cto"), hook, POOL_ID, _addresses3(alice, bob, carol), _shares3(2000, 3000, 5000) + ); + + hook.setAccrued(POOL_ID, 10 ether); + bytes32 approvalReference = keccak256("approved-cto-application"); + vm.prank(ctoAdmin); + ctoAuthority.executeCto(vault, _addresses1(ctoWallet), _shares1(10_000), approvalReference); + + assertEq(vault.beneficiaryCount(), 1); + assertEq(vault.beneficiaryAt(0), ctoWallet); + assertEq(vault.configurationEpoch(), 2); + assertEq(vault.claimable(alice), 2 ether); + assertEq(vault.claimable(bob), 3 ether); + assertEq(vault.claimable(carol), 5 ether); + assertEq(vault.claimable(ctoWallet), 0); + + hook.setAccrued(POOL_ID, 4 ether); + vm.prank(ctoWallet); + assertEq(vault.claim(), 4 ether); + vm.prank(alice); + assertEq(vault.claim(), 2 ether); + vm.prank(bob); + assertEq(vault.claim(), 3 ether); + vm.prank(carol); + assertEq(vault.claim(), 5 ether); + } + + function test_ctoCanReplaceOneAllocationWithFiveUnequalAllocations() public { + address[] memory recipients = _beneficiaries(5); + uint16[] memory shares = new uint16[](5); + shares[0] = 5000; + shares[1] = 2000; + shares[2] = 1500; + shares[3] = 1000; + shares[4] = 500; + ClassicRewardVaultV1 vault = + factory.deploy(bytes32("cto-five"), hook, POOL_ID, _addresses1(makeAddr("oldOwner")), _shares1(10_000)); + + vm.prank(ctoAdmin); + ctoAuthority.executeCto(vault, recipients, shares, keccak256("five-way-cto")); + + assertEq(vault.beneficiaryCount(), 5); + for (uint256 index; index < recipients.length; index++) { + assertEq(vault.beneficiaryAt(index), recipients[index]); + assertEq(vault.shareBpsAt(index), shares[index]); + } + } + + function test_onlyCtoAuthorityCanReplaceConfigurationAndReferenceCannotBeEmpty() public { + address owner = makeAddr("owner"); + address replacement = makeAddr("replacement"); + address attacker = makeAddr("attacker"); + ClassicRewardVaultV1 vault = + factory.deploy(bytes32("cto-auth"), hook, POOL_ID, _addresses1(owner), _shares1(10_000)); + + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(ClassicCtoAuthorityV1.UnauthorizedAuthority.selector, attacker, ctoAdmin) + ); + ctoAuthority.executeCto(vault, _addresses1(replacement), _shares1(10_000), keccak256("attack")); + + vm.prank(ctoAdmin); + vm.expectRevert(ClassicRewardVaultV1.InvalidCtoApprovalReference.selector); + ctoAuthority.executeCto(vault, _addresses1(replacement), _shares1(10_000), bytes32(0)); + } + + function test_ctoAuthorityMovesThroughTwoStepAcceptance() public { + address nextAuthority = makeAddr("nextAuthority"); + address replacement = makeAddr("replacement"); + ClassicRewardVaultV1 vault = factory.deploy( + bytes32("cto-transfer"), hook, POOL_ID, _addresses1(makeAddr("oldOwner")), _shares1(10_000) + ); + + vm.prank(ctoAdmin); + ctoAuthority.proposeAuthority(nextAuthority); + assertEq(ctoAuthority.pendingAuthority(), nextAuthority); + + vm.prank(nextAuthority); + ctoAuthority.acceptAuthority(); + assertEq(ctoAuthority.authority(), nextAuthority); + assertEq(ctoAuthority.pendingAuthority(), address(0)); + + vm.prank(ctoAdmin); + vm.expectRevert( + abi.encodeWithSelector(ClassicCtoAuthorityV1.UnauthorizedAuthority.selector, ctoAdmin, nextAuthority) + ); + ctoAuthority.executeCto(vault, _addresses1(replacement), _shares1(10_000), keccak256("old-admin")); + + vm.prank(nextAuthority); + ctoAuthority.executeCto(vault, _addresses1(replacement), _shares1(10_000), keccak256("new-admin")); + assertEq(vault.beneficiaryAt(0), replacement); + } + + function test_claimCannotCrossPoolVaultBoundaries() public { + address beneficiary = makeAddr("sharedBeneficiary"); + bytes32 poolA = keccak256("pool-a"); + bytes32 poolB = keccak256("pool-b"); + ClassicRewardVaultV1 vaultA = + factory.deploy(bytes32("vault-a"), hook, poolA, _addresses1(beneficiary), _shares1(10_000)); + ClassicRewardVaultV1 vaultB = + factory.deploy(bytes32("vault-b"), hook, poolB, _addresses1(beneficiary), _shares1(10_000)); + hook.setAccrued(poolA, 1 ether); + hook.setAccrued(poolB, 2 ether); + + vm.prank(beneficiary); + assertEq(vaultA.claim(), 1 ether); + assertEq(hook.accrued(poolB), 2 ether); + assertEq(vaultB.totalCreatorFeesReceived(), 0); + + vm.prank(beneficiary); + assertEq(vaultB.claim(), 2 ether); + assertEq(beneficiary.balance, 3 ether); + } + + function _beneficiaries(uint256 count) private pure returns (address[] memory values) { + values = new address[](count); + for (uint256 index; index < count; index++) { + values[index] = address(uint160(index + 1)); + } + } + + function _addresses1(address a) private pure returns (address[] memory values) { + values = new address[](1); + values[0] = a; + } + + function _shares1(uint16 a) private pure returns (uint16[] memory values) { + values = new uint16[](1); + values[0] = a; + } + + function _addresses2(address a, address b) private pure returns (address[] memory values) { + values = new address[](2); + values[0] = a; + values[1] = b; + } + + function _shares2(uint16 a, uint16 b) private pure returns (uint16[] memory values) { + values = new uint16[](2); + values[0] = a; + values[1] = b; + } + + function _addresses3(address a, address b, address c) private pure returns (address[] memory values) { + values = new address[](3); + values[0] = a; + values[1] = b; + values[2] = c; + } + + function _shares3(uint16 a, uint16 b, uint16 c) private pure returns (uint16[] memory values) { + values = new uint16[](3); + values[0] = a; + values[1] = b; + values[2] = c; + } + } diff --git a/test/EthCreatorFeeHookV3.t.sol b/test/EthCreatorFeeHookV3.t.sol new file mode 100644 index 00000000..0495cb5e --- /dev/null +++ b/test/EthCreatorFeeHookV3.t.sol @@ -0,0 +1,419 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { BaseHook } from "@openzeppelin/uniswap-hooks/src/base/BaseHook.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { FullMath } from "@uniswap/v4-core/src/libraries/FullMath.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { ModifyLiquidityParams, SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { Deployers } from "@uniswap/v4-core/test/utils/Deployers.sol"; +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; +import { PoolSwapTest } from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import { MockERC20 } from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import { EthCreatorFeeHookFactoryV3 } from "../src/EthCreatorFeeHookFactoryV3.sol"; +import { EthCreatorFeeHookV3 } from "../src/EthCreatorFeeHookV3.sol"; +import { FeeSplitVaultFactoryV1 } from "../src/FeeSplitVaultFactoryV1.sol"; +import { FeeSplitVaultV1 } from "../src/FeeSplitVaultV1.sol"; +import { IClassicFeeHookV3 } from "../src/interfaces/IClassicFeeHookV3.sol"; + +contract ClassicV3CreatorToken is MockERC20 { + address public immutable creator; + + constructor(address creator_) MockERC20("Classic V3", "CV3", 18) { + creator = creator_; + } +} + +contract RejectingPayout { + receive() external payable { + revert(); + } +} + +contract EthCreatorFeeHookV3Test is Deployers { + uint16 internal constant BUY_FEE_BPS = 200; + uint16 internal constant SELL_FEE_BPS = 700; + uint256 internal constant BASIS_POINTS = 10_000; + + EthCreatorFeeHookFactoryV3 internal hookFactory; + FeeSplitVaultFactoryV1 internal vaultFactory; + EthCreatorFeeHookV3 internal hook; + FeeSplitVaultV1 internal vault; + ClassicV3CreatorToken internal token; + PoolKey internal hookKey; + bytes32 internal poolId; + + address internal treasury; + address internal alice; + address internal bob; + + PoolSwapTest.TestSettings internal settings = + PoolSwapTest.TestSettings({ takeClaims: false, settleUsingBurn: false }); + + function setUp() public { + deployFreshManagerAndRouters(); + vm.deal(address(this), 1000 ether); + + treasury = makeAddr("programmableTreasury"); + alice = makeAddr("alice"); + bob = makeAddr("bob"); + vaultFactory = new FeeSplitVaultFactoryV1(); + hookFactory = new EthCreatorFeeHookFactoryV3(); + hook = _deployHook(); + + token = new ClassicV3CreatorToken(address(this)); + token.mint(address(this), 1_000_000 ether); + token.approve(address(modifyLiquidityRouter), type(uint256).max); + token.approve(address(swapRouter), type(uint256).max); + + hookKey = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(token)), + fee: hook.LP_FEE_PIPS(), + tickSpacing: hook.TICK_SPACING(), + hooks: hook + }); + poolId = PoolId.unwrap(hookKey.toId()); + vault = _deployVault(poolId, _addresses2(alice, bob), _shares2(6000, 4000), bytes32("main")); + assertEq(hook.registerPool(hookKey, address(vault), BUY_FEE_BPS, SELL_FEE_BPS), poolId); + manager.initialize(hookKey, SQRT_PRICE_1_1); + + LIQUIDITY_PARAMS = + ModifyLiquidityParams({ tickLower: -200, tickUpper: 200, liquidityDelta: 1000 ether, salt: 0 }); + modifyLiquidityRouter.modifyLiquidity{ value: 20 ether }(hookKey, LIQUIDITY_PARAMS, ZERO_BYTES); + } + + function test_configurationAndDisclosureAreExplicit() public view { + assertEq(hook.launcherFeeRecipient(), treasury); + assertEq(address(hook.feeSplitVaultFactory()), address(vaultFactory)); + assertEq(hook.LAUNCHER_FEE_BPS(), 10); + assertEq(hook.TRANSFER_TAX_BPS(), 0); + assertEq(hook.LP_FEE_PIPS(), 0); + assertEq(uint160(address(hook)) & hookFactory.ALL_HOOK_MASK(), hookFactory.REQUIRED_HOOK_FLAGS()); + + ( + uint16 buy, + uint16 sell, + uint16 buyCreator, + uint16 sellCreator, + uint16 platform, + uint16 transferTax, + uint24 lpFee, + address rewardVault + ) = hook.feeDisclosure(poolId); + assertEq(buy, 200); + assertEq(sell, 700); + assertEq(buyCreator, 190); + assertEq(sellCreator, 690); + assertEq(platform, 10); + assertEq(transferTax, 0); + assertEq(lpFee, 0); + assertEq(rewardVault, address(vault)); + + Hooks.Permissions memory permissions = hook.getHookPermissions(); + assertTrue(permissions.beforeInitialize); + assertTrue(permissions.beforeSwap); + assertTrue(permissions.afterSwap); + assertTrue(permissions.beforeSwapReturnDelta); + assertTrue(permissions.afterSwapReturnDelta); + } + + function test_buyExactInputUsesBuyFee() public { + uint256 gross = 0.1 ether; + BalanceDelta delta = _swap(true, -int256(gross), gross); + (uint256 creatorFee, uint256 platformFee) = hook.quoteGrossFees(gross, BUY_FEE_BPS); + + assertEq(uint256(-int256(delta.amount0())), gross); + _assertAccrued(creatorFee, platformFee); + } + + function test_buyExactOutputUsesBuyFee() public { + uint256 tokenOutput = 0.01 ether; + BalanceDelta delta = _swap(true, int256(tokenOutput), 1 ether); + uint256 creatorFee = _creatorAccrued(); + uint256 platformFee = hook.launcherFeesAccrued(); + uint256 grossNativeInput = uint256(-int256(delta.amount0())); + uint256 netNativeInput = grossNativeInput - creatorFee - platformFee; + (uint256 expectedCreator, uint256 expectedPlatform) = hook.quoteExactOutputFees(netNativeInput, BUY_FEE_BPS); + + assertEq(uint256(int256(delta.amount1())), tokenOutput); + _assertAccrued(expectedCreator, expectedPlatform); + } + + function test_sellExactInputUsesSellFee() public { + uint256 tokenInput = 0.01 ether; + BalanceDelta delta = _swap(false, -int256(tokenInput), 0); + uint256 creatorFee = _creatorAccrued(); + uint256 platformFee = hook.launcherFeesAccrued(); + uint256 grossNativeOutput = uint256(int256(delta.amount0())) + creatorFee + platformFee; + (uint256 expectedCreator, uint256 expectedPlatform) = hook.quoteGrossFees(grossNativeOutput, SELL_FEE_BPS); + + assertEq(uint256(-int256(delta.amount1())), tokenInput); + _assertAccrued(expectedCreator, expectedPlatform); + } + + function test_sellExactOutputUsesSellFee() public { + uint256 netNativeOutput = 0.005 ether; + BalanceDelta delta = _swap(false, int256(netNativeOutput), 0); + (uint256 creatorFee, uint256 platformFee) = hook.quoteExactOutputFees(netNativeOutput, SELL_FEE_BPS); + + assertEq(uint256(int256(delta.amount0())), netNativeOutput); + _assertAccrued(creatorFee, platformFee); + } + + function test_platformShareIsAlwaysTenBpsAndNotAddedOnTop() public view { + for (uint16 fee = 100; fee <= 1000; fee += 100) { + (uint256 creatorFee, uint256 platformFee) = hook.quoteGrossFees(1 ether, fee); + assertEq(platformFee, 0.001 ether); + assertEq(creatorFee + platformFee, uint256(fee) * 0.0001 ether); + } + } + + function test_rejectsInvalidBuyAndSellFees() public { + uint16[6] memory invalid = [uint16(0), 99, 101, 999, 1001, 1100]; + for (uint256 index; index < invalid.length; index++) { + ClassicV3CreatorToken candidateToken = new ClassicV3CreatorToken(address(this)); + PoolKey memory candidate = _candidateKey(address(candidateToken)); + bytes32 candidateId = PoolId.unwrap(candidate.toId()); + FeeSplitVaultV1 candidateVault = + _deployVault(candidateId, _addresses1(alice), _shares1(10_000), bytes32(index + 1)); + + vm.expectRevert(abi.encodeWithSelector(EthCreatorFeeHookV3.InvalidTotalSwapFee.selector, invalid[index])); + hook.registerPool(candidate, address(candidateVault), invalid[index], 100); + + vm.expectRevert(abi.encodeWithSelector(EthCreatorFeeHookV3.InvalidTotalSwapFee.selector, invalid[index])); + hook.registerPool(candidate, address(candidateVault), 100, invalid[index]); + } + } + + function test_acceptsIndependentOneAndTenPercentFeeBounds() public { + ClassicV3CreatorToken candidateToken = new ClassicV3CreatorToken(address(this)); + PoolKey memory candidate = _candidateKey(address(candidateToken)); + bytes32 candidateId = PoolId.unwrap(candidate.toId()); + FeeSplitVaultV1 candidateVault = + _deployVault(candidateId, _addresses1(alice), _shares1(10_000), bytes32("fee-bounds")); + + hook.registerPool(candidate, address(candidateVault), 100, 1000); + (,, uint16 buyFeeBps, uint16 sellFeeBps, bool registered,) = hook.poolFeeConfig(candidateId); + assertTrue(registered); + assertEq(buyFeeBps, 100); + assertEq(sellFeeBps, 1000); + } + + function test_onlyVaultCanPullCreatorFeesAndOnlyBeneficiaryCanClaim() public { + _swap(true, -int256(0.1 ether), 0.1 ether); + address attacker = makeAddr("attacker"); + + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(EthCreatorFeeHookV3.UnauthorizedCreatorClaim.selector, attacker, address(vault)) + ); + hook.claimCreatorFees(poolId); + + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(FeeSplitVaultV1.UnauthorizedBeneficiary.selector, attacker)); + vault.claim(); + } + + function test_splitClaimsConserveAllCreatorFees() public { + _swap(true, -int256(0.1 ether), 0.1 ether); + uint256 creatorFee = _creatorAccrued(); + + vm.prank(alice); + uint256 aliceClaim = vault.claim(); + vm.prank(bob); + uint256 bobClaim = vault.claim(); + + assertEq(aliceClaim, FullMath.mulDiv(creatorFee, 6000, BASIS_POINTS)); + assertEq(aliceClaim + bobClaim, creatorFee); + assertEq(alice.balance, aliceClaim); + assertEq(bob.balance, bobClaim); + assertEq(vault.totalCreatorFeesClaimed(), creatorFee); + } + + function test_addressChangeRedirectsExistingAndFutureRewardsWithoutMovingAuthority() public { + _swap(true, -int256(0.1 ether), 0.1 ether); + address destination = makeAddr("destination"); + + vm.prank(alice); + vault.setPayoutAddress(destination); + vm.prank(alice); + uint256 first = vault.claim(); + _swap(false, -int256(0.01 ether), 0); + vm.prank(alice); + uint256 second = vault.claim(); + + assertEq(destination.balance, first + second); + vm.prank(destination); + vm.expectRevert(abi.encodeWithSelector(FeeSplitVaultV1.UnauthorizedBeneficiary.selector, destination)); + vault.claim(); + } + + function test_duplicatePayoutDestinationsAreAllowed() public { + address destination = makeAddr("sharedDestination"); + vm.prank(alice); + vault.setPayoutAddress(destination); + vm.prank(bob); + vault.setPayoutAddress(destination); + _swap(true, -int256(0.1 ether), 0.1 ether); + uint256 creatorFee = _creatorAccrued(); + + vm.prank(alice); + vault.claim(); + vm.prank(bob); + vault.claim(); + assertEq(destination.balance, creatorFee); + } + + function test_revertingPayoutDoesNotBlockAnotherBeneficiary() public { + RejectingPayout rejecting = new RejectingPayout(); + vm.prank(alice); + vault.setPayoutAddress(address(rejecting)); + _swap(true, -int256(0.1 ether), 0.1 ether); + + vm.prank(alice); + vm.expectRevert(); + vault.claim(); + + vm.prank(bob); + uint256 bobClaim = vault.claim(); + assertGt(bobClaim, 0); + assertEq(bob.balance, bobClaim); + } + + function test_noDoubleClaimAndNoCrossBeneficiaryClaim() public { + _swap(true, -int256(0.1 ether), 0.1 ether); + vm.prank(alice); + vault.claim(); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(FeeSplitVaultV1.NoFeesToClaim.selector, alice)); + vault.claim(); + + FeeSplitVaultV1 otherVault = + _deployVault(bytes32("other-pool"), _addresses1(bob), _shares1(10_000), bytes32("other")); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(FeeSplitVaultV1.UnauthorizedBeneficiary.selector, alice)); + otherVault.claim(); + } + + function test_onlyTreasuryCanClaimOrRedirectPlatformFees() public { + _swap(true, -int256(0.1 ether), 0.1 ether); + address attacker = makeAddr("platformAttacker"); + vm.prank(attacker); + vm.expectRevert( + abi.encodeWithSelector(EthCreatorFeeHookV3.UnauthorizedFeeRedirect.selector, attacker, treasury) + ); + hook.claimLauncherFees(); + + address recipient = makeAddr("treasuryRecipient"); + uint256 accrued = hook.launcherFeesAccrued(); + vm.prank(treasury); + hook.claimLauncherFeesTo(recipient); + assertEq(recipient.balance, accrued); + } + + function test_onlyPoolManagerCanCallEnabledHookCallbacksAndUnlockCallback() public { + SwapParams memory params = + SwapParams({ zeroForOne: true, amountSpecified: -int256(0.01 ether), sqrtPriceLimitX96: MIN_PRICE_LIMIT }); + + vm.expectRevert(BaseHook.NotPoolManager.selector); + hook.beforeInitialize(address(this), hookKey, SQRT_PRICE_1_1); + + vm.expectRevert(BaseHook.NotPoolManager.selector); + hook.beforeSwap(address(this), hookKey, params, ""); + + vm.expectRevert(BaseHook.NotPoolManager.selector); + hook.afterSwap(address(this), hookKey, params, BalanceDelta.wrap(0), ""); + + vm.expectRevert(BaseHook.NotPoolManager.selector); + hook.unlockCallback(""); + } + + /// forge-config: default.fuzz.runs = 1000 + function testFuzz_feeQuotesPreserveFixedEconomics(uint96 rawGross, uint8 rawPercent) public view { + uint256 gross = bound(uint256(rawGross), 10_000, 100_000 ether); + uint16 totalFeeBps = uint16(bound(uint256(rawPercent), 1, 10) * 100); + (uint256 creatorFee, uint256 platformFee) = hook.quoteGrossFees(gross, totalFeeBps); + assertEq(creatorFee + platformFee, FullMath.mulDiv(gross, totalFeeBps, BASIS_POINTS)); + assertEq(platformFee, FullMath.mulDiv(gross, 10, BASIS_POINTS)); + } + + function _deployHook() private returns (EthCreatorFeeHookV3 deployed) { + (, bytes32 salt) = HookMiner.find( + address(hookFactory), + hookFactory.REQUIRED_HOOK_FLAGS(), + type(EthCreatorFeeHookV3).creationCode, + abi.encode(manager, treasury, vaultFactory) + ); + deployed = hookFactory.deploy(salt, manager, treasury, vaultFactory); + } + + function _deployVault(bytes32 id, address[] memory beneficiaries, uint16[] memory shares, bytes32 salt) + private + returns (FeeSplitVaultV1) + { + return vaultFactory.deploy(salt, IClassicFeeHookV3(address(hook)), id, beneficiaries, shares); + } + + function _candidateKey(address candidateToken) private view returns (PoolKey memory) { + return PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(candidateToken), + fee: 0, + tickSpacing: 200, + hooks: hook + }); + } + + function _swap(bool zeroForOne, int256 amountSpecified, uint256 value) private returns (BalanceDelta) { + return swapRouter.swap{ value: value }( + hookKey, + SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amountSpecified, + sqrtPriceLimitX96: zeroForOne ? MIN_PRICE_LIMIT : MAX_PRICE_LIMIT + }), + settings, + "" + ); + } + + function _creatorAccrued() private view returns (uint256 accrued) { + (,,,,, accrued) = hook.poolFeeConfig(poolId); + } + + function _assertAccrued(uint256 creatorFee, uint256 platformFee) private view { + assertEq(_creatorAccrued(), creatorFee); + assertEq(hook.launcherFeesAccrued(), platformFee); + assertEq(hook.totalNativeFeesAccrued(), creatorFee + platformFee); + assertEq(manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()), creatorFee + platformFee); + } + + function _addresses1(address a) private pure returns (address[] memory values) { + values = new address[](1); + values[0] = a; + } + + function _shares1(uint16 a) private pure returns (uint16[] memory values) { + values = new uint16[](1); + values[0] = a; + } + + function _addresses2(address a, address b) private pure returns (address[] memory values) { + values = new address[](2); + values[0] = a; + values[1] = b; + } + + function _shares2(uint16 a, uint16 b) private pure returns (uint16[] memory values) { + values = new uint16[](2); + values[0] = a; + values[1] = b; + } +} diff --git a/test/MemeLaunchV2.t.sol b/test/MemeLaunchV2.t.sol new file mode 100644 index 00000000..e28db1ab --- /dev/null +++ b/test/MemeLaunchV2.t.sol @@ -0,0 +1,451 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import { + ITimelockedPositionRecipient +} from "@uniswap/liquidity-launcher/src/interfaces/ITimelockedPositionRecipient.sol"; +import { PositionFeesForwarder } from "@uniswap/liquidity-launcher/src/periphery/PositionFeesForwarder.sol"; +import { UERC20Factory } from "@uniswap/uerc20-factory/src/factories/UERC20Factory.sol"; +import { UERC20Metadata } from "@uniswap/uerc20-factory/src/libraries/UERC20MetadataLibrary.sol"; +import { UERC20 } from "@uniswap/uerc20-factory/src/tokens/UERC20.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { Deployers } from "@uniswap/v4-core/test/utils/Deployers.sol"; +import { PositionManager } from "@uniswap/v4-periphery/src/PositionManager.sol"; +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ClassicCtoAuthorityV1 } from "../src/ClassicCtoAuthorityV1.sol"; +import { + ClassicInitialBuyCustodyConfig, + ClassicInitialBuyCustodyMode, + ClassicInitialBuyVestingWalletV1 +} from "../src/ClassicInitialBuyVestingWalletV1.sol"; +import { ClassicInitialBuyVestingWalletFactoryV1 } from "../src/ClassicInitialBuyVestingWalletFactoryV1.sol"; +import { ClassicLaunchPolicyV1 } from "../src/ClassicLaunchPolicyV1.sol"; +import { ClassicRewardVaultFactoryV1 } from "../src/ClassicRewardVaultFactoryV1.sol"; +import { ClassicRewardVaultV1 } from "../src/ClassicRewardVaultV1.sol"; +import { EthCreatorFeeHookFactoryV3 } from "../src/EthCreatorFeeHookFactoryV3.sol"; +import { EthCreatorFeeHookV3 } from "../src/EthCreatorFeeHookV3.sol"; +import { FeeSplitVaultFactoryV1 } from "../src/FeeSplitVaultFactoryV1.sol"; +import { LockedPositionFeeForwarderFactoryV1 } from "../src/LockedPositionFeeForwarderFactoryV1.sol"; +import { MemeLaunchV2 } from "../src/MemeLaunchV2.sol"; +import { IClassicCtoVaultV1 } from "../src/interfaces/IClassicCtoVaultV1.sol"; +import { IClassicFeeHookV3 } from "../src/interfaces/IClassicFeeHookV3.sol"; + +contract MemeLaunchV2Test is Deployers { + address internal constant CANONICAL_POOL_MANAGER = 0x000000000004444c5dc75cB358380D2e3dE08A90; + address internal constant CANONICAL_POSITION_MANAGER = 0xbD216513d74C8cf14cf4747E6AaA6420FF64ee9e; + uint256 internal constant MIN_INITIAL_BUY_WEI = 0.0006 ether; + + PositionManager internal positionManager; + UERC20Factory internal tokenFactory; + EthCreatorFeeHookFactoryV3 internal hookFactory; + EthCreatorFeeHookV3 internal feeHook; + ClassicCtoAuthorityV1 internal ctoAuthority; + ClassicRewardVaultFactoryV1 internal vaultFactory; + ClassicInitialBuyVestingWalletFactoryV1 internal initialBuyVestingWalletFactory; + ClassicLaunchPolicyV1 internal launchPolicy; + LockedPositionFeeForwarderFactoryV1 internal positionForwarderFactory; + MemeLaunchV2 internal launcher; + + address internal deployer; + address internal externalBeneficiary; + address internal treasury; + address internal ctoController; + + function setUp() public { + deployCodeTo("PoolManager.sol:PoolManager", abi.encode(address(this)), CANONICAL_POOL_MANAGER); + manager = IPoolManager(CANONICAL_POOL_MANAGER); + deployCodeTo( + "PositionManager.sol:PositionManager", + abi.encode(manager, address(0), uint256(0), address(0), address(0)), + CANONICAL_POSITION_MANAGER + ); + positionManager = PositionManager(payable(CANONICAL_POSITION_MANAGER)); + + tokenFactory = new UERC20Factory(); + hookFactory = new EthCreatorFeeHookFactoryV3(); + ctoController = makeAddr("ctoController"); + ctoAuthority = new ClassicCtoAuthorityV1(ctoController); + vaultFactory = new ClassicRewardVaultFactoryV1(ctoAuthority); + initialBuyVestingWalletFactory = new ClassicInitialBuyVestingWalletFactoryV1(); + launchPolicy = new ClassicLaunchPolicyV1(); + positionForwarderFactory = new LockedPositionFeeForwarderFactoryV1(positionManager); + treasury = makeAddr("programmableTreasury"); + feeHook = _deployHook(); + launcher = new MemeLaunchV2( + manager, + positionManager, + tokenFactory, + feeHook, + vaultFactory, + initialBuyVestingWalletFactory, + launchPolicy, + positionForwarderFactory + ); + + deployer = makeAddr("deployer"); + externalBeneficiary = makeAddr("externalBeneficiary"); + vm.deal(deployer, 10 ether); + } + + function test_launchWalletAsSoleBeneficiaryPreservesLockedClassicLifecycle() public { + MemeLaunchV2.LaunchResult memory result = + _launch(_parameters(bytes32("deployer-only"), _addresses1(deployer), _shares1(10_000))); + PoolKey memory key = launcher.poolKey(result.token); + ClassicRewardVaultV1 vault = ClassicRewardVaultV1(payable(result.rewardVault)); + PositionFeesForwarder forwarder = PositionFeesForwarder(payable(result.positionRecipient)); + + assertEq(result.poolId, PoolId.unwrap(key.toId())); + assertEq(launcher.rewardVaultOf(result.token), result.rewardVault); + assertEq(launcher.launchHashOf(result.token), result.launchHash); + assertEq(vault.beneficiaryCount(), 1); + assertEq(vault.beneficiaryAt(0), deployer); + assertEq(vault.shareBpsOf(deployer), 10_000); + + assertEq(IERC20(result.token).totalSupply(), launcher.TOKEN_SUPPLY()); + assertEq(IERC20(result.token).balanceOf(deployer), result.initialBuyTokenAmount); + assertEq(IERC20(result.token).balanceOf(address(launcher)), 0); + assertEq(IERC20(result.token).balanceOf(address(positionManager)), 0); + assertEq(result.tokenLiquidityAmount + result.lockedTokenDust, launcher.TOKEN_SUPPLY()); + assertEq(IERC721(address(positionManager)).ownerOf(result.positionTokenId), result.positionRecipient); + assertEq(UERC20(result.token).creator(), address(launcher)); + + assertEq(forwarder.operator(), address(0)); + assertEq(forwarder.timelockBlockNumber(), type(uint256).max); + assertEq(forwarder.feeRecipient(), deployer); + vm.expectRevert(ITimelockedPositionRecipient.Timelocked.selector); + forwarder.approveOperator(); + } + + function test_externalBeneficiaryNeedsNoAcceptanceAndAloneCanClaim() public { + MemeLaunchV2.LaunchResult memory result = + _launch(_parameters(bytes32("external"), _addresses1(externalBeneficiary), _shares1(10_000))); + ClassicRewardVaultV1 vault = ClassicRewardVaultV1(payable(result.rewardVault)); + uint256 accrued = _creatorAccrued(result.poolId); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(ClassicRewardVaultV1.NoFeesToClaim.selector, deployer)); + vault.claim(); + + uint256 beforeBalance = externalBeneficiary.balance; + vm.prank(externalBeneficiary); + assertEq(vault.claim(), accrued); + assertEq(externalBeneficiary.balance, beforeBalance + accrued); + } + + function test_splitLaunchStoresUniqueSharesAndDirectionalFees() public { + address bob = makeAddr("bob"); + address carol = makeAddr("carol"); + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("split"), _addresses3(externalBeneficiary, bob, carol), _shares3(2000, 3000, 5000)); + parameters.buySwapFeeBps = 300; + parameters.sellSwapFeeBps = 900; + MemeLaunchV2.LaunchResult memory result = _launch(parameters); + ClassicRewardVaultV1 vault = ClassicRewardVaultV1(payable(result.rewardVault)); + + assertEq(vault.shareBpsOf(externalBeneficiary), 2000); + assertEq(vault.shareBpsOf(bob), 3000); + assertEq(vault.shareBpsOf(carol), 5000); + (,, uint16 buy, uint16 sell, bool registered,) = feeHook.poolFeeConfig(result.poolId); + assertTrue(registered); + assertEq(buy, 300); + assertEq(sell, 900); + } + + function test_supportsFiveBeneficiariesAtLaunch() public { + address[] memory beneficiaries = new address[](5); + uint16[] memory shares = new uint16[](5); + for (uint256 index; index < 5; index++) { + beneficiaries[index] = makeAddr(string.concat("beneficiary", vm.toString(index))); + shares[index] = index == 4 ? 6000 : 1000; + } + + MemeLaunchV2.LaunchResult memory result = _launch(_parameters(bytes32("five"), beneficiaries, shares)); + assertEq(ClassicRewardVaultV1(payable(result.rewardVault)).beneficiaryCount(), 5); + } + + function test_rejectsInvalidRewardConfigurationsBeforeTokenCreation() public { + MemeLaunchV2.LaunchParameters memory parameters = _parameters( + bytes32("invalid"), _addresses2(externalBeneficiary, externalBeneficiary), _shares2(5000, 5000) + ); + (address predicted,) = + launcher.predictTokenAddress(parameters.name, parameters.symbol, deployer, parameters.creatorSalt); + vm.prank(deployer); + vm.expectRevert( + abi.encodeWithSelector(ClassicLaunchPolicyV1.DuplicateRewardBeneficiary.selector, externalBeneficiary) + ); + launcher.launch{ value: MIN_INITIAL_BUY_WEI }(parameters); + assertEq(predicted.code.length, 0); + + parameters = _parameters( + bytes32("bad-total"), _addresses2(externalBeneficiary, makeAddr("bob")), _shares2(4000, 5000) + ); + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(ClassicLaunchPolicyV1.InvalidRewardShareTotal.selector, 9000)); + launcher.launch{ value: MIN_INITIAL_BUY_WEI }(parameters); + + address[] memory sixBeneficiaries = new address[](6); + uint16[] memory sixShares = new uint16[](6); + for (uint256 index; index < 6; index++) { + sixBeneficiaries[index] = makeAddr(string.concat("tooMany", vm.toString(index))); + sixShares[index] = index == 5 ? 5000 : 1000; + } + parameters = _parameters(bytes32("too-many"), sixBeneficiaries, sixShares); + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(ClassicLaunchPolicyV1.InvalidBeneficiaryCount.selector, 6)); + launcher.launch{ value: MIN_INITIAL_BUY_WEI }(parameters); + } + + function test_changePayoutWalletKeepsUnclaimedEthWithPreviousWallet() public { + address replacement = makeAddr("replacement"); + MemeLaunchV2.LaunchResult memory result = + _launch(_parameters(bytes32("payout-change"), _addresses1(externalBeneficiary), _shares1(10_000))); + ClassicRewardVaultV1 vault = ClassicRewardVaultV1(payable(result.rewardVault)); + uint256 accruedBeforeChange = _creatorAccrued(result.poolId); + assertGt(accruedBeforeChange, 0); + + vm.prank(externalBeneficiary); + vault.changePayoutWallet(0, replacement); + + assertEq(vault.beneficiaryAt(0), replacement); + assertEq(vault.claimable(externalBeneficiary), accruedBeforeChange); + assertEq(vault.claimable(replacement), 0); + + vm.prank(externalBeneficiary); + assertEq(vault.claim(), accruedBeforeChange); + assertEq(externalBeneficiary.balance, accruedBeforeChange); + } + + function test_approvedCtoChangesOnlyFutureRewardConfiguration() public { + address bob = makeAddr("bob"); + address ctoRecipient = makeAddr("ctoRecipient"); + MemeLaunchV2.LaunchResult memory result = + _launch(_parameters(bytes32("cto"), _addresses2(externalBeneficiary, bob), _shares2(3000, 7000))); + ClassicRewardVaultV1 vault = ClassicRewardVaultV1(payable(result.rewardVault)); + uint256 accruedBeforeCto = _creatorAccrued(result.poolId); + assertGt(accruedBeforeCto, 0); + + vm.prank(ctoController); + ctoAuthority.executeCto( + IClassicCtoVaultV1(address(vault)), _addresses1(ctoRecipient), _shares1(10_000), keccak256("approved-cto") + ); + + assertEq(vault.beneficiaryCount(), 1); + assertEq(vault.beneficiaryAt(0), ctoRecipient); + assertEq(vault.claimable(externalBeneficiary) + vault.claimable(bob), accruedBeforeCto); + assertEq(vault.claimable(ctoRecipient), 0); + } + + function test_rejectsInvalidDirectionalFeesAtomically() public { + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("bad-fee"), _addresses1(deployer), _shares1(10_000)); + parameters.buySwapFeeBps = 150; + (address predicted,) = + launcher.predictTokenAddress(parameters.name, parameters.symbol, deployer, parameters.creatorSalt); + + vm.prank(deployer); + vm.expectRevert(abi.encodeWithSelector(EthCreatorFeeHookV3.InvalidTotalSwapFee.selector, 150)); + launcher.launch{ value: MIN_INITIAL_BUY_WEI }(parameters); + assertEq(predicted.code.length, 0); + } + + function test_creatorCanChooseLargerInitialBuy() public { + uint256 largerBuy = 0.002 ether; + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("large-buy"), _addresses1(deployer), _shares1(10_000)); + vm.prank(deployer); + MemeLaunchV2.LaunchResult memory result = launcher.launch{ value: largerBuy }(parameters); + assertEq(result.initialBuyNativeAmount, largerBuy); + assertGt(result.initialBuyTokenAmount, 0); + } + + function test_fixedLockRoutesTheEntireInitialBuyDirectlyIntoAuthenticatedCustody() public { + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("fixed-lock"), _addresses1(deployer), _shares1(10_000)); + parameters.initialBuyCustody = ClassicInitialBuyCustodyConfig({ + mode: ClassicInitialBuyCustodyMode.FixedLock, durationDays: 30, cliffDays: 0 + }); + + MemeLaunchV2.LaunchResult memory result = _launch(parameters); + ClassicInitialBuyVestingWalletV1 custody = ClassicInitialBuyVestingWalletV1(payable(result.initialBuyCustody)); + + assertEq(launcher.initialBuyCustodyOf(result.token), address(custody)); + assertEq(IERC20(result.token).balanceOf(deployer), 0); + assertEq(IERC20(result.token).balanceOf(address(custody)), result.initialBuyTokenAmount); + assertEq(custody.owner(), deployer); + assertEq(address(custody.initialBuyToken()), result.token); + assertEq(custody.start(), block.timestamp + 30 days); + + vm.warp(block.timestamp + 30 days); + vm.prank(deployer); + custody.release(result.token); + assertEq(IERC20(result.token).balanceOf(deployer), result.initialBuyTokenAmount); + } + + function test_cliffLinearVestingStartsAtZeroAndUsesTheLaunchWalletForever() public { + uint64 launchTimestamp = uint64(block.timestamp); + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("cliff-linear"), _addresses1(deployer), _shares1(10_000)); + parameters.initialBuyCustody = ClassicInitialBuyCustodyConfig({ + mode: ClassicInitialBuyCustodyMode.CliffLinearVesting, durationDays: 100, cliffDays: 20 + }); + + MemeLaunchV2.LaunchResult memory result = _launch(parameters); + ClassicInitialBuyVestingWalletV1 custody = ClassicInitialBuyVestingWalletV1(payable(result.initialBuyCustody)); + assertEq(custody.owner(), deployer); + assertEq(custody.start(), uint256(launchTimestamp) + 20 days); + assertEq(custody.end(), uint256(launchTimestamp) + 100 days); + assertEq(custody.releasable(result.token), 0); + + vm.warp(uint256(launchTimestamp) + 60 days); + assertEq(custody.releasable(result.token), result.initialBuyTokenAmount / 2); + } + + function test_invalidInitialBuyCustodyRevertsBeforeTokenCreation() public { + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("invalid-custody"), _addresses1(deployer), _shares1(10_000)); + parameters.initialBuyCustody = ClassicInitialBuyCustodyConfig({ + mode: ClassicInitialBuyCustodyMode.FixedLock, durationDays: 0, cliffDays: 0 + }); + (address predicted,) = + launcher.predictTokenAddress(parameters.name, parameters.symbol, deployer, parameters.creatorSalt); + + vm.prank(deployer); + vm.expectRevert(); + launcher.launch{ value: MIN_INITIAL_BUY_WEI }(parameters); + assertEq(predicted.code.length, 0); + } + + function test_onlyPoolManagerCanCallInitialBuyUnlockCallback() public { + vm.expectRevert(abi.encodeWithSelector(MemeLaunchV2.UnauthorizedUnlockCallback.selector, address(this))); + launcher.unlockCallback(""); + } + + function test_forcedEthCannotBlockFutureLaunchesOrSubsidizeInitialBuy() public { + uint256 forcedBalance = 1 wei; + // Models native ETH forced in via SELFDESTRUCT without introducing deprecated test bytecode. + vm.deal(address(launcher), forcedBalance); + assertEq(address(launcher).balance, forcedBalance); + + MemeLaunchV2.LaunchResult memory result = + _launch(_parameters(bytes32("forced-eth"), _addresses1(deployer), _shares1(10_000))); + + assertEq(result.initialBuyNativeAmount, MIN_INITIAL_BUY_WEI); + assertGt(result.initialBuyTokenAmount, 0); + assertEq(address(launcher).balance, forcedBalance); + } + + function test_reusesMatchingPredeployedRewardVaultInsteadOfAllowingMempoolGriefing() public { + MemeLaunchV2.LaunchParameters memory parameters = + _parameters(bytes32("predeployed-vault"), _addresses1(externalBeneficiary), _shares1(10_000)); + (address token,) = + launcher.predictTokenAddress(parameters.name, parameters.symbol, deployer, parameters.creatorSalt); + PoolKey memory key = launcher.poolKey(token); + bytes32 poolId = PoolId.unwrap(key.toId()); + bytes32 vaultSalt = keccak256(abi.encode("programmable.classic-reward-vault.v1", token, deployer)); + ClassicRewardVaultV1 predeployed = vaultFactory.deploy( + vaultSalt, + IClassicFeeHookV3(address(feeHook)), + poolId, + parameters.rewardBeneficiaries, + parameters.rewardSharesBps + ); + + MemeLaunchV2.LaunchResult memory result = _launch(parameters); + assertEq(result.rewardVault, address(predeployed)); + } + + function _deployHook() private returns (EthCreatorFeeHookV3 deployed) { + (, bytes32 salt) = HookMiner.find( + address(hookFactory), + hookFactory.REQUIRED_HOOK_FLAGS(), + type(EthCreatorFeeHookV3).creationCode, + abi.encode(manager, treasury, FeeSplitVaultFactoryV1(address(vaultFactory))) + ); + deployed = hookFactory.deploy(salt, manager, treasury, FeeSplitVaultFactoryV1(address(vaultFactory))); + } + + function _launch(MemeLaunchV2.LaunchParameters memory parameters) + private + returns (MemeLaunchV2.LaunchResult memory result) + { + vm.prank(deployer); + result = launcher.launch{ value: MIN_INITIAL_BUY_WEI }(parameters); + } + + function _parameters(bytes32 salt, address[] memory beneficiaries, uint16[] memory shares) + private + pure + returns (MemeLaunchV2.LaunchParameters memory parameters) + { + parameters = MemeLaunchV2.LaunchParameters({ + name: string.concat("Classic ", _hexNibble(uint8(uint256(salt) & 0xf))), + symbol: string.concat("CV", _hexNibble(uint8(uint256(salt) & 0xf))), + buySwapFeeBps: 200, + sellSwapFeeBps: 700, + creatorSalt: salt, + metadata: UERC20Metadata({ + description: "Classic lifecycle fixture", + website: "https://programmable.family", + image: "ipfs://classic", + extraData: bytes("") + }), + rewardBeneficiaries: beneficiaries, + rewardSharesBps: shares, + initialBuyCustody: ClassicInitialBuyCustodyConfig({ + mode: ClassicInitialBuyCustodyMode.Unlocked, durationDays: 0, cliffDays: 0 + }) + }); + } + + function _creatorAccrued(bytes32 id) private view returns (uint256 accrued) { + (,,,,, accrued) = feeHook.poolFeeConfig(id); + } + + function _hexNibble(uint8 value) private pure returns (string memory) { + bytes memory alphabet = "0123456789abcdef"; + bytes memory output = new bytes(1); + output[0] = alphabet[value]; + return string(output); + } + + function _addresses1(address a) private pure returns (address[] memory values) { + values = new address[](1); + values[0] = a; + } + + function _shares1(uint16 a) private pure returns (uint16[] memory values) { + values = new uint16[](1); + values[0] = a; + } + + function _addresses2(address a, address b) private pure returns (address[] memory values) { + values = new address[](2); + values[0] = a; + values[1] = b; + } + + function _shares2(uint16 a, uint16 b) private pure returns (uint16[] memory values) { + values = new uint16[](2); + values[0] = a; + values[1] = b; + } + + function _addresses3(address a, address b, address c) private pure returns (address[] memory values) { + values = new address[](3); + values[0] = a; + values[1] = b; + values[2] = c; + } + + function _shares3(uint16 a, uint16 b, uint16 c) private pure returns (uint16[] memory values) { + values = new uint16[](3); + values[0] = a; + values[1] = b; + values[2] = c; + } +} diff --git a/test/invariant/ClassicRewardVaultV1Invariant.t.sol b/test/invariant/ClassicRewardVaultV1Invariant.t.sol new file mode 100644 index 00000000..b67de76a --- /dev/null +++ b/test/invariant/ClassicRewardVaultV1Invariant.t.sol @@ -0,0 +1,249 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { StdInvariant } from "forge-std/StdInvariant.sol"; +import { Test } from "forge-std/Test.sol"; + +import { ClassicCtoAuthorityV1 } from "../../src/ClassicCtoAuthorityV1.sol"; +import { ClassicRewardVaultFactoryV1 } from "../../src/ClassicRewardVaultFactoryV1.sol"; +import { ClassicRewardVaultV1 } from "../../src/ClassicRewardVaultV1.sol"; +import { IClassicCtoVaultV1 } from "../../src/interfaces/IClassicCtoVaultV1.sol"; +import { IClassicFeeHookV3 } from "../../src/interfaces/IClassicFeeHookV3.sol"; + +contract ClassicRewardInvariantPoolManager { + function pay(address recipient, uint256 amount) external { + (bool success,) = recipient.call{ value: amount }(""); + require(success); + } + + receive() external payable { + // Invariant pool manager accepts native settlement. + } +} + +contract ClassicRewardInvariantHook is IClassicFeeHookV3 { + IPoolManager public immutable override poolManager; + mapping(bytes32 poolId => uint256 amount) public accrued; + + constructor(IPoolManager poolManager_) { + poolManager = poolManager_; + } + + function addAccrued(bytes32 poolId, uint256 amount) external { + accrued[poolId] += amount; + } + + function creatorFeesAccrued(bytes32 poolId) external view returns (uint256) { + return accrued[poolId]; + } + + function claimCreatorFees(bytes32 poolId) external returns (uint256 amount) { + amount = accrued[poolId]; + accrued[poolId] = 0; + ClassicRewardInvariantPoolManager(payable(address(poolManager))).pay(msg.sender, amount); + } +} + + contract ClassicRewardInvariantActor { + ClassicRewardVaultV1 internal vault; + + function configure(ClassicRewardVaultV1 vault_) external { + require(address(vault) == address(0)); + vault = vault_; + } + + function claim() external { + try vault.claim() { } catch { } + } + + function changePayoutWallet(uint256 allocationIndex, address newPayoutWallet) external { + try vault.changePayoutWallet(allocationIndex, newPayoutWallet) { } catch { } + } + + receive() external payable { + // Actor balances are checked against claimed accounting. + } + } + + contract ClassicRewardInvariantCtoActor { + ClassicCtoAuthorityV1 internal immutable ctoAuthority; + address internal controller; + + constructor(ClassicCtoAuthorityV1 ctoAuthority_) { + ctoAuthority = ctoAuthority_; + } + + function configure(address controller_) external { + require(controller == address(0)); + controller = controller_; + } + + function executeCto( + IClassicCtoVaultV1 vault, + address[] memory beneficiaries, + uint16[] memory shares, + bytes32 approvalReference + ) external { + require(msg.sender == controller); + ctoAuthority.executeCto(vault, beneficiaries, shares, approvalReference); + } + } + + contract ClassicRewardVaultInvariantHandler { + bytes32 internal constant POOL_ID = keccak256("classic-reward-invariant"); + + ClassicRewardInvariantHook internal immutable hook; + ClassicRewardInvariantCtoActor internal immutable ctoActor; + ClassicRewardVaultV1 internal immutable vault; + ClassicRewardInvariantActor[] internal actors; + + constructor( + ClassicRewardInvariantHook hook_, + ClassicRewardInvariantCtoActor ctoActor_, + ClassicRewardVaultV1 vault_, + ClassicRewardInvariantActor[] memory actors_ + ) { + hook = hook_; + ctoActor = ctoActor_; + vault = vault_; + for (uint256 index; index < actors_.length; index++) { + actors.push(actors_[index]); + } + } + + function accrue(uint96 rawAmount) external { + uint256 amount = 1 + (uint256(rawAmount) % 1 ether); + hook.addAccrued(POOL_ID, amount); + } + + function claim(uint8 rawActor) external { + actors[uint256(rawActor) % actors.length].claim(); + } + + function changePayoutWallet(uint8 rawOwner, uint8 rawIndex, uint8 rawDestination) external { + ClassicRewardInvariantActor owner = actors[uint256(rawOwner) % actors.length]; + address destination = address(actors[uint256(rawDestination) % actors.length]); + owner.changePayoutWallet(uint256(rawIndex) % 5, destination); + } + + function executeCto(uint8 rawConfiguration) external { + uint256 configuration = uint256(rawConfiguration) % 4; + uint256 count = configuration == 0 ? 1 : configuration == 1 ? 2 : configuration == 2 ? 3 : 5; + address[] memory beneficiaries = new address[](count); + uint16[] memory shares = new uint16[](count); + + for (uint256 index; index < count; index++) { + beneficiaries[index] = address(actors[index]); + } + if (count == 1) { + shares[0] = 10_000; + } else if (count == 2) { + shares[0] = 4000; + shares[1] = 6000; + } else if (count == 3) { + shares[0] = 2000; + shares[1] = 3000; + shares[2] = 5000; + } else { + shares[0] = 1000; + shares[1] = 1500; + shares[2] = 2000; + shares[3] = 2500; + shares[4] = 3000; + } + + bytes32 approvalReference = keccak256(abi.encode("invariant-cto", rawConfiguration, block.number)); + ctoActor.executeCto(IClassicCtoVaultV1(address(vault)), beneficiaries, shares, approvalReference); + } + } + + contract ClassicRewardVaultV1InvariantTest is StdInvariant, Test { + bytes32 internal constant POOL_ID = keccak256("classic-reward-invariant"); + + ClassicRewardInvariantPoolManager internal manager; + ClassicRewardInvariantHook internal hook; + ClassicCtoAuthorityV1 internal ctoAuthority; + ClassicRewardInvariantCtoActor internal ctoActor; + ClassicRewardVaultFactoryV1 internal factory; + ClassicRewardVaultV1 internal vault; + ClassicRewardVaultInvariantHandler internal handler; + ClassicRewardInvariantActor[] internal actors; + + function setUp() public { + manager = new ClassicRewardInvariantPoolManager(); + hook = new ClassicRewardInvariantHook(IPoolManager(address(manager))); + vm.deal(address(manager), 1_000_000 ether); + + for (uint256 index; index < 5; index++) { + actors.push(new ClassicRewardInvariantActor()); + } + ClassicRewardInvariantActor[] memory actorList = new ClassicRewardInvariantActor[](actors.length); + for (uint256 index; index < actors.length; index++) { + actorList[index] = actors[index]; + } + + ctoAuthority = new ClassicCtoAuthorityV1(address(this)); + ctoActor = new ClassicRewardInvariantCtoActor(ctoAuthority); + ctoAuthority.proposeAuthority(address(ctoActor)); + vm.prank(address(ctoActor)); + ctoAuthority.acceptAuthority(); + factory = new ClassicRewardVaultFactoryV1(ctoAuthority); + + address[] memory beneficiaries = new address[](2); + beneficiaries[0] = address(actors[0]); + beneficiaries[1] = address(actors[1]); + uint16[] memory shares = new uint16[](2); + shares[0] = 3333; + shares[1] = 6667; + vault = factory.deploy( + bytes32("classic-reward-invariant"), IClassicFeeHookV3(address(hook)), POOL_ID, beneficiaries, shares + ); + + handler = new ClassicRewardVaultInvariantHandler(hook, ctoActor, vault, actorList); + ctoActor.configure(address(handler)); + for (uint256 index; index < actors.length; index++) { + actors[index].configure(vault); + } + + targetContract(address(handler)); + } + + function invariant_allReceivedEthIsClaimableOrAlreadyClaimed() public view { + uint256 totalClaimable; + uint256 totalClaimedByWallet; + for (uint256 index; index < actors.length; index++) { + address actor = address(actors[index]); + totalClaimable += vault.claimable(actor); + totalClaimedByWallet += vault.claimedBy(actor); + assertEq(actor.balance, vault.claimedBy(actor)); + } + + assertEq(vault.totalCreatorFeesClaimed(), totalClaimedByWallet); + assertEq(vault.totalCreatorFeesReceived(), vault.totalCreatorFeesClaimed() + totalClaimable); + assertEq(address(vault).balance, totalClaimable); + } + + function invariant_activeSharesAlwaysTotalOneHundredPercent() public view { + uint256 count = vault.beneficiaryCount(); + assertGe(count, 1); + assertLe(count, 5); + + uint256 totalShareBps; + for (uint256 index; index < count; index++) { + assertTrue(vault.beneficiaryAt(index) != address(0)); + assertGt(vault.shareBpsAt(index), 0); + totalShareBps += vault.shareBpsAt(index); + } + assertEq(totalShareBps, 10_000); + assertGe(vault.configurationEpoch(), 1); + } + + function invariant_ctoAuthorityAndVaultDependenciesNeverChange() public view { + assertEq(address(vault.feeHook()), address(hook)); + assertEq(address(vault.poolManager()), address(manager)); + assertEq(address(vault.ctoAuthority()), address(ctoAuthority)); + assertEq(vault.poolId(), POOL_ID); + assertEq(ctoAuthority.authority(), address(ctoActor)); + } + } diff --git a/test/invariant/ClassicV3FeeAccountingInvariant.t.sol b/test/invariant/ClassicV3FeeAccountingInvariant.t.sol new file mode 100644 index 00000000..fa52ced2 --- /dev/null +++ b/test/invariant/ClassicV3FeeAccountingInvariant.t.sol @@ -0,0 +1,305 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeCast } from "@openzeppelin/contracts/utils/math/SafeCast.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { PoolSwapTest } from "@uniswap/v4-core/src/test/PoolSwapTest.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { ModifyLiquidityParams, SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { Deployers } from "@uniswap/v4-core/test/utils/Deployers.sol"; +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; +import { MockERC20 } from "solmate/src/test/utils/mocks/MockERC20.sol"; + +import { EthCreatorFeeHookFactoryV3 } from "../../src/EthCreatorFeeHookFactoryV3.sol"; +import { EthCreatorFeeHookV3 } from "../../src/EthCreatorFeeHookV3.sol"; +import { FeeSplitVaultFactoryV1 } from "../../src/FeeSplitVaultFactoryV1.sol"; +import { FeeSplitVaultV1 } from "../../src/FeeSplitVaultV1.sol"; +import { IClassicFeeHookV3 } from "../../src/interfaces/IClassicFeeHookV3.sol"; + +contract ClassicV3InvariantToken is MockERC20 { + address public immutable creator; + + constructor(address creator_) MockERC20("Classic V3 Invariant", "CV3I", 18) { + creator = creator_; + } +} + +contract ClassicV3SwapHandler { + using SafeCast for uint256; + + PoolSwapTest internal immutable router; + IERC20 internal immutable token; + PoolKey internal poolKey; + + PoolSwapTest.TestSettings internal settings = + PoolSwapTest.TestSettings({ takeClaims: false, settleUsingBurn: false }); + + constructor(PoolSwapTest router_, IERC20 token_, PoolKey memory poolKey_) payable { + router = router_; + token = token_; + poolKey = poolKey_; + token_.approve(address(router_), type(uint256).max); + } + + function buyExactInput(uint96 rawAmount) external { + uint256 amount = 10_000 + (uint256(rawAmount) % 1e14); + if (address(this).balance < amount) return; + router.swap{ value: amount }( + poolKey, + SwapParams({ + zeroForOne: true, amountSpecified: -amount.toInt256(), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + settings, + "" + ); + } + + function sellExactInput(uint96 rawAmount) external { + uint256 balance = token.balanceOf(address(this)); + if (balance == 0) return; + uint256 amount = 10_000 + (uint256(rawAmount) % 1e14); + if (amount > balance) amount = balance; + router.swap( + poolKey, + SwapParams({ + zeroForOne: false, amountSpecified: -amount.toInt256(), sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + settings, + "" + ); + } + + function buyExactOutput(uint96 rawAmount) external { + uint256 amount = 10_000 + (uint256(rawAmount) % 1e12); + uint256 value = amount * 5; + if (address(this).balance < value) return; + router.swap{ value: value }( + poolKey, + SwapParams({ + zeroForOne: true, amountSpecified: amount.toInt256(), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + settings, + "" + ); + } + + function sellExactOutput(uint96 rawAmount) external { + if (token.balanceOf(address(this)) == 0) return; + uint256 amount = 10_000 + (uint256(rawAmount) % 1e12); + router.swap( + poolKey, + SwapParams({ + zeroForOne: false, amountSpecified: amount.toInt256(), sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + settings, + "" + ); + } + + receive() external payable { } +} + +contract ClassicV3BeneficiaryHandler { + address internal immutable configurator; + address internal immutable primaryPayout; + address internal immutable secondaryPayout; + FeeSplitVaultV1 internal vault; + + error AlreadyConfigured(); + error UnauthorizedConfigurator(address caller); + + constructor(address primaryPayout_, address secondaryPayout_) { + configurator = msg.sender; + primaryPayout = primaryPayout_; + secondaryPayout = secondaryPayout_; + } + + function configure(FeeSplitVaultV1 vault_) external { + if (msg.sender != configurator) revert UnauthorizedConfigurator(msg.sender); + if (address(vault) != address(0)) revert AlreadyConfigured(); + vault = vault_; + } + + function claim() external { + try vault.claim() { } catch { } + } + + function usePrimaryPayout() external { + try vault.setPayoutAddress(primaryPayout) { } catch { } + } + + function useSecondaryPayout() external { + try vault.setPayoutAddress(secondaryPayout) { } catch { } + } + + function isAllowedPayout(address payout) external view returns (bool) { + return payout == address(this) || payout == primaryPayout || payout == secondaryPayout; + } + + receive() external payable { } +} + +contract ClassicV3FeeAccountingInvariantTest is Deployers { + EthCreatorFeeHookFactoryV3 internal hookFactory; + FeeSplitVaultFactoryV1 internal vaultFactory; + EthCreatorFeeHookV3 internal hook; + FeeSplitVaultV1 internal vault; + ClassicV3InvariantToken internal token; + ClassicV3SwapHandler internal handler; + ClassicV3BeneficiaryHandler internal beneficiaryHandlerA; + ClassicV3BeneficiaryHandler internal beneficiaryHandlerB; + PoolKey internal hookKey; + bytes32 internal poolId; + + address internal beneficiaryA; + address internal beneficiaryB; + address internal primaryPayoutA; + address internal secondaryPayoutA; + address internal primaryPayoutB; + address internal secondaryPayoutB; + address internal treasury; + + function setUp() public { + deployFreshManagerAndRouters(); + vm.deal(address(this), 1_000_000 ether); + + primaryPayoutA = makeAddr("primaryPayoutA"); + secondaryPayoutA = makeAddr("secondaryPayoutA"); + primaryPayoutB = makeAddr("primaryPayoutB"); + secondaryPayoutB = makeAddr("secondaryPayoutB"); + beneficiaryHandlerA = new ClassicV3BeneficiaryHandler(primaryPayoutA, secondaryPayoutA); + beneficiaryHandlerB = new ClassicV3BeneficiaryHandler(primaryPayoutB, secondaryPayoutB); + beneficiaryA = address(beneficiaryHandlerA); + beneficiaryB = address(beneficiaryHandlerB); + treasury = makeAddr("treasury"); + vaultFactory = new FeeSplitVaultFactoryV1(); + hookFactory = new EthCreatorFeeHookFactoryV3(); + (, bytes32 hookSalt) = HookMiner.find( + address(hookFactory), + hookFactory.REQUIRED_HOOK_FLAGS(), + type(EthCreatorFeeHookV3).creationCode, + abi.encode(manager, treasury, vaultFactory) + ); + hook = hookFactory.deploy(hookSalt, manager, treasury, vaultFactory); + + token = new ClassicV3InvariantToken(address(this)); + token.mint(address(this), 1e36); + token.approve(address(modifyLiquidityRouter), type(uint256).max); + hookKey = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(token)), + fee: 0, + tickSpacing: 200, + hooks: hook + }); + poolId = PoolId.unwrap(hookKey.toId()); + address[] memory beneficiaries = new address[](2); + beneficiaries[0] = beneficiaryA; + beneficiaries[1] = beneficiaryB; + uint16[] memory shares = new uint16[](2); + shares[0] = 3333; + shares[1] = 6667; + vault = vaultFactory.deploy( + bytes32("classic-v3-invariant"), IClassicFeeHookV3(address(hook)), poolId, beneficiaries, shares + ); + beneficiaryHandlerA.configure(vault); + beneficiaryHandlerB.configure(vault); + hook.registerPool(hookKey, address(vault), 200, 900); + manager.initialize(hookKey, SQRT_PRICE_1_1); + + LIQUIDITY_PARAMS = ModifyLiquidityParams({ tickLower: -200, tickUpper: 200, liquidityDelta: 1e22, salt: 0 }); + modifyLiquidityRouter.modifyLiquidity{ value: 1000 ether }(hookKey, LIQUIDITY_PARAMS, ZERO_BYTES); + + handler = new ClassicV3SwapHandler{ value: 10_000 ether }(swapRouter, IERC20(address(token)), hookKey); + assertTrue(token.transfer(address(handler), 1e30)); + + bytes4[] memory selectors = new bytes4[](4); + selectors[0] = ClassicV3SwapHandler.buyExactInput.selector; + selectors[1] = ClassicV3SwapHandler.sellExactInput.selector; + selectors[2] = ClassicV3SwapHandler.buyExactOutput.selector; + selectors[3] = ClassicV3SwapHandler.sellExactOutput.selector; + targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); + targetContract(address(handler)); + + bytes4[] memory beneficiarySelectors = new bytes4[](3); + beneficiarySelectors[0] = ClassicV3BeneficiaryHandler.claim.selector; + beneficiarySelectors[1] = ClassicV3BeneficiaryHandler.usePrimaryPayout.selector; + beneficiarySelectors[2] = ClassicV3BeneficiaryHandler.useSecondaryPayout.selector; + targetSelector(FuzzSelector({ addr: address(beneficiaryHandlerA), selectors: beneficiarySelectors })); + targetSelector(FuzzSelector({ addr: address(beneficiaryHandlerB), selectors: beneficiarySelectors })); + } + + function invariant_nativeClaimsExactlyCoverAccruedAccounting() public view { + uint256 nativeClaims = manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()); + assertEq(nativeClaims, hook.totalNativeFeesAccrued()); + assertEq(manager.balanceOf(address(hook), hookKey.currency1.toId()), 0); + + (,,,,, uint256 creatorFees) = hook.poolFeeConfig(poolId); + assertEq(creatorFees + hook.launcherFeesAccrued(), hook.totalNativeFeesAccrued()); + } + + function invariant_directionalEconomicsNeverChange() public view { + (address rewardVault, address registrar, uint16 buy, uint16 sell, bool registered,) = hook.poolFeeConfig(poolId); + assertEq(rewardVault, address(vault)); + assertEq(registrar, address(this)); + assertEq(buy, 200); + assertEq(sell, 900); + assertTrue(registered); + + ( + uint16 disclosedBuy, + uint16 disclosedSell, + uint16 buyCreator, + uint16 sellCreator, + uint16 platform, + uint16 transferTax, + uint24 lpFee, + address disclosedVault + ) = hook.feeDisclosure(poolId); + assertEq(disclosedBuy, 200); + assertEq(disclosedSell, 900); + assertEq(buyCreator, 190); + assertEq(sellCreator, 890); + assertEq(platform, 10); + assertEq(transferTax, 0); + assertEq(lpFee, 0); + assertEq(disclosedVault, address(vault)); + } + + function invariant_rewardConfigurationNeverChanges() public view { + assertEq(vault.beneficiaryCount(), 2); + assertEq(vault.beneficiaryAt(0), beneficiaryA); + assertEq(vault.beneficiaryAt(1), beneficiaryB); + assertEq(vault.shareBpsOf(beneficiaryA), 3333); + assertEq(vault.shareBpsOf(beneficiaryB), 6667); + } + + function invariant_claimAndPayoutAccountingIsConserved() public view { + uint256 received = vault.totalCreatorFeesReceived(); + uint256 claimedA = vault.claimedBy(beneficiaryA); + uint256 claimedB = vault.claimedBy(beneficiaryB); + uint256 totalClaimed = vault.totalCreatorFeesClaimed(); + + assertEq(totalClaimed, claimedA + claimedB); + assertEq(received, totalClaimed + vault.claimable(beneficiaryA) + vault.claimable(beneficiaryB)); + assertEq(address(vault).balance, received - totalClaimed); + assertTrue(beneficiaryHandlerA.isAllowedPayout(vault.payoutAddressOf(beneficiaryA))); + assertTrue(beneficiaryHandlerB.isAllowedPayout(vault.payoutAddressOf(beneficiaryB))); + } + + function invariant_callbackMaskAndLooseBalancesRemainExact() public view { + assertEq(uint160(address(hook)) & hookFactory.ALL_HOOK_MASK(), hookFactory.REQUIRED_HOOK_FLAGS()); + Hooks.Permissions memory permissions = hook.getHookPermissions(); + assertTrue(permissions.beforeInitialize); + assertTrue(permissions.beforeSwap); + assertTrue(permissions.afterSwap); + assertTrue(permissions.beforeSwapReturnDelta); + assertTrue(permissions.afterSwapReturnDelta); + assertEq(address(hook).balance, 0); + assertEq(token.balanceOf(address(hook)), 0); + } +} From a28d9ee7006df7082372723cbf54c88d3ec9a0e5 Mon Sep 17 00:00:00 2001 From: hazarxyz <258789013+hazarxyz@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:21:24 +0200 Subject: [PATCH 2/2] Record configurable Classic Sepolia evidence --- .gas-snapshot | 77 +++- .github/workflows/verify.yml | 3 + MODELS.md | 10 + README.md | 2 + SECURITY.md | 3 + .../CLASSIC_CONFIGURABLE_PROPERTIES.md | 61 +++ models/classic/README.md | 11 + .../classic/candidates/configurable/README.md | 46 +++ .../candidates/configurable/sepolia.json | 363 ++++++++++++++++++ .../classic/candidates/configurable/spec.json | 95 +++++ .../verify-classic-configurable-candidate.mjs | 131 +++++++ 11 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md create mode 100644 models/classic/candidates/configurable/README.md create mode 100644 models/classic/candidates/configurable/sepolia.json create mode 100644 models/classic/candidates/configurable/spec.json create mode 100755 scripts/verify-classic-configurable-candidate.mjs diff --git a/.gas-snapshot b/.gas-snapshot index 966065fe..638e5da5 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -1,6 +1,17 @@ -ClassicMemeLaunchSecurityRegressionTest:testFuzz_launchAcceptsEveryWholePercentAndNothingIsAddedToIt(uint8) (runs: 64, μ: 2905401, ~: 2905499) -ClassicMemeLaunchSecurityRegressionTest:testFuzz_launcherPointOnePercentIsIncludedNotAdded(uint96,uint8) (runs: 1000, μ: 17275, ~: 17371) -ClassicMemeLaunchSecurityRegressionTest:testFuzz_metadataBytesRoundTripThroughOfficialFactory(bytes32,bytes32) (runs: 64, μ: 2973664, ~: 2976152) +ClassicInitialBuyVestingWalletV1Test:test_cliffThenLinearStartsAtZeroAndReachesFullAllocationAtEnd() (gas: 853418) +ClassicInitialBuyVestingWalletV1Test:test_deployOrGetRejectsCodeThatWasNotAuthenticatedByTheFactory() (gas: 28873) +ClassicInitialBuyVestingWalletV1Test:test_factoryAddressAndConfigurationAreDeterministicAndAuthenticated() (gas: 776035) +ClassicInitialBuyVestingWalletV1Test:test_fixedLockReleasesEverythingOnlyAfterTheReleaseDay() (gas: 845889) +ClassicInitialBuyVestingWalletV1Test:test_linearVestingReleasesProRataFromLaunchUntilEnd() (gas: 845973) +ClassicInitialBuyVestingWalletV1Test:test_onlyImmutableBeneficiaryCanReleaseOrAttemptOwnershipChanges() (gas: 807726) +ClassicInitialBuyVestingWalletV1Test:test_rejectsInvalidSchedulesAndUnlockedDeployment() (gas: 31003) +ClassicLaunchPolicyV1Test:test_acceptsEveryPublishedBoundaryAndFiveUnequalAllocations() (gas: 690967) +ClassicLaunchPolicyV1Test:test_rejectsEachMetadataFieldAboveItsPublishedLimit() (gas: 713125) +ClassicLaunchPolicyV1Test:test_rejectsEmptyNameAndSymbol() (gas: 16930) +ClassicLaunchPolicyV1Test:test_rejectsInvalidRewardCountsWalletsSharesAndTotals() (gas: 38862) +ClassicMemeLaunchSecurityRegressionTest:testFuzz_launchAcceptsEveryWholePercentAndNothingIsAddedToIt(uint8) (runs: 64, μ: 2905406, ~: 2905499) +ClassicMemeLaunchSecurityRegressionTest:testFuzz_launcherPointOnePercentIsIncludedNotAdded(uint96,uint8) (runs: 1000, μ: 17279, ~: 17371) +ClassicMemeLaunchSecurityRegressionTest:testFuzz_metadataBytesRoundTripThroughOfficialFactory(bytes32,bytes32) (runs: 64, μ: 2973042, ~: 2976152) ClassicMemeLaunchSecurityRegressionTest:test_creatorClaimBlocksReceiveReentrancyWithoutBlockingPayout() (gas: 3555280) ClassicMemeLaunchSecurityRegressionTest:test_exactSupplyIsAccountedForInOneSidedPermanentlyCustodiedPosition() (gas: 3007527) ClassicMemeLaunchSecurityRegressionTest:test_feeBoundariesLaunchAndRecordExactlyOneAndTenPercent() (gas: 5676952) @@ -9,13 +20,37 @@ ClassicMemeLaunchSecurityRegressionTest:test_largerCreatorDevBuyExecutesInFullAn ClassicMemeLaunchSecurityRegressionTest:test_launchRequiresTheMinimumCreatorDevBuy() (gas: 2926732) ClassicMemeLaunchSecurityRegressionTest:test_rejectsFeesBelowAboveAndBetweenWholePercentSelections() (gas: 77261) ClassicMemeLaunchSecurityRegressionTest:test_unregisteredPoolCannotBeClaimedAndPermissionlessClaimCannotRedirect() (gas: 3055701) +ClassicRewardVaultV1InvariantTest:invariant_activeSharesAlwaysTotalOneHundredPercent() (runs: 256, calls: 16384, reverts: 0) +ClassicRewardVaultV1InvariantTest:invariant_allReceivedEthIsClaimableOrAlreadyClaimed() (runs: 256, calls: 16384, reverts: 0) +ClassicRewardVaultV1InvariantTest:invariant_ctoAuthorityAndVaultDependenciesNeverChange() (runs: 256, calls: 16384, reverts: 0) +ClassicRewardVaultV1Test:testFuzz_splitConservationLeavesNoCreatorFeeStranded(uint96,uint16) (runs: 10000, μ: 1758176, ~: 1758353) +ClassicRewardVaultV1Test:test_acceptsSmartAndCounterfactualWalletBeneficiaries() (gas: 1646802) +ClassicRewardVaultV1Test:test_claimCannotCrossPoolVaultBoundaries() (gas: 3280649) +ClassicRewardVaultV1Test:test_ctoAuthorityMovesThroughTwoStepAcceptance() (gas: 1593992) +ClassicRewardVaultV1Test:test_ctoCanReplaceOneAllocationWithFiveUnequalAllocations() (gas: 1691508) +ClassicRewardVaultV1Test:test_ctoReplacesTheCompleteFutureConfigurationWithoutTakingOldRewards() (gas: 1915250) +ClassicRewardVaultV1Test:test_factoryDeploysAtPredictedAddressAndCommitsConfiguration() (gas: 1620987) +ClassicRewardVaultV1Test:test_onlyCtoAuthorityCanReplaceConfigurationAndReferenceCannotBeEmpty() (gas: 1560650) +ClassicRewardVaultV1Test:test_onlyCurrentPayoutWalletCanChangeItself() (gas: 1548297) +ClassicRewardVaultV1Test:test_payoutWalletCanConsolidateWithAnExistingPayoutWallet() (gas: 1706411) +ClassicRewardVaultV1Test:test_payoutWalletChangeMovesOnlyFutureRewardsAndNeedsNoAcceptance() (gas: 1850715) +ClassicRewardVaultV1Test:test_rejectsShareTotalOtherThanTenThousand() (gas: 69623) +ClassicRewardVaultV1Test:test_rejectsZeroAndMoreThanFiveBeneficiaries() (gas: 131122) +ClassicRewardVaultV1Test:test_rejectsZeroDuplicateAndZeroShareBeneficiaries() (gas: 190868) +ClassicRewardVaultV1Test:test_roundingRemainderGoesToFinalBeneficiaryWithoutStrandingCreatorFees() (gas: 1754534) +ClassicRewardVaultV1Test:test_supportsFiveUnequalRewardAllocations() (gas: 1658539) +ClassicV3FeeAccountingInvariantTest:invariant_callbackMaskAndLooseBalancesRemainExact() (runs: 256, calls: 16384, reverts: 0) +ClassicV3FeeAccountingInvariantTest:invariant_claimAndPayoutAccountingIsConserved() (runs: 256, calls: 16384, reverts: 0) +ClassicV3FeeAccountingInvariantTest:invariant_directionalEconomicsNeverChange() (runs: 256, calls: 16384, reverts: 0) +ClassicV3FeeAccountingInvariantTest:invariant_nativeClaimsExactlyCoverAccruedAccounting() (runs: 256, calls: 16384, reverts: 0) +ClassicV3FeeAccountingInvariantTest:invariant_rewardConfigurationNeverChanges() (runs: 256, calls: 16384, reverts: 0) EthCreatorFeeHookV2InvariantTest:invariant_callbackMaskRemainsExact() (runs: 256, calls: 16384, reverts: 0) EthCreatorFeeHookV2InvariantTest:invariant_feesNeverAccumulateAsLooseHookBalances() (runs: 256, calls: 16384, reverts: 0) EthCreatorFeeHookV2InvariantTest:invariant_nativeClaimsAlwaysCoverInternalAccounting() (runs: 256, calls: 16384, reverts: 0) EthCreatorFeeHookV2InvariantTest:invariant_poolFeeConfigurationNeverChanges() (runs: 256, calls: 16384, reverts: 0) EthCreatorFeeHookV2InvariantTest:invariant_publicFeeDisclosureNeverChanges() (runs: 256, calls: 16384, reverts: 0) -EthCreatorFeeHookV2Test:testFuzz_exactOutputQuotesPreserveNetAmount(uint96,uint8) (runs: 10000, μ: 17945, ~: 17896) -EthCreatorFeeHookV2Test:testFuzz_grossFeeQuotesSplitTheSelectedTotal(uint96,uint8) (runs: 10000, μ: 16759, ~: 16709) +EthCreatorFeeHookV2Test:testFuzz_exactOutputQuotesPreserveNetAmount(uint96,uint8) (runs: 10000, μ: 17947, ~: 17896) +EthCreatorFeeHookV2Test:testFuzz_grossFeeQuotesSplitTheSelectedTotal(uint96,uint8) (runs: 10000, μ: 16762, ~: 16709) EthCreatorFeeHookV2Test:test_allFourSwapModesAccrueOnlyNativeClaims() (gas: 501740) EthCreatorFeeHookV2Test:test_anAlternativePoolDoesNotAccrueHookFees() (gas: 127709) EthCreatorFeeHookV2Test:test_buyEmitsOpenZeppelinHookFeeAndUrc2HookSwap() (gas: 249672) @@ -40,6 +75,23 @@ EthCreatorFeeHookV2Test:test_sellExactInputChargesCreatorAndLauncherInEth() (gas EthCreatorFeeHookV2Test:test_sellExactOutputPreservesRequestedNetEthOutput() (gas: 236972) EthCreatorFeeHookV2Test:test_sellExactOutputRevertsInsteadOfChargingARequestedPartialFill() (gas: 182555) EthCreatorFeeHookV2Test:test_tinyGrossAmountsUseExplicitFloorRounding() (gas: 14655) +EthCreatorFeeHookV3Test:testFuzz_feeQuotesPreserveFixedEconomics(uint96,uint8) (runs: 1000, μ: 15459, ~: 15412) +EthCreatorFeeHookV3Test:test_acceptsIndependentOneAndTenPercentFeeBounds() (gas: 1569185) +EthCreatorFeeHookV3Test:test_addressChangeRedirectsExistingAndFutureRewardsWithoutMovingAuthority() (gas: 491928) +EthCreatorFeeHookV3Test:test_buyExactInputUsesBuyFee() (gas: 244471) +EthCreatorFeeHookV3Test:test_buyExactOutputUsesBuyFee() (gas: 252714) +EthCreatorFeeHookV3Test:test_configurationAndDisclosureAreExplicit() (gas: 44309) +EthCreatorFeeHookV3Test:test_duplicatePayoutDestinationsAreAllowed() (gas: 422469) +EthCreatorFeeHookV3Test:test_noDoubleClaimAndNoCrossBeneficiaryClaim() (gas: 1170754) +EthCreatorFeeHookV3Test:test_onlyPoolManagerCanCallEnabledHookCallbacksAndUnlockCallback() (gas: 28557) +EthCreatorFeeHookV3Test:test_onlyTreasuryCanClaimOrRedirectPlatformFees() (gas: 269819) +EthCreatorFeeHookV3Test:test_onlyVaultCanPullCreatorFeesAndOnlyBeneficiaryCanClaim() (gas: 247402) +EthCreatorFeeHookV3Test:test_platformShareIsAlwaysTenBpsAndNotAddedOnTop() (gas: 36659) +EthCreatorFeeHookV3Test:test_rejectsInvalidBuyAndSellFees() (gas: 8992154) +EthCreatorFeeHookV3Test:test_revertingPayoutDoesNotBlockAnotherBeneficiary() (gas: 491160) +EthCreatorFeeHookV3Test:test_sellExactInputUsesSellFee() (gas: 238488) +EthCreatorFeeHookV3Test:test_sellExactOutputUsesSellFee() (gas: 238048) +EthCreatorFeeHookV3Test:test_splitClaimsConserveAllCreatorFees() (gas: 432515) MemeLaunchV1Test:test_acceptsEveryMetadataFieldAtItsExactUtf8ByteLimit() (gas: 9437731) MemeLaunchV1Test:test_buyAndSellAccrueOnlyEthFeesForCreatorAndLauncher() (gas: 4419423) MemeLaunchV1Test:test_creatorCanChooseALargerAtomicDevBuy() (gas: 5643416) @@ -51,6 +103,21 @@ MemeLaunchV1Test:test_rejectsNonIntegerAndOutOfRangeTotalSwapFeesBeforeTokenCrea MemeLaunchV1Test:test_rejectsOverlongDirectCallMetadataBeforeRegistryWrite() (gas: 840755) MemeLaunchV1Test:test_reusesMatchingPredeployedPermanentPositionRecipient() (gas: 2891652) MemeLaunchV1Test:test_supportsEveryIntegerTotalSwapFeeFromOneToTenPercent() (gas: 27777931) +MemeLaunchV2Test:test_approvedCtoChangesOnlyFutureRewardConfiguration() (gas: 4596640) +MemeLaunchV2Test:test_changePayoutWalletKeepsUnclaimedEthWithPreviousWallet() (gas: 4611114) +MemeLaunchV2Test:test_cliffLinearVestingStartsAtZeroAndUsesTheLaunchWalletForever() (gas: 5248238) +MemeLaunchV2Test:test_creatorCanChooseLargerInitialBuy() (gas: 4473953) +MemeLaunchV2Test:test_externalBeneficiaryNeedsNoAcceptanceAndAloneCanClaim() (gas: 4651134) +MemeLaunchV2Test:test_fixedLockRoutesTheEntireInitialBuyDirectlyIntoAuthenticatedCustody() (gas: 5276201) +MemeLaunchV2Test:test_forcedEthCannotBlockFutureLaunchesOrSubsidizeInitialBuy() (gas: 4473479) +MemeLaunchV2Test:test_invalidInitialBuyCustodyRevertsBeforeTokenCreation() (gas: 54276) +MemeLaunchV2Test:test_launchWalletAsSoleBeneficiaryPreservesLockedClassicLifecycle() (gas: 4500509) +MemeLaunchV2Test:test_onlyPoolManagerCanCallInitialBuyUnlockCallback() (gas: 10971) +MemeLaunchV2Test:test_rejectsInvalidDirectionalFeesAtomically() (gas: 3764042) +MemeLaunchV2Test:test_rejectsInvalidRewardConfigurationsBeforeTokenCreation() (gas: 125012) +MemeLaunchV2Test:test_reusesMatchingPredeployedRewardVaultInsteadOfAllowingMempoolGriefing() (gas: 4496450) +MemeLaunchV2Test:test_splitLaunchStoresUniqueSharesAndDirectionalFees() (gas: 4547072) +MemeLaunchV2Test:test_supportsFiveBeneficiariesAtLaunch() (gas: 4596598) ProtocolRevenueDeepenerV1InvariantTest:invariant_compoundNonceMatchesSuccessfulCycles() (runs: 256, calls: 16384, reverts: 0) ProtocolRevenueDeepenerV1InvariantTest:invariant_everyAcquiredTokenIsPendingOrPermanentlyAdded() (runs: 256, calls: 16384, reverts: 0) ProtocolRevenueDeepenerV1InvariantTest:invariant_everyNativeWeiIsPendingOrPermanentlyProcessed() (runs: 256, calls: 16384, reverts: 0) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 0ff909d0..a0f23599 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -35,6 +35,9 @@ jobs: - name: Verify model registry run: node scripts/verify-model-registry.mjs + - name: Verify configurable Classic candidate + run: node scripts/verify-classic-configurable-candidate.mjs + - name: Verify release evidence run: node scripts/verify-release-evidence.mjs diff --git a/MODELS.md b/MODELS.md index a0187f32..75b8b762 100644 --- a/MODELS.md +++ b/MODELS.md @@ -34,6 +34,16 @@ launch position and executes the creator's initial buy in one transaction. Its c [Ethereum deployment](deployments/ethereum.json) · [Security properties](docs/security/CLASSIC_PROPERTIES.md) +### Configurable candidate + +The next Classic release is deployed and lifecycle-tested on Sepolia. It adds separate buy and sell fees, up to five +reward wallets, beneficiary-owned payout changes, disclosed community takeovers and optional custody for the Initial +Buy. The current Ethereum release remains unchanged until equivalent Mainnet evidence is published. + +[Candidate behavior and evidence](models/classic/candidates/configurable/README.md) · +[Sepolia deployment](models/classic/candidates/configurable/sepolia.json) · +[Security properties](docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md) + ## Deep
diff --git a/README.md b/README.md
index d721a0ef..ed2bde7f 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,7 @@ change a model that has already been deployed.
| --- | --- | --- |
| Model registry | Current lifecycle status and documentation | [`models/registry.json`](models/registry.json) |
| Model manifest | Release, network, contracts and review state | [`models/