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//model.json`](models/) |
+| Candidate evidence | Source and rehearsal records awaiting an Ethereum release | [`models/classic/candidates/`](models/classic/candidates/) |
| Contract source | Hook, launcher and custody behavior | [`src/`](src/) |
| Test evidence | Unit, integration, fuzz, invariant and regression coverage | [`test/`](test/) |
| Fixed parameters | Compiler, dependencies and model settings | [`spec/`](spec/) |
@@ -130,6 +131,7 @@ or mutable fee allocation. Classic has not received an independent smart-contrac
| --- | --- |
| Current security status | [`SECURITY.md`](SECURITY.md) |
| Classic trust boundaries and invariants | [`docs/security/CLASSIC_PROPERTIES.md`](docs/security/CLASSIC_PROPERTIES.md) |
+| Configurable Classic candidate | [`docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md`](docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md) |
| Protocol Revenue Deepener candidate | [`docs/security/PROTOCOL_REVENUE_DEEPENER_V1.md`](docs/security/PROTOCOL_REVENUE_DEEPENER_V1.md) |
| Automated checks and incident process | [`docs/OPERATIONS.md`](docs/OPERATIONS.md) |
| Independent review archive | [`audits/`](audits/) |
diff --git a/SECURITY.md b/SECURITY.md
index 3cff4844..fcd5bb8e 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -11,6 +11,7 @@ These checks are evidence, not a safety guarantee.
| Record | Scope |
| --- | --- |
| [Classic security properties](docs/security/CLASSIC_PROPERTIES.md) | Trust boundaries, permissions, accounting and invariant evidence |
+| [Configurable Classic candidate](docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md) | Directional fees, reward custody and Sepolia lifecycle evidence |
| [Protocol Revenue Deepener V1](docs/security/PROTOCOL_REVENUE_DEEPENER_V1.md) | Candidate add-only `$V4` liquidity program |
| [Operations](docs/OPERATIONS.md) | Automated checks, monitoring status and incident response |
| [Independent reviews](audits/README.md) | Published external reports, currently none |
@@ -69,5 +70,7 @@ The detailed property-to-test map is in
- Metadata and project links may be indexed inconsistently by third-party services.
- Permanent lock properties depend on the pinned forwarder and PositionManager semantics described in the
[Classic model documentation](models/classic/README.md#liquidity-custody).
+- Configurable Classic remains a Sepolia candidate until the exact Mainnet deployment and lifecycle evidence are
+ published.
- Protocol Revenue Deepener V1 is a candidate and is not active until an Ethereum deployment, exact source
verification and keeper receipts are published.
diff --git a/docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md b/docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md
new file mode 100644
index 00000000..4f5050a5
--- /dev/null
+++ b/docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md
@@ -0,0 +1,61 @@
+# Configurable Classic security properties
+
+This document covers the configurable Classic candidate deployed on Sepolia. It is a review record, not an audit.
+
+## Fixed boundaries
+
+- Buy and sell fees are selected independently at launch from `1%` through `10%` in one-point steps.
+- Programmable receives `0.10` percentage points from the applied fee. It is not added on top.
+- Token transfers have no tax and the Uniswap v4 LP fee is zero.
+- The complete token supply is accounted for between the permanently custodied position, the Initial Buy and bounded
+ launch dust.
+- A launch has one to five non-zero reward allocations that total exactly `100%`.
+- Only a beneficiary can claim its own ETH or redirect its future allocation.
+- A payout change or community takeover checkpoints accrued fees before changing future destinations.
+- Fixed-lock and vesting custody always use the launch wallet as the immutable beneficiary.
+- There is no owner mint, blacklist, pause or post-launch fee setter.
+
+## Authority
+
+`ClassicCtoAuthorityV1` is the only administrative component. It can replace future creator-reward allocations after a
+disclosed community-takeover decision. It cannot take previously accrued rewards, modify swap fees, unlock liquidity,
+mint tokens or change Initial Buy custody.
+
+The authority itself uses a two-step transfer. A transfer does not change any vault until a separate takeover call is
+made.
+
+## Accounting
+
+For a gross native ETH amount `x` and selected fee rate `t`:
+
+```text
+totalFee = floor(x × t / 10,000)
+programmable = floor(x × 10 / 10,000)
+creatorRewards = totalFee - programmable
+```
+
+Creator rewards are pulled from `PoolManager` into the launch-specific reward vault. Rounding remainder is assigned to
+the final allocation, so the full amount remains claimable.
+
+## Evidence
+
+| Property | Primary test |
+| --- | --- |
+| Directional fee settings never change | `invariant_directionalEconomicsNeverChange` |
+| Native claims cover accrued accounting | `invariant_nativeClaimsExactlyCoverAccruedAccounting` |
+| Reward accounting is conserved | `invariant_claimAndPayoutAccountingIsConserved` |
+| Active shares always total `100%` | `invariant_activeSharesAlwaysTotalOneHundredPercent` |
+| Prior rewards survive payout changes and takeovers | `test_approvedCtoChangesOnlyFutureRewardConfiguration` |
+| Beneficiaries cannot claim for each other | `test_noDoubleClaimAndNoCrossBeneficiaryClaim` |
+| Five unequal allocations are supported | `test_supportsFiveBeneficiariesAtLaunch` |
+| Initial Buy custody is immutable | `test_cliffLinearVestingStartsAtZeroAndUsesTheLaunchWalletForever` |
+
+The complete candidate suites are listed in
+[`spec.json`](../../models/classic/candidates/configurable/spec.json). The Sepolia deployment and lifecycle evidence is
+in [`sepolia.json`](../../models/classic/candidates/configurable/sepolia.json).
+
+## Remaining release boundary
+
+Sepolia evidence does not activate this candidate on Ethereum Mainnet. Mainnet deployment, runtime verification,
+source verification and a complete Mainnet lifecycle must be published before the production interface can mark the
+configurable release available.
diff --git a/models/classic/README.md b/models/classic/README.md
index 1f310a2d..c27a82a6 100644
--- a/models/classic/README.md
+++ b/models/classic/README.md
@@ -96,3 +96,14 @@ Classic has unit, integration, fuzz, invariant and regression coverage. It has n
smart-contract audit or public security contest. The full trust model and known limitations are in
[`SECURITY.md`](../../SECURITY.md), with the property-to-test map in
[`docs/security/CLASSIC_PROPERTIES.md`](../../docs/security/CLASSIC_PROPERTIES.md).
+
+## Configurable candidate
+
+A configurable Classic release is deployed and lifecycle-tested on Sepolia. It keeps the same launch model while
+adding separate buy and sell fees, up to five reward wallets, beneficiary-controlled payout changes, disclosed
+community takeovers and optional lock or vesting for the Initial Buy.
+
+This candidate is not active on Ethereum Mainnet. The current Mainnet behavior and addresses above remain unchanged.
+
+[Open the candidate record](candidates/configurable/README.md) ·
+[Inspect the Sepolia evidence](candidates/configurable/sepolia.json)
diff --git a/models/classic/candidates/configurable/README.md b/models/classic/candidates/configurable/README.md
new file mode 100644
index 00000000..4e528073
--- /dev/null
+++ b/models/classic/candidates/configurable/README.md
@@ -0,0 +1,46 @@
+# Configurable Classic candidate
+
+This candidate extends Classic without creating a separate launch model. It keeps the fixed supply, native ETH pool,
+atomic initial buy and permanently custodied launch position, while adding launch-time controls for fees, reward
+allocation and Initial Buy custody.
+
+It is deployed and lifecycle-tested on Sepolia. It is not the current Ethereum release.
+
+## Launch controls
+
+| Setting | Candidate behavior |
+| --- | --- |
+| Buy fee | `1%` to `10%`, selected independently in one-point steps |
+| Sell fee | `1%` to `10%`, selected independently in one-point steps |
+| Programmable share | Fixed `0.10` percentage points of each applied fee |
+| Creator rewards | One wallet or immutable unequal shares across up to five wallets |
+| Claims | Each beneficiary claims only its own native ETH |
+| Payout changes | A beneficiary may redirect only its future accrual |
+| Community takeover | Programmable may replace the future allocation with a disclosed reference |
+| Initial Buy | Unlocked, fixed lock, linear vesting or cliff plus linear vesting |
+
+Fees and reward percentages are fixed at launch. A payout-wallet change checkpoints all prior accrual first, so unpaid
+rewards stay with the wallet that earned them. A community takeover changes only future allocation.
+
+## Sepolia evidence
+
+The Sepolia release completed deployment, a launch with separate `2%` buy and `7%` sell fees, a buy, a sell, a creator
+claim and a Programmable claim. Two independent RPCs agreed on the receipts, runtime hashes, constructor bindings,
+position custody and final accounting.
+
+All seven infrastructure contracts have full Sourcify matches and verified source views on Blockscout and Routescan.
+The Etherscan relay reached its daily source-submission limit, so Etherscan is not recorded as a verification provider
+for this rehearsal.
+
+[Read the Sepolia record](sepolia.json) ·
+[Read the fixed parameters](spec.json) ·
+[Read the security properties](../../../../docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md)
+
+## Source
+
+The public source begins at commit
+[`2c2938a`](https://github.com/0xprogrammable/programmable/commit/2c2938a5fc6220f40da584a01271941a6df324dd).
+The technical contract names remain versioned so deployed bytecode can be traced exactly. The product model remains
+**Classic**.
+
+This candidate has not received an independent smart-contract audit or public security contest.
diff --git a/models/classic/candidates/configurable/sepolia.json b/models/classic/candidates/configurable/sepolia.json
new file mode 100644
index 00000000..f2773105
--- /dev/null
+++ b/models/classic/candidates/configurable/sepolia.json
@@ -0,0 +1,363 @@
+{
+ "schemaVersion": 1,
+ "model": "classic",
+ "internalContractRelease": "classic-v3",
+ "status": "deployment-source-and-lifecycle-verified",
+ "chainId": 11155111,
+ "releaseCommit": "7e78103fc326c6fa6b0deb38fae3560212c075a6",
+ "sourcePublicationCommit": "2c2938a5fc6220f40da584a01271941a6df324dd",
+ "sourceCommitment": "0x19b0bc50cdffb1872a581c4c410a4ebf1acfe4e7ac8ddb334d1696218f3b2b0c",
+ "startingNonce": 39,
+ "hookSalt": "0x0000000000000000000000000000000000000000000000000000000000001db9",
+ "candidatePlan": {
+ "status": "simulation-only-refresh-before-signing",
+ "observedAtBlock": 11375415,
+ "deployer": "0x2Bb333d48DFAF1596D9036671d2E43168994249E",
+ "startingNonce": 39,
+ "transactionCount": 7,
+ "ctoAuthority": "0x7AEf9a4038FAbb1d477BbFD3A106F81b93EB5aeB",
+ "rewardVaultFactory": "0xB7Eb3Ae60EE2f7F2F89DA8D372529919C700e76c",
+ "initialBuyVestingWalletFactory": "0xF41568B2b4681bDbA4f5e03345597a526973F65c",
+ "launchPolicy": "0x5b36779d6B6F571A518725974Fde243a58afFEa2",
+ "hookFactory": "0xBdAbD708Ff728ca871739d05393EE60F4E63955E",
+ "feeHook": "0xE36D165CD527A282f472aA9049618341129d20Cc",
+ "launcher": "0xDAa2EA44f4cb77781714EC5Aa8144d054272f0e8",
+ "hookSalt": "0x0000000000000000000000000000000000000000000000000000000000001db9",
+ "estimatedGas": "21737178"
+ },
+ "addresses": {
+ "deployer": "0x2Bb333d48DFAF1596D9036671d2E43168994249E",
+ "treasury": "0x4957f49620AFf3Adbbe8195a4f633E49cc93376c",
+ "ctoAuthority": "0x7AEf9a4038FAbb1d477BbFD3A106F81b93EB5aeB",
+ "rewardVaultFactory": "0xB7Eb3Ae60EE2f7F2F89DA8D372529919C700e76c",
+ "initialBuyVestingWalletFactory": "0xF41568B2b4681bDbA4f5e03345597a526973F65c",
+ "launchPolicy": "0x5b36779d6B6F571A518725974Fde243a58afFEa2",
+ "hookFactory": "0xBdAbD708Ff728ca871739d05393EE60F4E63955E",
+ "feeHook": "0xE36D165CD527A282f472aA9049618341129d20Cc",
+ "launcher": "0xDAa2EA44f4cb77781714EC5Aa8144d054272f0e8",
+ "positionForwarderFactory": "0xaE3C324B742a7576863A546120c4280b7c9E8448"
+ },
+ "transactions": {
+ "ctoAuthority": "0x93cd6a1d19673e695afbf12abe34a2d23a358dc051af6e1c9a841312e2924c28",
+ "rewardVaultFactory": "0x6fa5e07eb673db3fd28f5a967d76304b3c644326c4ea80644b992ef75ad0d4c0",
+ "initialBuyVestingWalletFactory": "0xa8f1e5c7b63d000015ead887d769d42570159b5618ba9f3cfaeb4033df37cd1d",
+ "launchPolicy": "0x4cd75607a663f79aaba3ee3f7fe2cae65c6cc78955f6a88628b03185f61404e6",
+ "hookFactory": "0xfe91b3429b806ff46da1bc9f00c10f6dccfae962e9c946d559665b83790ea651",
+ "feeHook": "0x3e5b1a1a47c2acad083df242913c7bae2a3d41c40b2412d3a42371a625c1aeea",
+ "launcher": "0x1e3830de5e39f095fb4675f528819019773a4e6b475c6f3eb223603111629461"
+ },
+ "runtimeCodeHashes": {
+ "ctoAuthority": "0x7beafb575fba4ffce22da7b3f927df8248eebc6c33e77cb43ed967a91a36984c",
+ "rewardVaultFactory": "0x89f07bb8a3158631e10177259699a4cbf88a54e18901de90592042a211861c73",
+ "initialBuyVestingWalletFactory": "0x13b7578a8abd0bc0ba724b5815d9bd0aff0d07c2677c00d2577004e8c1f6d5f4",
+ "launchPolicy": "0xb6b31b6cf326784774e13f6d60f9b251dde118469a506fa3b1c124c9f11b49be",
+ "hookFactory": "0x068ef58aed42a9772bd63614d535071627ff2812001b884cf936816bff8ce163",
+ "feeHook": "0xbdca6d47e15236693168e166b60186c572e19e06276b7b37632aa26b067518e3",
+ "launcher": "0x88582cc02188c007ab11bca4c0b0e22f73e55c032e9f3edf5c08d1df48dc80b9",
+ "positionForwarderFactory": "0x49e040806b0664b2fa4f41c5abc11241cdb8f847c538c13d6874c32804b74ebc"
+ },
+ "artifactRuntime": {
+ "ctoAuthority": {
+ "bytes": 1597,
+ "codeHash": "0x7beafb575fba4ffce22da7b3f927df8248eebc6c33e77cb43ed967a91a36984c"
+ },
+ "rewardVaultFactory": {
+ "bytes": 11474,
+ "codeHash": "0x84b44e2f7b8c315b1f802012d8a1f8b1a4281478a2781e32d1cfbe1cc5469916",
+ "note": "Artifact runtime before constructor immutable references are patched"
+ },
+ "initialBuyVestingWalletFactory": {
+ "bytes": 8715,
+ "codeHash": "0x13b7578a8abd0bc0ba724b5815d9bd0aff0d07c2677c00d2577004e8c1f6d5f4"
+ },
+ "launchPolicy": {
+ "bytes": 2100,
+ "codeHash": "0xb6b31b6cf326784774e13f6d60f9b251dde118469a506fa3b1c124c9f11b49be"
+ },
+ "hookFactory": {
+ "bytes": 15702,
+ "codeHash": "0x068ef58aed42a9772bd63614d535071627ff2812001b884cf936816bff8ce163"
+ },
+ "feeHookTemplate": {
+ "bytes": 12944,
+ "codeHash": "0x58716c294584fef53aba811bff75ac6b491a1c52a1d96f2c9927e5aaa317b5e4",
+ "note": "Artifact runtime before constructor immutable references are patched"
+ },
+ "launcherTemplate": {
+ "bytes": 22933,
+ "codeHash": "0xde9d67fe0a5cae202df0dff2a4fe8a2073ab5c240205e849af1e0cfcc5e7adb6",
+ "note": "Artifact runtime before constructor immutable references are patched"
+ }
+ },
+ "officialDependencies": {
+ "poolManager": {
+ "address": "0xE03A1074c86CFeDd5C142C4F04F1a1536e203543",
+ "runtimeCodeHash": "0x09930125a49f5b95caf8052991cc14d1240dca8b43f42b899115b86867e4bce1"
+ },
+ "positionManager": {
+ "address": "0x429ba70129df741B2Ca2a85BC3A2a3328e5c09b4",
+ "runtimeCodeHash": "0xcffd746f78c2b50aafd19076bbe9c48f14446e5248fc5d76b9b4896610e51aab"
+ },
+ "stateView": {
+ "address": "0xE1Dd9c3fA50EDB962E442f60DfBc432e24537E4C",
+ "runtimeCodeHash": "0xaaed3db8eb8ebde8014ce4c8a3938496687f4c6374e17a7d735288f6c65ceb9e"
+ },
+ "v4Quoter": {
+ "address": "0x61B3f2011A92d183C7dbaDBdA940a7555Ccf9227",
+ "runtimeCodeHash": "0xf481a751ac453d40c46d12360b85b05472028c1b113ab63749d69a5f8b0e47d1"
+ },
+ "uerc20Factory": {
+ "address": "0x000000e200088D55C39a11F609E5F667729ad49b",
+ "runtimeCodeHash": "0x9f042af1533641f048ced56b55898d9e87b2ccb0ec6854292e2cd8ea733e6aeb"
+ },
+ "permit2": {
+ "address": "0x000000000022D473030F116dDEE9F6B43aC78BA3",
+ "runtimeCodeHash": "0x96d9f5c3f0fb0423426b7f970186235b7347027f4e5c19c40c412b7d97fc3751"
+ },
+ "universalRouter": {
+ "address": "0x470FFC67b1feEEC31D16C46AC7545C98716a194c",
+ "runtimeCodeHash": "0x14b733fce7cfcca643ef884ed59d2cb2d23b3fead8692613dcee311d65555caf"
+ },
+ "positionForwarderFactory": {
+ "address": "0xaE3C324B742a7576863A546120c4280b7c9E8448",
+ "runtimeCodeHash": "0x49e040806b0664b2fa4f41c5abc11241cdb8f847c538c13d6874c32804b74ebc"
+ }
+ },
+ "sourceVerification": {
+ "status": "verified",
+ "checkedAt": "2026-07-29T13:18:38Z",
+ "primaryProvider": "Sourcify",
+ "secondaryExplorers": [
+ "Blockscout",
+ "Routescan"
+ ],
+ "etherscanStatus": "not-verified-provider-daily-submission-limit",
+ "ctoAuthority": {
+ "status": "verified",
+ "contractName": "ClassicCtoAuthorityV1",
+ "verifiedAt": "2026-07-29T13:15:57Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43019972",
+ "jobId": "b288cd56-dd72-4400-9e4f-771d7dad4a38",
+ "url": "https://sourcify.dev/server/v2/verify/b288cd56-dd72-4400-9e4f-771d7dad4a38"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0x7AEf9a4038FAbb1d477BbFD3A106F81b93EB5aeB?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0x7AEf9a4038FAbb1d477BbFD3A106F81b93EB5aeB?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ },
+ "rewardVaultFactory": {
+ "status": "verified",
+ "contractName": "ClassicRewardVaultFactoryV1",
+ "verifiedAt": "2026-07-29T13:18:32Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43020094",
+ "jobId": "364a4675-e1bc-40d8-ba38-cc55199bd064",
+ "url": "https://sourcify.dev/server/v2/verify/364a4675-e1bc-40d8-ba38-cc55199bd064"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0xB7Eb3Ae60EE2f7F2F89DA8D372529919C700e76c?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0xB7Eb3Ae60EE2f7F2F89DA8D372529919C700e76c?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ },
+ "initialBuyVestingWalletFactory": {
+ "status": "verified",
+ "contractName": "ClassicInitialBuyVestingWalletFactoryV1",
+ "verifiedAt": "2026-07-29T13:18:31Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43020091",
+ "jobId": "15159f30-444d-4304-96fa-c9a01feacee1",
+ "url": "https://sourcify.dev/server/v2/verify/15159f30-444d-4304-96fa-c9a01feacee1"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0xF41568B2b4681bDbA4f5e03345597a526973F65c?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0xF41568B2b4681bDbA4f5e03345597a526973F65c?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ },
+ "launchPolicy": {
+ "status": "verified",
+ "contractName": "ClassicLaunchPolicyV1",
+ "verifiedAt": "2026-07-29T13:18:31Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43020092",
+ "jobId": "311809d3-5120-4891-988a-a133a31b9d73",
+ "url": "https://sourcify.dev/server/v2/verify/311809d3-5120-4891-988a-a133a31b9d73"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0x5b36779d6B6F571A518725974Fde243a58afFEa2?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0x5b36779d6B6F571A518725974Fde243a58afFEa2?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ },
+ "hookFactory": {
+ "status": "verified",
+ "contractName": "EthCreatorFeeHookFactoryV3",
+ "verifiedAt": "2026-07-29T13:18:32Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43020093",
+ "jobId": "2d37e0df-dced-4659-85d4-f8c3260d94ba",
+ "url": "https://sourcify.dev/server/v2/verify/2d37e0df-dced-4659-85d4-f8c3260d94ba"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0xBdAbD708Ff728ca871739d05393EE60F4E63955E?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0xBdAbD708Ff728ca871739d05393EE60F4E63955E?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ },
+ "feeHook": {
+ "status": "verified",
+ "contractName": "EthCreatorFeeHookV3",
+ "verifiedAt": "2026-07-29T13:18:33Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43020096",
+ "jobId": "9674d585-9c35-442f-8aed-2fd07e7cc6c4",
+ "url": "https://sourcify.dev/server/v2/verify/9674d585-9c35-442f-8aed-2fd07e7cc6c4"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0xE36D165CD527A282f472aA9049618341129d20Cc?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0xE36D165CD527A282f472aA9049618341129d20Cc?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ },
+ "launcher": {
+ "status": "verified",
+ "contractName": "MemeLaunchV2",
+ "verifiedAt": "2026-07-29T13:18:38Z",
+ "sourcify": {
+ "match": "match",
+ "creationMatch": "match",
+ "runtimeMatch": "match",
+ "matchId": "43020099",
+ "jobId": "34985b08-3e95-4f73-95e1-8d12ee223d61",
+ "url": "https://sourcify.dev/server/v2/verify/34985b08-3e95-4f73-95e1-8d12ee223d61"
+ },
+ "blockscout": {
+ "verified": true,
+ "url": "https://eth-sepolia.blockscout.com/address/0xDAa2EA44f4cb77781714EC5Aa8144d054272f0e8?tab=contract"
+ },
+ "routescan": {
+ "verified": true,
+ "url": "https://routescan.io/address/0xDAa2EA44f4cb77781714EC5Aa8144d054272f0e8?chainid=11155111"
+ },
+ "etherscan": {
+ "verified": false,
+ "reason": "Daily limit of 500 source code submissions reached"
+ }
+ }
+ },
+ "lifecycleEvidence": {
+ "status": "verified-current-release",
+ "releaseEligible": true,
+ "checkedAt": "2026-07-29T13:52:53.342Z",
+ "verifier": "scripts/serve-classic-v3-sepolia-canary.mjs",
+ "independentRpcCount": 2,
+ "deploymentTransactionsVerified": true,
+ "runtimeBindingsVerified": true,
+ "positionLockVerified": true,
+ "buyAndSellVerified": true,
+ "creatorClaimVerified": true,
+ "launcherClaimVerified": true,
+ "canaryToken": "0x727AB0Ae6A83D3449750a132670F39Ba0A91c415",
+ "rewardVault": "0x095aD5b7EEa592Afe8fE17Df3EaE2f9b1A323087",
+ "poolId": "0x40aefeb8694f783804bd9938efa5cc2e5d717db8b318f135732a44bb5c7afb3f",
+ "positionRecipient": "0x4d3e23754CdB7DA764d69f6389Ff462a1c037837",
+ "positionTokenId": "37888",
+ "positionLiquidity": "36819258015569838458222",
+ "buySwapFeeBps": 200,
+ "sellSwapFeeBps": 700,
+ "creatorFeesClaimedWei": "25137990488535",
+ "launcherFeesClaimedWei": "871565079543",
+ "transactions": {
+ "launch": "0xb32c229f0a54b3b0bb63064973f0ae689e9c6e3e2b7601adb4728f2257987d6a",
+ "buy": "0x7385e34bd01e47ba13372ba8c709069a01984fafe47a0f854b3c80784ea3964f",
+ "tokenApproval": "0xacf0f67ff3102b15ec9e10be3d690c3799a35ca7cb99413c1a63187463e02577",
+ "routerApproval": "0x4c180e5cbc3b7dc0723226ac3209c457c124c5d1d4cced42a999130baa9caa3d",
+ "sell": "0x69a9f182b7c805a6f5b0f69a78985e57332c2d94fc24900328fbc513930d7e98",
+ "creatorClaim": "0xd106640e755120c70995a82e4576dceb2d00d64e86dc9a078cf5e2076b19eb3d",
+ "launcherClaim": "0x3f17db829b6ee7f00d1ffbe4b56f69ac1effc7f323f227d73af43aab91c182eb",
+ "treasuryFunding": "0x15e705bb9218a3857e3443943562c34cd9b66d0f4bf13ced682b3f3302797fd6"
+ },
+ "blocks": {
+ "launch": 11375796,
+ "buy": 11375813,
+ "tokenApproval": 11375814,
+ "routerApproval": 11375815,
+ "sell": 11375817,
+ "creatorClaim": 11375818,
+ "launcherClaim": 11375842,
+ "treasuryFunding": 11375838
+ }
+ },
+ "deploymentBlocks": {
+ "ctoAuthority": 11375639,
+ "rewardVaultFactory": 11375654,
+ "initialBuyVestingWalletFactory": 11375656,
+ "launchPolicy": 11375660,
+ "hookFactory": 11375664,
+ "feeHook": 11375685,
+ "launcher": 11375703
+ }
+}
diff --git a/models/classic/candidates/configurable/spec.json b/models/classic/candidates/configurable/spec.json
new file mode 100644
index 00000000..33023702
--- /dev/null
+++ b/models/classic/candidates/configurable/spec.json
@@ -0,0 +1,95 @@
+{
+ "schemaVersion": 1,
+ "model": "classic",
+ "candidate": "configurable",
+ "status": "candidate",
+ "sourcePublicationCommit": "2c2938a5fc6220f40da584a01271941a6df324dd",
+ "network": {
+ "name": "Sepolia",
+ "chainId": 11155111
+ },
+ "compiler": {
+ "solc": "0.8.26",
+ "evmVersion": "cancun",
+ "optimizerRuns": 1000
+ },
+ "token": {
+ "supply": "1000000000000000000000000000",
+ "decimals": 18,
+ "minimumInitialBuyWei": "600000000000000"
+ },
+ "fees": {
+ "buyFeeBps": {
+ "minimum": 100,
+ "maximum": 1000,
+ "step": 100
+ },
+ "sellFeeBps": {
+ "minimum": 100,
+ "maximum": 1000,
+ "step": 100
+ },
+ "programmableFeeBps": 10,
+ "transferTaxBps": 0,
+ "lpFeePips": 0,
+ "currency": "native ETH"
+ },
+ "rewards": {
+ "minimumBeneficiaries": 1,
+ "maximumBeneficiaries": 5,
+ "totalSharesBps": 10000,
+ "beneficiaryClaimsOnly": true,
+ "payoutWalletChangesAffectFutureAccrualOnly": true,
+ "communityTakeoverCanReplaceFutureAllocation": true
+ },
+ "initialBuyCustody": {
+ "modes": [
+ "unlocked",
+ "fixed-lock",
+ "linear-vesting",
+ "cliff-plus-linear"
+ ],
+ "minimumDurationDays": 1,
+ "maximumDurationDays": 3650,
+ "beneficiary": "launch wallet"
+ },
+ "liquidity": {
+ "pair": "native ETH",
+ "tickSpacing": 200,
+ "initialTick": 204200,
+ "positionOperator": "0x0000000000000000000000000000000000000000",
+ "positionTimelockBlock": "115792089237316195423570985008687907853269984665640564039457584007913129639935"
+ },
+ "evidence": {
+ "deployment": "models/classic/candidates/configurable/sepolia.json",
+ "security": "docs/security/CLASSIC_CONFIGURABLE_PROPERTIES.md",
+ "sourceFiles": [
+ "src/ClassicCtoAuthorityV1.sol",
+ "src/ClassicInitialBuyVestingWalletFactoryV1.sol",
+ "src/ClassicInitialBuyVestingWalletV1.sol",
+ "src/ClassicLaunchPolicyV1.sol",
+ "src/ClassicRewardVaultFactoryV1.sol",
+ "src/ClassicRewardVaultV1.sol",
+ "src/EthCreatorFeeHookFactoryV3.sol",
+ "src/EthCreatorFeeHookV3.sol",
+ "src/FeeSplitVaultFactoryV1.sol",
+ "src/FeeSplitVaultV1.sol",
+ "src/MemeLaunchV2.sol"
+ ],
+ "tests": [
+ "test/ClassicInitialBuyVestingWalletV1.t.sol",
+ "test/ClassicLaunchPolicyV1.t.sol",
+ "test/ClassicRewardVaultV1.t.sol",
+ "test/EthCreatorFeeHookV3.t.sol",
+ "test/MemeLaunchV2.t.sol",
+ "test/invariant/ClassicRewardVaultV1Invariant.t.sol",
+ "test/invariant/ClassicV3FeeAccountingInvariant.t.sol"
+ ]
+ },
+ "releaseGates": [
+ "Deploy the exact candidate to Ethereum Mainnet",
+ "Verify Mainnet runtime code and constructor bindings through two independent RPCs",
+ "Publish Mainnet source verification and a complete launch, buy, sell and claim lifecycle",
+ "Activate the production interface only after the Mainnet release manifest passes"
+ ]
+}
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/scripts/verify-classic-configurable-candidate.mjs b/scripts/verify-classic-configurable-candidate.mjs
new file mode 100755
index 00000000..edb4bfaa
--- /dev/null
+++ b/scripts/verify-classic-configurable-candidate.mjs
@@ -0,0 +1,131 @@
+#!/usr/bin/env node
+
+import fs from "node:fs";
+import path from "node:path";
+import process from "node:process";
+import { execFileSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const candidateRoot = "models/classic/candidates/configurable";
+const spec = readJson(`${candidateRoot}/spec.json`);
+const deployment = readJson(`${candidateRoot}/sepolia.json`);
+const errors = [];
+const addressPattern = /^0x[a-fA-F0-9]{40}$/;
+const hashPattern = /^0x[a-fA-F0-9]{64}$/;
+const commitPattern = /^[a-f0-9]{40}$/;
+const contractFields = [
+ "ctoAuthority",
+ "rewardVaultFactory",
+ "initialBuyVestingWalletFactory",
+ "launchPolicy",
+ "hookFactory",
+ "feeHook",
+ "launcher"
+];
+
+function readJson(relativePath) {
+ return JSON.parse(fs.readFileSync(path.join(root, relativePath), "utf8"));
+}
+
+function requireFile(relativePath) {
+ if (!fs.existsSync(path.join(root, relativePath))) {
+ errors.push(`missing ${relativePath}`);
+ }
+}
+
+if (spec.status !== "candidate" || deployment.status !== "deployment-source-and-lifecycle-verified") {
+ errors.push("candidate status is inconsistent");
+}
+if (spec.network?.chainId !== 11155111 || deployment.chainId !== 11155111) {
+ errors.push("candidate evidence must identify Sepolia");
+}
+if (
+ !commitPattern.test(spec.sourcePublicationCommit ?? "") ||
+ spec.sourcePublicationCommit !== deployment.sourcePublicationCommit
+) {
+ errors.push("source publication commit is missing or inconsistent");
+} else {
+ try {
+ execFileSync("git", ["merge-base", "--is-ancestor", spec.sourcePublicationCommit, "HEAD"], {
+ cwd: root,
+ stdio: "ignore"
+ });
+ } catch {
+ errors.push("source publication commit is not an ancestor of HEAD");
+ }
+}
+if (deployment.sourceCommitment !== "0x19b0bc50cdffb1872a581c4c410a4ebf1acfe4e7ac8ddb334d1696218f3b2b0c") {
+ errors.push("reviewed source commitment changed");
+}
+if (spec.fees?.programmableFeeBps !== 10 || spec.fees?.transferTaxBps !== 0) {
+ errors.push("published fee policy changed");
+}
+if (spec.rewards?.maximumBeneficiaries !== 5 || spec.rewards?.totalSharesBps !== 10000) {
+ errors.push("published reward bounds changed");
+}
+
+for (const relativePath of [
+ spec.evidence?.deployment,
+ spec.evidence?.security,
+ ...(spec.evidence?.sourceFiles ?? []),
+ ...(spec.evidence?.tests ?? [])
+]) {
+ requireFile(relativePath);
+}
+
+for (const field of contractFields) {
+ if (!addressPattern.test(deployment.addresses?.[field] ?? "")) {
+ errors.push(`${field}: invalid address`);
+ }
+ if (!hashPattern.test(deployment.transactions?.[field] ?? "")) {
+ errors.push(`${field}: invalid transaction hash`);
+ }
+ if (!hashPattern.test(deployment.runtimeCodeHashes?.[field] ?? "")) {
+ errors.push(`${field}: invalid runtime hash`);
+ }
+ if (!Number.isInteger(deployment.deploymentBlocks?.[field]) || deployment.deploymentBlocks[field] <= 0) {
+ errors.push(`${field}: invalid deployment block`);
+ }
+ const source = deployment.sourceVerification?.[field];
+ if (
+ source?.status !== "verified" ||
+ source?.sourcify?.match !== "match" ||
+ source?.sourcify?.creationMatch !== "match" ||
+ source?.sourcify?.runtimeMatch !== "match" ||
+ source?.blockscout?.verified !== true ||
+ source?.routescan?.verified !== true
+ ) {
+ errors.push(`${field}: incomplete source verification`);
+ }
+}
+
+const lifecycle = deployment.lifecycleEvidence;
+for (const field of [
+ "deploymentTransactionsVerified",
+ "runtimeBindingsVerified",
+ "positionLockVerified",
+ "buyAndSellVerified",
+ "creatorClaimVerified",
+ "launcherClaimVerified"
+]) {
+ if (lifecycle?.[field] !== true) {
+ errors.push(`lifecycle: ${field} is not verified`);
+ }
+}
+if (
+ lifecycle?.status !== "verified-current-release" ||
+ lifecycle?.releaseEligible !== true ||
+ lifecycle?.independentRpcCount !== 2
+) {
+ errors.push("lifecycle evidence is incomplete");
+}
+
+if (errors.length > 0) {
+ for (const error of errors) {
+ console.error(`- ${error}`);
+ }
+ process.exit(1);
+}
+
+console.log("Verified configurable Classic source, Sepolia deployment and lifecycle evidence.");
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);
+ }
+}