diff --git a/.gas-snapshot b/.gas-snapshot index e40a3be8..5a7bcc55 100644 --- a/.gas-snapshot +++ b/.gas-snapshot @@ -9,9 +9,9 @@ ClassicLaunchPolicyV1Test:test_acceptsEveryPublishedBoundaryAndFiveUnequalAlloca ClassicLaunchPolicyV1Test:test_rejectsEachMetadataFieldAboveItsPublishedLimit() (gas: 713125) ClassicLaunchPolicyV1Test:test_rejectsEmptyNameAndSymbol() (gas: 16930) ClassicLaunchPolicyV1Test:test_rejectsInvalidRewardCountsWalletsSharesAndTotals() (gas: 38862) -ClassicMemeLaunchSecurityRegressionTest:testFuzz_launchAcceptsEveryWholePercentAndNothingIsAddedToIt(uint8) (runs: 64, μ: 2905393, ~: 2905499) -ClassicMemeLaunchSecurityRegressionTest:testFuzz_launcherPointOnePercentIsIncludedNotAdded(uint96,uint8) (runs: 1000, μ: 17285, ~: 17371) -ClassicMemeLaunchSecurityRegressionTest:testFuzz_metadataBytesRoundTripThroughOfficialFactory(bytes32,bytes32) (runs: 64, μ: 2973042, ~: 2976152) +ClassicMemeLaunchSecurityRegressionTest:testFuzz_launchAcceptsEveryWholePercentAndNothingIsAddedToIt(uint8) (runs: 64, μ: 2905413, ~: 2905499) +ClassicMemeLaunchSecurityRegressionTest:testFuzz_launcherPointOnePercentIsIncludedNotAdded(uint96,uint8) (runs: 1000, μ: 17301, ~: 17371) +ClassicMemeLaunchSecurityRegressionTest:testFuzz_metadataBytesRoundTripThroughOfficialFactory(bytes32,bytes32) (runs: 64, μ: 2974286, ~: 2976152) ClassicMemeLaunchSecurityRegressionTest:test_creatorClaimBlocksReceiveReentrancyWithoutBlockingPayout() (gas: 3555280) ClassicMemeLaunchSecurityRegressionTest:test_exactSupplyIsAccountedForInOneSidedPermanentlyCustodiedPosition() (gas: 3007527) ClassicMemeLaunchSecurityRegressionTest:test_feeBoundariesLaunchAndRecordExactlyOneAndTenPercent() (gas: 5676952) @@ -23,7 +23,7 @@ ClassicMemeLaunchSecurityRegressionTest:test_unregisteredPoolCannotBeClaimedAndP 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, μ: 1758173, ~: 1758353) +ClassicRewardVaultV1Test:testFuzz_splitConservationLeavesNoCreatorFeeStranded(uint96,uint16) (runs: 10000, μ: 1758181, ~: 1758353) ClassicRewardVaultV1Test:test_acceptsSmartAndCounterfactualWalletBeneficiaries() (gas: 1646802) ClassicRewardVaultV1Test:test_claimCannotCrossPoolVaultBoundaries() (gas: 3280649) ClassicRewardVaultV1Test:test_ctoAuthorityMovesThroughTwoStepAcceptance() (gas: 1593992) @@ -49,8 +49,8 @@ EthCreatorFeeHookV2InvariantTest:invariant_feesNeverAccumulateAsLooseHookBalance 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, μ: 17947, ~: 17896) -EthCreatorFeeHookV2Test:testFuzz_grossFeeQuotesSplitTheSelectedTotal(uint96,uint8) (runs: 10000, μ: 16761, ~: 16709) +EthCreatorFeeHookV2Test:testFuzz_exactOutputQuotesPreserveNetAmount(uint96,uint8) (runs: 10000, μ: 17969, ~: 17896) +EthCreatorFeeHookV2Test:testFuzz_grossFeeQuotesSplitTheSelectedTotal(uint96,uint8) (runs: 10000, μ: 16784, ~: 16709) EthCreatorFeeHookV2Test:test_allFourSwapModesAccrueOnlyNativeClaims() (gas: 501740) EthCreatorFeeHookV2Test:test_anAlternativePoolDoesNotAccrueHookFees() (gas: 127709) EthCreatorFeeHookV2Test:test_buyEmitsOpenZeppelinHookFeeAndUrc2HookSwap() (gas: 249672) @@ -75,7 +75,7 @@ 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, μ: 15465, ~: 15412) +EthCreatorFeeHookV3Test:testFuzz_feeQuotesPreserveFixedEconomics(uint96,uint8) (runs: 1000, μ: 15480, ~: 15412) EthCreatorFeeHookV3Test:test_acceptsIndependentOneAndTenPercentFeeBounds() (gas: 1569185) EthCreatorFeeHookV3Test:test_addressChangeRedirectsExistingAndFutureRewardsWithoutMovingAuthority() (gas: 491928) EthCreatorFeeHookV3Test:test_buyExactInputUsesBuyFee() (gas: 244471) @@ -92,6 +92,15 @@ EthCreatorFeeHookV3Test:test_revertingPayoutDoesNotBlockAnotherBeneficiary() (ga EthCreatorFeeHookV3Test:test_sellExactInputUsesSellFee() (gas: 238488) EthCreatorFeeHookV3Test:test_sellExactOutputUsesSellFee() (gas: 238048) EthCreatorFeeHookV3Test:test_splitClaimsConserveAllCreatorFees() (gas: 432515) +GeometricRendererV1Test:testFuzz_neverReverts(uint256) (runs: 1000, μ: 1367886, ~: 924864) +GeometricRendererV1Test:test_attributesAreValidJsonFragment() (gas: 74094) +GeometricRendererV1Test:test_differentSeedsProduceDifferentArt() (gas: 1539264) +GeometricRendererV1Test:test_dormantIsDistinctFromLive() (gas: 202532) +GeometricRendererV1Test:test_noExternalReferences() (gas: 92960461) +GeometricRendererV1Test:test_producesWellFormedSvg() (gas: 274478) +GeometricRendererV1Test:test_sameSeedIsDeterministic() (gas: 1662856) +GeometricRendererV1Test:test_svgDeclaresTheSvgNamespace() (gas: 12388481) +GeometricRendererV1Test:test_traitsAreFlat() (gas: 14530667) MemeLaunchV1Test:test_acceptsEveryMetadataFieldAtItsExactUtf8ByteLimit() (gas: 9437731) MemeLaunchV1Test:test_buyAndSellAccrueOnlyEthFeesForCreatorAndLauncher() (gas: 4419423) MemeLaunchV1Test:test_creatorCanChooseALargerAtomicDevBuy() (gas: 5643416) @@ -118,3 +127,328 @@ MemeLaunchV2Test:test_rejectsInvalidRewardConfigurationsBeforeTokenCreation() (g MemeLaunchV2Test:test_reusesMatchingPredeployedRewardVaultInsteadOfAllowingMempoolGriefing() (gas: 4496450) MemeLaunchV2Test:test_splitLaunchStoresUniqueSharesAndDirectionalFees() (gas: 4547072) MemeLaunchV2Test:test_supportsFiveBeneficiariesAtLaunch() (gas: 4596598) +ShardCheckedTransferV1Test:test_buyMaxRejectsFalseReturnWhenReturningFractionalShard() (gas: 1441593) +ShardCheckedTransferV1Test:test_initialiseRejectsFalseReturnFromLiquiditySettlementTransfer() (gas: 263779) +ShardCheckedTransferV1Test:test_redeemRejectsFalseReturnFromShardTransferFrom() (gas: 450214) +ShardCheckedTransferV1Test:test_routerRejectsFalseReturnFromShardTransferFrom() (gas: 1963983) +ShardFeeDistributorV1Test:testFuzz_totalDistributedIsConserved(uint96,uint96,uint96,uint96) (runs: 1000, μ: 451605, ~: 445694) +ShardFeeDistributorV1Test:test_acquireAndReleaseInSameBlockDoesNotCorruptCount() (gas: 200526) +ShardFeeDistributorV1Test:test_acquireAndReleaseInSameBlockWithDistributionEarnsNothing() (gas: 223876) +ShardFeeDistributorV1Test:test_distributeNeverAccruesToBuilderOrLauncher() (gas: 121667) +ShardFeeDistributorV1Test:test_dustIsCarriedNotLost() (gas: 326606) +ShardFeeDistributorV1Test:test_escrowFoldsInOnFirstDistributionWithHolders() (gas: 174142) +ShardFeeDistributorV1Test:test_evenSplitAcrossHolders() (gas: 313389) +ShardFeeDistributorV1Test:test_feesWithNoHoldersGoToEscrow() (gas: 42214) +ShardFeeDistributorV1Test:test_holderEarnsFromNextBlockOnward() (gas: 265320) +ShardFeeDistributorV1Test:test_releaseOlderTokenWhileNewerIsPending() (gas: 161430) +ShardFeeDistributorV1Test:test_releaseSettlesToOutgoingOwner() (gas: 144834) +ShardFeeDistributorV1Test:test_sameBlockAcquisitionEarnsNothing() (gas: 217342) +ShardFeeDistributorV1Test:test_settlePreservesFractionalRemainder() (gas: 4524035) +ShardFeeDistributorV1Test:test_transferInAcquisitionBlockEarnsNothing() (gas: 227467) +ShardFeeDonationV1Test:test_anyoneCanDonate() (gas: 498002) +ShardFeeDonationV1Test:test_anyoneCanFlush() (gas: 500907) +ShardFeeDonationV1Test:test_donateEscrowsWhenNothingCirculates() (gas: 75518) +ShardFeeDonationV1Test:test_donateIsNeverSplitWithTheBeneficiaries() (gas: 76238) +ShardFeeDonationV1Test:test_donateLeavesTheBackingInvariantIntact() (gas: 502792) +ShardFeeDonationV1Test:test_donateRaisesTheAccumulator() (gas: 495458) +ShardFeeDonationV1Test:test_donateRevertsOnZeroValue() (gas: 30483) +ShardFeeDonationV1Test:test_donateSplitsAcrossEveryHolder() (gas: 731879) +ShardFeeDonationV1Test:test_donatedEthIsClaimableByAHolder() (gas: 534174) +ShardFeeDonationV1Test:test_escrowedDonationReleasesOnTheNextDistribution() (gas: 549853) +ShardFeeDonationV1Test:test_flushPushesEverythingIntoTheFeePool() (gas: 613047) +ShardFeeDonationV1Test:test_flushRevertsWhenEmpty() (gas: 8288) +ShardFeeDonationV1Test:test_flushedEthIsNotSplitWithTheBeneficiaries() (gas: 502285) +ShardFeeDonationV1Test:test_forwarderAcceptsPlainEth() (gas: 118434) +ShardFeeDonationV1Test:test_forwarderHoldsNoEthAfterFlush() (gas: 499577) +ShardFeeDonationV1Test:test_forwarderRejectsAZeroHook() (gas: 35707) +ShardFeeDonationV1Test:test_setupUsesAtomicFactory() (gas: 10622) +ShardFeeSplitV1Test:testFuzz_outerFeeIsCumulative(uint256[],bool) (runs: 1000, μ: 100070, ~: 98090) +ShardFeeSplitV1Test:testFuzz_splitIsConservativeAndCumulative(uint256[]) (runs: 1000, μ: 371992, ~: 363506) +ShardFeeSplitV1Test:testFuzz_split_conserves(uint256) (runs: 1000, μ: 120542, ~: 120296) +ShardFeeSplitV1Test:test_builderClaimRevertsAndRestoresAccrualForRejectingRecipient() (gas: 734412) +ShardFeeSplitV1Test:test_buyMax_clampCarriesUncollectedWeiAndAccruesToProgrammable() (gas: 700580) +ShardFeeSplitV1Test:test_buyNFTFeeIsSplit() (gas: 849182) +ShardFeeSplitV1Test:test_buyRefundRevertsAndRollsBackForRejectingRecipient() (gas: 912748) +ShardFeeSplitV1Test:test_claimBuilderFees_paysAndZeroes() (gas: 704310) +ShardFeeSplitV1Test:test_claimBuilderFees_revertsForStranger() (gas: 687485) +ShardFeeSplitV1Test:test_claimBuilderFees_revertsWhenNothingAccrued() (gas: 42921) +ShardFeeSplitV1Test:test_claimLauncherFees_paysAndZeroes() (gas: 702199) +ShardFeeSplitV1Test:test_claimLauncherFees_revertsForStranger() (gas: 685350) +ShardFeeSplitV1Test:test_constructor_rejectsZeroRecipients() (gas: 41581460) +ShardFeeSplitV1Test:test_donationIsNotSplit() (gas: 74577) +ShardFeeSplitV1Test:test_holderClaimLeavesCutsIntact() (gas: 1033435) +ShardFeeSplitV1Test:test_holderClaimRevertsAndRollsBackForRejectingRecipient() (gas: 1115461) +ShardFeeSplitV1Test:test_launcherClaimRevertsAndRestoresAccrualForRejectingRecipient() (gas: 732313) +ShardFeeSplitV1Test:test_outerFee_exactInFragmentedMatchesAggregated() (gas: 49778) +ShardFeeSplitV1Test:test_outerFee_exactOutFragmentedMatchesAggregated() (gas: 50674) +ShardFeeSplitV1Test:test_poolSwapFeeIsSplit() (gas: 669043) +ShardFeeSplitV1Test:test_sellPayoutRevertsAndRollsBackForRejectingRecipient() (gas: 954795) +ShardFeeSplitV1Test:test_setBuilderFeeRecipient_revertsForStranger() (gas: 13275) +ShardFeeSplitV1Test:test_setBuilderFeeRecipient_revertsOnZero() (gas: 13235) +ShardFeeSplitV1Test:test_setBuilderFeeRecipient_transfersClaimRights() (gas: 735990) +ShardFeeSplitV1Test:test_split_exactTenthsOnRoundAmount() (gas: 89939) +ShardFeeSplitV1Test:test_split_roundingRemainderGoesToLauncher() (gas: 129716) +ShardFeeSplitV1Test:test_split_tinyFeeStillCreditsOperator() (gas: 109832) +ShardFeeSplitV1Test:test_tinyFeesAccumulateToTheSameEntitlement() (gas: 145439) +ShardHookAttackV1Test:test_ATTACK_batchBuyInSameBlockEarnsNoFees() (gas: 5944028) +ShardHookAttackV1Test:test_ATTACK_boundaryCrossingStillFillsCompletely() (gas: 24116510) +ShardHookAttackV1Test:test_ATTACK_buyManyCannotExceedBatchCap() (gas: 5791708) +ShardHookAttackV1Test:test_ATTACK_buyManyIsNotAFreeFeeBypass() (gas: 2718320) +ShardHookAttackV1Test:test_ATTACK_buyMaxIsNotAFreeFeeBypass() (gas: 3924070) +ShardHookAttackV1Test:test_ATTACK_buyMaxLeftoverShardsCannotBeDoubleSpent() (gas: 2918812) +ShardHookAttackV1Test:test_ATTACK_buyNftIsNotAFreeFeeBypass() (gas: 1051415) +ShardHookAttackV1Test:test_ATTACK_cannotClaimTwice() (gas: 714318) +ShardHookAttackV1Test:test_ATTACK_cannotFrontRunInitialiseAtDifferentPrice() (gas: 9641168) +ShardHookAttackV1Test:test_ATTACK_cannotHijackSetNft() (gas: 6529616) +ShardHookAttackV1Test:test_ATTACK_cannotInitialiseForeignPool() (gas: 305955) +ShardHookAttackV1Test:test_ATTACK_cannotMintPastTenThousand() (gas: 579156388) +ShardHookAttackV1Test:test_ATTACK_cannotRedeemWithoutShards() (gas: 457121) +ShardHookAttackV1Test:test_ATTACK_cannotRerollArtForFree() (gas: 676019) +ShardHookAttackV1Test:test_ATTACK_cannotStealAnotherHoldersFees() (gas: 932464) +ShardHookAttackV1Test:test_ATTACK_cannotStealErc6909FeeClaims() (gas: 723825) +ShardHookAttackV1Test:test_ATTACK_cannotSwapBeforeInitialise() (gas: 9752593) +ShardHookAttackV1Test:test_ATTACK_cannotWithdrawLiquidity() (gas: 7574127) +ShardHookAttackV1Test:test_ATTACK_directNftTransferStrandsNothing() (gas: 530350) +ShardHookAttackV1Test:test_ATTACK_dustCannotBeGriefedToStrandFunds() (gas: 5328978) +ShardHookAttackV1Test:test_ATTACK_reentrantBuyDuringUnlockFails() (gas: 684215) +ShardHookAttackV1Test:test_ATTACK_reentrantClaimFails() (gas: 1081948) +ShardHookAttackV1Test:test_ATTACK_reentrantSellDuringUnlockFails() (gas: 1107275) +ShardHookAttackV1Test:test_ATTACK_reentrantTransferDuringReleasingFails() (gas: 1475695) +ShardHookAttackV1Test:test_ATTACK_sameBlockFeeSnipeEarnsNothing() (gas: 1046967) +ShardHookAttackV1Test:test_ATTACK_sandwichBuyNftIsBoundedByMaxEthIn() (gas: 1518010) +ShardHookAttackV1Test:test_ATTACK_transferInAcquisitionBlockEarnsNothing() (gas: 954255) +ShardHookAttackV1Test:test_setupUsesAtomicFactory() (gas: 10601) +ShardHookBatchV1Test:test_batchBuysCannotSpendHolderFeeEth() (gas: 1651941) +ShardHookBatchV1Test:test_buyManyBackingInvariantHolds() (gas: 1794367) +ShardHookBatchV1Test:test_buyManyChargesOnePercentInclusiveOnce() (gas: 794685) +ShardHookBatchV1Test:test_buyManyIsCheaperThanLoopingBuyNFT() (gas: 1665358) +ShardHookBatchV1Test:test_buyManyMintsExactlyCount() (gas: 810169) +ShardHookBatchV1Test:test_buyManyRefundsExcess() (gas: 655479) +ShardHookBatchV1Test:test_buyManyRespectsDeadline() (gas: 42195) +ShardHookBatchV1Test:test_buyManyRespectsMaxEthIn() (gas: 171338) +ShardHookBatchV1Test:test_buyManyRevertsAboveCap() (gas: 47491) +ShardHookBatchV1Test:test_buyManyRevertsOnZeroCount() (gas: 47277) +ShardHookBatchV1Test:test_buyMaxAtTheCapBoundaryStillMints() (gas: 1862562) +ShardHookBatchV1Test:test_buyMaxBackingInvariantHolds() (gas: 3233164) +ShardHookBatchV1Test:test_buyMaxChargesOnePercentInclusiveOnce() (gas: 1084646) +ShardHookBatchV1Test:test_buyMaxRespectsMinCount() (gas: 229859) +ShardHookBatchV1Test:test_buyMaxReturnsLeftoverShardsToCaller() (gas: 1092373) +ShardHookBatchV1Test:test_buyMaxRevertsOnZeroValue() (gas: 36977) +ShardHookBatchV1Test:test_buyMaxRevertsRatherThanExceedingTheCap() (gas: 257351) +ShardHookBatchV1Test:test_buyMaxSpendsTheEthAndMintsWholeNfts() (gas: 1109294) +ShardHookBatchV1Test:test_hookOwnBatchIsNotBoundByTheSwapCap() (gas: 5653137) +ShardHookBatchV1Test:test_redeemManyBackingInvariantHolds() (gas: 958502) +ShardHookBatchV1Test:test_redeemManyChargesNoAdditionalFee() (gas: 822885) +ShardHookBatchV1Test:test_redeemManyIsCheaperThanLoopingRedeem() (gas: 1418257) +ShardHookBatchV1Test:test_redeemManyMintsCountAndBurnsTheShards() (gas: 901398) +ShardHookBatchV1Test:test_redeemManyRequiresTheFullAllowance() (gas: 372019) +ShardHookBatchV1Test:test_redeemManyRevertsAboveCap() (gas: 41420) +ShardHookBatchV1Test:test_redeemManyRevertsOnZeroCount() (gas: 40573) +ShardHookBatchV1Test:test_sellManyBackingInvariantHolds() (gas: 1044878) +ShardHookBatchV1Test:test_sellManyChargesOnePercentInclusiveOnce() (gas: 911311) +ShardHookBatchV1Test:test_sellManyIsCheaperThanLoopingSellNFT() (gas: 1946733) +ShardHookBatchV1Test:test_sellManyRejectsDuplicateIds() (gas: 450215) +ShardHookBatchV1Test:test_sellManyRejectsSomeoneElsesToken() (gas: 615845) +ShardHookBatchV1Test:test_sellManyReleasesEveryIdAndPaysTheSeller() (gas: 930287) +ShardHookBatchV1Test:test_sellManyRespectsDeadline() (gas: 655424) +ShardHookBatchV1Test:test_sellManyRespectsMinEthOut() (gas: 708539) +ShardHookBatchV1Test:test_sellManyRevertsAboveCap() (gas: 46034) +ShardHookBatchV1Test:test_sellManyRevertsOnEmptyList() (gas: 39316) +ShardHookBatchV1Test:test_sellManySellersDoNotEarnFromTheirOwnExitFee() (gas: 1201983) +ShardHookBatchV1Test:test_setupUsesAtomicFactory() (gas: 10612) +ShardHookExhaustionV1Test:testFuzz_buyMaxNeverOverspendsMsgValue(uint96) (runs: 1000, μ: 365875, ~: 294466) +ShardHookExhaustionV1Test:test_buyMaxChargesOnlyOnWhatTheCurveConsumed() (gas: 17943761) +ShardHookExhaustionV1Test:test_buyMaxStillChargesTheWholeSendWhenFullyConsumed() (gas: 1189764) +ShardHookExhaustionV1Test:test_buyNftRevertsRatherThanMintingOnAShortFill() (gas: 15489303) +ShardHookExhaustionV1Test:test_buyNftStillWorksWhileTheCurveHasDepth() (gas: 502787) +ShardHookExhaustionV1Test:test_setupUsesAtomicFactory() (gas: 10545) +ShardHookFeesV1Test:testFuzz_feeNeverExceedsOnePercent(uint96) (runs: 1000, μ: 713218, ~: 710229) +ShardHookFeesV1Test:testFuzz_poolIsNeverLeftWithNegativeDelta(uint96,bool) (runs: 1000, μ: 706885, ~: 696257) +ShardHookFeesV1Test:testFuzz_theSplitConservesEveryFee(uint96) (runs: 1000, μ: 723086, ~: 720097) +ShardHookFeesV1Test:test_completeFillLandingExactlyOnTheLimitSucceeds() (gas: 920613) +ShardHookFeesV1Test:test_exactInputBuyOverTheCapReverts() (gas: 747153) +ShardHookFeesV1Test:test_exactOutputEthSucceedsWhenTheLimitDoesNotBind() (gas: 761017) +ShardHookFeesV1Test:test_feeGoesToEscrowWhenNothingCirculating() (gas: 668919) +ShardHookFeesV1Test:test_feeIsAlwaysDenominatedInEth() (gas: 780337) +ShardHookFeesV1Test:test_feeIsExactlyOnePercent_oneForZero_exactIn() (gas: 780169) +ShardHookFeesV1Test:test_feeIsExactlyOnePercent_oneForZero_exactOut() (gas: 757628) +ShardHookFeesV1Test:test_feeIsExactlyOnePercent_zeroForOne_exactIn() (gas: 674257) +ShardHookFeesV1Test:test_feeIsExactlyOnePercent_zeroForOne_exactOut() (gas: 687681) +ShardHookFeesV1Test:test_feeReachesAccumulatorWhenHoldersExist() (gas: 789564) +ShardHookFeesV1Test:test_firstSwapSucceedsOnFreshPoolManager() (gas: 664403) +ShardHookFeesV1Test:test_foreignPoolSwapReverts() (gas: 921567) +ShardHookFeesV1Test:test_hookClaimBalancePlusEthCoversFeesTaken() (gas: 673758) +ShardHookFeesV1Test:test_nonBindingPriceLimitStillWorks() (gas: 664395) +ShardHookFeesV1Test:test_operatorSplitHoldsAcrossAllFourQuadrants() (gas: 1047770) +ShardHookFeesV1Test:test_partialFillIsRejectedNotOvercharged() (gas: 634003) +ShardHookFeesV1Test:test_partialFillIsRejectedOnExactOutputEthToo() (gas: 773916) +ShardHookFeesV1Test:test_sellOfExactlyTheCapSucceeds() (gas: 868773) +ShardHookFeesV1Test:test_sellOneWeiOverTheCapReverts() (gas: 869262) +ShardHookFeesV1Test:test_swapBeforeInitialiseReverts() (gas: 76504) +ShardHookFeesV1Test:test_swapOfExactlyTheCapSucceeds() (gas: 705512) +ShardHookFeesV1Test:test_swapOneWeiOverTheCapReverts() (gas: 566177) +ShardHookFeesV1Test:test_swapperReceivesExpectedAmountAfterFee() (gas: 668029) +ShardHookFeesV1Test:test_theCapAssertionDoesNotMatchAnUnrelatedRevert() (gas: 917532) +ShardHookLiquidityV1Test:test_activeLiquidityIsTheSumInsideTheBand() (gas: 672406) +ShardHookLiquidityV1Test:test_bandIsDenserThanTheFullRange() (gas: 414954) +ShardHookLiquidityV1Test:test_beforeInitializeRejectsForeignPool() (gas: 522323) +ShardHookLiquidityV1Test:test_buyingMovesTickDown() (gas: 671681) +ShardHookLiquidityV1Test:test_constructorRejectsUnalignedTicks() (gas: 15425209) +ShardHookLiquidityV1Test:test_frontRunAtDifferentPriceReverts() (gas: 442233) +ShardHookLiquidityV1Test:test_hookPermissionsMatchAddressBits() (gas: 16346) +ShardHookLiquidityV1Test:test_initialiseIsOneShot() (gas: 413532) +ShardHookLiquidityV1Test:test_initialiseOnlyDeployer() (gas: 10935) +ShardHookLiquidityV1Test:test_initialiseRecordsSeedDust() (gas: 421269) +ShardHookLiquidityV1Test:test_initialiseRequiresNoEth() (gas: 414089) +ShardHookLiquidityV1Test:test_initialiseRerevertsNonAlreadyInitialisedErrors() (gas: 48488) +ShardHookLiquidityV1Test:test_initialiseRevertsOnWrongShardBalance() (gas: 17252087) +ShardHookLiquidityV1Test:test_initialiseSeedsAllTenThousandShards() (gas: 433159) +ShardHookLiquidityV1Test:test_initialiseSurvivesFrontRunAtSamePrice() (gas: 441218) +ShardHookLiquidityV1Test:test_noWithdrawalFunctionExists() (gas: 470050) +ShardHookLiquidityV1Test:test_seedDustIsRecordedAndBackingHolds() (gas: 423090) +ShardHookLiquidityV1Test:test_seedSplitAccountsForTheWholeSupply() (gas: 3587) +ShardHookLiquidityV1Test:test_seedsTwoOverlappingPositions() (gas: 430735) +ShardHookLiquidityV1Test:test_startPriceMustBeAtOrAboveTickUpper() (gas: 67015165) +ShardHookLiquidityV1Test:test_sweepRedeems6909ClaimsToRealEth() (gas: 72125) +ShardHookLiquidityV1Test:test_unlockCallbackOnlyPoolManager() (gas: 13474) +ShardHookMarketV1Test:testFuzz_invariantHoldsUnderRandomSequence(uint8) (runs: 1000, μ: 878890, ~: 863428) +ShardHookMarketV1Test:test_buyNftAndSwapThenRedeemCostTheSame() (gas: 723309) +ShardHookMarketV1Test:test_buyNftCannotSpendHolderFeeEth() (gas: 640696) +ShardHookMarketV1Test:test_buyNftChargesOnePercentExplicitly() (gas: 483900) +ShardHookMarketV1Test:test_buyNftMintsAndChargesBondingCurvePrice() (gas: 491411) +ShardHookMarketV1Test:test_buyNftRefundsExcessEth() (gas: 477454) +ShardHookMarketV1Test:test_buyNftRespectsMaxEthIn() (gas: 169115) +ShardHookMarketV1Test:test_buyNftRevertsOnInsufficientEth() (gas: 117756) +ShardHookMarketV1Test:test_buyThenSellRoundTripCostsAboutTwoPercent() (gas: 497472) +ShardHookMarketV1Test:test_claimRevertsWhenNothingOwed() (gas: 43538) +ShardHookMarketV1Test:test_claimSweepsClaimsThenPays() (gas: 675016) +ShardHookMarketV1Test:test_deadlineIsEnforced() (gas: 40254) +ShardHookMarketV1Test:test_feeBasisIsInclusiveOnBuy() (gas: 483173) +ShardHookMarketV1Test:test_hookSelfSwapDoesNotDoubleCharge() (gas: 483184) +ShardHookMarketV1Test:test_priceFallsAfterSelling() (gas: 502357) +ShardHookMarketV1Test:test_priceRisesAsSupplyIsBought() (gas: 648348) +ShardHookMarketV1Test:test_redeemChargesNoAdditionalFee() (gas: 563559) +ShardHookMarketV1Test:test_redeemConvertsOneShardToNft() (gas: 567574) +ShardHookMarketV1Test:test_reentrantClaimReverts() (gas: 974489) +ShardHookMarketV1Test:test_sellNftChargesOnePercentExplicitly() (gas: 503446) +ShardHookMarketV1Test:test_sellNftDestroysArt() (gas: 500340) +ShardHookMarketV1Test:test_sellNftReleasesIdBackToPool() (gas: 509817) +ShardHookMarketV1Test:test_sellNftRespectsMinEthOut() (gas: 487847) +ShardHookMarketV1Test:test_sellNftReturnsEthToSeller() (gas: 497479) +ShardHookMarketV1Test:test_sellNftRevertsIfNotOwner() (gas: 511793) +ShardHookMarketV1Test:test_sellNftSellerDoesNotEarnFromOwnExitFee() (gas: 740712) +ShardHookMarketV1Test:test_setNftOnlyDeployerAndOneShot() (gas: 17014) +ShardHookMarketV1Test:test_settleOnTransferOnlyCallableByNft() (gas: 15531) +ShardHookMarketV1Test:test_setupUsesAtomicFactory() (gas: 10624) +ShardHookMarketV1Test:test_shardBackingInvariantHolds() (gas: 745755) +ShardHookMarketV1Test:test_thereIsNoNftToShardPath() (gas: 13066) +ShardLaunchFactoryV1Test:test_constructorPinsReviewedInputsAndDeploysOneRenderer() (gas: 20051) +ShardLaunchFactoryV1Test:test_constructorRejectsZeroHookCodeHash() (gas: 49186) +ShardLaunchFactoryV1Test:test_constructorRejectsZeroPoolManager() (gas: 49049) +ShardLaunchFactoryV1Test:test_deployedAddressesEqualPredictions() (gas: 10287439) +ShardLaunchFactoryV1Test:test_duplicateHookSaltAndConfigurationCannotOverwriteEvidence() (gas: 10469683) +ShardLaunchFactoryV1Test:test_duplicateTokenSaltForSameBuilderRevertsWithoutChangingFirstLaunch() (gas: 10473188) +ShardLaunchFactoryV1Test:test_effectiveTokenSaltBindsHookSaltAndEveryLaunchParameter() (gas: 40293) +ShardLaunchFactoryV1Test:test_effectiveTokenSaltNamespacesTheBuilderRole() (gas: 36498) +ShardLaunchFactoryV1Test:test_factoryAndHookStayWithinEipSizeLimitsAndFactoryDoesNotEmbedHookBlob() (gas: 1501993) +ShardLaunchFactoryV1Test:test_factoryHoldsZeroShardAfterSuccess() (gas: 10277268) +ShardLaunchFactoryV1Test:test_failureAfterDeploymentsRollsBackTokenHookNftMappingAndEvent() (gas: 21900374) +ShardLaunchFactoryV1Test:test_hookDeployerIsFactoryAndBothOneShotPowersAreConsumed() (gas: 10282851) +ShardLaunchFactoryV1Test:test_launchRejectsHookSaltWithWrongPermissionBitsBeforeDeployment() (gas: 2030976) +ShardLaunchFactoryV1Test:test_launchRejectsInvalidStartPrice() (gas: 1838075) +ShardLaunchFactoryV1Test:test_launchRejectsInvalidTicks() (gas: 1833605) +ShardLaunchFactoryV1Test:test_launchRejectsMalformedCreationCodeOfTheSameLength() (gas: 1819732) +ShardLaunchFactoryV1Test:test_launchRejectsOccupiedPredictedNftBeforeDeployingTokenOrHook() (gas: 1881505) +ShardLaunchFactoryV1Test:test_launchRejectsWrongCreationCode() (gas: 23417) +ShardLaunchFactoryV1Test:test_launchRejectsZeroBuilder() (gas: 1830627) +ShardLaunchFactoryV1Test:test_launchScriptPrecomputesEveryLaunchCommitment() (gas: 78586026) +ShardLaunchFactoryV1Test:test_launchStoresExactConfigurationHashAndEmitsExactEvent() (gas: 10310752) +ShardLaunchFactoryV1Test:test_launcherRecipientIsBoundToTheProgrammableConstant() (gas: 8444) +ShardLaunchFactoryV1Test:test_logDeploymentAndLaunchGas() (gas: 113141069) +ShardLaunchFactoryV1Test:test_nftPredictionIsStableAcrossUnrelatedLaunches() (gas: 29838981) +ShardLaunchFactoryV1Test:test_predictionHelpersAreDeterministicAndUseExactInitCode() (gas: 2782775) +ShardLaunchFactoryV1Test:test_sameRawSaltAndBuilderWithDifferentConfigurationCannotConsumeIntendedToken() (gas: 120810134) +ShardLaunchFactoryV1Test:test_sameRawTokenSaltWithDifferentBuilderCreatesIndependentTokenAddress() (gas: 255416190) +ShardLaunchFactoryV1Test:test_sharedMinerIsDeterministicAndDeploysOnlyOnce() (gas: 101261118) +ShardLaunchFactoryV1Test:test_twoCollectionsShareOneRendererAndRemainIndependent() (gas: 23243880) +ShardLaunchSequenceV1Test:test_buyingIsClosedUntilInitialise() (gas: 325387403) +ShardLaunchSequenceV1Test:test_cheapCurveIsTheProductionCurveShiftedByAConstant() (gas: 3661) +ShardLaunchSequenceV1Test:test_launchFeeSplitsWithoutChangingWhatTheBuyerPays() (gas: 185840306) +ShardLaunchSequenceV1Test:test_launchIsOneShotSoItCannotBeReplayed() (gas: 325744227) +ShardLaunchSequenceV1Test:test_oneTransactionFactoryLaunchMintsTheFirstFiftyIds() (gas: 185986156) +ShardLaunchSequenceV1Test:test_onlyTheDeployerCanRunTheLaunch() (gas: 325333303) +ShardLaunchSequenceV1Test:test_productionTicksStillLaunchAtAboutAThousandthOfAnEth() (gas: 4459) +ShardLaunchSequenceV1Test:test_setNFTIsOneShotSoABlindRerunWouldBrickTheLaunch() (gas: 325745958) +ShardLaunchSequenceV1Test:test_testnetBandEdgeKeepsTheProductionShape() (gas: 7928) +ShardLaunchSequenceV1Test:test_testnetTicksPriceTheLaunchAtAboutOneMillionthOfAnEth() (gas: 4485) +ShardNFTV1Test:testFuzz_lowestAvailableIdIsAlwaysUnheld(uint8,uint256) (runs: 1000, μ: 539988, ~: 468528) +ShardNFTV1Test:test_acquireAlwaysWritesFreshSeed() (gas: 229152) +ShardNFTV1Test:test_acquireHandsOutLowestAvailableId() (gas: 198706) +ShardNFTV1Test:test_acquireMintsLazily() (gas: 124659) +ShardNFTV1Test:test_acquireUsesUnsafeMintOnly() (gas: 315986) +ShardNFTV1Test:test_archiveMoveDoesNotCallSettle() (gas: 230485) +ShardNFTV1Test:test_circulatingSupplyTracksAcquireAndRelease() (gas: 251993) +ShardNFTV1Test:test_directTransferFromToArchiveReverts() (gas: 120533) +ShardNFTV1Test:test_directTransferFromToHookReverts() (gas: 120686) +ShardNFTV1Test:test_maxSupplyIsTenThousand() (gas: 8425) +ShardNFTV1Test:test_onlyHookCanAcquire() (gas: 11104) +ShardNFTV1Test:test_onlyHookCanRelease() (gas: 120186) +ShardNFTV1Test:test_outOfRangeTokenUriReverts() (gas: 14513) +ShardNFTV1Test:test_poolHeldIdRendersDormant() (gas: 53499) +ShardNFTV1Test:test_releaseReturnsIdToArchiveAndZeroesSeed() (gas: 153332) +ShardNFTV1Test:test_releasedIdIsHandedOutAgain() (gas: 268119) +ShardNFTV1Test:test_tokenUriIsBase64DataUri() (gas: 166398) +ShardNFTV1Test:test_walletTransferCallsSettleOnHook() (gas: 225862) +ShardNFTV1Test:test_walletTransferDoesNotChangeSeed() (gas: 223187) +ShardScaffoldV1Test:test_base64Encode() (gas: 4069) +ShardScaffoldV1Test:test_baseHookImportResolves() (gas: 3551) +ShardScaffoldV1Test:test_create2PredictionImportResolves() (gas: 3289) +ShardScaffoldV1Test:test_currencyWrapUnwrap() (gas: 3137) +ShardScaffoldV1Test:test_swapParamsImportResolves() (gas: 3212) +ShardScaffoldV1Test:test_tickMathConstants() (gas: 3546) +ShardSwapRouterV1Test:testFuzz_swapNeverLeavesRouterHoldingFunds(uint96,uint16) (runs: 1000, μ: 444732, ~: 439880) +ShardSwapRouterV1Test:test_ethForShardDoesNotSweepStrandedEth() (gas: 314836) +ShardSwapRouterV1Test:test_ethForShardRefundsOnlyItsOwnUnspentEth() (gas: 322711) +ShardSwapRouterV1Test:test_rejectsPlainEthTransfers() (gas: 17967) +ShardSwapRouterV1Test:test_respectsDeadline() (gas: 73740) +ShardSwapRouterV1Test:test_respectsMinAmountOut() (gas: 730208) +ShardSwapRouterV1Test:test_revertsOnZeroAmount() (gas: 66501) +ShardSwapRouterV1Test:test_setupUsesAtomicFactory() (gas: 10634) +ShardSwapRouterV1Test:test_shardForEthRequiresApproval() (gas: 544613) +ShardSwapRouterV1Test:test_swapChargesTheHookOnePercent() (gas: 435032) +ShardSwapRouterV1Test:test_swapEthForShardDeliversShard() (gas: 325243) +ShardSwapRouterV1Test:test_swapShardForEthDeliversEth() (gas: 435938) +ShardSwapRouterV1Test:test_unlockCallbackOnlyPoolManager() (gas: 13011) +ShardTokenV1Test:test_entireSupplyGoesToDeployer() (gas: 13524) +ShardTokenV1Test:test_hasNoBurnFunction() (gas: 8307) +ShardTokenV1Test:test_hasNoMintFunction() (gas: 8320) +ShardTokenV1Test:test_metadata() (gas: 17851) +ShardTokenV1Test:test_oneShardEqualsOneNft() (gas: 10526) +ShardTokenV1Test:test_totalSupplyIsTenThousandWhole() (gas: 10498) +ShardV1Invariants:invariant_accumulatorNeverDecreases() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_bandPositionIsNeverReduced() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_bandStaysDenserThanTheFullRange() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_builderAndLauncherCutsMatch() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_builderFeesAreAlwaysClaimable() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_buyMaxNeverReturnsAWholeShard() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_callSummary() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_circulatingNeverExceedsMaxSupply() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_claimsNeverExceedFeesTaken() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_dustNeverExceedsMaxSupply() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_dustStaysSubWei() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_earningSetMatchesBackingSet() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_hookAssetsCoverAllClaims() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_launchUsesAtomicFactory() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_liquidityPositionIsNeverReduced() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_lowestAvailableIdIsActuallyAvailable() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_poolHeldPlusCirculatingEqualsTenThousand() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_shardBackingMatchesNftCirculating() (runs: 256, calls: 16384, reverts: 0) +ShardV1Invariants:invariant_shardSupplyIsConserved() (runs: 256, calls: 16384, reverts: 0) +ShardV1MainnetForkTest:setUp() (gas: 0) +ShardWiringV1Test:test_correctNftBackReferenceBindsAndMarketRemainsUsable() (gas: 917323) +ShardWiringV1Test:test_ethereumArtSeedDoesNotDependOnArbitrumPrecompileAddress() (gas: 1453288) +ShardWiringV1Test:test_setNftNormalizesMalformedHookGetter() (gas: 48244) +ShardWiringV1Test:test_setNftNormalizesMissingHookGetter() (gas: 45213) +ShardWiringV1Test:test_setNftRejectsNftBoundToAnotherValidHook() (gas: 41866118) +ShardWiringV1Test:test_setNftRetainsExactAuthorizationAndOneShotErrors() (gas: 40911) +ShardWiringV1Test:test_setNftRetainsExactZeroAddressError() (gas: 10661) \ No newline at end of file diff --git a/.github/workflows/mainnet-evidence.yml b/.github/workflows/mainnet-evidence.yml index 493a1e19..fa0d7732 100644 --- a/.github/workflows/mainnet-evidence.yml +++ b/.github/workflows/mainnet-evidence.yml @@ -8,6 +8,7 @@ on: paths: - "src/**" - "test/ClassicV3MainnetFork.t.sol" + - "test/ShardV1MainnetFork.t.sol" - "deployments/**" - "models/**" - "releases/**" @@ -54,3 +55,15 @@ jobs: done done exit 1 + + - name: Run Shards lifecycle fork test + run: | + for rpc_url in https://rpc.flashbots.net https://eth.drpc.org; do + for attempt in 1 2; do + echo "Fork attempt ${attempt} with ${rpc_url}" + if ETHEREUM_RPC_URL="${rpc_url}" forge test --match-contract ShardV1MainnetForkTest; then + exit 0 + fi + done + done + exit 1 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index e2cf6932..5886aff3 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -48,7 +48,7 @@ jobs: - name: Measure source coverage run: >- forge coverage - --no-match-contract ClassicV3MainnetForkTest + --no-match-contract '(ClassicV3MainnetForkTest|ShardV1MainnetForkTest)' --exclude-tests --report lcov --report-file lcov.info diff --git a/MODELS.md b/MODELS.md index 07ded2a8..0bc6def3 100644 --- a/MODELS.md +++ b/MODELS.md @@ -10,6 +10,7 @@ accounted for. [`models/registry.json`](models/registry.json) is the canonical m | Classic | **Available** | [`classic-v3`](releases/classic-v3/RELEASE.md) | [Open model](models/classic/README.md) | | Stock-Paired | **Candidate** | Deployed candidate | [Open candidate](models/stock-paired/README.md) | | Deep | **Design** | None | [Open design](models/deep/README.md) | +| Shards | **Design** | None | [Open design](models/shards/README.md) | `Available` means the exact source, parameters, deployment, runtime hashes and security status are public. It does not mean that a model has received an independent audit. @@ -77,6 +78,19 @@ is reached. It has no deployed contracts and is not available for launch. [Design and open release gates](models/deep/README.md) +## Shards + +**Design only.** Shards proposes a single-sided bonding-curve market for a fixed 10,000-piece on-chain-art NFT +collection. Each launch deploys its own hook, token and NFT contract through an atomic factory and uses that +factory's shared renderer; the whole supply is locked into a +permanent Uniswap v4 position with no withdrawal path, and art is regenerated on every acquisition from the pool. +Its 1.00% native-ETH swap fee is split 0.80% to collection holders, 0.10% to the hook builder and 0.10% to +Programmable. It has no deployed contracts and is not available for launch. + +[Design and open release gates](models/shards/README.md) · +[Security properties](models/shards/SECURITY.md) · +[Fixed parameters](spec/shards-v1.json) + ## Adding a model New models start at `design`. They become `candidate` only after source, tests, fixed parameters and security properties diff --git a/docs/SHARDS_LAUNCH_RUNBOOK.md b/docs/SHARDS_LAUNCH_RUNBOOK.md new file mode 100644 index 00000000..0d7cb97e --- /dev/null +++ b/docs/SHARDS_LAUNCH_RUNBOOK.md @@ -0,0 +1,150 @@ +# Shards V1 launch runbook + +This runbook reproduces a Shards launch from canonical source. It deliberately separates factory deployment, salt mining, and launch broadcast. The factory is deployed through the canonical CREATE2 proxy, so its address depends only on the factory salt and init-code — never on the deployer's nonce — and can be predicted and reproduced by any sender. + +Shards remains in `design` status. None of the commands below authorizes a production deployment. + +## Fixed inputs + +- Ethereum PoolManager: `0x000000000004444c5dc75cB358380D2e3dE08A90` +- CREATE2 deployment proxy: `0x4e59b44847b379578588920cA78FbF26c0B4956C` +- factory deployer (EOA that broadcasts): `0x2Bb333d48DFAF1596D9036671d2E43168994249E` +- launcher (Programmable 0.10%) recipient: `0x4957f49620AFf3Adbbe8195a4f633E49cc93376c` — an immutable constant in `ShardLaunchFactoryV1`, not a constructor argument +- builder (0.10%) recipient: `0xceeBB3A6543CeBEB2ED66963897A0abEA52A50cC` +- factory salt: `0x655a4b5a2b704bef84b4ff94adde0a7ac40ad0366c82ddca5290180fe4c3986d` (`keccak256("programmable.shards-v1.factory.v1")`) +- raw token salt: `0xca9944c923e24ba5cb3188a29b18c3305158e686e39473e91bbe31fc019816ab` (`keccak256("programmable.shards-v1.token.v1")`) +- hook creation-code hash: `0x34df1ce932b3ca8eebc45eff8116378cbcd5a4a285fd2bf0c28bd78a350d8a2f` +- tick spacing: `60` +- production tick lower: `-887220` +- production tick band: `22980` +- production tick upper: `69060` +- production start price: `TickMath.getSqrtPriceAtTick(69060)` +- required low hook bits: exactly `beforeInitialize`, `beforeSwap`, `afterSwap`, `beforeSwapReturnDelta`, and `afterSwapReturnDelta` + +The full pinned plan — factory, renderer, effective token salt, mined hook salt, predicted SHARD/hook/NFT, and expected configuration hash — is recorded in `releases/shards-v1/mainnet-manifest.json` under `candidatePlan`. Every one of those values is valid only for the exact reviewed source at this revision; any source change invalidates them and requires re-mining. + +## 1. Build and inspect artifacts + +```bash +./scripts/bootstrap-deps.sh +forge fmt --check +forge build --sizes +forge inspect ShardHookV1 bytecode | cast keccak +forge inspect ShardHookV1 deployedBytecode | cast keccak +forge inspect ShardLaunchFactoryV1 bytecode | cast keccak +forge inspect ShardLaunchFactoryV1 deployedBytecode | cast keccak +``` + +`bytecode` is the constructor-free creation-code artifact. Record it separately from full deployment initcode, which appends constructor arguments. Every runtime must remain below 24,576 bytes and full factory deployment initcode must remain below 49,152 bytes. `keccak256` of the `ShardHookV1` creation-code artifact must equal the hook creation-code hash in Fixed inputs. + +## 2. Predict the factory address (no broadcast) + +The factory address is nonce-independent — it is `CREATE2(proxy, factorySalt, keccak256(initcode))` — so it can be checked before any transaction: + +```bash +forge script script/LaunchShardsV1.s.sol:LaunchShardsV1 \ + --rpc-url "$ETHEREUM_RPC_URL" \ + --sig "previewFactory(address,bytes32,bytes32)" \ + 0x000000000004444c5dc75cB358380D2e3dE08A90 "$HOOK_CODE_HASH" "$FACTORY_SALT" +``` + +Confirm the printed factory and renderer equal the `candidatePlan` values. No nonce is involved; the prediction does not change if the deployer sends other transactions first. + +## 3. Deploy the factory through the CREATE2 proxy + +After explicit deployment authorization, use a configured hardware wallet or encrypted Foundry account: + +```bash +forge script script/LaunchShardsV1.s.sol:LaunchShardsV1 \ + --rpc-url "$ETHEREUM_RPC_URL" --account "$FOUNDRY_ACCOUNT" --broadcast \ + --sig "deployFactory(address,bytes32,bytes32)" \ + 0x000000000004444c5dc75cB358380D2e3dE08A90 "$HOOK_CODE_HASH" "$FACTORY_SALT" +``` + +`deployFactory` reverts unless the supplied hash equals `keccak256(type(ShardHookV1).creationCode)` and unless the deployed address equals the CREATE2 prediction. Verify `poolManager`, `launcherFeeRecipient`, `renderer`, and `hookCreationCodeHash` from chain state. Verify the factory and shared renderer source using the exact build settings in `foundry.toml`. + +Never put a private key, mnemonic, API token, or broadcast secret in a command line, repository file, shell history, or plan. Use a hardware signer or encrypted account prompt. + +## 4. Mine twice against the factory + +Mining is a non-broadcast view call. Use identical inputs twice and save both complete outputs: + +```bash +forge script script/LaunchShardsV1.s.sol:LaunchShardsV1 \ + --rpc-url "$ETHEREUM_RPC_URL" \ + --sig "predictAndMine(address,bytes32,bytes32,(int24,int24,int24,uint160,address))" \ + "$FACTORY" "$TOKEN_SALT" 0x0 \ + "(-887220,22980,69060,$START_SQRT_PRICE_X96,$BUILDER)" +``` + +Repeat the exact command from a clean shell. The raw and effective salts, creation-code and initcode hashes, predicted SHARD, hook and NFT, expected configuration hash, and mined hook salt must match byte-for-byte, and must equal the `candidatePlan` values. Confirm the hook's low 14 bits equal the exact required mask. + +## 5. Simulate the canonical launch + +```bash +forge script script/LaunchShardsV1.s.sol:LaunchShardsV1 \ + --rpc-url "$ETHEREUM_RPC_URL" \ + --sender "$DEPLOYER" \ + --sig "launch(address,bytes32,bytes32,(int24,int24,int24,uint160,address))" \ + "$FACTORY" "$TOKEN_SALT" "$HOOK_SALT" \ + "(-887220,22980,69060,$START_SQRT_PRICE_X96,$BUILDER)" +``` + +Run this against a block at or after the confirmed factory receipt. For an offline rehearsal, replay factory deployment and launch in the same local fork. The returned SHARD, hook, and NFT must equal the mined predictions, and the configuration hash must equal the pre-broadcast commitment. Confirm the simulation emits one `ShardLaunched` event and leaves the factory with zero SHARD. + +## 6. Broadcast one launch + +Only after the source-review, security-review, and explicit deployment-authorization gates are satisfied: + +```bash +forge script script/LaunchShardsV1.s.sol:LaunchShardsV1 \ + --rpc-url "$ETHEREUM_RPC_URL" --account "$FOUNDRY_ACCOUNT" --broadcast \ + --sig "launch(address,bytes32,bytes32,(int24,int24,int24,uint160,address))" \ + "$FACTORY" "$TOKEN_SALT" "$HOOK_SALT" \ + "(-887220,22980,69060,$START_SQRT_PRICE_X96,$BUILDER)" +``` + +Do not retry blindly. First inspect the transaction and `configurationHashOf(predictedHook)`. An exact-configuration observer can sponsor the public launch before the intended sender; that creates the same contracts and recipients rather than redirecting the builder role. + +## 7. Post-launch verification + +Verify exact source for the factory, renderer, hook, SHARD, and NFT. Then independently: + +1. recompute the effective token salt from the raw salt, hook salt, ticks, start price, and builder recipient; +2. recompute token, hook, and NFT CREATE2 addresses from the factory; +3. hash the actual `ShardHookV1` creation bytes and exact constructor initcode; +4. recompute the configuration hash in the field order used by `ConfigurationData`; +5. check `configurationHashOf(hook)` and the `ShardLaunched` event; +6. check the exact five hook permission bits and PoolManager authentication; +7. check `hook.deployer() == factory`, NFT-to-hook and hook-to-NFT back-references, and consumed one-shot powers; +8. check both liquidity positions and the SHARD/NFT backing identity. + +A second launch with the same raw token salt, builder, and hook salt must revert because the predicted addresses are occupied. It must not change the first launch. + +## Rehearsal record + +The predicted plan was generated deterministically from the reviewed source: the factory address was computed as `CREATE2(0x4e59…4956C, factorySalt, keccak256(initcode))`, the factory was deployed at that address through a CREATE2 deployer to run its constructor (which deploys the shared renderer), and the hook salt was mined against it. Pinned outputs (all recorded in `candidatePlan`): + +```text +expected factory: 0xDc1Aae9A32c5220dAAE2CCD4D10329bEb854bf39 +expected renderer: 0x3C8B0168a345116A7f40d866D07b3aed832D0812 +launcher fee recipient: 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c +builder fee recipient: 0xceeBB3A6543CeBEB2ED66963897A0abEA52A50cC +hook creation-code hash: 0x34df1ce932b3ca8eebc45eff8116378cbcd5a4a285fd2bf0c28bd78a350d8a2f +raw token salt: 0xca9944c923e24ba5cb3188a29b18c3305158e686e39473e91bbe31fc019816ab +effective token salt: 0xe1387dff2e86548ec4a0a8e8678c4b55d50b64f522ba7681cf3c3f5535ca6d4a +hook salt: 0x00000000000000000000000000000000000000000000000000000000000031ec +predicted SHARD: 0x92541FCEd29417859d1eA3fC6a9506464FC51D94 +predicted hook: 0x8Add9914734D3178a296e8C007Ab2F22559Ea0cC +predicted NFT: 0x62D7bD7F95eE7e35956f76531757C9514a4b762b +expected configuration hash: 0x475e4ee8be4b757b4e10fffbd5410a145eac6c05a44a7904c03d4504934e639c +``` + +The pinned Mainnet-fork suite reproduces the full lifecycle against the canonical v4 PoolManager: + +```text +ETHEREUM_RPC_URL=https://eth.drpc.org forge test --match-contract ShardV1MainnetForkTest -vv + 4 passed; factory deployment gas 7,176,677; atomic launch gas 8,532,815; block 25639000 +``` + +The factory unit suite separately covers deterministic re-mining, failure after each deployment stage with full rollback, and a duplicate launch reverting `AddressOccupied` without changing the first launch. diff --git a/docs/security/SHARDS_PROPERTIES.md b/docs/security/SHARDS_PROPERTIES.md new file mode 100644 index 00000000..3cddd74c --- /dev/null +++ b/docs/security/SHARDS_PROPERTIES.md @@ -0,0 +1,108 @@ +# Shards V1 security properties + +This is a design-stage property record for exact source, not an audit. Shards has no production deployment and remains unavailable. + +## P1. Launch is atomic and rollback is complete + +`ShardLaunchFactoryV1.launch` validates the supplied hook bytes and permission bits, deploys the token and hook with CREATE2, deploys the NFT, binds both directions, transfers the full fixed supply, and initialises the pool in one transaction. Failure at any later step rolls back token, hook, NFT, configuration mapping, event, and factory balances. + +Evidence: `test_failureAfterDeploymentsRollsBackTokenHookNftMappingAndEvent`, `test_duplicateTokenSaltForSameBuilderRevertsWithoutChangingFirstLaunch`, and `test_factoryHoldsZeroShardAfterSuccess` in `test/ShardLaunchFactoryV1.t.sol`. + +## P2. Hook and NFT wiring is exact + +The hook accepts an NFT only if a bounded `staticcall` to `hook()` returns exactly one ABI word equal to the hook. Missing, malformed, reverting, zero, wrong-hook, unauthorized, and repeat bindings revert. The NFT independently stores its immutable hook. + +Evidence: all `ShardWiringV1Test` tests, especially `test_correctNftBackReferenceBindsAndMarketRemainsUsable`, `test_setNftRejectsNftBoundToAnotherValidHook`, and the missing/malformed getter cases. + +## P3. Configuration evidence binds every launch input + +The stored and emitted configuration hash binds chain id, factory, PoolManager, shared renderer, launcher and builder recipients, CREATE2-predicted token, hook and NFT, ticks, start price, raw and effective token salts, hook salt, and actual hook creation-code hash. Unrelated launches cannot change any predicted address. + +Evidence: `test_launchStoresExactConfigurationHashAndEmitsExactEvent`, deterministic prediction tests in `ShardLaunchFactoryV1.t.sol`, and `test_factoryPredictionAndConfigurationEvidenceAreReproducible` on the pinned Mainnet fork. + +## P4. Hook permissions and callback authentication are exact + +The predicted hook's low 14 bits must equal the five permissions returned by `getHookPermissions`: `beforeInitialize`, `beforeSwap`, `afterSwap`, `beforeSwapReturnDelta`, and `afterSwapReturnDelta`. The hook rejects foreign pool keys, wrong start prices, non-PoolManager unlock callbacks, and swaps before initialization. + +Evidence: `test_launchRejectsHookSaltWithWrongPermissionBitsBeforeDeployment`, `test_hookPermissionsMatchAddressBits`, `test_beforeInitializeRejectsForeignPool`, and both router/hook `test_unlockCallbackOnlyPoolManager` tests. + +## P5. Every circulating NFT is backed + +At all reachable states: + +```text +shard.balanceOf(hook) == nft.circulatingSupply() * 1e18 + hook.seedDust() +``` + +The fixed SHARD supply is conserved across the PoolManager, hook, NFT, handler, and actors. Partial fills revert instead of creating unbacked art. + +Evidence: `invariant_shardBackingMatchesNftCirculating`, `invariant_shardSupplyIsConserved`, the market/batch backing tests, and `ShardHookExhaustionV1Test`. + +## P6. Both seeded liquidity positions are permanent + +The only production `modifyLiquidity` path adds positive liquidity during initialization. There is no removal, rescue, owner, or upgrade path. Both the full-range and concentrated-band positions never decrease. + +Evidence: `invariant_liquidityPositionIsNeverReduced`, `invariant_bandPositionIsNeverReduced`, `invariant_bandStaysDenserThanTheFullRange`, and `test_ATTACK_cannotWithdrawLiquidity`. + +## P7. Fee accounting is conserved and solvent + +Each swap fee is native ETH and splits exactly into builder, launcher, and holder amounts. Real ETH plus PoolManager native claims covers holder, builder, launcher, and escrow liabilities. Donations bypass the beneficiary split. + +Evidence: `invariant_hookAssetsCoverAllClaims`, `invariant_builderAndLauncherCutsMatch`, `testFuzz_theSplitConservesEveryFee`, `test_hookClaimBalancePlusEthCoversFeesTaken`, and `ShardFeeDonationV1Test`. + +## P8. New holders cannot earn their acquisition-block fee + +A piece joins the earning set only after its acquisition block. Transfer and release settle the outgoing owner before membership changes. Same-block buy/transfer/sell strategies cannot capture their own fee. + +Evidence: `test_sameBlockAcquisitionEarnsNothing`, `test_transferInAcquisitionBlockEarnsNothing`, `test_ATTACK_sameBlockFeeSnipeEarnsNothing`, `invariant_earningSetMatchesBackingSet`, and accumulator invariants. + +## P9. Token and ETH transfer failures revert + +Every production ERC20 transfer/transferFrom whose result affects accounting checks the returned boolean. ETH refunds, payouts, and claims check call success and are reentrancy guarded. + +Evidence: `ShardCheckedTransferV1Test` covers settlement transfer, `buyMax` leftover transfer, redeem transferFrom, and router transferFrom; the factory rollback test covers its full-supply transfer. `ShardFeeSplitV1Test` covers rejecting recipients and rollback for buy refunds, sell payouts, holder claims, builder claims, and launcher claims. Reentrant refund, sell, claim, and NFT-release paths are exercised in `ShardHookAttackV1Test`. + +## P10. Launch powers are authorized and consumed + +Only the factory is the production hook deployer. Its `setNFT` and `initialise` powers are consumed during the atomic transaction. Neither the factory nor an external account can replay them, and the factory retains no SHARD. + +Evidence: `test_hookDeployerIsFactoryAndBothOneShotPowersAreConsumed`, factory-deployer assertions in migrated production suites, and focused manual authorization tests in `ShardLaunchSequenceV1.t.sol`. + +## P11. Partial fills and oversized swaps fail closed + +The hook rejects partial fills where the precomputed ETH fee would no longer match execution and caps each third-party swap to 50 SHARD of movement. Hook-market methods enforce deadlines, count bounds, and slippage bounds. + +Evidence: `test_partialFillIsRejectedNotOvercharged`, `test_partialFillIsRejectedOnExactOutputEthToo`, boundary tests in `ShardHookFeesV1.t.sol`, and the shallow-range exhaustion suite. + +## P12. Public salts do not grant role redirection + +Launch inputs and mined salts are public. An observer can submit an exact configuration first and thereby sponsor the same launch. The effective token salt commits to the raw token salt, hook salt, ticks, start price, and builder recipient. Changing any committed value changes the token prediction, so a different configuration cannot consume the intended token address. The NFT is CREATE2-predicted from the hook and shared renderer, so unrelated launches cannot race the NFT or configuration hash. + +Evidence: `test_effectiveTokenSaltBindsHookSaltAndEveryLaunchParameter`, `test_sameRawSaltAndBuilderWithDifferentConfigurationCannotConsumeIntendedToken`, `test_nftPredictionIsStableAcrossUnrelatedLaunches`, deterministic-miner tests, and duplicate configuration tests. + +## P13. Art seeds are non-secure Ethereum inputs + +Seeds mix the preceding block hash, timestamp, recipient, and an acquisition nonce. They support deterministic on-chain rendering and practical per-acquisition variation, not unpredictability. Block producers and callers can observe or influence inputs. + +Evidence: `test_ethereumArtSeedDoesNotDependOnArbitrumPrecompileAddress`, renderer determinism/difference tests, and `test_ethereumSeedInputsProduceDistinctRenderedArt` on the pinned Mainnet fork. + +## P14. Builder and launcher entitlements are cumulative and split-invariant + +The builder and launcher entitlements are cumulative and split-invariant: a stream of small swaps accrues the same operator total (±1 wei) as one aggregated swap, because the combined operator cut is taken with a carried remainder rather than flooring each 0.10% cut per swap. Conservation `builderCut + launcherCut + holderAmount == fee` holds every call, and the launcher is never shorted below the builder in absolute accrual. The outer 1% fee is cumulative on both bases too: `_chargeFee` carries its per-swap remainder (`feeCarryIn`/`feeCarryOut`), so a stream of tiny swaps accrues the same total as one aggregated swap instead of flooring to zero — verified by `ShardFeeSplitV1.t.sol::test_outerFee_exactInFragmentedMatchesAggregated`, `::test_outerFee_exactOutFragmentedMatchesAggregated`, and `::testFuzz_outerFeeIsCumulative`. `buyMax` clamps the inclusive fee to its exact-input reserve so the buyer is never overspent, and returns the wei it cannot collect to `feeCarryOut` so that entitlement is preserved and collected on a later exact-output swap — not shed once per qualifying call — verified by `::test_buyMax_clampCarriesUncollectedWeiAndAccruesToProgrammable`. + +Evidence: `testFuzz_splitIsConservativeAndCumulative` in `test/ShardFeeSplitV1.t.sol` and `test_operatorSplitHoldsAcrossAllFourQuadrants` in `test/ShardHookFeesV1.t.sol`. + +## P15. The launcher recipient is bound to the Programmable constant + +The launcher (Programmable, 0.10%) recipient is an immutable constant `0x4957f49620AFf3Adbbe8195a4f633E49cc93376c` in `ShardLaunchFactoryV1` — the same address Classic v3 uses — not a constructor argument. The factory cannot be constructed with any other launcher recipient, so it cannot route the Programmable share elsewhere. The builder recipient stays per-launch. + +Evidence: `test_launcherRecipientIsBoundToTheProgrammableConstant` in `test/ShardLaunchFactoryV1.t.sol`. + +## Remaining release gates + +1. Maintainer re-review of the exact final source. +2. Independent security-review status recorded for that source. +3. User-authorized Ethereum deployment and exact source verification. +4. Deployment, runtime, and release evidence published. +5. Bytecode and lifecycle checks passing for the deployed addresses. +6. Production interface configured for that exact release. diff --git a/models/registry.json b/models/registry.json index a9167cdf..f0bff7ed 100644 --- a/models/registry.json +++ b/models/registry.json @@ -1,7 +1,7 @@ { "$schema": "./schema/registry.schema.json", "schemaVersion": 1, - "updatedAt": "2026-07-30", + "updatedAt": "2026-08-04", "statuses": [ "design", "candidate", @@ -32,6 +32,14 @@ "summary": "A proposed launch model that directs the creator fee share into add-only locked liquidity until an immutable target is reached.", "manifest": "models/deep/model.json", "documentation": "models/deep/README.md" + }, + { + "id": "shards", + "name": "Shards", + "status": "design", + "summary": "Single-sided bonding-curve market for a fixed 10,000-piece on-chain-art NFT collection; every swap pays a 1.00% native-ETH fee split 0.80% to collection holders, 0.10% to the builder and 0.10% to Programmable.", + "manifest": "models/shards/model.json", + "documentation": "models/shards/README.md" } ] } diff --git a/models/shards/README.md b/models/shards/README.md new file mode 100644 index 00000000..8b26a226 --- /dev/null +++ b/models/shards/README.md @@ -0,0 +1,297 @@ +# Shards + +**Status:** Design
+**Target network:** Ethereum
+**Model id:** `shards` + +Single-sided bonding-curve market for a fixed 10,000-piece on-chain-art NFT collection; every swap pays a 1.00% native-ETH fee split 0.80% to collection holders, 0.10% to the builder and 0.10% to Programmable. + +This document describes a proposed model. It is not available for launch and has no production deployment. + +[Fixed parameters](../../spec/shards-v1.json) · +[Security properties](SECURITY.md) · +[Numbered source properties](../../docs/security/SHARDS_PROPERTIES.md) · +[Test plan](TEST_PLAN.md) · +[Candidate deployment plan](../../releases/shards-v1/mainnet-manifest.json) · +[Model manifest](model.json) + +**Builder:** [`jesse-stahl`](https://github.com/jesse-stahl)
+**Builder beneficiary:** `0xceeBB3A6543CeBEB2ED66963897A0abEA52A50cC` + +## Behavior + +One factory can launch multiple independent collections. Each launch has its own `ShardHookV1`, `ShardTokenV1` +and `ShardNFTV1`; every collection from that factory uses the factory's one immutable shared renderer. + +```mermaid +flowchart LR + creator["Builder"] -->|"one atomic launch"| factory["ShardLaunchFactoryV1"] + factory --> hook["ShardHookV1"] + hook --> pool["Uniswap v4 ETH/SHARD pool"] + hook --> position["Permanently locked single-sided position"] + hook --> nft["ShardNFTV1 (10,000 pieces)"] + factory --> renderer["Shared on-chain renderer"] + nft --> renderer + hook --> holders["NFT holders 0.80%"] + hook --> builderFees["Builder 0.10%"] + hook --> programmable["Programmable 0.10%"] +``` + +The lifecycle: + +1. **Predict.** The raw salt, hook salt, curve parameters, start price, and builder recipient commit to SHARD. + The exact supplied hook creation bytes plus constructor arguments predict the hook, whose low 14 bits must + equal the five required permission flags. The hook and shared renderer then predict the NFT. +2. **Launch atomically.** `ShardLaunchFactoryV1` validates the pinned hook-code hash, CREATE2-deploys SHARD and + the hook, deploys the NFT against its shared renderer, checks `nft.hook() == hook`, binds the NFT, checked- + transfers all `10_000e18` SHARD and calls `initialise` in one transaction. Any failure rolls everything back. +3. **Initialise.** Factory-driven `initialise` requires the hook to hold exactly `10_000e18` SHARD, + requires the start tick to sit at or above `tickUpper`, initialises the pool at `startSqrtPriceX96` and seeds + liquidity. It is one-shot, spends no ETH and tolerates a front-run: if the canonical pool already exists the + `Pool.PoolAlreadyInitialized` revert is caught and seeding continues, and any other revert is re-thrown. +4. **Lock.** Seeding mints two overlapping single-sided positions owned by the hook: 3,000 SHARD across the full + range `[tickLower, tickUpper]` and 7,000 SHARD across the concentrated band `[tickBand, tickUpper]`. The + rounding remainder that never entered a position is recorded as `seedDust`. `poolManager.modifyLiquidity` is + called only from `_mintPosition` and only with a positive `liquidityDelta`. There is no withdrawal path, no + operator and no owner; v4 keys positions on `msg.sender`, so no other contract can address them. +5. **Trade.** Buying SHARD pushes the tick down, so the collection gets more expensive as it sells through and + the pool thins out below `tickBand`. Two mutually exclusive trade paths exist. Third parties swap through any + router or aggregator and pay the 1.00% in `beforeSwap`/`afterSwap`; they then call `redeem` or `redeemMany` to + turn 1e18 SHARD into a piece, with no second fee. Buyers of art call `buyNFT`, `buyMany` or `buyMax` on the + hook, which swap through `poolManager.unlock` — v4 skips a hook's own callbacks, so those paths charge the + 1.00% explicitly in their own bodies. Sellers call `sellNFT` or `sellMany`. Batches are capped at + `MAX_BATCH = 50`, and a third-party swap is capped at the same 50 SHARD of movement (`SwapTooLarge`). +6. **Regenerate.** `acquire` writes a fresh seed for the id it hands out, which is the art. `release` sets the + seed to zero and the piece is gone. `_update` deliberately does not touch the seed, so a wallet-to-wallet + transfer never rerolls the art. There is no NFT-to-SHARD reverse path, which is what makes a free reroll + impossible. V1 does not accept user-supplied artwork or renderer code: every collection launched by a factory + uses that factory's immutable `GeometricRendererV1`. +7. **Accrue.** Every fee is native ETH. Third-party fees are minted as ERC-6909 claims against the pool manager + and redeemed to real ETH by `_sweepClaims` before any payout. Holder fees run through a scaled accumulator; + an acquired piece joins the earning set from the block after acquisition, and fees accrued while nothing is + circulating are escrowed and released to the first holder. +8. **Claim.** Holders call `claim(tokenIds)` and are paid their settled balance. The builder calls + `claimBuilderFees`. Programmable calls `claimLauncherFees`. All three are beneficiary-only, and all fees sit + in the hook until claimed. + +The effective token salt commits to the raw token salt, hook salt, all curve parameters, start price, and builder +recipient. The hook CREATE2 prediction hashes the actual hook creation bytes plus exact constructor arguments; +the factory does not infer initcode from a code hash alone. The NFT is also CREATE2-predicted from the hook and +immutable shared renderer, so unrelated factory launches cannot change its address. +Each launch stores and emits a configuration hash binding chain, factory, PoolManager, shared renderer, +beneficiaries, deployed addresses, curve parameters, both token salts, hook salt, and hook creation-code hash. +Because these inputs are public, an observer can sponsor the exact same configuration first. Changing any launch +parameter or hook salt changes the token prediction and cannot consume the intended configuration. + +Anyone may pay outside revenue into the holder pool with `donate()`, or by sending ETH to a `ShardFeeForwarderV1` +and letting anyone flush it. Donations are not split — they go to holders in full. + +## Pool and hook + +| Setting | Value | +| --- | --- | +| Currency0 | Native ETH (`address(0)`) | +| Currency1 | SHARD (`ShardTokenV1`, 18 decimals, fixed `10_000e18` supply) | +| Uniswap v4 LP fee | `0` — the hook takes the fee, not the LP | +| Tick spacing | `60` | +| Pools per deployment | One, pinned in `poolKey` at construction and re-checked on every callback | +| Liquidity | 3,000 SHARD full range plus 7,000 SHARD concentrated band, both single-sided, both permanent | +| Direction | Buying SHARD moves the tick down | + +### Hook permissions + +Read directly from `getHookPermissions()` in [`src/ShardHookV1.sol`](../../src/ShardHookV1.sol): + +| Permission | Enabled | +| --- | --- | +| `beforeInitialize` | Yes | +| `afterInitialize` | No | +| `beforeAddLiquidity` | No | +| `afterAddLiquidity` | No | +| `beforeRemoveLiquidity` | No | +| `afterRemoveLiquidity` | No | +| `beforeSwap` | Yes | +| `afterSwap` | Yes | +| `beforeDonate` | No | +| `afterDonate` | No | +| `beforeSwapReturnDelta` | Yes | +| `afterSwapReturnDelta` | Yes | +| `afterAddLiquidityReturnDelta` | No | +| `afterRemoveLiquidityReturnDelta` | No | + +Both return-delta flags are required because the fee is always taken on the ETH leg, and ETH is the specified +currency exactly when `zeroForOne == exactIn`: + +| `zeroForOne` | Kind | ETH is | Charged in | Return delta | +| --- | --- | --- | --- | --- | +| `true` | exactIn | specified | `beforeSwap` | positive specified delta | +| `true` | exactOut | unspecified | `afterSwap` | positive unspecified delta | +| `false` | exactIn | unspecified | `afterSwap` | positive unspecified delta | +| `false` | exactOut | specified | `beforeSwap` | positive specified delta | + +A positive delta means the hook is owed, which exactly cancels the ERC-6909 claim minted for the fee. + +`beforeInitialize` rejects any pool whose currency0 is not native ETH, whose currency1 is not this launch's SHARD, +whose fee is not `0`, whose tick spacing is not `60`, or whose start price is not the exact `startSqrtPriceX96` +(`WrongStartPrice`). `beforeSwap` and `afterSwap` both refuse to run before `initialise` (`NotInitialised`) and +refuse any pool id other than the canonical one (`WrongPool`). + +### Parameters + +Factory immutables are `poolManager`, `launcherFeeRecipient`, the shared `renderer`, and +`hookCreationCodeHash`. Hook immutables are `deployer` (the factory), `shard`, `tickLower`, `tickBand`, +`tickUpper`, `startSqrtPriceX96`, +`launcherFeeRecipient`, and the constants `FEE_BPS = 100`, `HOLDER_SHARE_BPS = 8000`, +`BUILDER_SHARE_BPS = 1000`, `LAUNCHER_SHARE_BPS = 1000`, `MAX_BATCH = 50`, `SEED_AMOUNT = 10_000e18`. +`ShardNFTV1` holds `hook` and `renderer` as immutables. `poolKey` is a storage struct (structs cannot be +Solidity `immutable`) assigned in the constructor and never written afterwards; no function mutates it. + +Write-once: `nft` (via `setNFT`), `initialised`, `seedDust`, `seedLiquidity`, `seedLiquidityBand`. + +The only mutable configuration in the model is `builderFeeRecipient`. Only the current holder of the role may +call `setBuilderFeeRecipient`, the zero address is rejected, and accrued-but-unclaimed builder fees follow the +role to the successor. There is no owner, no admin, no pause, no upgrade path and no proxy. + +### External calls and dependencies + +The hook calls Uniswap v4's `PoolManager` (`initialize`, `unlock`, `modifyLiquidity`, `swap`, `settle`, `take`, +`mint`, `burn`, `balanceOf`), its own launch's SHARD token and its own launch's NFT contract. Art seeds use the +previous Ethereum block hash, block timestamp, recipient, and an acquisition nonce. These public and +miner-influenceable inputs are non-secure randomness. The hook sends raw ETH for buyer refunds, seller +payouts and claims. There is no oracle, no price feed, no keeper, no relayer and no offchain service. + +Dependencies are pinned in [`foundry.toml`](../../foundry.toml): solc `0.8.26`, `cancun`, optimizer on at +1,000 runs, with Uniswap v4 core and periphery, OpenZeppelin contracts, OpenZeppelin uniswap-hooks and Solady +under `lib/`. Exact revisions are recorded in [`spec/shards-v1.json`](../../spec/shards-v1.json). + +### Addresses that can move funds or change behavior + +| Address | Power | Bound | +| --- | --- | --- | +| factory as `deployer` | `setNFT`, `initialise` | One-shot each; both are consumed inside the atomic launch transaction | +| `builderFeeRecipient` | `claimBuilderFees`, `setBuilderFeeRecipient` | Only the accrued builder balance; cannot touch holder, launcher or pool funds | +| `launcherFeeRecipient` | `claimLauncherFees` | Immutable address; only the accrued launcher balance | +| Any NFT holder | `claim(tokenIds)` | Settlement credits each token's current owner; the ETH transfer pays only the caller's own accrued balance | +| Any account | `donate()`, `redeem`, `buyNFT`, `buyMany`, `buyMax`, `sellNFT`, `sellMany`, third-party swaps | Ordinary market access; `donate` can only give ETH away | +| `PoolManager` | `unlockCallback`, hook callbacks | Caller identity checked on every entry | + +Nobody can remove liquidity, change the fee rate, change the split, mint or burn SHARD, mint an NFT outside the +market path, or redirect holder fees. + +## Economics + +| Setting | Value | +| --- | --- | +| Total swap fee | `1.00%` of the ETH leg, inclusive | +| Holder share | `0.80%` of swap volume (`10_000 - 1_000 - 1_000` bps of the fee) | +| Builder share | `0.10%` of swap volume (`1_000` bps of the fee) | +| Programmable share | `0.10%` of swap volume (`1_000` bps of the fee) | +| Fee currency | Native ETH | +| LP fee | Zero | +| Token transfer tax | None | + +A disclosure for the acceptance record: `BUILDER_PROGRAM.md` allocates the 0.80% share to the *token creator*. +Shards has no separate creator payout — the collection holders collectively receive that share, and the creator +participates by holding pieces of their own collection. If Programmable requires a distinct creator allocation, +that is a contract change and a new model version. + +The fee is charged once, on the ETH leg, on every third-party swap and on every hook-market trade — `buyNFT`, +`buyMany`, `buyMax`, `sellNFT` and `sellMany`. The two paths are mutually exclusive by construction, because v4 +skips a hook's callbacks when the hook is itself the swapper, so nothing is charged twice and nothing is free. +`redeem` and `redeemMany` charge nothing: those shards already paid on the way out of the pool. + +### Inclusive basis + +The fee is always 1.00% of the total ETH the trade moves, never 1.00% added on top: + +- **Exact input.** The known amount is already the total, so `fee = gross * 100 / 10000`. +- **Exact output.** The known amount is the net the user receives or the pool must find, so the total is + `net + fee` and `fee = net * 100 / 9900`, which solves `fee = 1% * (net + fee)`. Charging `net * 100 / 10000` + there would be 0.990% and quietly cheaper than the exact-input path. + +`buyNFT`, `buyMany` and `buyMax` use the exact-output basis on what the curve actually consumed; `sellNFT` and +`sellMany` use the exact-input basis on what the pool released. `buyMax` sizes its swap against a worst-case +exact-input fee first and clamps the final fee to it, so a partially consumed exact-input buy is never billed on +the refunded remainder. + +### The split + +Every fee event runs through `_distributeFee` in `ShardFeeDistributorV1`. The combined builder + launcher +operator cut (20% of the fee) is taken with a carried remainder rather than flooring each 0.10% cut +independently: + +- The operator entitlement is computed against the cumulative fee stream and carried in `operatorFeeRemainder`, + so it is split-invariant: a run of tiny swaps accrues the same operator total as one aggregated swap. The old + independent flooring let a stream of sub-threshold swaps evade the cut (ten 900-wei swaps paid 0; one 9,000-wei + swap paid 9). It no longer does. +- The operator cut is then split evenly between the two payees, with the odd wei carried to the launcher + (`operatorSplitParity`). Over any stream both cuts stay within one wei of the ideal cumulative 10%, and the + launcher (Programmable) is never shorted below the builder in absolute accrual. +- `builderCut + launcherCut + holderAmount == fee` holds on every call. Whatever the operator cut does not take + goes to holders. + +The launcher recipient is the immutable constant `0x4957f49620AFf3Adbbe8195a4f633E49cc93376c` — the same address +Classic v3 uses — baked into `ShardLaunchFactoryV1`. It is not a constructor argument, so the factory cannot +route the Programmable share anywhere else. The builder recipient stays per-launch +(`LaunchParams.builderFeeRecipient`). + +**Worked example — a 1 ether fee event.** A trade whose ETH leg is 100 ether pays a 1 ether fee. + +| Recipient | Amount | Share of the fee | Share of the 100 ether traded | +| --- | ---: | ---: | ---: | +| Holders | `0.8 ether` | 80% | 0.80% | +| Builder | `0.1 ether` | 10% | 0.10% | +| Programmable | `0.1 ether` | 10% | 0.10% | + +**Worked example — a stream of tiny fees.** Ten 900-wei fee events, previously worth 0 to the operators under +independent flooring, now accrue their cumulative 20% cut: `10 * 900 = 9000 wei` of fees yields `1800 wei` to the +operators, split `900 wei` builder and `900 wei` launcher, with the odd wei on an odd total carried to the +launcher. The remaining `7200 wei` goes to holders. The operator total matches a single 9,000-wei fee event. + +**Donations are not split.** `donate()` calls `_distribute` directly, so all of it goes to holders. A gift to a +collection is not swap volume. + +### Holder accounting and custody + +Holder fees accrue into a scaled accumulator (`accFeePerNFT`, precision `1e18`), with the sub-wei remainder +carried in `dustScaled` so no wei is stranded. A piece joins the earning set from the block after it is acquired, +so a buyer cannot earn from the fee their own purchase paid. A seller is settled out of the earning set before +their exit fee is distributed, for the same reason. Fees that accrue while nothing is circulating go to +`escrowBalance` and are released to the pool the moment a piece is circulating again. + +All fees — holder, builder and launcher — sit in the hook until claimed. Third-party fees arrive as ERC-6909 +claims and are converted to real ETH by `_sweepClaims`, which every claim path runs first. The launch position +itself is never custody: nothing can withdraw it, so the only ETH that ever leaves the hook is a buyer refund, a +seller payout or a claim. + +## Release gates + +This model is not available for launch. The complete lifecycle — atomic factory launch, third-party swap, +redeem, hook-market buy and sell, holder accrual and all three claim paths — now runs against the pinned canonical +Uniswap v4 `PoolManager` on an Ethereum Mainnet fork in +[`test/ShardV1MainnetFork.t.sol`](../../test/ShardV1MainnetFork.t.sol), confirming the design composes with the +real v4 contract it would market-make on. Before it can move past `design`: + +- **Exact-source re-review.** Maintainers must re-review the final source after the factory and checked-transfer changes. +- **Independent review.** Record independent security-review status for the exact source. +- **Deployment and source verification.** Complete a user-authorized Ethereum deployment, exact source + verification, runtime/lifecycle evidence, and production-interface configuration for that release. + +See [`SECURITY.md`](SECURITY.md) and [`TEST_PLAN.md`](TEST_PLAN.md). + +## Source + +| Area | Path | +| --- | --- | +| Hook | [`src/ShardHookV1.sol`](../../src/ShardHookV1.sol) | +| Launch factory | [`src/ShardLaunchFactoryV1.sol`](../../src/ShardLaunchFactoryV1.sol) | +| Fee distributor | [`src/ShardFeeDistributorV1.sol`](../../src/ShardFeeDistributorV1.sol) | +| NFT | [`src/ShardNFTV1.sol`](../../src/ShardNFTV1.sol) | +| Token | [`src/ShardTokenV1.sol`](../../src/ShardTokenV1.sol) | +| Constants | [`src/ShardConstantsV1.sol`](../../src/ShardConstantsV1.sol) | +| Errors | [`src/ShardErrorsV1.sol`](../../src/ShardErrorsV1.sol) | +| Renderer | [`src/GeometricRendererV1.sol`](../../src/GeometricRendererV1.sol) | +| Optional router helper | [`src/ShardSwapRouterV1.sol`](../../src/ShardSwapRouterV1.sol) | +| Optional donation-forwarder helper | [`src/ShardFeeForwarderV1.sol`](../../src/ShardFeeForwarderV1.sol) | +| Tests | [`test/`](../../test/) | +| Launch runbook | [`docs/SHARDS_LAUNCH_RUNBOOK.md`](../../docs/SHARDS_LAUNCH_RUNBOOK.md) | diff --git a/models/shards/SECURITY.md b/models/shards/SECURITY.md new file mode 100644 index 00000000..509c5cd6 --- /dev/null +++ b/models/shards/SECURITY.md @@ -0,0 +1,153 @@ +# Shards security + +This is a design-stage record for a model with no production deployment and no independent audit. + +## Trust assumptions + +| Party or contract | Trusted for | Cannot | +| --- | --- | --- | +| Uniswap v4 `PoolManager` | Pool accounting, swap execution, ERC-6909 claim balances, unlock/settle semantics | Be replaced; the address is an immutable constructor argument | +| `ShardLaunchFactoryV1` as deployer | Atomic deployment, exact hook-code validation, bidirectional NFT binding and initialization | Change pinned inputs, retain SHARD after launch, or act through consumed one-shot powers | +| `builderFeeRecipient` | Nothing; it is a payee | Touch holder funds, launcher funds, the pool or the NFT contract | +| `launcherFeeRecipient` | Nothing; it is a payee | Be changed after deployment | +| `ShardNFTV1` | Calling `settleOnTransfer` truthfully; enforcing ownership on `release` | Be swapped out after `setNFT` | +| `ShardTokenV1` | Fixed supply, no mint, no burn, no owner | Change supply | +| Factory-shared renderer | Producing SVG and attribute strings for `tokenURI` | Affect accounting, custody or trading | + +Production launches have no externally reachable unwired window: token, hook and NFT deployment, exact +`nft.hook()` validation, binding, checked full-supply transfer and initialization share one factory transaction. +Focused tests retain manual unbound hooks only to exercise authorization and failure states. + +There is no oracle, no price feed, no keeper, no relayer, no offchain service, no owner, no pause, no timelock +and no upgrade path. The only mutable configuration in the whole model is `builderFeeRecipient`, and only the +current holder of that role can change it. + +## Invariants + +**Custody.** The ETH the hook controls — its real balance plus its ERC-6909 native claims against the pool +manager — is at least everything it owes: + +``` +address(hook).balance + poolManager.balanceOf(hook, ETH) + >= escrowBalance + sum(claimable) + builderFeesAccrued + launcherFeesAccrued +``` + +`claimable` is materialised from `accFeePerNFT` by `_settle`, with the sub-wei remainder carried in `dustScaled`, +so unsettled holder entitlement is bounded by the accumulator and no wei is stranded. Each market path asserts a +weaker local form directly: after a buy, `address(this).balance >= holderEth + fee`, otherwise `FeeEthMissing`. + +**Backing.** Every circulating piece is backed one-for-one by SHARD the hook holds: + +``` +shard.balanceOf(hook) == nft.circulatingSupply() * 1e18 + seedDust +``` + +`seedDust` is fixed at `initialise` and is the SHARD that liquidity rounding left behind. This is why `buyMax` +transfers its sub-whole leftover to the caller, why `acquire`/`release` never use the `_safe*` ERC-721 variants, +why `_update` rejects direct deposits to the NFT contract or the hook, and why every hook-initiated swap reverts +`PartialFillNotSupported` rather than mint a piece it cannot back. + +**Fee conservation.** For every fee event, `builderCut + launcherCut + holderAmount == fee`. The combined +builder + launcher operator cut is taken with a carried remainder (`operatorFeeRemainder`), so it is cumulative +and split-invariant: a stream of tiny swaps accrues the same operator total as one aggregated swap rather than +flooring to zero per swap. That cut is split evenly between the two payees with the odd wei carried to the +launcher (`operatorSplitParity`), so over any stream both cuts stay within one wei of the ideal cumulative 10% +and the launcher is never shorted below the builder in absolute accrual. Donations bypass the split entirely and +reach holders in full. + +The launcher (Programmable, 0.10%) recipient is the immutable constant `0x4957f49620AFf3Adbbe8195a4f633E49cc93376c` +in `ShardLaunchFactoryV1` — the same address Classic v3 uses — and is no longer a constructor argument, so the +factory cannot route the launcher share to any other address. The builder 0.10% recipient stays per-launch +(`LaunchParams.builderFeeRecipient`). + +**Authorization.** `claimBuilderFees` reverts `NotBuilder` for anyone but the current `builderFeeRecipient`; +`claimLauncherFees` reverts `NotLauncher` for anyone but the immutable `launcherFeeRecipient`; +`setBuilderFeeRecipient` reverts `NotBuilder` for anyone else and `ZeroAddress` for the zero address; `claim` +credits `nft.ownerOf(id)` and pays only `msg.sender`; `setNFT` and `initialise` revert `NotDeployer`; +`unlockCallback` reverts `NotPoolManager`; `settleOnTransfer` reverts `NotNFT`; `acquire` and `release` revert +`NotHook`. + +**Configuration.** `initialise` and `setNFT` are each one-shot (`AlreadyInitialised`) and are consumed by the +factory. The stored and emitted configuration hash binds every address, parameter, salt, and hook-code hash. The three ticks, the +start price, the fee constants and the split constants are Solidity `immutable`/`constant`; `poolKey` is a +storage struct assigned once in the constructor with no write path afterwards. Every swap callback re-checks +the pool id against `poolKey.toId()` (`WrongPool`), so a foreign pool can never route a fee in the wrong currency +into the accumulator. + +**Liquidity.** `poolManager.modifyLiquidity` is called from exactly one place, `_mintPosition`, and always with a +positive `liquidityDelta`. There is no removal path anywhere in the model, behind any guard or role, and v4 keys +positions on `msg.sender`, so no external contract can address them. + +**Failure recovery.** Fees accrued with nothing circulating escrow and release to the first holder rather than +being lost. `claim` reverts `NothingToClaim` rather than paying zero, and rolls back any settlement it performed +in the same call — settling on another holder's behalf only persists when the caller is also owed something. +Every ETH send checks its return value and reverts `EthTransferFailed`. All ETH-moving entry points carry a +reentrancy guard, and refunds are sent after `unlock` returns, never inside the callback, so a contract buyer's +re-entry cannot self-DoS on `AlreadyUnlocked`. Every ERC20 `transfer` and `transferFrom` result used by the +factory, hook, and router is checked and reverts `TokenTransferFailed` on a false return. + +## Ordering and MEV + +The model is a bonding curve on a public pool, so ordinary Uniswap ordering risk applies. Its defences are +explicit bounds rather than any price oracle: + +- **Sandwich bounds.** `buyNFT` and `buyMany` take `maxEthIn`, the largest total (curve cost plus fee) the caller + will accept, and revert `SlippageExceeded`. `buyMax` takes `minCount` and reverts `InsufficientOutput`. + `sellNFT` and `sellMany` take `minEthOut`, the smallest payout after fee. Every market path also takes a + `deadline` and reverts `Expired`. +- **Per-swap size cap.** `afterSwap` caps a single third-party swap at `MAX_BATCH * 1e18 = 50e18` SHARD of + movement, measured on the SHARD leg so one check covers all four direction × exactness quadrants + (`SwapTooLarge`). This makes the batch limit a property of the pool, not of the front end used; without it, a + direct swap plus `redeemMany` bypasses it. It is symmetric on purpose, so a large position cannot be unwound + in one transaction either. The accepted cost is that large entries and exits take several transactions. +- **Public launch ordering.** An observer can submit an exact factory configuration first and sponsor the same + launch. The effective token salt commits to the hook salt and every launch parameter, so a changed builder, + curve, price, or salt cannot consume the intended token address. CREATE2 NFT deployment keeps the complete + address/configuration commitment stable across unrelated launches. +- **Front-run-tolerant initialisation.** `beforeInitialize` always fires for a third party, and validates both + the pool key and the exact start price (`WrongPool`, `WrongStartPrice`). A front-runner can therefore only + create the pool the hook was going to create, at the price the hook was going to use, and `initialise` catches + `PoolAlreadyInitialized` and proceeds to seed it. Swapping before the seed is blocked by the `NotInitialised` + guard in both swap callbacks. +- **Same-block accrual guard.** A piece joins the earning set only from the block after acquisition, so a trader + cannot buy into the holder pool, collect from the fee their own trade generated, and leave. +- **Art entropy.** Seeds come from the previous Ethereum `blockhash`, `block.timestamp`, the recipient and an + acquisition nonce. Grinding resistance is deliberately not a goal: inputs are public and miner-influenceable, + traits are flat, and a reroll affects only the roller's draw. Do not treat the seed as secure randomness. + +## Known limitations + +- **No production deployment.** No contract in this model is deployed on Ethereum or any other production + network. The only live evidence is for a prior version of this design on the Robinhood chain testnet, which is + not evidence for the code in this repository. +- **No audit.** No independent smart-contract audit and no public security contest. +- **Public salts and exact-configuration sponsorship.** A public observer can launch the same reviewed + configuration first. Duplicate addresses then revert; callers must inspect chain state before retrying. +- **One collection per hook.** One hook serves one pool and one 10,000-piece collection. A factory can launch + multiple collections, all using its one shared renderer. +- **EIP-170 headroom.** The hook's runtime bytecode is 24,352 of the 24,576-byte limit at the pinned compiler + settings — 224 bytes of headroom. Almost any addition to the hook will need code moved out of it first. +- **Per-swap cap.** Third-party swaps larger than 50 SHARD revert (`SwapTooLarge`). Aggregators that route large + ETH amounts through this pool in a single hop will fail rather than partially fill. +- **Partial fills are rejected, not repriced.** When ETH is the specified currency the fee is fixed before + execution, on the requested size. If the swap then stops at its price limit, that fee becomes a large share of + what actually executed — measured at 7,655 bps on a 1 ETH request that filled 0.013 ETH — and `afterSwap` + cannot correct it, because its return value adjusts only the unspecified currency. Such swaps revert + `PartialFillNotSupported` rather than being overcharged. Ordinary slippage limits that never bind are + unaffected. The hook's own swap paths assert exactness for the same reason. +- **Immutable payees.** `launcherFeeRecipient` cannot be changed. If it becomes uncontrollable, its accrued + share is permanently unclaimable. The rest of the model keeps working. +- **Direct ETH transfers are unrecoverable.** `receive()` must stay silent, because v4 delivers native ETH that + way during swaps. ETH sent straight to the hook outside `donate()` is never distributed and never claimable on + a contract that cannot be upgraded. Use `donate()` or `ShardFeeForwarderV1`. +- **Claims require the holder to act.** Holding accrues value in the accumulator, but `claim` needs the token ids + passed in — the hook does not track which ids an address holds. It is not a keeper interface. +- **The 1% fee is cumulative on both bases.** The pool-level fee (`_chargeFee` in `src/ShardHookV1.sol`) carries its + per-swap remainder (`feeCarryIn` for exact-input, `feeCarryOut` for exact-output), so a stream of tiny swaps + accrues the same total 1% as one aggregated swap instead of each flooring to zero. A single swap may still take + zero for itself until its sub-wei share accumulates to one wei. `buyMax` clamps the inclusive fee to its exact-input + reserve so the buyer is never overspent, and returns the wei it cannot collect to `feeCarryOut` so that entitlement + is preserved and collected on a later exact-output swap — it is not shed per call. Once charged, the builder and + launcher cuts are conserved to the wei and never floored away. + +This file does not claim an audit. diff --git a/models/shards/TEST_PLAN.md b/models/shards/TEST_PLAN.md new file mode 100644 index 00000000..75d6608e --- /dev/null +++ b/models/shards/TEST_PLAN.md @@ -0,0 +1,171 @@ +# Shards test plan + +Suites live in [`test/`](../../test/), and each one owns a named area so no coverage is claimed twice. + +| Suite | Owns | +| --- | --- | +| [`ShardTokenV1.t.sol`](../../test/ShardTokenV1.t.sol) | Fixed supply, no mint, no burn, 1 SHARD == 1 NFT | +| [`ShardNFTV1.t.sol`](../../test/ShardNFTV1.t.sol) | Archive inventory, seeds, transfer guards, `tokenURI` | +| [`GeometricRendererV1.t.sol`](../../test/GeometricRendererV1.t.sol) | Fully on-chain SVG, determinism, flat traits | +| [`ShardScaffoldV1.t.sol`](../../test/ShardScaffoldV1.t.sol) | Pinned-dependency import and type surface | +| [`ShardLaunchFactoryV1.t.sol`](../../test/ShardLaunchFactoryV1.t.sol) | Exact prediction, atomic launch, rollback, configuration evidence, sizes and gas | +| [`ShardWiringV1.t.sol`](../../test/ShardWiringV1.t.sol) | Bidirectional NFT wiring, normalized getter failures, checked ERC20 returns, Ethereum seed inputs | +| [`ShardHookLiquidityV1.t.sol`](../../test/ShardHookLiquidityV1.t.sol) | Permissions, `initialise`, seeding, the lock | +| [`ShardHookMarketV1.t.sol`](../../test/ShardHookMarketV1.t.sol) | `buyNFT`/`sellNFT`, inclusive-fee basis, slippage, deadlines | +| [`ShardHookBatchV1.t.sol`](../../test/ShardHookBatchV1.t.sol) | `buyMany`, `buyMax`, `sellMany`, `redeemMany`, `MAX_BATCH` | +| [`ShardHookExhaustionV1.t.sol`](../../test/ShardHookExhaustionV1.t.sol) | Short fills, thin curve, `buyMax` fee basis | +| [`ShardHookFeesV1.t.sol`](../../test/ShardHookFeesV1.t.sol) | Third-party fee capture across all four quadrants, the swap cap | +| [`ShardHookAttackV1.t.sol`](../../test/ShardHookAttackV1.t.sol) | Adversarial paths: withdrawal, front-running, reentrancy, fee snipes | +| [`ShardFeeSplitV1.t.sol`](../../test/ShardFeeSplitV1.t.sol) | The 80/10/10 split, both claim paths, recipient handover | +| [`ShardFeeDistributorV1.t.sol`](../../test/ShardFeeDistributorV1.t.sol) | Accumulator, escrow, dust, same-block guard, settlement paths | +| [`ShardFeeDonationV1.t.sol`](../../test/ShardFeeDonationV1.t.sol) | `donate()`, the forwarder, donations bypassing the split | +| [`ShardLaunchSequenceV1.t.sol`](../../test/ShardLaunchSequenceV1.t.sol) | One-transaction factory happy path plus focused manual authorization/one-shot failures | +| [`ShardSwapRouterV1.t.sol`](../../test/ShardSwapRouterV1.t.sol) | Third-party routing, refunds, approvals, wrong-pool rejection | +| [`invariant/ShardV1.t.sol`](../../test/invariant/ShardV1.t.sol) | Stateful backing, custody, conservation and lock invariants | +| [`ShardV1MainnetFork.t.sol`](../../test/ShardV1MainnetFork.t.sol) | Full lifecycle against the pinned Ethereum Uniswap v4 `PoolManager` on a Mainnet fork | + +## Unit behavior + +- **Validate every parameter boundary and revert path.** Constructor tick ordering and spacing + (`InvalidTickRange`), zero addresses (`ZeroAddress`), wrong seed balance (`WrongShardBalance`), start price + below `tickUpper` (`InvalidStartPrice`), foreign pool key or wrong start price + (`test_beforeInitializeRejectsForeignPool`, `test_frontRunAtDifferentPriceReverts`, + `test_startPriceMustBeAtOrAboveTickUpper` in `ShardHookLiquidityV1.t.sol`), batch bounds + (`test_buyManyRevertsAboveCap`, `test_buyManyRevertsOnZeroCount`, `test_buyMaxRevertsOnZeroValue` in + `ShardHookBatchV1.t.sol`), slippage and deadline bounds (`test_buyNftRespectsMaxEthIn`, + `test_deadlineIsEnforced`, `test_buyNftRevertsOnInsufficientEth` in `ShardHookMarketV1.t.sol`), NFT range and + deposit guards (`test_outOfRangeTokenUriReverts`, `test_directTransferFromToArchiveReverts`, + `test_directTransferFromToHookReverts` in `ShardNFTV1.t.sol`), and `PartialFillNotSupported` on a short fill + (`test_buyNftRevertsRatherThanMintingOnAShortFill` in `ShardHookExhaustionV1.t.sol`). `SwapTooLarge` is pinned + one wei either side of the cap by `test_swapOneWeiOverTheCapReverts`, `test_sellOneWeiOverTheCapReverts` and + `test_exactInputBuyOverTheCapReverts` in `ShardHookFeesV1.t.sol`, which assert the revert selector rather than + merely that the call reverted. `ShardSwapRouterV1.t.sol` covers the router's own bounds + (`test_respectsMinAmountOut`, `test_respectsDeadline`, `test_revertsOnZeroAmount`, + `test_shardForEthRequiresApproval`, `test_rejectsPlainEthTransfers`). +- **Test fee arithmetic and the cumulative split with exact examples.** `ShardFeeSplitV1.t.sol` pins the split at + exact tenths on a round amount, proves that a stream of tiny swaps accrues the same operator entitlement as one + aggregated swap rather than flooring per swap (`test_tinyFeesAccumulateToTheSameEntitlement`), fuzzes that the + split is conservative and cumulative (`testFuzz_splitIsConservativeAndCumulative`, `testFuzz_split_conserves`), + and that donations are never split. The launcher recipient binding is pinned by + `test_launcherRecipientIsBoundToTheProgrammableConstant` in `ShardLaunchFactoryV1.t.sol`. The operator cut + holding across all four direction × exactness quadrants is pinned by + `test_operatorSplitHoldsAcrossAllFourQuadrants` and fuzzed by `testFuzz_theSplitConservesEveryFee` in + `ShardHookFeesV1.t.sol`. The inclusive basis is pinned by `test_feeBasisIsInclusiveOnBuy`, + `test_buyNftAndSwapThenRedeemCostTheSame` and `test_buyThenSellRoundTripCostsAboutTwoPercent` in + `ShardHookMarketV1.t.sol`, and by `test_buyManyChargesOnePercentInclusiveOnce`, + `test_buyMaxChargesOnePercentInclusiveOnce` and `test_buyMaxChargesOnlyOnWhatTheCurveConsumed`. +- **Test each authorized and unauthorized caller.** `test_claimBuilderFees_revertsForStranger`, + `test_claimLauncherFees_revertsForStranger`, `test_setBuilderFeeRecipient_revertsForStranger`, + `test_setBuilderFeeRecipient_revertsOnZero`, `test_constructor_rejectsZeroRecipients` in + `ShardFeeSplitV1.t.sol`; `test_initialiseOnlyDeployer`, `test_initialiseIsOneShot` in + `ShardHookLiquidityV1.t.sol`; `test_onlyTheDeployerCanRunTheLaunch`, + `test_setNFTIsOneShotSoABlindRerunWouldBrickTheLaunch` and `test_launchIsOneShotSoItCannotBeReplayed` in + `ShardLaunchSequenceV1.t.sol`; `test_ATTACK_cannotHijackSetNft` in `ShardHookAttackV1.t.sol`. `NotHook` is + covered by `test_onlyHookCanAcquire` and `test_onlyHookCanRelease` in `ShardNFTV1.t.sol`, `NotNFT` and + `NotPoolManager` by `ShardHookAttackV1.t.sol`, and `NotPoolManager` again by + `test_unlockCallbackOnlyPoolManager` in `ShardHookLiquidityV1.t.sol` and `ShardSwapRouterV1.t.sol`. + +## Integration lifecycle + +- **Create the token and pool.** `test_initialiseSeedsAllTenThousandShards`, `test_initialiseRecordsSeedDust`, + `test_initialiseRequiresNoEth`, `test_initialiseSurvivesFrontRunAtSamePrice` and + `test_initialiseRerevertsNonAlreadyInitialisedErrors` in `ShardHookLiquidityV1.t.sol` cover the whole + deploy-to-seeded path, including the front-run case. `ShardLaunchFactoryV1.t.sol` proves exact code hashing, + deterministic CREATE2 prediction, rollback, and configuration evidence. `ShardLaunchSequenceV1.t.sol` runs the launch as a + creator would (`test_oneTransactionFactoryLaunchMintsTheFirstFiftyIds`, `test_buyingIsClosedUntilInitialise`, + `test_launchFeeSplitsWithoutChangingWhatTheBuyerPays`) and pins the shipped tick configurations + (`test_productionTicksStillLaunchAtAboutAThousandthOfAnEth`, + `test_cheapCurveIsTheProductionCurveShiftedByAConstant`). +- **Execute both swap directions and exact input/output modes.** `_buySwap` (exact output), + `_buyExactOutSwap`, `_buyExactInSwap` and `_sellSwap` are exercised through `buyNFT`, `buyMany`, `buyMax`, + `sellNFT` and `sellMany` in `ShardHookMarketV1.t.sol` and `ShardHookBatchV1.t.sol`. + `test_priceRisesAsSupplyIsBought` and `test_buyingMovesTickDown` pin the curve direction. All four direction × + exactness quadrants of a third-party swap are covered one per test by + `test_feeIsExactlyOnePercent_zeroForOne_exactIn`, `..._zeroForOne_exactOut`, `..._oneForZero_exactIn` and + `..._oneForZero_exactOut` in `ShardHookFeesV1.t.sol`, alongside + `test_feeIsAlwaysDenominatedInEth`, `test_swapperReceivesExpectedAmountAfterFee` and + `test_firstSwapSucceedsOnFreshPoolManager`, which covers the ERC-6909 claim path on a manager holding no + native liquidity. Router-level routing is covered by `test_swapEthForShardDeliversShard` and + `test_swapShardForEthDeliversEth` in `ShardSwapRouterV1.t.sol`. +- **Exercise fee accrual, custody and claims.** `test_poolSwapFeeIsSplit`, `test_buyNFTFeeIsSplit`, + `test_claimBuilderFees_paysAndZeroes`, `test_claimLauncherFees_paysAndZeroes`, + `test_holderClaimLeavesCutsIntact` and `test_setBuilderFeeRecipient_transfersClaimRights` in + `ShardFeeSplitV1.t.sol`; `test_buyNftCannotSpendHolderFeeEth` in `ShardHookMarketV1.t.sol`; + `test_ATTACK_sameBlockFeeSnipeEarnsNothing`, `test_ATTACK_transferInAcquisitionBlockEarnsNothing` and + `test_ATTACK_cannotClaimTwice` in `ShardHookAttackV1.t.sol`. Escrow, release, the scaled dust carry, the + same-block guard and settlement on transfer and sale are owned by `ShardFeeDistributorV1.t.sol` + (`test_feesWithNoHoldersGoToEscrow`, `test_escrowFoldsInOnFirstDistributionWithHolders`, + `test_evenSplitAcrossHolders`, `test_sameBlockAcquisitionEarnsNothing`, `test_holderEarnsFromNextBlockOnward`, + `test_dustIsCarriedNotLost`, `test_settlePreservesFractionalRemainder`, + `test_releaseSettlesToOutgoingOwner`). Donation routing is owned by `ShardFeeDonationV1.t.sol` + (`test_donateIsNeverSplitWithTheBeneficiaries`, `test_donateEscrowsWhenNothingCirculates`, + `test_donateLeavesTheBackingInvariantIntact`, `test_flushPushesEverythingIntoTheFeePool`). + `test_hookClaimBalancePlusEthCoversFeesTaken` in `ShardHookFeesV1.t.sol` pins the custody bound. +- **Cover external-call and recipient failures.** Reentrancy from a contract counterparty is covered by + `test_ATTACK_reentrantBuyDuringUnlockFails`, `test_ATTACK_reentrantSellDuringUnlockFails`, + `test_ATTACK_reentrantClaimFails` and `test_ATTACK_reentrantTransferDuringReleasingFails` in + `ShardHookAttackV1.t.sol`; the direct-deposit guard by `test_ATTACK_directNftTransferStrandsNothing`; the + absence of a liquidity exit by `test_noWithdrawalFunctionExists`; and ERC-6909 claim theft by + `test_ATTACK_cannotStealErc6909FeeClaims`. `ShardCheckedTransferV1Test` forces false-return ERC20 behavior + through liquidity settlement, `buyMax`, redeem, and the router; the factory suite proves the same failure + rolls the entire launch back. `ShardFeeSplitV1Test` asserts `EthTransferFailed` and complete + state rollback for a reverting recipient on a buy refund, sell payout, holder claim, + `claimBuilderFees`, and `claimLauncherFees`. + +## Properties + +- **Add stateful invariants for accounting and immutable configuration.** `invariant/ShardV1.t.sol` drives a + random mix of buys, batch buys, sells, redeems, third-party swaps, transfers, donations and claims through a + handler and holds: backing (`invariant_shardBackingMatchesNftCirculating`, `invariant_shardSupplyIsConserved`, + `invariant_buyMaxNeverReturnsAWholeShard`, `invariant_earningSetMatchesBackingSet`); supply and inventory + (`invariant_circulatingNeverExceedsMaxSupply`, `invariant_poolHeldPlusCirculatingEqualsTenThousand`, + `invariant_lowestAvailableIdIsActuallyAvailable`); custody and conservation + (`invariant_hookAssetsCoverAllClaims`, `invariant_claimsNeverExceedFeesTaken`, + `invariant_builderAndLauncherCutsMatch`, `invariant_builderFeesAreAlwaysClaimable`, + `invariant_accumulatorNeverDecreases`, `invariant_dustStaysSubWei`, `invariant_dustNeverExceedsMaxSupply`); + and the lock (`invariant_liquidityPositionIsNeverReduced`, `invariant_bandPositionIsNeverReduced`, + `invariant_bandStaysDenserThanTheFullRange`). +- **Fuzz all bounded parameters and native/token amount ranges.** `testFuzz_split_conserves` in + `ShardFeeSplitV1.t.sol` fuzzes the split over fee amounts bounded to `[0, 1_000_000 ether]`; + `testFuzz_buyMaxNeverOverspendsMsgValue` in `ShardHookExhaustionV1.t.sol` fuzzes the exact-input buy against + `msg.value`; `testFuzz_neverReverts` in `GeometricRendererV1.t.sol` fuzzes the seed space; + `testFuzz_feeNeverExceedsOnePercent` and `testFuzz_poolIsNeverLeftWithNegativeDelta` in + `ShardHookFeesV1.t.sol` fuzz third-party swap sizes, and `testFuzz_theSplitConservesEveryFee` in the same + suite fuzzes the split over real swap flow; `testFuzz_totalDistributedIsConserved` in + `ShardFeeDistributorV1.t.sol` fuzzes the accumulator; `testFuzz_invariantHoldsUnderRandomSequence` in + `ShardHookMarketV1.t.sol` fuzzes market sequences; `testFuzz_lowestAvailableIdIsAlwaysUnheld` in + `ShardNFTV1.t.sol` fuzzes the inventory; and `testFuzz_swapNeverLeavesRouterHoldingFunds` in + `ShardSwapRouterV1.t.sol` fuzzes the router. Fuzz runs are 1,000 by default and 10,000 + under the `ci` profile; invariant runs are 256 × 64 by default and 1,000 × 128 under `ci`. +- **Test ordering, oracle and liquidity assumptions when applicable.** There is no oracle. Ordering coverage is + the same-block accrual guard, the front-run-tolerant `initialise`, the free-reroll asymmetry + (`test_ATTACK_cannotRerollArtForFree`, `test_walletTransferDoesNotChangeSeed`) and the per-swap size cap, + including its deliberate non-application to the hook's own batches + (`test_hookOwnBatchIsNotBoundByTheSwapCap`). Sandwich bounds are covered by + `test_ATTACK_sandwichBuyNftIsBoundedByMaxEthIn`, `test_buyNftRespectsMaxEthIn` and + `test_buyManyRespectsMaxEthIn`. Liquidity assumptions are covered by + `test_ATTACK_cannotWithdrawLiquidity` and by the exhaustion suite, which drives the curve thin enough to + exercise the short-fill guards. + +## Release evidence + +- **Run against pinned dependencies.** solc `0.8.26`, `cancun`, optimizer on at 1,000 runs, `bytecode_hash` + none, from [`foundry.toml`](../../foundry.toml). Uniswap v4 core and periphery, OpenZeppelin contracts, + OpenZeppelin uniswap-hooks and Solady revisions are recorded in + [`spec/shards-v1.json`](../../spec/shards-v1.json). `ShardScaffoldV1.t.sol` pins the import and type surface + those libraries expose, so a dependency bump that moves a path or a constant fails the build rather than the + economics. +- **Mainnet-fork lifecycle.** [`ShardV1MainnetFork.t.sol`](../../test/ShardV1MainnetFork.t.sol) runs the whole + lifecycle — atomic factory launch, third-party swap, redeem, hook-market buy and sell, holder accrual, and all + three claim paths — against the pinned canonical Uniswap v4 `PoolManager` on an Ethereum fork, in the style of + [`test/ClassicV3MainnetFork.t.sol`](../../test/ClassicV3MainnetFork.t.sol). It deploys the factory, mines twice, + launches once, reproduces the token/hook/NFT CREATE2 predictions and configuration hash, and pins the + PoolManager's runtime code hash so the fork cannot silently swap in a + different contract. It needs an RPC (`ETHEREUM_RPC_URL`, or the public default), so it is excluded from the + default no-RPC `forge test`, coverage and gas-snapshot runs exactly as the Classic fork suite is. The + scheduled-and-on-PR `Ethereum Evidence` workflow runs it against a live RPC; run it locally with + `forge test --match-contract ShardV1MainnetForkTest`. +- **Record runtime code hashes and source verification after deployment.** For every deployed contract, publish + the deployment transaction, the runtime code hash and the explorer verification state, and record the hook's + runtime size against the 24,576-byte EIP-170 limit — 24,352 bytes at the pinned settings, 224 bytes of + headroom, which is small enough that any compiler or dependency change must be re-measured before release. diff --git a/models/shards/model.json b/models/shards/model.json new file mode 100644 index 00000000..895d93f0 --- /dev/null +++ b/models/shards/model.json @@ -0,0 +1,31 @@ +{ + "$schema": "../schema/model.schema.json", + "schemaVersion": 1, + "id": "shards", + "name": "Shards", + "status": "design", + "summary": "Single-sided bonding-curve market for a fixed 10,000-piece on-chain-art NFT collection; every swap pays a 1.00% native-ETH fee split 0.80% to collection holders, 0.10% to the builder and 0.10% to Programmable.", + "documentation": "models/shards/README.md", + "currentRelease": null, + "releaseManifest": null, + "specification": null, + "deployment": null, + "security": "models/shards/SECURITY.md", + "network": { + "name": "Ethereum", + "chainId": 1 + }, + "contracts": [], + "review": { + "independentAudit": false, + "publicContest": false + }, + "releaseGates": [ + "Obtain maintainer re-review of the exact final source.", + "Record independent security-review status for the exact release.", + "Complete a user-authorized Ethereum deployment and exact source verification.", + "Publish deployment transactions, runtime hashes and version-bound release evidence.", + "Pass bytecode and lifecycle checks against the deployed addresses.", + "Configure the production interface for the exact published release." + ] +} diff --git a/releases/shards-v1/mainnet-manifest.json b/releases/shards-v1/mainnet-manifest.json new file mode 100644 index 00000000..009237f4 --- /dev/null +++ b/releases/shards-v1/mainnet-manifest.json @@ -0,0 +1,140 @@ +{ + "schemaVersion": 1, + "model": "shards", + "internalContractRelease": "shards-v1", + "status": "not-deployed", + "chainId": 1, + "releaseCommit": null, + "sourceCommitment": null, + "startingNonce": null, + "hookSalt": null, + "releaseScope": { + "coreContracts": [ + "ShardLaunchFactoryV1", + "GeometricRendererV1", + "ShardTokenV1", + "ShardHookV1", + "ShardNFTV1" + ], + "optionalHelpers": [ + "ShardSwapRouterV1", + "ShardFeeForwarderV1" + ], + "policy": "Only deployed and pinned helpers become release evidence; helpers are not core launch contracts." + }, + "candidatePlan": { + "status": "launch-plan-pinned-pending-maintainer-source-freeze-and-authorization", + "note": "Every predicted address, salt and configuration hash below is valid ONLY for the exact reviewed source at this PR head. Any later source change alters the hook/factory bytecode and invalidates them; re-mine and re-pin if the maintainer requests changes.", + "observedAtBlock": 25639000, + "factoryDeployer": "0x2Bb333d48DFAF1596D9036671d2E43168994249E", + "factoryDeploymentMethod": "create2-proxy", + "create2Proxy": "0x4e59b44847b379578588920cA78FbF26c0B4956C", + "factorySalt": "0x655a4b5a2b704bef84b4ff94adde0a7ac40ad0366c82ddca5290180fe4c3986d", + "factoryDeployerNonce": null, + "nonceNote": "The factory is deployed through the canonical CREATE2 proxy, so its address depends only on the proxy, factorySalt and init-code — never on the deployer nonce.", + "expectedFactory": "0xDc1Aae9A32c5220dAAE2CCD4D10329bEb854bf39", + "expectedRenderer": "0x3C8B0168a345116A7f40d866D07b3aed832D0812", + "launcherFeeRecipient": "0x4957f49620AFf3Adbbe8195a4f633E49cc93376c", + "builderFeeRecipient": "0xceeBB3A6543CeBEB2ED66963897A0abEA52A50cC", + "rawTokenSalt": "0xca9944c923e24ba5cb3188a29b18c3305158e686e39473e91bbe31fc019816ab", + "effectiveTokenSalt": "0xe1387dff2e86548ec4a0a8e8678c4b55d50b64f522ba7681cf3c3f5535ca6d4a", + "hookSalt": "0x00000000000000000000000000000000000000000000000000000000000031ec", + "predictedShard": "0x92541FCEd29417859d1eA3fC6a9506464FC51D94", + "predictedHook": "0x8Add9914734D3178a296e8C007Ab2F22559Ea0cC", + "predictedNft": "0x62D7bD7F95eE7e35956f76531757C9514a4b762b", + "expectedConfigurationHash": "0x475e4ee8be4b757b4e10fffbd5410a145eac6c05a44a7904c03d4504934e639c", + "hookCreationCodeHash": "0x34df1ce932b3ca8eebc45eff8116378cbcd5a4a285fd2bf0c28bd78a350d8a2f", + "transactionOrder": [ + "Deploy ShardLaunchFactoryV1 through the CREATE2 proxy with factorySalt", + "Call factory.launch(rawTokenSalt, hookSalt, hookCreationCode, params)" + ], + "postconditions": [ + "Deployed factory runtime matches the reviewed artifact and its immutable hookCreationCodeHash equals the value above", + "factory.renderer() equals expectedRenderer and the NFT is wired to the hook (nft.hook() == hook)", + "The hook address low 14 bits equal the five required v4 permission flags", + "The factory holds zero SHARD after launch and both liquidity positions are non-removable", + "factory.configurationHashOf(hook) reproduces expectedConfigurationHash" + ], + "forkFactoryDeploymentGas": "7176677", + "forkLaunchGas": "8520072" + }, + "addresses": { + "factoryDeployer": null, + "launcherFeeRecipient": null, + "builderFeeRecipient": null, + "factory": null, + "renderer": null, + "hook": null, + "shard": null, + "nft": null + }, + "transactions": { + "factory": null, + "launch": null + }, + "runtimeCodeHashes": { + "factory": null, + "renderer": null, + "hook": null, + "shard": null, + "nft": null + }, + "artifactCode": { + "factory": { + "runtimeBytes": 18455, + "creationCodeBytes": 35617, + "deploymentInitcodeBytes": 35681, + "runtimeCodeHash": "0xd0038fe238c3aefd72f37d53b40285bcc3c9a32014035229f20eb04d7e174558", + "creationCodeHash": "0x45ce4abf67e05763136b40ebbc2f87bdb3f695e6e0b383d42a55289c001b2621", + "note": "Runtime artifact is unpatched; deployment initcode adds 64 bytes of constructor arguments (poolManager, hookCreationCodeHash). The launcher recipient is a compile-time constant, not a constructor argument." + }, + "renderer": { + "runtimeBytes": 16777, + "creationCodeBytes": 16805, + "deploymentInitcodeBytes": 16805, + "runtimeCodeHash": "0x9b54a61918b2ddf9b7daf41d9bf2d705cbef3a0fd618275762b99e19c53459bf", + "creationCodeHash": "0x910d02d740c71d608b1dc3f49e26288b0f8a62abda0c7767e251d53520a6b51e" + }, + "hookTemplate": { + "runtimeBytes": 24358, + "creationCodeBytes": 26295, + "deploymentInitcodeBytes": 26583, + "runtimeCodeHash": "0x4a535daba1b2eb5fc83156f6ddb4f1e341fe6b240674b6dc770a359a93f8d167", + "creationCodeHash": "0x34df1ce932b3ca8eebc45eff8116378cbcd5a4a285fd2bf0c28bd78a350d8a2f", + "note": "Runtime artifact is unpatched; deployment initcode adds 288 bytes of constructor arguments. Its hash is recorded after launch inputs are fixed." + }, + "shard": { + "runtimeBytes": 1875, + "creationCodeBytes": 2792, + "deploymentInitcodeBytes": 2792, + "runtimeCodeHash": "0xb2737fd93f2ff31e850e2be773e6e7a92a239b28091be1d4b122ff864cd7aae8", + "creationCodeHash": "0x6b55632624f9bb9fab7974eb4c1591d060775fb771862e6bdd4572f84f303d59" + }, + "nftTemplate": { + "runtimeBytes": 7258, + "creationCodeBytes": 8019, + "deploymentInitcodeBytes": 8083, + "runtimeCodeHash": "0x2daa6dd619b7f8946b6fbf5a105774d73b6a3d90d3c4689bd4f6743b4c1f81af", + "creationCodeHash": "0x2934b883eeb2e0e658e87eeddf3e61842dc44010367c150ceed38e582a2798fe", + "note": "Deployment initcode adds 64 bytes for the predicted hook and shared renderer. Its hash is recorded after launch inputs are fixed." + } + }, + "officialDependencies": { + "poolManager": { + "address": "0x000000000004444c5dc75cB358380D2e3dE08A90", + "runtimeCodeHash": "0x785f1014552b7ce7d5fb7d0c970ca60edee94fd00425d7ca21609acac7ce1293" + } + }, + "sourceVerification": { + "status": "not-submitted", + "factory": null, + "renderer": null, + "hook": null, + "shard": null, + "nft": null + }, + "lifecycleEvidence": { + "status": "not-run", + "releaseEligible": false + } +} diff --git a/script/LaunchShardsV1.s.sol b/script/LaunchShardsV1.s.sol new file mode 100644 index 00000000..2ab70595 --- /dev/null +++ b/script/LaunchShardsV1.s.sol @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Script, console2 } from "forge-std/Script.sol"; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; + +/// @notice Reproducible prediction and narrowly separated broadcast entry points for Shards V1. +contract LaunchShardsV1 is Script { + struct MinedPrediction { + bytes32 hookSalt; + address shard; + address hook; + bytes32 hookInitCodeHash; + address nft; + bytes32 configurationHash; + } + + error WrongCanonicalHookCodeHash(bytes32 expected, bytes32 actual); + error PredictionMismatch(address expected, address actual); + error ConfigurationMismatch(bytes32 expected, bytes32 actual); + + function predictAndMine( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + bytes32 hookSaltStart, + ShardLaunchFactoryV1.LaunchParams calldata params + ) external view returns (bytes32, address, address, address, bytes32) { + bytes memory hookCreationCode = type(ShardHookV1).creationCode; + bytes32 canonicalHash = keccak256(hookCreationCode); + _requireCanonicalHash(factory.hookCreationCodeHash(), canonicalHash); + + MinedPrediction memory prediction = _mine(factory, tokenSalt, hookSaltStart, hookCreationCode, params); + _completePrediction(factory, tokenSalt, params, prediction); + _logPrediction(factory, tokenSalt, params, canonicalHash, prediction); + return (prediction.hookSalt, prediction.shard, prediction.hook, prediction.nft, prediction.configurationHash); + } + + /// @notice The canonical Arachnid deterministic-deployment proxy. Deploying the factory through it + /// makes the factory address depend only on the salt and init-code — not on any deployer + /// nonce — so it is reproducible by any sender and pinnable in advance. + address internal constant CREATE2_PROXY = 0x4e59b44847b379578588920cA78FbF26c0B4956C; + + /// @notice Nonce-independent factory/renderer prediction. No broadcast, no network writes. + function previewFactory(IPoolManager poolManager, bytes32 hookCreationCodeHash, bytes32 factorySalt) + external + view + returns (address factory, address renderer) + { + _requireCanonicalHash(hookCreationCodeHash, keccak256(type(ShardHookV1).creationCode)); + bytes memory initcode = + bytes.concat(type(ShardLaunchFactoryV1).creationCode, abi.encode(poolManager, hookCreationCodeHash)); + factory = Create2.computeAddress(factorySalt, keccak256(initcode), CREATE2_PROXY); + renderer = vm.computeCreateAddress(factory, 1); // factory deploys the renderer as its first CREATE + console2.log("expected factory", factory); + console2.log("expected renderer", renderer); + } + + function deployFactory(IPoolManager poolManager, bytes32 hookCreationCodeHash, bytes32 factorySalt) + external + returns (address factory) + { + _requireCanonicalHash(hookCreationCodeHash, keccak256(type(ShardHookV1).creationCode)); + bytes memory initcode = + bytes.concat(type(ShardLaunchFactoryV1).creationCode, abi.encode(poolManager, hookCreationCodeHash)); + address predicted = Create2.computeAddress(factorySalt, keccak256(initcode), CREATE2_PROXY); + vm.startBroadcast(); + (bool ok, bytes memory ret) = CREATE2_PROXY.call(bytes.concat(factorySalt, initcode)); + vm.stopBroadcast(); + require(ok, "factory deploy via CREATE2 proxy failed"); + factory = address(bytes20(ret)); + if (factory != predicted) revert PredictionMismatch(predicted, factory); + console2.log("factory", factory); + console2.log("renderer", address(ShardLaunchFactoryV1(factory).renderer())); + } + + function launch( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + bytes32 hookSalt, + ShardLaunchFactoryV1.LaunchParams calldata params + ) external returns (address hook, address shard, address nft) { + bytes32 canonicalHash = keccak256(type(ShardHookV1).creationCode); + _requireCanonicalHash(factory.hookCreationCodeHash(), canonicalHash); + address predictedShard = factory.predictToken(tokenSalt, hookSalt, params); + address predictedHook = factory.predictHook(hookSalt, type(ShardHookV1).creationCode, predictedShard, params); + address predictedNft = factory.predictNFT(predictedHook); + bytes32 predictedConfiguration = + factory.computeConfigurationHash(predictedHook, predictedShard, predictedNft, tokenSalt, hookSalt, params); + + vm.startBroadcast(); + (hook, shard, nft) = factory.launch(tokenSalt, hookSalt, type(ShardHookV1).creationCode, params); + vm.stopBroadcast(); + + if (shard != predictedShard) revert PredictionMismatch(predictedShard, shard); + if (hook != predictedHook) revert PredictionMismatch(predictedHook, hook); + if (nft != predictedNft) revert PredictionMismatch(predictedNft, nft); + bytes32 actualConfiguration = factory.configurationHashOf(hook); + if (actualConfiguration != predictedConfiguration) { + revert ConfigurationMismatch(predictedConfiguration, actualConfiguration); + } + console2.log("hook", hook); + console2.log("SHARD", shard); + console2.log("NFT", nft); + console2.log("configuration hash"); + console2.logBytes32(actualConfiguration); + } + + function _hookInitCodeTemplate( + ShardLaunchFactoryV1 factory, + bytes memory hookCreationCode, + ShardLaunchFactoryV1.LaunchParams calldata params + ) private view returns (bytes memory) { + return bytes.concat( + hookCreationCode, + abi.encode( + factory.poolManager(), + ShardTokenV1(address(0)), + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + address(factory), + factory.launcherFeeRecipient(), + params.builderFeeRecipient + ) + ); + } + + function _mine( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + bytes32 hookSaltStart, + bytes memory hookCreationCode, + ShardLaunchFactoryV1.LaunchParams calldata params + ) private view returns (MinedPrediction memory prediction) { + bytes32 tokenCreationCodeHash = keccak256(type(ShardTokenV1).creationCode); + bytes memory initCode = _hookInitCodeTemplate(factory, hookCreationCode, params); + uint256 shardWord = hookCreationCode.length + 64; + uint256 candidate = uint256(hookSaltStart); + uint160 mask = factory.ALL_HOOK_MASK(); + uint160 requiredFlags = factory.REQUIRED_HOOK_FLAGS(); + while (true) { + prediction.hookSalt = bytes32(candidate); + prediction.shard = Create2.computeAddress( + _effectiveTokenSalt(tokenSalt, prediction.hookSalt, params), tokenCreationCodeHash, address(factory) + ); + address shard = prediction.shard; + assembly ("memory-safe") { + mstore(add(initCode, shardWord), shard) + } + prediction.hookInitCodeHash = keccak256(initCode); + prediction.hook = Create2.computeAddress(prediction.hookSalt, prediction.hookInitCodeHash, address(factory)); + if (uint160(prediction.hook) & mask == requiredFlags) break; + unchecked { + ++candidate; + } + } + + address factoryShard = factory.predictToken(tokenSalt, prediction.hookSalt, params); + if (factoryShard != prediction.shard) revert PredictionMismatch(prediction.shard, factoryShard); + address factoryHook = factory.predictHook(prediction.hookSalt, hookCreationCode, prediction.shard, params); + if (factoryHook != prediction.hook) revert PredictionMismatch(prediction.hook, factoryHook); + } + + function _logPrediction( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + ShardLaunchFactoryV1.LaunchParams calldata params, + bytes32 canonicalHash, + MinedPrediction memory prediction + ) private view { + console2.log("factory", address(factory)); + console2.log("pool manager", address(factory.poolManager())); + console2.log("renderer", address(factory.renderer())); + console2.log("launcher fee recipient", factory.launcherFeeRecipient()); + console2.log("builder fee recipient", params.builderFeeRecipient); + console2.log("raw token salt"); + console2.logBytes32(tokenSalt); + console2.log("effective token salt"); + console2.logBytes32(factory.effectiveTokenSalt(tokenSalt, prediction.hookSalt, params)); + console2.log("hook salt"); + console2.logBytes32(prediction.hookSalt); + console2.log("hook creation-code hash"); + console2.logBytes32(canonicalHash); + console2.log("hook initcode hash"); + console2.logBytes32(prediction.hookInitCodeHash); + console2.log("predicted SHARD", prediction.shard); + console2.log("predicted hook", prediction.hook); + console2.log("predicted NFT", prediction.nft); + console2.log("predicted configuration hash"); + console2.logBytes32(prediction.configurationHash); + console2.log("tick lower", int256(params.tickLower)); + console2.log("tick band", int256(params.tickBand)); + console2.log("tick upper", int256(params.tickUpper)); + console2.log("start sqrt price X96", uint256(params.startSqrtPriceX96)); + } + + function _completePrediction( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + ShardLaunchFactoryV1.LaunchParams calldata params, + MinedPrediction memory prediction + ) private view { + prediction.nft = factory.predictNFT(prediction.hook); + prediction.configurationHash = factory.computeConfigurationHash( + prediction.hook, prediction.shard, prediction.nft, tokenSalt, prediction.hookSalt, params + ); + } + + function _effectiveTokenSalt(bytes32 tokenSalt, bytes32 hookSalt, ShardLaunchFactoryV1.LaunchParams calldata params) + private + pure + returns (bytes32) + { + return keccak256( + abi.encode( + tokenSalt, + hookSalt, + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + params.builderFeeRecipient + ) + ); + } + + function _requireCanonicalHash(bytes32 supplied, bytes32 canonical) private pure { + if (supplied != canonical) revert WrongCanonicalHookCodeHash(canonical, supplied); + } +} diff --git a/spec/shards-v1.json b/spec/shards-v1.json new file mode 100644 index 00000000..13d41763 --- /dev/null +++ b/spec/shards-v1.json @@ -0,0 +1,205 @@ +{ + "schemaVersion": 1, + "release": "shards-v1", + "chainId": 1, + "status": "design", + "collection": { + "standard": "ERC721", + "fixedSupply": 10000, + "tokenIds": "1..10000", + "shardStandard": "ERC20", + "shardDecimals": 18, + "shardFixedSupply": "10000000000000000000000", + "shardsPerNft": "1000000000000000000", + "artStorage": "fully on-chain SVG, generated by the factory's shared renderer", + "artRegeneration": "on every acquisition from the pool archive; never on a wallet-to-wallet transfer", + "customDesigns": "not supported in v1; launchers cannot supply artwork or renderer code", + "reversePath": "none; an NFT cannot be turned back into SHARD outside a sale", + "collectionsPerHook": 1 + }, + "launch": { + "factory": "ShardLaunchFactoryV1", + "atomicOrder": [ + "validate actual hook creation bytes and exact permission bits", + "CREATE2 deploy SHARD", + "CREATE2 deploy hook", + "deploy NFT against the factory-shared renderer", + "bind hook and NFT in both directions", + "checked-transfer the full SHARD supply", + "initialise both locked positions", + "store and emit the configuration hash" + ], + "factoryImmutables": [ + "poolManager", + "renderer", + "hookCreationCodeHash" + ], + "factoryConstants": [ + "launcherFeeRecipient (0x4957f49620AFf3Adbbe8195a4f633E49cc93376c) is a compile-time constant, not a constructor argument" + ], + "rendererScope": "one renderer shared by every collection launched from a factory", + "rawTokenSalt": "builder-selected bytes32 input", + "effectiveTokenSalt": "keccak256(abi.encode(rawTokenSalt, hookSalt, tickLower, tickBand, tickUpper, startSqrtPriceX96, builderFeeRecipient))", + "tokenPrediction": "CREATE2(factory, effectiveTokenSalt, keccak256(type(ShardTokenV1).creationCode))", + "hookPrediction": "CREATE2(factory, hookSalt, keccak256(bytes.concat(actualHookCreationCode, abi.encode(exact constructor arguments))))", + "nftPrediction": "CREATE2(factory, keccak256(abi.encode(hook)), keccak256(bytes.concat(type(ShardNFTV1).creationCode, abi.encode(hook, renderer))))", + "requiredLow14Bits": "exactly beforeInitialize | beforeSwap | afterSwap | beforeSwapReturnDelta | afterSwapReturnDelta", + "configurationHashFields": [ + "chainId", + "factory", + "poolManager", + "renderer", + "launcherFeeRecipient", + "builderFeeRecipient", + "shard", + "hook", + "nft", + "tickLower", + "tickBand", + "tickUpper", + "startSqrtPriceX96", + "rawTokenSalt", + "effectiveTokenSalt", + "hookSalt", + "hookCreationCodeHash" + ], + "publicOrdering": "an observer can sponsor the exact configuration; changing any launch parameter or hook salt changes the token prediction and cannot consume the intended configuration" + }, + "pool": { + "currency0": "0x0000000000000000000000000000000000000000", + "currency1": "the launch's SHARD token", + "lpFeePips": 0, + "tickSpacing": 60, + "poolsPerDeployment": 1, + "buyDirection": "buying SHARD moves the tick down", + "tickLower": "constructor-supplied; must be a multiple of the tick spacing and below tickBand", + "tickBand": "constructor-supplied; lower edge of the concentrated band, between tickLower and tickUpper", + "tickUpper": "constructor-supplied; must be a multiple of the tick spacing", + "startSqrtPriceX96": "constructor-supplied; its tick must be at or above tickUpper, and beforeInitialize rejects any other start price", + "seedFullRangeShard": "3000000000000000000000", + "seedBandShard": "7000000000000000000000", + "seedTotalShard": "10000000000000000000000" + }, + "hookPermissions": { + "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 + }, + "limits": { + "maxBatch": 50, + "maxThirdPartySwapShard": "50000000000000000000", + "partialFill": "rejected with PartialFillNotSupported when ETH is the specified currency" + }, + "fees": { + "totalSwapFeeBps": 100, + "feeCurrency": "native ETH", + "feeBasis": "inclusive; the fee is part of the total ETH the trade moves, never added on top", + "exactInputCalculation": "fee = gross * 100 / 10000", + "exactOutputCalculation": "fee = net * 100 / 9900", + "chargedOn": [ + "third-party swaps, in beforeSwap or afterSwap depending on which side ETH is specified", + "buyNFT, buyMany, buyMax, sellNFT and sellMany, charged explicitly because v4 skips a hook's own callbacks" + ], + "notChargedOn": [ + "redeem and redeemMany, whose shards already paid on the way out of the pool", + "donate, which is not swap volume" + ], + "holderShareBps": 8000, + "builderShareBps": 1000, + "programmableShareBps": 1000, + "operatorCut": "the combined builder + launcher 20% cut is taken with a carried remainder (operatorFeeRemainder), so the operator entitlement is cumulative and split-invariant: it is not floored per swap, and a stream of tiny swaps accrues the same operator total as one aggregated swap", + "operatorSplit": "the operator cut is split evenly between builder and launcher, with the odd wei carried to the launcher (operatorSplitParity), so over any stream both cuts stay within one wei of the ideal cumulative 10% and the launcher is never shorted below the builder in absolute accrual", + "conservation": "builderCut + launcherCut + holderAmount == fee on every call", + "launcherFeeRecipient": "0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; an immutable constant in ShardLaunchFactoryV1, not a constructor argument, and the same address Classic v3 uses; the factory cannot route the launcher share elsewhere", + "outerFeeCarry": "the pool-level 1% fee is cumulative on both bases: the per-swap floor is carried (feeCarryIn for exact-input, feeCarryOut for exact-output) so a stream of tiny swaps accrues the same total as one aggregated swap rather than each flooring to zero; a single swap may still take zero for itself until its sub-wei share accumulates to one wei", + "buyMaxCarry": "buyMax clamps the fee to its exact-input reserve so the buyer is never overspent, and returns the uncollected wei to feeCarryOut so the entitlement is preserved and collected on a later exact-output swap rather than shed once per qualifying call" + }, + "rewards": { + "currency": "native ETH", + "custody": "held by the hook until claimed", + "claimAuthorization": "beneficiary-only", + "holderClaims": "settled per token id and paid to nft.ownerOf(id); escrowed while nothing is circulating and released to the first holder", + "holderAccrualDelay": "one block; a piece earns from the block after acquisition", + "builderPayoutWalletChanges": "builder-only, via setBuilderFeeRecipient; accrued fees follow the role", + "programmableRecipient": "immutable from construction", + "donations": "donate() and ShardFeeForwarderV1 pay holders in full and are never split" + }, + "positionLock": { + "duration": "permanent", + "operator": "0x0000000000000000000000000000000000000000", + "withdrawalPath": "none", + "modifyLiquidityCallSites": 1, + "liquidityDeltaSign": "positive only", + "positionOwner": "the hook itself; v4 keys positions on msg.sender, so no other contract can address them" + }, + "roles": { + "deployer": "the launch factory; its one-shot setNFT and initialise powers are consumed atomically", + "builderFeeRecipient": "claims the builder share and may hand the role to a successor", + "launcherFeeRecipient": "claims the Programmable share; immutable", + "holders": "claim their settled share", + "admin": "none; no owner, no pause, no timelock, no upgrade path" + }, + "transferChecks": { + "erc20": "all production transfer and transferFrom results used by the factory, hook, and router are checked and revert TokenTransferFailed on false", + "eth": "refunds, payouts, and claims check call success and revert EthTransferFailed" + }, + "artEntropy": { + "sources": [ + "previous Ethereum block hash", + "block timestamp", + "recipient", + "per-acquisition nonce" + ], + "security": "non-secure randomness; inputs are public and miner-influenceable" + }, + "build": { + "solc": "0.8.26", + "evmVersion": "cancun", + "optimizer": true, + "optimizerRuns": 1000, + "bytecodeHash": "none", + "hookRuntimeBytes": 24352, + "hookCreationCodeBytes": 26289, + "hookDeploymentInitcodeBytes": 26577, + "factoryRuntimeBytes": 18493, + "factoryCreationCodeBytes": 35738, + "factoryDeploymentInitcodeBytes": 35834, + "rendererRuntimeBytes": 16777, + "rendererCreationCodeBytes": 16805, + "rendererDeploymentInitcodeBytes": 16805, + "nftRuntimeBytes": 7258, + "nftCreationCodeBytes": 8019, + "nftDeploymentInitcodeBytes": 8083, + "shardRuntimeBytes": 1875, + "shardCreationCodeBytes": 2792, + "shardDeploymentInitcodeBytes": 2792, + "measuredMainnetForkFactoryDeploymentGas": "7180480", + "measuredMainnetForkAtomicLaunchGas": "8667331", + "measuredLocalFactoryDeploymentGas": "7147357", + "measuredLocalAtomicLaunchGas": "9156093", + "eip170LimitBytes": 24576, + "eip3860InitcodeLimitBytes": 49152, + "dependencies": { + "v4-core": "59d3ecf53afa9264a16bba0e38f4c5d2231f80bc", + "v4-periphery": "ad04c9f24a170accf5ea1b2836bbafd514537ca6", + "openzeppelin-contracts": "21c8312b022f495ebe3621d5daeed20552b43ff9", + "openzeppelin-uniswap-hooks": "26dc8e53f812a1ca390d470342adb6cd8c3286ad", + "solady": "33b4b98e350bbcba6aa85642957c313e98b5f911" + } + }, + "builder": { + "github": "jesse-stahl", + "beneficiary": "0xceeBB3A6543CeBEB2ED66963897A0abEA52A50cC" + } +} diff --git a/src/GeometricRendererV1.sol b/src/GeometricRendererV1.sol new file mode 100644 index 00000000..f80e2fe9 --- /dev/null +++ b/src/GeometricRendererV1.sol @@ -0,0 +1,636 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IShardRendererV1 } from "./interfaces/IShardRendererV1.sol"; +import { LibString } from "solady/utils/LibString.sol"; + +/// @notice Immutable stateless on-chain SVG generator. Layered abstract geometry. +/// @dev tokenURI is a view call, so this contract's gas is never paid. +/// All traits are uniformly distributed - deliberately no rarity tiers. +contract GeometricRendererV1 is IShardRendererV1 { + using LibString for uint256; + + uint256 private constant PALETTES = 16; + uint256 private constant LAYOUTS = 8; + uint256 private constant DENSITIES = 6; + uint256 private constant PRIMITIVES = 6; + uint256 private constant ROTATIONS = 12; + uint256 private constant BACKGROUNDS = 8; + + /// @dev 16 palettes x 4 colours x 3 bytes, packed rrggbb. + bytes private constant PAL = hex"1b1109f2542df7c59fe8871e06202a1b98a07de2d1e8f6ef2b1b2fff4d8dffa5c3ffe06612200f4c934ca3c9a8e9f5db" + hex"191e334a4e7a9a8fbff2e9e4241b00ffc300ff8c00fff3b00b0b0d4a4a578a8a99f5f5f70f2b36ff6b6bffd93d6bcb77" + hex"1a0b2e7b2ff7f107a338c6d92e1e17b5651de0a96df5e1c00d1b2a415a77778da9e0e1dd17301ca7c957f2e8cfbc4749" + hex"2211118c2f0dd95d39f0a8681e12266a3d9ac77dffe0aaff14171c5a6b80c9a227ede6d60012190a7a8f0a939694d2bd"; + + /// @dev sin(d) * 1e4 for d = 0..90, one uint16 per degree. + bytes private constant SIN = hex"000000af015d020b02ba0368041504c30570061c06c80774081f08ca09730a1c0ac40b6c0c120cb8" + hex"0d5c0e000ea20f430fe31082112011bc125712f01388141e14b3154615d8166816f61782180d1895" + hex"191c19a11a231aa41b231b9f1c191c921d071d7b1dec1e5b1ec81f321f9a2000206220c32120217c" + hex"21d4222a227d22ce231c236723af23f52438247824b524ef2527255b258d25bb25e7261026352658" + hex"2678269526af26c526d926ea26f82702270a270e2710"; + + struct Ctx { + uint256 seed; + uint256 palette; + uint256 layout; + uint256 density; + uint256 primitive; + uint256 rotation; + uint256 background; + uint256 count; + uint256 base; + string c0; + string c1; + string c2; + string c3; + } + + struct Sh { + uint256 kind; + int256 cx; + int256 cy; + uint256 r; + string col; + string op; + uint256 ang; + bool outline; + } + + /* -------------------------------------------------------------------- */ + /* AXES */ + /* -------------------------------------------------------------------- */ + + /// @dev Uniform for any n, unlike `% n`. Requires x in [0, 256). + function _pick(uint256 x, uint256 n) private pure returns (uint256) { + return (x * n) >> 8; + } + + function _axes(uint256 seed) + private + pure + returns ( + uint256 palette, + uint256 layout, + uint256 density, + uint256 primitive, + uint256 rotation, + uint256 background + ) + { + palette = _pick((seed >> 0) & 0xFF, PALETTES); + layout = _pick((seed >> 8) & 0xFF, LAYOUTS); + density = _pick((seed >> 16) & 0xFF, DENSITIES); + primitive = _pick((seed >> 24) & 0xFF, PRIMITIVES); + rotation = _pick((seed >> 32) & 0xFF, ROTATIONS); + background = _pick((seed >> 40) & 0xFF, BACKGROUNDS); + } + + /// @notice Exposed for analysis and tests: the six independent axes of a seed. + function axesOf(uint256 seed) + external + pure + returns ( + uint256 palette, + uint256 layout, + uint256 density, + uint256 primitive, + uint256 rotation, + uint256 background + ) + { + return _axes(seed); + } + + function _ctx(uint256 seed) private pure returns (Ctx memory c) { + c.seed = seed; + (c.palette, c.layout, c.density, c.primitive, c.rotation, c.background) = _axes(seed); + c.count = [uint256(3), 5, 8, 13, 21, 34][c.density]; + c.base = [uint256(205), 170, 140, 110, 84, 62][c.density]; + c.c0 = _col(c.palette, 0); + c.c1 = _col(c.palette, 1); + c.c2 = _col(c.palette, 2); + c.c3 = _col(c.palette, 3); + } + + /* -------------------------------------------------------------------- */ + /* PRIMITIVES */ + /* -------------------------------------------------------------------- */ + + function _col(uint256 p, uint256 i) private pure returns (string memory) { + uint256 o = (p * 4 + i) * 3; + uint256 v = (uint256(uint8(PAL[o])) << 16) | (uint256(uint8(PAL[o + 1])) << 8) | uint256(uint8(PAL[o + 2])); + return string(abi.encodePacked("#", LibString.toHexStringNoPrefix(v, 3))); + } + + function _accent(Ctx memory c, uint256 i) private pure returns (string memory) { + if (i == 0) return c.c1; + if (i == 1) return c.c2; + return c.c3; + } + + function _u(uint256 v) private pure returns (string memory) { + return LibString.toString(v); + } + + function _i(int256 v) private pure returns (string memory) { + return LibString.toString(v); + } + + function _sinT(uint256 d) private pure returns (int256) { + return int256((uint256(uint8(SIN[d * 2])) << 8) | uint256(uint8(SIN[d * 2 + 1]))); + } + + /// @dev sin(deg) scaled by 1e4. + function _sin(int256 deg) private pure returns (int256) { + deg = ((deg % 360) + 360) % 360; + if (deg <= 90) return _sinT(uint256(deg)); + if (deg <= 180) return _sinT(uint256(180 - deg)); + if (deg <= 270) return -_sinT(uint256(deg - 180)); + return -_sinT(uint256(360 - deg)); + } + + function _cos(int256 deg) private pure returns (int256) { + return _sin(deg + 90); + } + + function _polar(Sh memory s, uint256 deg) private pure returns (int256 x, int256 y) { + x = s.cx + (_cos(int256(deg)) * int256(s.r)) / 10_000; + y = s.cy + (_sin(int256(deg)) * int256(s.r)) / 10_000; + } + + function _pt(Sh memory s, uint256 deg) private pure returns (string memory) { + (int256 x, int256 y) = _polar(s, deg); + return string(abi.encodePacked(_i(x), ",", _i(y))); + } + + function _op(uint256 i) private pure returns (string memory) { + if (i == 0) return "0.42"; + if (i == 1) return "0.55"; + if (i == 2) return "0.66"; + if (i == 3) return "0.78"; + if (i == 4) return "0.9"; + return "1"; + } + + /* -------------------------------------------------------------------- */ + /* PLACEMENT */ + /* -------------------------------------------------------------------- */ + + function _cols(uint256 n) private pure returns (uint256 k) { + k = 1; + while (k * k < n) ++k; + } + + function _vary(Ctx memory c, uint256 h) private pure returns (uint256) { + return (c.base * (75 + _pick((h >> 16) & 0xFF, 60))) / 100; + } + + function _place(Ctx memory c, uint256 i, uint256 h) private pure returns (int256 cx, int256 cy, uint256 r) { + uint256 n = c.count; + int256 jx = int256(_pick(h & 0xFF, 120)) - 60; + int256 jy = int256(_pick((h >> 8) & 0xFF, 120)) - 60; + + if (c.layout == 0) { + // grid, centred as a block with the final partial row centred too + uint256 k = _cols(n); + uint256 cell = 720 / k; + uint256 rows = (n + k - 1) / k; + uint256 row = i / k; + uint256 inRow = n - row * k; + if (inRow > k) inRow = k; + cx = int256(500 - (inRow * cell) / 2 + cell * (i % k) + cell / 2) + jx / 4; + cy = int256(500 - (rows * cell) / 2 + cell * row + cell / 2) + jy / 4; + r = (cell * (36 + _pick((h >> 16) & 0xFF, 22))) / 100; + } else if (c.layout == 1) { + // radial + int256 ang = int256((i * 360) / n) + int256(_pick((h >> 24) & 0xFF, 24)); + uint256 rad = 190 + _pick((h >> 8) & 0xFF, 190); + cx = 500 + (_cos(ang) * int256(rad)) / 10_000; + cy = 500 + (_sin(ang) * int256(rad)) / 10_000; + r = _vary(c, h); + } else if (c.layout == 2) { + // stack + cx = 500 + (jx * 5) / 2; + cy = int256(120 + (760 * i) / (n - 1)); + r = _vary(c, h); + } else if (c.layout == 3) { + // scatter + cx = int256(120 + _pick(h & 0xFF, 760)); + cy = int256(120 + _pick((h >> 8) & 0xFF, 760)); + r = _vary(c, h); + } else if (c.layout == 4) { + // arc + int256 ang = 165 + int256((i * 210) / (n - 1)); + cx = 500 + (_cos(ang) * 340) / 10_000; + cy = 540 + (_sin(ang) * 340) / 10_000; + r = (c.base * (55 + (90 * i) / (n - 1))) / 100; + } else if (c.layout == 5) { + // nested + cx = 500 + jx / 3; + cy = 500 + jy / 3; + r = (430 * (n - i)) / n; + } else if (c.layout == 6) { + // split + if (i * 2 < n) { + cx = int256(120 + _pick(h & 0xFF, 360)); + cy = int256(120 + _pick((h >> 8) & 0xFF, 360)); + } else { + cx = int256(520 + _pick(h & 0xFF, 360)); + cy = int256(520 + _pick((h >> 8) & 0xFF, 360)); + } + r = _vary(c, h); + } else { + // weave + cx = int256(120 + (760 * i) / (n - 1)); + cy = 500 + (_sin(int256((i * 720) / n)) * 250) / 10_000 + jy / 5; + r = _vary(c, h); + } + if (r < 8) r = 8; + // keep every centre on-canvas; oversized shapes may still bleed, which reads as intentional cropping + if (cx < 110) cx = 110; + if (cx > 890) cx = 890; + if (cy < 110) cy = 110; + if (cy > 890) cy = 890; + } + + /* -------------------------------------------------------------------- */ + /* SHAPE EMITTERS */ + /* -------------------------------------------------------------------- */ + + function _stroke(Sh memory s, uint256 div) private pure returns (string memory) { + uint256 w = s.r / div; + if (w < 4) w = 4; + return string( + abi.encodePacked( + '" fill="none" stroke="', + s.col, + '" stroke-width="', + _u(w), + '" stroke-linecap="round" opacity="', + s.op, + '"/>' + ) + ); + } + + function _fillOrLine(Sh memory s) private pure returns (string memory) { + if (s.outline) { + uint256 w = s.r / 7; + if (w < 3) w = 3; + return string( + abi.encodePacked(' fill="none" stroke="', s.col, '" stroke-width="', _u(w), '" opacity="', s.op, '"/>') + ); + } + return string(abi.encodePacked(' fill="', s.col, '" opacity="', s.op, '"/>')); + } + + function _emit(Sh memory s) private pure returns (string memory) { + if (s.kind == 0) { + return string( + abi.encodePacked('> 32) & 0xFF, 3)); + s.op = _op(_pick((h >> 24) & 0xFF, 6)); + s.outline = ((h >> 48) & 1) == 1; + } + out = abi.encodePacked(out, _emit(s)); + } + return string(out); + } + + /* -------------------------------------------------------------------- */ + /* BACKGROUNDS */ + /* -------------------------------------------------------------------- */ + + function _bg(Ctx memory c) private pure returns (string memory) { + bytes memory out = abi.encodePacked(''); + if (c.background == 1) { + out = abi.encodePacked( + out, + '' + ); + } else if (c.background == 2) { + out = abi.encodePacked( + out, + '' + ); + } else if (c.background == 3) { + out = abi.encodePacked( + out, + '' + ); + } else if (c.background == 4) { + for (uint256 k = 1; k <= 7; ++k) { + out = abi.encodePacked( + out, + '' + ); + } + } else if (c.background == 5) { + for (uint256 k = 1; k < 20; ++k) { + out = abi.encodePacked( + out, + '' + ); + } + } else if (c.background == 6) { + for (uint256 k = 0; k < 10; ++k) { + out = abi.encodePacked( + out, '' + ); + } + } + return string(out); + } + + function _overlay(Ctx memory c) private pure returns (string memory) { + bytes memory out; + if (c.background == 7) { + out = abi.encodePacked( + '', + '' + ); + } + return string( + abi.encodePacked( + out, + '' + ) + ); + } + + /* -------------------------------------------------------------------- */ + /* OUTPUT */ + /* -------------------------------------------------------------------- */ + + function generate(uint256 seed) external pure returns (string memory) { + Ctx memory c = _ctx(seed); + return string( + abi.encodePacked( + '', + _bg(c), + '', + _layer(c, true), + _layer(c, false), + "", + _overlay(c), + "" + ) + ); + } + + function generateDormant(uint256 tokenId) external pure returns (string memory) { + string memory id = _u(tokenId); + uint256 len = bytes(id).length; + uint256 w = 760 / (len * 3 + 1); + if (w > 70) w = 70; + if (w < 4) w = 4; + uint256 gap = w / 2; + uint256 total = len * w + (len - 1) * gap; + uint256 x0 = 500 - total / 2; + + bytes memory digits; + for (uint256 k = 0; k < len; ++k) { + digits = abi.encodePacked(digits, _digit(uint8(bytes(id)[k]) - 48, x0 + k * (w + gap), 470, w)); + } + + return string( + abi.encodePacked( + '', + "Dormant #", + id, + "", + '', + '', + '', + '', + '', + digits, + "", + '', + "" + ) + ); + } + + /// @dev Seven-segment digit drawn from rects: no fonts, identical in every renderer. + function _digit(uint256 d, uint256 x, uint256 y, uint256 w) private pure returns (string memory) { + uint256[10] memory map = [uint256(0x3F), 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F]; + uint256 m = map[d]; + uint256 h = w * 2; + uint256 t = w / 6; + if (t == 0) t = 1; + uint256 vl = (h - 3 * t) / 2; + bytes memory out; + if (m & 1 != 0) out = abi.encodePacked(out, _rect(x + t, y, w - 2 * t, t)); + if (m & 2 != 0) out = abi.encodePacked(out, _rect(x + w - t, y + t, t, vl)); + if (m & 4 != 0) out = abi.encodePacked(out, _rect(x + w - t, y + 2 * t + vl, t, vl)); + if (m & 8 != 0) out = abi.encodePacked(out, _rect(x + t, y + h - t, w - 2 * t, t)); + if (m & 16 != 0) out = abi.encodePacked(out, _rect(x, y + 2 * t + vl, t, vl)); + if (m & 32 != 0) out = abi.encodePacked(out, _rect(x, y + t, t, vl)); + if (m & 64 != 0) out = abi.encodePacked(out, _rect(x + t, y + (h - t) / 2, w - 2 * t, t)); + return string(out); + } + + function _rect(uint256 x, uint256 y, uint256 w, uint256 h) private pure returns (string memory) { + return + string(abi.encodePacked('')); + } + + /* -------------------------------------------------------------------- */ + /* ATTRIBUTES */ + /* -------------------------------------------------------------------- */ + + function attributes(uint256 seed) external pure returns (string memory) { + (uint256 p, uint256 l, uint256 d, uint256 pr, uint256 r, uint256 b) = _axes(seed); + return string( + abi.encodePacked( + '[{"trait_type":"Palette","value":"', + _paletteName(p), + '"},{"trait_type":"Layout","value":"', + _layoutName(l), + '"},{"trait_type":"Density","value":"', + _densityName(d), + '"},{"trait_type":"Primitive","value":"', + _primitiveName(pr), + '"},{"trait_type":"Rotation","value":"', + _u(r * 30), + '"},{"trait_type":"Background","value":"', + _backgroundName(b), + '"}]' + ) + ); + } + + function _paletteName(uint256 i) private pure returns (string memory) { + string[16] memory n = [ + "Ember", + "Tidal", + "Bloom", + "Moss", + "Dusk", + "Solar", + "Ink", + "Reef", + "Vapor", + "Clay", + "Arctic", + "Citrus", + "Rust", + "Orchid", + "Bullion", + "Marine" + ]; + return n[i]; + } + + function _layoutName(uint256 i) private pure returns (string memory) { + string[8] memory n = ["Grid", "Radial", "Stack", "Scatter", "Arc", "Nested", "Split", "Weave"]; + return n[i]; + } + + function _densityName(uint256 i) private pure returns (string memory) { + string[6] memory n = ["Sparse", "Light", "Balanced", "Dense", "Packed", "Swarm"]; + return n[i]; + } + + function _primitiveName(uint256 i) private pure returns (string memory) { + string[6] memory n = ["Circle", "Square", "Triangle", "Hexagon", "Arc", "Line"]; + return n[i]; + } + + function _backgroundName(uint256 i) private pure returns (string memory) { + string[8] memory n = ["Void", "Gradient", "Halo", "Split", "Rings", "Mesh", "Bands", "Vignette"]; + return n[i]; + } +} diff --git a/src/ShardConstantsV1.sol b/src/ShardConstantsV1.sol new file mode 100644 index 00000000..ef9adbca --- /dev/null +++ b/src/ShardConstantsV1.sol @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +library ShardConstantsV1 { + uint256 internal constant MAX_NFTS = 10_000; + uint256 internal constant SHARDS_PER_NFT = 1 ether; + uint256 internal constant FEE_BPS = 100; // 1% + uint256 internal constant BPS_DENOMINATOR = 10_000; + uint24 internal constant POOL_FEE = 0; // the hook takes the fee, not the LP + int24 internal constant TICK_SPACING = 60; + uint256 internal constant ACC_PRECISION = 1e18; + + /// @notice SHARD seeded into the FULL-RANGE position, the one that runs to infinity. + /// Thin on its own: it is what keeps the collection undrainable. + uint256 internal constant SEED_FULL_RANGE = 3000 ether; + + /// @notice SHARD seeded into the concentrated BAND, stacked on top of the full range so + /// both are active below the band edge. This is what holds prices affordable + /// through the bulk of the collection. + uint256 internal constant SEED_BAND = 7000 ether; +} diff --git a/src/ShardErrorsV1.sol b/src/ShardErrorsV1.sol new file mode 100644 index 00000000..0cb962b6 --- /dev/null +++ b/src/ShardErrorsV1.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +library ShardErrorsV1 { + error NotHook(); + error NotNFT(); + error NotDeployer(); + error NotPoolManager(); + error AlreadyInitialised(); + error NotInitialised(); + error WrongPool(); + error WrongStartPrice(uint160 expected, uint160 provided); + error PoolExhausted(); + error TokenDoesNotExist(uint256 tokenId); + error InsufficientPayment(uint256 required, uint256 provided); + error SlippageExceeded(uint256 limit, uint256 actual); + error Expired(); + error NothingToClaim(); + error EthTransferFailed(); + error DirectTransferRejected(); + error ZeroAddress(); + error InvalidTickRange(); + error InvalidStartPrice(); + error WrongShardBalance(uint256 expected, uint256 actual); + error WrongNFT(address nft, address expectedHook); + error TokenTransferFailed(); + /// @dev A buy consumed ETH that belonged to fee holders rather than to the buyer. + error FeeEthMissing(uint256 expected, uint256 actual); + /// @dev A swap stopped at its price limit while ETH was the specified currency. The fee + /// for that case is fixed before execution and cannot be reduced afterwards, so the + /// swap is rejected rather than overcharged. See {ShardHookV1._afterSwap}. + error PartialFillNotSupported(); + /// @dev A buy was sent no ETH at all. Without this the swap reverts opaquely deep in v4, + /// or (with minCount 0) succeeds as a confusing no-op that mints nothing. + error ZeroAmount(); + /// @dev A batch buy asked for more NFTs than one transaction can safely mint. + error BatchTooLarge(uint256 requested, uint256 max); + /// @dev buyMax bought fewer whole NFTs than the caller was willing to accept. + error InsufficientOutput(uint256 minCount, uint256 actual); + /// @dev A third-party swap tried to move more SHARD than one swap is allowed to, in either + /// direction. Amounts are in SHARD wei, not whole SHARD. See {ShardHookV1._afterSwap}. + error SwapTooLarge(uint256 shard, uint256 maxShard); + /// @dev Caller is not the current builder beneficiary. + error NotBuilder(); + /// @dev Caller is not the launcher (Programmable) fee recipient. + error NotLauncher(); +} diff --git a/src/ShardFeeDistributorV1.sol b/src/ShardFeeDistributorV1.sol new file mode 100644 index 00000000..1d70d8c8 --- /dev/null +++ b/src/ShardFeeDistributorV1.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IShardHookV1 } from "./interfaces/IShardHookV1.sol"; +import { ShardErrorsV1 } from "./ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "./ShardConstantsV1.sol"; + +/// @title ShardFeeDistributorV1 +/// @notice Running-accumulator fee sharing across circulating NFTs. +/// @dev Dust is carried in SCALED units so no wei is stranded. NFTs join the +/// earning set only from the block AFTER acquisition — the same-block +/// accrual guard — keyed on acquiredBlock per token, not a global block. +abstract contract ShardFeeDistributorV1 is IShardHookV1 { + uint256 internal constant ACC_PRECISION = ShardConstantsV1.ACC_PRECISION; + + uint256 public constant BPS_DENOMINATOR = 10_000; + /// @dev Documentation of the holder share: holders receive the remainder after the two + /// cuts, so rounding dust always stays with them. 8_000 = 10_000 - 1_000 - 1_000. + uint256 public constant HOLDER_SHARE_BPS = 8000; + uint256 public constant BUILDER_SHARE_BPS = 1000; + uint256 public constant LAUNCHER_SHARE_BPS = 1000; + + /// @notice Receives Programmable's fixed 0.10% share of swap volume. Immutable. + address public immutable launcherFeeRecipient; + + /// @notice Receives the builder's fixed 0.10% share of swap volume. Only the current + /// recipient may claim or hand the role to a successor. + address public builderFeeRecipient; + + uint256 public builderFeesAccrued; + uint256 public launcherFeesAccrued; + + /// @dev Carried numerator (< BPS_DENOMINATOR) for the combined builder+launcher cut, so a stream + /// of tiny fees cannot floor the entitlement to zero. See {_distributeFee}. + uint256 internal operatorFeeRemainder; + /// @dev Carried odd wei (0 or 1) from the even builder/launcher split; the launcher takes it. + uint256 internal operatorSplitParity; + + uint256 public accFeePerNFT; // scaled by ACC_PRECISION + uint256 public dustScaled; // scaled remainder, < circulating + uint256 public escrowBalance; // fees accrued while nothing circulated + uint256 public circulating; // the EARNING set (lags acquisitions by a block) + uint256 public pendingCount; + uint256 public pendingBlock; + + mapping(uint256 => uint256) public feeSnapshot; + mapping(uint256 => uint256) public acquiredBlock; + mapping(uint256 => uint256) public flushAcc; // block => accFeePerNFT at the moment that block's tokens joined + mapping(address => uint256) public override claimable; + + event FeeDistributed(uint256 amount, uint256 circulating); + event FeeEscrowed(uint256 amount); + event EscrowReleased(uint256 amount); + event Claimed(address indexed account, uint256 amount); + event BuilderFeesClaimed(address indexed recipient, uint256 amount); + event LauncherFeesClaimed(address indexed recipient, uint256 amount); + event BuilderFeeRecipientChanged(address indexed previous, address indexed current); + + constructor(address _launcherFeeRecipient, address _builderFeeRecipient) { + if (_launcherFeeRecipient == address(0)) revert ShardErrorsV1.ZeroAddress(); + if (_builderFeeRecipient == address(0)) revert ShardErrorsV1.ZeroAddress(); + launcherFeeRecipient = _launcherFeeRecipient; + builderFeeRecipient = _builderFeeRecipient; + } + + /// @notice Hands the builder share to a successor. Accrued-but-unclaimed fees follow the + /// role: the successor claims them, the predecessor is locked out immediately. + function setBuilderFeeRecipient(address next) external { + if (msg.sender != builderFeeRecipient) revert ShardErrorsV1.NotBuilder(); + if (next == address(0)) revert ShardErrorsV1.ZeroAddress(); + emit BuilderFeeRecipientChanged(builderFeeRecipient, next); + builderFeeRecipient = next; + } + + /// @dev SWAP-FEE entry point: carves the fixed 0.10% builder and 0.10% launcher shares before + /// the remainder joins the holder pool. The combined operator cut (2_000/10_000 of the fee) + /// is taken with a CARRIED remainder so a stream of sub-threshold swaps cannot floor the + /// Programmable or builder entitlement to zero — ten 9-wei fees accrue the same total as one + /// 90-wei fee. No underflow: with `operatorFeeRemainder < BPS_DENOMINATOR`, + /// `operatorCut = (amount*2000 + rem)/10000 <= amount` for every `amount >= 0`, so the holder + /// remainder `amount - operatorCut` is always non-negative. The even split carries its odd + /// wei (`operatorSplitParity`) to the launcher, so Programmable is never shorted below the + /// builder and `builderCut + launcherCut + holderAmount == amount` holds every call. + /// Donations bypass this and call {_distribute} directly — a gift to holders is not swap + /// volume and is never split. + function _distributeFee(uint256 amount) internal { + uint256 operatorNum = amount * (BUILDER_SHARE_BPS + LAUNCHER_SHARE_BPS) + operatorFeeRemainder; + uint256 operatorCut = operatorNum / BPS_DENOMINATOR; + operatorFeeRemainder = operatorNum % BPS_DENOMINATOR; + + uint256 splitNum = operatorCut + operatorSplitParity; + uint256 builderCut = splitNum / 2; + operatorSplitParity = splitNum % 2; + uint256 launcherCut = operatorCut - builderCut; // launcher (Programmable) takes the odd wei + + builderFeesAccrued += builderCut; + launcherFeesAccrued += launcherCut; + _distribute(amount - operatorCut); + } + + /// @dev True while a token has been acquired but has not yet joined the earning + /// set. Such a token must never accrue: `circulating` already excludes it, + /// so this block's fees were divided among — and paid in full to — the + /// existing holders. + function _isPending(uint256 tokenId) private view returns (bool) { + return pendingCount != 0 && acquiredBlock[tokenId] == pendingBlock; + } + + function _flushPending() internal { + if (pendingCount != 0 && pendingBlock != block.number) { + flushAcc[pendingBlock] = accFeePerNFT; // acc as of just BEFORE this block's fees + circulating += pendingCount; + pendingCount = 0; + } + } + + function _distribute(uint256 amount) internal { + _flushPending(); + + if (circulating == 0) { + if (amount != 0) { + escrowBalance += amount; + emit FeeEscrowed(amount); + } + return; + } + + uint256 total = amount; + if (escrowBalance != 0) { + total += escrowBalance; + emit EscrowReleased(escrowBalance); + escrowBalance = 0; + } + if (total == 0 && dustScaled == 0) return; + + uint256 totalScaled = total * ACC_PRECISION + dustScaled; + accFeePerNFT += totalScaled / circulating; + dustScaled = totalScaled % circulating; + + emit FeeDistributed(amount, circulating); + } + + function _acquireAccounting(uint256 tokenId, address) internal { + _flushPending(); + feeSnapshot[tokenId] = accFeePerNFT; + acquiredBlock[tokenId] = block.number; + if (pendingCount == 0) pendingBlock = block.number; + pendingCount += 1; + } + + function _releaseAccounting(uint256 tokenId, address from) internal { + if (_isPending(tokenId)) { + // Path 2 + // Never joined the earning set — it earned nothing by construction. + feeSnapshot[tokenId] = accFeePerNFT; + pendingCount -= 1; // decrement pending, NOT circulating + } else { + _settle(tokenId, from); + _flushPending(); + circulating -= 1; + } + } + + function _settle(uint256 tokenId, address owner) internal { + if (_isPending(tokenId)) return; // Path 3 + uint256 acc = accFeePerNFT; + uint256 snap = feeSnapshot[tokenId]; + uint256 floor_ = flushAcc[acquiredBlock[tokenId]]; + if (floor_ > snap) snap = floor_; // Path 1: never earn from before you joined + if (acc > snap) { + uint256 delta = acc - snap; + uint256 owed = delta / ACC_PRECISION; + if (owed != 0) claimable[owner] += owed; + feeSnapshot[tokenId] = acc - (delta % ACC_PRECISION); // keep the fraction + } + } + + function _claim(address account) internal returns (uint256 amount) { + amount = claimable[account]; + if (amount == 0) revert ShardErrorsV1.NothingToClaim(); + claimable[account] = 0; // effects before interaction + (bool ok,) = account.call{ value: amount }(""); + if (!ok) revert ShardErrorsV1.EthTransferFailed(); + emit Claimed(account, amount); + } +} diff --git a/src/ShardFeeForwarderV1.sol b/src/ShardFeeForwarderV1.sol new file mode 100644 index 00000000..3fa927cf --- /dev/null +++ b/src/ShardFeeForwarderV1.sol @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IShardHookDonate { + function donate() external payable; +} + +/// @title ShardFeeForwarderV1 +/// @notice Turns a plain ETH transfer into a payment to SHARDS holders. +/// +/// @dev A cousin contract, deliberately tiny and stateless. It exists because most +/// things that might want to pay holders (a launchpad, a royalty splitter, a +/// marketplace payout) know how to send ETH to an address and nothing more. +/// They cannot be expected to encode a call to {ShardHookV1-donate}. +/// +/// So: send ETH here, then anyone calls {flush} to push the whole balance into +/// the fee pool. Sending straight to the hook instead would strand the ETH +/// forever, because the hook's `receive` cannot distribute (v4 delivers swap +/// proceeds through it). +/// +/// Holding no state and no privileges is the point. There is no owner, no +/// withdrawal path and no way to redirect the ETH: every wei that arrives can +/// only ever leave through `donate`. Anyone may flush, so nobody has to be +/// trusted to do it. +contract ShardFeeForwarderV1 { + error ZeroAddress(); + error NothingToFlush(); + + /// @notice The hook whose holders get paid. Fixed at deployment. + address payable public immutable hook; + + /// @notice ETH pushed into the fee pool. + event Flushed(address indexed caller, uint256 amount); + + constructor(address payable _hook) { + if (_hook == address(0)) revert ZeroAddress(); + hook = _hook; + } + + /// @notice Accepts ETH from anyone, with no calldata. Held until {flush}. + receive() external payable { } + + /// @notice Push the entire balance into the hook's fee pool. + /// @dev Permissionless. The contract has no other exit, so an untrusted caller + /// can only move ETH to the one destination it was built for. + function flush() external { + uint256 amount = address(this).balance; + if (amount == 0) revert NothingToFlush(); + + // Balance is read and spent in the same call and the contract keeps no + // accounting, so there is nothing a re-entrant caller could double spend. + IShardHookDonate(hook).donate{ value: amount }(); + + emit Flushed(msg.sender, amount); + } +} diff --git a/src/ShardHookV1.sol b/src/ShardHookV1.sol new file mode 100644 index 00000000..9daad5fa --- /dev/null +++ b/src/ShardHookV1.sol @@ -0,0 +1,1106 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { BaseHook } from "@openzeppelin/uniswap-hooks/src/base/BaseHook.sol"; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { Pool } from "@uniswap/v4-core/src/libraries/Pool.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { + BeforeSwapDelta, + BeforeSwapDeltaLibrary, + toBeforeSwapDelta +} from "@uniswap/v4-core/src/types/BeforeSwapDelta.sol"; +import { SafeCast } from "@uniswap/v4-core/src/libraries/SafeCast.sol"; +import { SwapParams, ModifyLiquidityParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { LiquidityAmounts } from "@uniswap/v4-periphery/src/libraries/LiquidityAmounts.sol"; + +import { ReentrancyGuard } from "solady/utils/ReentrancyGuard.sol"; +import { IERC721 } from "@openzeppelin/contracts/token/ERC721/IERC721.sol"; + +import { ShardFeeDistributorV1 } from "./ShardFeeDistributorV1.sol"; +import { ShardTokenV1 } from "./ShardTokenV1.sol"; +import { ShardErrorsV1 } from "./ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "./ShardConstantsV1.sol"; +import { IShardNFTV1 } from "./interfaces/IShardNFTV1.sol"; + +/// @title ShardHookV1 +/// @notice Uniswap v4 hook owning a permanently-locked, single-sided SHARD position. +/// +/// @dev ============================ LIQUIDITY IS LOCKED FOREVER ============================ +/// THERE IS NO LIQUIDITY WITHDRAWAL PATH IN THIS CONTRACT AND THERE NEVER MAY BE. +/// `poolManager.modifyLiquidity` is called ONLY from `_mintPosition`, and ALWAYS with a +/// POSITIVE `liquidityDelta`. There are two seeded positions and there is no counterpart +/// that ever removes either of them. The invariant is NEVER A NEGATIVE DELTA — not "one +/// call site". A negative `liquidityDelta` anywhere — behind any guard, owner, timelock or +/// emergency path — would break the entire trust model of this protocol. v4 positions key on `msg.sender`, so no +/// external contract can address this position; the only party that could ever remove it is this contract itself, and +/// it +/// deliberately has no code to do so. DO NOT ADD ONE. +/// ====================================================================================== +contract ShardHookV1 is BaseHook, ShardFeeDistributorV1, IUnlockCallback, ReentrancyGuard { + using CurrencyLibrary for Currency; + + /*////////////////////////////////////////////////////////////// + TYPES + //////////////////////////////////////////////////////////////*/ + + /// @dev Append only — never reorder (payloads are abi-encoded). + enum Action { + SEED_LIQUIDITY, + SWEEP, + BUY, + SELL, + BUY_EXACT_OUT, + BUY_EXACT_IN + } + + /*////////////////////////////////////////////////////////////// + IMMUTABLES + //////////////////////////////////////////////////////////////*/ + + /// @notice The only address permitted to call {initialise}. This gate is what closes the + /// window between deployment and seeding. + address public immutable deployer; + + ShardTokenV1 public immutable shard; + + int24 public immutable tickLower; + + /// @notice Lower edge of the CONCENTRATED band, stacked on top of the full-range position. + /// The band spans `[tickBand, tickUpper]`, so AT OR ABOVE this tick both positions + /// are active and below it only the full range is. Mind the direction: buying + /// pushes the tick DOWN, so "above `tickBand`" is the early, cheap part of the + /// collection — the deep region — and the pool thins out as it sells through. + int24 public immutable tickBand; + + int24 public immutable tickUpper; + uint160 public immutable startSqrtPriceX96; + + /// @notice The full seed: 10,000 SHARD, one per NFT. + uint256 public constant SEED_AMOUNT = ShardConstantsV1.MAX_NFTS * ShardConstantsV1.SHARDS_PER_NFT; + + /*////////////////////////////////////////////////////////////// + STORAGE + //////////////////////////////////////////////////////////////*/ + + /// @notice The canonical pool. Fixed at construction; ETH is currency0, SHARD is currency1. + PoolKey public poolKey; + + /// @notice True once {initialise} has run. One-shot. + bool public initialised; + + /// @notice SHARD left with the hook by `getLiquidityForAmount1` rounding the seed liquidity + /// down (~221 wei). Load-bearing: the core invariant is + /// `shard.balanceOf(hook) == nft.circulatingSupply() * 1e18 + seedDust`. + uint256 public seedDust; + + /// @notice The NFT contract. Set post-deployment (a CREATE2 cycle rules out a ctor arg). + /// @dev Task 13 adds the deployer-gated one-shot `setNFT`. + IShardNFTV1 public nft; + + /// @notice Liquidity minted into the FULL-RANGE position. + uint128 public seedLiquidity; + + /// @notice Liquidity minted into the concentrated BAND position. + uint128 public seedLiquidityBand; + + event Initialised(PoolId indexed poolId, uint128 liquidityFull, uint128 liquidityBand, uint256 seedDust); + + /// @notice Outside ETH paid into the holder fee pool. + event Donated(address indexed from, uint256 amount); + + /*////////////////////////////////////////////////////////////// + CONSTRUCTOR + //////////////////////////////////////////////////////////////*/ + + /// @param _deployer The address permitted to call {setNFT} and {initialise}. + /// @dev This is an explicit argument, NOT `msg.sender`, and that is load-bearing. + /// A hook address must encode its permission flags in its low bits, so it has to + /// be CREATE2-deployed at a mined salt — but an EOA cannot execute CREATE2. Under + /// `vm.broadcast`, forge rewrites `new ShardHookV1{salt:}` into a call to the + /// canonical CREATE2 factory, so `msg.sender` here would be `0x4e59…`, never the + /// deployer's wallet, and both gated functions would be PERMANENTLY UNREACHABLE. + /// (It works in tests only because a test *contract* can execute CREATE2 itself, + /// which is exactly why the bug hid behind a green suite.) + /// Unlike the NFT address, the wallet is known before mining, so passing it in + /// creates no CREATE2 cycle. + constructor( + IPoolManager _poolManager, + ShardTokenV1 _shard, + int24 _tickLower, + int24 _tickBand, + int24 _tickUpper, + uint160 _startSqrtPriceX96, + address _deployer, + address _launcherFeeRecipient, + address _builderFeeRecipient + ) BaseHook(_poolManager) ShardFeeDistributorV1(_launcherFeeRecipient, _builderFeeRecipient) { + if (address(_shard) == address(0)) revert ShardErrorsV1.ZeroAddress(); + if (_deployer == address(0)) revert ShardErrorsV1.ZeroAddress(); + if (_tickLower >= _tickUpper) revert ShardErrorsV1.InvalidTickRange(); + if (_tickLower % ShardConstantsV1.TICK_SPACING != 0) revert ShardErrorsV1.InvalidTickRange(); + if (_tickUpper % ShardConstantsV1.TICK_SPACING != 0) revert ShardErrorsV1.InvalidTickRange(); + if (_tickLower >= _tickBand || _tickBand >= _tickUpper) revert ShardErrorsV1.InvalidTickRange(); + if (_tickBand % ShardConstantsV1.TICK_SPACING != 0) revert ShardErrorsV1.InvalidTickRange(); + + deployer = _deployer; + shard = _shard; + tickLower = _tickLower; + tickBand = _tickBand; + tickUpper = _tickUpper; + startSqrtPriceX96 = _startSqrtPriceX96; + + poolKey = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, // native ETH always sorts first + currency1: Currency.wrap(address(_shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: ShardConstantsV1.TICK_SPACING, + hooks: IHooks(address(this)) + }); + } + + /// @dev The hook must be able to hold native ETH (swap proceeds and fee claims). + receive() external payable { } + + /*////////////////////////////////////////////////////////////// + HOOK PERMISSIONS + //////////////////////////////////////////////////////////////*/ + + function getHookPermissions() public pure override returns (Hooks.Permissions memory) { + 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 + }); + } + + /*////////////////////////////////////////////////////////////// + BEFORE INITIALIZE + //////////////////////////////////////////////////////////////*/ + + /// @dev Validates the KEY *and* the PRICE. A stored-poolId guard cannot work here: + /// `beforeInitialize` carries `noSelfCall` (Hooks.sol:171) so it never fires for this + /// hook's own {initialise}, and before that a stored id is zero so any pool would pass. + /// It ALWAYS fires for a front-runner though — which is exactly the leverage needed. + function _beforeInitialize(address, PoolKey calldata key, uint160 sqrtPriceX96) + internal + view + override + returns (bytes4) + { + if (Currency.unwrap(key.currency0) != address(0)) revert ShardErrorsV1.WrongPool(); + if (Currency.unwrap(key.currency1) != address(shard)) revert ShardErrorsV1.WrongPool(); + if (key.fee != ShardConstantsV1.POOL_FEE) revert ShardErrorsV1.WrongPool(); + if (key.tickSpacing != ShardConstantsV1.TICK_SPACING) revert ShardErrorsV1.WrongPool(); + // Without this, a front-runner initialises the canonical pool at ANY price: + // below tickUpper the seed would demand ETH the hook doesn't have (permanent + // grief); far above it, the first buyer collapses the price for free. + if (sqrtPriceX96 != startSqrtPriceX96) { + revert ShardErrorsV1.WrongStartPrice(startSqrtPriceX96, sqrtPriceX96); + } + return BaseHook.beforeInitialize.selector; + } + + /*////////////////////////////////////////////////////////////// + INITIALISE + //////////////////////////////////////////////////////////////*/ + + /// @notice Initialises the canonical pool and locks all 10,000 SHARD into a single-sided + /// position for ever. Deployer-gated, one-shot, front-run tolerant, spends no ETH. + function initialise() external returns (uint128 liquidity) { + if (msg.sender != deployer) revert ShardErrorsV1.NotDeployer(); + if (initialised) revert ShardErrorsV1.AlreadyInitialised(); + + uint256 balance = shard.balanceOf(address(this)); + if (balance != SEED_AMOUNT) revert ShardErrorsV1.WrongShardBalance(SEED_AMOUNT, balance); + + // The whole range must sit at or below the current tick, otherwise the position is + // partly priced in ETH and the seed would demand ETH the hook does not have. + if (TickMath.getTickAtSqrtPrice(startSqrtPriceX96) < tickUpper) { + revert ShardErrorsV1.InvalidStartPrice(); + } + + initialised = true; + + try poolManager.initialize(poolKey, startSqrtPriceX96) returns (int24) { } + catch (bytes memory reason) { + // Narrow catch. A blanket `catch {}` would swallow a genuine config error and then + // seed into a pool that does not exist. + if (bytes4(reason) != Pool.PoolAlreadyInitialized.selector) { + assembly ("memory-safe") { + revert(add(reason, 0x20), mload(reason)) + } + } + // Safe to continue: `_beforeInitialize` guarantees any pre-existing pool is the + // canonical one at the canonical price. + } + + (uint128 liquidityFull, uint128 liquidityBand) = + abi.decode(poolManager.unlock(abi.encode(Action.SEED_LIQUIDITY, SEED_AMOUNT)), (uint128, uint128)); + seedLiquidity = liquidityFull; + seedLiquidityBand = liquidityBand; + liquidity = liquidityFull; + + // `getLiquidityForAmount1` rounds down ONCE PER POSITION, so a little SHARD never made + // it in. Record it — the core supply invariant is stated relative to this. + seedDust = shard.balanceOf(address(this)); + + emit Initialised(poolKey.toId(), liquidityFull, liquidityBand, seedDust); + } + + /*////////////////////////////////////////////////////////////// + UNLOCK CALLBACK + //////////////////////////////////////////////////////////////*/ + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + if (msg.sender != address(poolManager)) revert ShardErrorsV1.NotPoolManager(); + + Action action = abi.decode(rawData[:32], (Action)); + + if (action == Action.SEED_LIQUIDITY) { + (, uint256 amount1) = abi.decode(rawData, (Action, uint256)); + return _seedLiquidity(amount1); + } + if (action == Action.SWEEP) { + (, uint256 amount) = abi.decode(rawData, (Action, uint256)); + return _sweep(amount); + } + if (action == Action.BUY) { + (, uint256 maxSpend) = abi.decode(rawData, (Action, uint256)); + return _buySwap(maxSpend); + } + if (action == Action.BUY_EXACT_OUT) { + (, uint256 shardsOut, uint256 maxSpend) = abi.decode(rawData, (Action, uint256, uint256)); + return _buyExactOutSwap(shardsOut, maxSpend); + } + if (action == Action.BUY_EXACT_IN) { + (, uint256 ethIn) = abi.decode(rawData, (Action, uint256)); + return _buyExactInSwap(ethIn); + } + // Action.SELL — `shardsIn` is 1e18 for a single sale and count * 1e18 for a batch. + (, uint256 shardsIn) = abi.decode(rawData, (Action, uint256)); + return _sellSwap(shardsIn); + } + + /// @dev Seeds BOTH positions. They overlap: the band sits inside the full range, so at or + /// above `tickBand` the pool's active liquidity is the sum of the two and below it only + /// the full-range position remains. That step is the whole shape of the curve. + function _seedLiquidity(uint256) internal returns (bytes memory) { + uint128 liquidityFull = _mintPosition(tickLower, tickUpper, ShardConstantsV1.SEED_FULL_RANGE); + uint128 liquidityBand = _mintPosition(tickBand, tickUpper, ShardConstantsV1.SEED_BAND); + return abi.encode(liquidityFull, liquidityBand); + } + + /// @dev The ONLY `modifyLiquidity` call site in this contract. `liquidityDelta` is POSITIVE + /// and there is no counterpart that ever removes. See the contract-level notice. + /// + /// Both positions use salt 0: v4 keys a position on (owner, tickLower, tickUpper, salt) + /// and the tick ranges differ, so these are two distinct positions. + function _mintPosition(int24 lower, int24 upper, uint256 amount1) private returns (uint128 liquidity) { + // Single-sided currency1 liquidity: the whole range sits at/below the current tick, so + // the position is priced entirely in currency1 and needs zero currency0. + liquidity = LiquidityAmounts.getLiquidityForAmount1( + TickMath.getSqrtPriceAtTick(lower), TickMath.getSqrtPriceAtTick(upper), amount1 + ); + + (BalanceDelta delta,) = poolManager.modifyLiquidity( + poolKey, + ModifyLiquidityParams({ + tickLower: lower, + tickUpper: upper, + liquidityDelta: int256(uint256(liquidity)), // POSITIVE — always, forever + salt: bytes32(0) + }), + "" + ); + + // delta0 must be zero — no ETH is owed. delta1 is the SHARD debt. + if (delta.amount0() != 0) revert ShardErrorsV1.InvalidStartPrice(); + _settleCurrency(poolKey.currency1, uint256(uint128(-delta.amount1()))); + } + + /*////////////////////////////////////////////////////////////// + CLAIM SWEEP + //////////////////////////////////////////////////////////////*/ + + /// @dev Redeems ERC-6909 ETH claims into real ETH so _claim has funds. + /// Idempotent; safe to call anytime. + function _sweepClaims() internal { + uint256 bal = poolManager.balanceOf(address(this), CurrencyLibrary.ADDRESS_ZERO.toId()); + if (bal != 0) poolManager.unlock(abi.encode(Action.SWEEP, bal)); + } + + function _sweep(uint256 amount) internal returns (bytes memory) { + Currency native = CurrencyLibrary.ADDRESS_ZERO; + // burn creates a POSITIVE delta (a credit) which can then be taken as real ETH. + poolManager.burn(address(this), native.toId(), amount); + poolManager.take(native, address(this), amount); + return ""; + } + + /*////////////////////////////////////////////////////////////// + SETTLEMENT HELPERS + //////////////////////////////////////////////////////////////*/ + + function _settleCurrency(Currency currency, uint256 amount) internal { + if (amount == 0) return; + if (currency.isAddressZero()) { + // native: no sync required, the value is carried by the call + poolManager.settle{ value: amount }(); + } else { + poolManager.sync(currency); + if (!IERC20Minimal(Currency.unwrap(currency)).transfer(address(poolManager), amount)) { + revert ShardErrorsV1.TokenTransferFailed(); + } + poolManager.settle(); + } + } + + /*////////////////////////////////////////////////////////////// + FEE ACCOUNTING + //////////////////////////////////////////////////////////////*/ + + /// @notice Settles the outgoing holder's accrued fees before ownership moves. + function settleOnTransfer(uint256 tokenId, address from, address) external override { + if (msg.sender != address(nft)) revert ShardErrorsV1.NotNFT(); + _flushPending(); + _settle(tokenId, from); + } + + /*////////////////////////////////////////////////////////////// + SWAP + //////////////////////////////////////////////////////////////*/ + + // Task 12: _beforeSwap / _afterSwap third-party fee capture + + /// @dev ===================== THIS PATH IS FOR THIRD PARTIES ONLY ===================== + /// v4 SKIPS hook callbacks when the hook is itself the swapper (Hooks.sol:253 and + /// :293 return early on `msg.sender == address(self)`). So {buyNFT} and {sellNFT}, + /// which swap via `poolManager.unlock()`, never reach here — they charge the 1% + /// explicitly in their own bodies. The two paths are mutually exclusive by + /// construction, so nothing is ever double-charged and nothing is ever free. + /// ============================================================================== + /// + /// The fee is ALWAYS 1% of the total ETH the swap moves, and is ALWAYS inclusive. + /// ETH is currency0, so it is the "specified" currency exactly when + /// `zeroForOne == exactIn` — the same rule v4 itself uses. Which side it lands on + /// decides which callback charges it: + /// + /// zeroForOne | kind | ETH is | charged in + /// ------------|----------|--------------|------------ + /// true | exactIn | specified | beforeSwap + /// true | exactOut | unspecified | afterSwap + /// false | exactIn | unspecified | afterSwap + /// false | exactOut | specified | beforeSwap + /// + /// The rate differs by exactness, not by direction. For exactIn the known amount is + /// already the total that moves, so the fee is 1% of it. For exactOut the known + /// amount is the NET the user receives (or the pool must find), so the total is + /// `net + fee` and the fee is `net * 100 / 9900` — solving `fee = 1% * (net + fee)`. + /// Charging `net * 100 / 10000` there would be 0.990%, quietly cheaper. + /// @dev Carried fee numerators so the 1% fee is cumulative and transaction-frequency invariant: + /// a stream of tiny swaps accrues the same total fee as one aggregated swap instead of each + /// swap flooring its sub-wei share to zero. Two accumulators because exact-input fees divide + /// by BPS_DENOMINATOR and exact-output fees by (BPS_DENOMINATOR - FEE_BPS). + uint256 internal feeCarryIn; + uint256 internal feeCarryOut; + /// @dev The fee {_beforeSwap} charged, handed to {_afterSwap} so it reconstructs the exact + /// requested swap size without recomputing (which would double-consume the carry). Written + /// by {_beforeSwap} immediately before {_afterSwap} reads it within the same swap, so it is + /// pure scratch — any value left between swaps is always overwritten before the next read. + uint256 private pendingBeforeSwapFee; + + /// @dev The actual 1% fee taken on a swap, carrying the sub-wei remainder forward so nothing is + /// floored away over a stream of swaps: a run of tiny swaps accrues the same total as one + /// aggregated swap. `exactIn` selects the inclusive basis (fee is 1% of the total moved, + /// divide by BPS_DENOMINATOR) versus the exact-output gross-up (fee is net*100/9900). + function _chargeFee(uint256 gross, bool exactIn) internal returns (uint256 fee) { + uint256 denom = + exactIn ? ShardConstantsV1.BPS_DENOMINATOR : ShardConstantsV1.BPS_DENOMINATOR - ShardConstantsV1.FEE_BPS; + uint256 num = gross * ShardConstantsV1.FEE_BPS + (exactIn ? feeCarryIn : feeCarryOut); + fee = num / denom; + uint256 rem = num % denom; + if (exactIn) feeCarryIn = rem; + else feeCarryOut = rem; + } + + /// @dev Takes the ETH fee as an ERC-6909 claim rather than `poolManager.take`. + /// `take` moves REAL ETH out of the PoolManager before the swapper has settled, so on + /// a manager holding no other native liquidity the very first swap reverts. Minting a + /// claim balances the hook's delta identically without touching real balances; the + /// claims are redeemed to ETH by {_sweepClaims}, which {claim} runs first. + function _takeEthFee(uint256 fee) private { + poolManager.mint(address(this), CurrencyLibrary.ADDRESS_ZERO.toId(), fee); + _distributeFee(fee); + } + + function _guardPool(PoolKey calldata key) private view { + // Blocks a front-runner who initialised the canonical pool from swapping the price + // away before the hook seeds it. + if (!initialised) revert ShardErrorsV1.NotInitialised(); + // Without this a foreign pool routes a fee in the WRONG CURRENCY into _distribute, + // inflating accFeePerNFT against ETH the hook does not hold and permanently bricking + // _claim for real holders. + if (PoolId.unwrap(key.toId()) != PoolId.unwrap(poolKey.toId())) { + revert ShardErrorsV1.WrongPool(); + } + } + + function _beforeSwap(address, PoolKey calldata key, SwapParams calldata params, bytes calldata) + internal + virtual + override + returns (bytes4, BeforeSwapDelta, uint24) + { + _guardPool(key); + + bool exactIn = params.amountSpecified < 0; + // ETH (currency0) is the specified currency exactly when zeroForOne == exactIn. + if (params.zeroForOne != exactIn) { + return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + } + + uint256 specified = exactIn ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); + uint256 fee = _chargeFee(specified, exactIn); + // Hand the exact charged fee to _afterSwap so its partial-fill check reconstructs the + // requested size without recomputing (which would double-consume the carry). Recorded even + // when zero, so a later swap in the same transaction never reads a stale value. + pendingBeforeSwapFee = fee; + if (fee == 0) return (BaseHook.beforeSwap.selector, BeforeSwapDeltaLibrary.ZERO_DELTA, 0); + + _takeEthFee(fee); + + // POSITIVE specified delta == the hook is owed, which exactly cancels the mint above. + // v4 folds it into amountToSwap: exactIn -100 becomes -99 (pool swaps 99, hook keeps 1); + // exactOut +100 becomes +101 (pool releases 101, user gets 100, hook keeps 1). + return (BaseHook.beforeSwap.selector, toBeforeSwapDelta(SafeCast.toInt128(int256(fee)), int128(0)), 0); + } + + function _afterSwap(address, PoolKey calldata key, SwapParams calldata params, BalanceDelta delta, bytes calldata) + internal + virtual + override + returns (bytes4, int128) + { + _guardPool(key); + + // ================== ONE SWAP MAY MOVE AT MOST MAX_BATCH SHARD ================== + // {buyMany}, {buyMax}, {sellMany} and {redeemMany} have always capped a single action + // at MAX_BATCH NFTs. That cap was decorative while anyone could bypass it by swapping + // SHARD directly — through {ShardSwapRouterV1}, through Uniswap's own interface, or + // through any aggregator — and then calling {redeemMany}. Capping the SWAP makes the + // limit a property of the pool rather than of which front end was used. + // + // Measured on the SHARD leg, which is why ONE check covers all four + // direction x exactness quadrants: the fee is always taken on the ETH leg, so + // `delta.amount1()` is exactly the SHARD that moved no matter which side was + // specified. Exact-input buys in particular cannot be checked any earlier — they + // specify ETH, and the SHARD they buy is not known until the pool has run. + // + // Symmetric by decision: capping only buys would let a position be unwound in one go + // through the other direction. The accepted cost is that exiting a large position + // takes several transactions. + // + // This runs only for THIRD-PARTY swaps. v4 skips hook callbacks when the hook is the + // swapper (Hooks.sol:253/:293), so {buyNFT} and friends never reach here — their own + // MAX_BATCH checks are what bound them, and this cap does not double-bind them. + // Widened to int256 BEFORE negating. `-x` on `type(int128).min` overflows and + // reverts in 0.8, so taking the absolute value in int128 would brick the swap at + // that one input. Unreachable here — the pool can never hold more than the 10,000 + // SHARD supply, twenty orders of magnitude below that bound — but the widening is + // free and does not depend on the supply staying where it is. + int256 shardDelta = int256(delta.amount1()); + uint256 shardMoved = uint256(shardDelta < 0 ? -shardDelta : shardDelta); + uint256 maxShard = MAX_BATCH * ShardConstantsV1.SHARDS_PER_NFT; + if (shardMoved > maxShard) revert ShardErrorsV1.SwapTooLarge(shardMoved, maxShard); + // ============================================================================== + + bool exactIn = params.amountSpecified < 0; + + // ETH was the SPECIFIED currency, so beforeSwap already charged the fee — before + // execution, and therefore on the REQUESTED size. If the swap then stopped at its + // price limit and only partially filled, that fee is a far larger share of what + // actually executed: measured at 7,655 bps (76.5%) on a 1 ETH request that filled + // 0.013 ETH. The fee cannot be corrected here, because afterSwap's return value + // adjusts only the UNSPECIFIED currency and the fee must stay denominated in ETH. + // So reject the swap instead of overcharging it. Swaps whose limit never binds — + // ordinary slippage protection, which is most routing — are unaffected. + // + // Measured off the DELTA, not off the resulting price. Comparing `sqrtPriceNow` to + // the limit cannot tell a partial fill from a complete one that happens to land + // exactly ON the limit, and would reject the latter. `delta` here is the raw pool + // delta for the post-beforeSwap size, so the two are directly comparable: v4 leaves + // the specified amount unfilled ONLY when the limit binds. + if (params.zeroForOne == exactIn) { + uint256 specified = exactIn ? uint256(-params.amountSpecified) : uint256(params.amountSpecified); + // What beforeSwap actually handed the pool, after taking its cut out of the specified + // side: exactIn swaps less, exactOut asks for more. Read the exact fee beforeSwap charged + // (via transient storage) rather than recomputing, since the carry has since moved on. + uint256 hookFee = pendingBeforeSwapFee; + uint256 requested = exactIn ? specified - hookFee : specified + hookFee; + + int128 ethDelta = delta.amount0(); + uint256 filled = ethDelta < 0 ? uint256(uint128(-ethDelta)) : uint256(uint128(ethDelta)); + if (filled != requested) revert ShardErrorsV1.PartialFillNotSupported(); + return (BaseHook.afterSwap.selector, int128(0)); + } + + // ETH is unspecified, so its amount is whatever the pool computed: delta.amount0(). + int128 eth0 = delta.amount0(); + uint256 gross = eth0 < 0 ? uint256(uint128(-eth0)) : uint256(uint128(eth0)); + uint256 fee = _chargeFee(gross, exactIn); + if (fee == 0) return (BaseHook.afterSwap.selector, int128(0)); + + _takeEthFee(fee); + + // Applies to the UNSPECIFIED currency; positive again means the hook is owed. + return (BaseHook.afterSwap.selector, SafeCast.toInt128(int256(fee))); + } + + // Task 13: buyNFT / sellNFT / redeem / claim + explicit fee on hook-initiated swaps + + /*////////////////////////////////////////////////////////////// + MARKET + //////////////////////////////////////////////////////////////*/ + + /// @dev ================== WHY THE FEE IS CHARGED TWICE OVER ================== + /// v4 skips hook callbacks when the hook is the swapper (Hooks.sol:253/:293). + /// {buyNFT} and {sellNFT} swap via `poolManager.unlock()`, so their swaps NEVER + /// reach {_beforeSwap}/{_afterSwap} — they must charge the 1% themselves, right + /// here. Third-party router swaps take the callback path instead. The two are + /// mutually exclusive, so nothing is double-charged and nothing is free. + /// Deleting either half silently guts the economics while tests stay green. + /// ========================================================================= + + /// @notice ETH in, one NFT out. Excess ETH is refunded. + /// @param maxEthIn Largest total (curve cost + 1% fee) you will accept. Sandwich bound. + /// @param deadline Unix timestamp after which this reverts. + function buyNFT(uint256 maxEthIn, uint256 deadline) external payable nonReentrant returns (uint256 tokenId) { + if (block.timestamp > deadline) revert ShardErrorsV1.Expired(); + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + + // ETH already held on behalf of fee claimants. A buy must never dip into it. + uint256 holderEth = address(this).balance - msg.value; + + uint256 ethSpent = abi.decode(poolManager.unlock(abi.encode(Action.BUY, msg.value)), (uint256)); + + (uint256 fee, uint256 total) = _chargeBuy(ethSpent, maxEthIn); + + tokenId = nft.acquire(msg.sender, _nextSeed(msg.sender)); + _acquireAccounting(tokenId, msg.sender); + + // Refund AFTER unlock has returned — forwarding inside the callback would make a + // contract buyer's re-entry hit AlreadyUnlocked and self-DoS. + _sendEth(msg.sender, msg.value - total); + + _verifyFeeHeld(holderEth, fee); + } + + /// @notice The largest number of NFTs one transaction may mint. Each acquire is a fresh + /// storage write, so an unbounded batch would exceed the block gas limit. + uint256 public constant MAX_BATCH = 50; + + /// @notice ETH in, `count` NFTs out, in ONE swap. Excess ETH is refunded. + /// @dev v4 skips this hook's own beforeSwap/afterSwap (Hooks.sol:253/:293) because the + /// hook is the swapper, so the 1% is charged EXPLICITLY here. Removing it makes + /// bulk buying free while every test stays green. + function buyMany(uint256 count, uint256 maxEthIn, uint256 deadline) + external + payable + nonReentrant + returns (uint256[] memory tokenIds) + { + if (block.timestamp > deadline) revert ShardErrorsV1.Expired(); + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + if (count == 0 || count > MAX_BATCH) revert ShardErrorsV1.BatchTooLarge(count, MAX_BATCH); + + uint256 holderEth = address(this).balance - msg.value; + + uint256 ethSpent = abi.decode( + poolManager.unlock(abi.encode(Action.BUY_EXACT_OUT, count * ShardConstantsV1.SHARDS_PER_NFT, msg.value)), + (uint256) + ); + + (uint256 fee, uint256 total) = _chargeBuy(ethSpent, maxEthIn); + + tokenIds = new uint256[](count); + for (uint256 i; i < count; ++i) { + uint256 id = nft.acquire(msg.sender, _nextSeed(msg.sender)); + _acquireAccounting(id, msg.sender); + tokenIds[i] = id; + } + + _sendEth(msg.sender, msg.value - total); // after unlock returns, never inside it + + _verifyFeeHeld(holderEth, fee); + } + + /// @notice Spend as much of `msg.value` as the curve takes and mint as many whole NFTs as + /// that buys. Any leftover fractional SHARD is transferred to the caller, so no + /// value is stranded and the backing invariant stays exact. + /// @dev Reverts above `MAX_BATCH` rather than clamping, so no path can ever hand out + /// more than the cap OR a large SHARD balance in its place. Below the cap the + /// leftover is always under one whole SHARD, by construction. + function buyMax(uint256 minCount, uint256 deadline) + external + payable + nonReentrant + returns (uint256[] memory tokenIds) + { + if (block.timestamp > deadline) revert ShardErrorsV1.Expired(); + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + if (msg.value == 0) revert ShardErrorsV1.ZeroAmount(); + + uint256 holderEth = address(this).balance - msg.value; + + // Size the swap against the WORST-CASE fee, so the reserve is always sufficient. This is a + // pure ceiling used only to size the swap; it must NOT consume the carry, so it does not go + // through {_chargeFee}. + uint256 maxFee = (msg.value * ShardConstantsV1.FEE_BPS) / ShardConstantsV1.BPS_DENOMINATOR; + uint256 ethForSwap = msg.value - maxFee; + + (uint256 shardsOut, uint256 ethConsumed) = + abi.decode(poolManager.unlock(abi.encode(Action.BUY_EXACT_IN, ethForSwap)), (uint256, uint256)); + + // Then charge on what the curve ACTUALLY consumed, not on what was sent. An exact-input + // swap can stop early at the price limit, and billing the reserve in full would charge + // 1% of the refunded remainder too — 1 ETH of fee on a 1 ETH purchase if 100 ETH was + // sent into a pool whose limit binds immediately. That is precisely the + // fee-out-of-proportion-to-what-executed failure {_afterSwap} refuses to inflict on + // third parties; it must not be inflicted here either. + // + // `ethConsumed` is the NET the curve took, so the fee is inclusive on top of it, the + // same basis {buyNFT} and {buyMany} use. Clamped to `maxFee` because at full + // consumption the two bases agree only up to rounding: with + // `msg.value = 100 * maxFee + 99` the inclusive form lands one wei higher, which would + // overspend `msg.value` by that wei. + uint256 fee = _chargeFee(ethConsumed, false); + // The inclusive basis can land above the exact-input reserve at full consumption; clamp so the + // buyer is never overspent, and return the uncollected wei to the carry so the entitlement is + // preserved and collected on a later exact-output swap — not shed once per qualifying call. + if (fee > maxFee) { + // `fee - maxFee` is an exact whole-wei count and its sub-wei remainder is already held in + // feeCarryOut, so multiplying by the constant denominator converts those wei back into + // carry-numerator units with no precision loss. Slither flags this as divide-before- + // multiply; it is an intentional, exact unit conversion, not a rounding hazard. + unchecked { + feeCarryOut += (fee - maxFee) * (ShardConstantsV1.BPS_DENOMINATOR - ShardConstantsV1.FEE_BPS); + } + fee = maxFee; + } + + _distributeFee(fee); + + // Refund whatever neither the curve nor the fee took. Without this the remainder is + // stranded in the hook permanently — never distributed as a fee, never sweepable, on a + // contract that cannot be upgraded. + uint256 unspent = msg.value - ethConsumed - fee; + + uint256 count = shardsOut / ShardConstantsV1.SHARDS_PER_NFT; + // HARD cap, not a silent clamp. Clamping used to mint MAX_BATCH and hand the + // rest back as ERC-20, so 0.01 ETH could return 50 NFTs plus 845 SHARD that + // the buyer then had to redeem fifty at a time. Somebody asking for art + // should never be given a pile of tokens instead. Send less and try again. + if (count > MAX_BATCH) revert ShardErrorsV1.BatchTooLarge(count, MAX_BATCH); + if (count < minCount) revert ShardErrorsV1.InsufficientOutput(minCount, count); + + tokenIds = new uint256[](count); + for (uint256 i; i < count; ++i) { + uint256 id = nft.acquire(msg.sender, _nextSeed(msg.sender)); + _acquireAccounting(id, msg.sender); + tokenIds[i] = id; + } + + // Whatever did not become a whole NFT goes to the caller as ERC-20. Keeping it would + // break `shard.balanceOf(hook) == circulatingSupply * 1e18 + seedDust`. + uint256 leftover = shardsOut - count * ShardConstantsV1.SHARDS_PER_NFT; + if (leftover != 0 && !shard.transfer(msg.sender, leftover)) revert ShardErrorsV1.TokenTransferFailed(); + + _sendEth(msg.sender, unspent); // after unlock returns, never inside it + + _verifyFeeHeld(holderEth, fee); + } + + /// @notice One NFT in, ETH out. The artwork is destroyed. + /// @param minEthOut Smallest payout you will accept, after the 1% fee. Sandwich bound. + function sellNFT(uint256 tokenId, uint256 minEthOut, uint256 deadline) + external + nonReentrant + returns (uint256 payout) + { + if (block.timestamp > deadline) revert ShardErrorsV1.Expired(); + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + + // Ordering is economically meaningful and must stay this way: settle the seller out + // of the earning set BEFORE the exit fee is distributed, so they do not earn from + // their own fee — and if this was the last NFT, the fee correctly escrows. + // + // _releaseAccounting trusts its caller. `nft.release` is what enforces ownership: + // OZ's _transfer reverts ERC721IncorrectOwner if msg.sender is not the holder, which + // rolls back the accounting above. That check is load-bearing, not incidental. + uint256[] memory tokenIds = new uint256[](1); + tokenIds[0] = tokenId; + payout = _sellBatch(tokenIds, minEthOut); + } + + /// @notice Several NFTs in, ETH out, in ONE swap. Every artwork is destroyed. + /// @param tokenIds The ids to sell. Must all be held by the caller; duplicates revert. + /// @param minEthOut Smallest TOTAL payout you will accept, after the 1% fee. + /// @dev v4 skips this hook's own beforeSwap/afterSwap (Hooks.sol:253/:293) because the + /// hook is the swapper, so the 1% is charged EXPLICITLY here — the sell-side twin of + /// the {buyMany} note. Removing it makes bulk exits free while every test stays green. + function sellMany(uint256[] calldata tokenIds, uint256 minEthOut, uint256 deadline) + external + nonReentrant + returns (uint256 payout) + { + if (block.timestamp > deadline) revert ShardErrorsV1.Expired(); + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + + payout = _sellBatch(tokenIds, minEthOut); + } + + /// @dev The whole sell side, shared by {sellNFT} and {sellMany}. Callers do the deadline + /// and initialisation checks; this does the batch bound, the release loop, the single + /// swap and the single fee. + function _sellBatch(uint256[] memory tokenIds, uint256 minEthOut) private returns (uint256 payout) { + uint256 count = tokenIds.length; + if (count == 0 || count > MAX_BATCH) revert ShardErrorsV1.BatchTooLarge(count, MAX_BATCH); + + // Ordering is economically meaningful and must stay this way: settle EVERY seller out + // of the earning set BEFORE the exit fee is distributed, so they do not earn from + // their own fee — and if these were the last NFTs, the fee correctly escrows. That is + // why the whole loop runs before `_distribute` below, not once per id. + // + // _releaseAccounting trusts its caller. `nft.release` is what enforces ownership: + // OZ's _transfer reverts ERC721IncorrectOwner if msg.sender is not the holder, which + // rolls back the accounting above. That check is load-bearing, not incidental — and + // it is also what rejects a duplicated id, whose second copy the archive now owns. + for (uint256 i; i < count; ++i) { + _releaseAccounting(tokenIds[i], msg.sender); + nft.release(msg.sender, tokenIds[i]); + } + + uint256 ethOut = + abi.decode(poolManager.unlock(abi.encode(Action.SELL, count * ShardConstantsV1.SHARDS_PER_NFT)), (uint256)); + + // Inclusive: the pool released `ethOut` in total, so the fee is 1% of that. + uint256 fee = _chargeFee(ethOut, true); + payout = ethOut - fee; + if (payout < minEthOut) revert ShardErrorsV1.SlippageExceeded(minEthOut, payout); + + _distributeFee(fee); + _sendEth(msg.sender, payout); // after unlock, as above + } + + /// @notice 1.0 SHARD in, one NFT out. No extra fee — that shard already paid 1% when it + /// was swapped. There is deliberately NO reverse path (NFT to SHARD): the + /// asymmetry is what makes a free art reroll impossible. + function redeem() external nonReentrant returns (uint256 tokenId) { + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + + tokenId = _redeemBatch(1)[0]; + } + + /// @notice `count` SHARD in, `count` NFTs out. Still no extra fee, for the same reason. + /// @dev The arrival path for anyone who bought SHARD on a third-party router: those shards + /// paid their 1% in {_beforeSwap}/{_afterSwap} on the way out, so charging again here + /// would make swap-then-redeem strictly worse than {buyMany}. + function redeemMany(uint256 count) external nonReentrant returns (uint256[] memory tokenIds) { + if (!initialised || address(nft) == address(0)) revert ShardErrorsV1.NotInitialised(); + + tokenIds = _redeemBatch(count); + } + + /// @dev Shared by {redeem} and {redeemMany}. One `transferFrom` for the whole batch, so a + /// caller needs a single allowance covering `count * 1e18`. + function _redeemBatch(uint256 count) private returns (uint256[] memory tokenIds) { + if (count == 0 || count > MAX_BATCH) revert ShardErrorsV1.BatchTooLarge(count, MAX_BATCH); + + if (!shard.transferFrom(msg.sender, address(this), count * ShardConstantsV1.SHARDS_PER_NFT)) { + revert ShardErrorsV1.TokenTransferFailed(); + } + + tokenIds = new uint256[](count); + for (uint256 i; i < count; ++i) { + uint256 id = nft.acquire(msg.sender, _nextSeed(msg.sender)); + _acquireAccounting(id, msg.sender); + tokenIds[i] = id; + } + } + + /// @notice Pay ETH into the fee pool, to be shared out to NFT holders. + /// + /// @dev The entry point for outside revenue: a launchpad, a royalty router, or + /// anything else that wants to pay this collection's holders. Permissionless + /// on purpose, because the only thing a caller can do is give away ETH. + /// + /// Use this rather than sending ETH to the contract directly. `receive` has + /// to stay silent, because v4's `take` delivers native ETH that way during + /// every swap, so a plain transfer lands in the balance and is NEVER + /// distributed, never claimable, and unrecoverable on a contract that cannot + /// be upgraded. {ShardFeeForwarderV1} exists for senders that can only transfer. + /// + /// Distribution is exactly the one used for trading fees, so a donation + /// escrows when nothing is circulating and is released to the first buyer. + function donate() external payable nonReentrant { + if (msg.value == 0) revert ShardErrorsV1.ZeroAmount(); + _distribute(msg.value); + emit Donated(msg.sender, msg.value); + } + + /// @notice Settle the given tokens and withdraw everything owed to the caller. + /// @param tokenIds Tokens to settle first. Pass the ones you hold. May be empty, which + /// withdraws only what is ALREADY settled (a transfer or sale settles for you). + /// + /// @dev Why this takes an argument at all: fees accrue into `accFeePerNFT`, but a + /// holder's `claimable` balance is only materialised by {_settle}, which fires on + /// transfer and on sale. Someone who merely HOLDS has accrued value that has never + /// been written to their balance — so a no-argument claim would revert + /// NothingToClaim while they were genuinely owed money. The hook does not track + /// which ids an address holds (that would cost gas on every transfer), so the + /// caller supplies them. + /// + /// Settlement is deliberately permissionless: each token settles to + /// `nft.ownerOf(id)`, its rightful owner, never to the caller. So passing someone + /// else's id is harmless — it credits them, and only the caller's own balance is + /// ever paid out. + /// + /// NOTE: this is NOT a keeper hook. If the caller ends up owed nothing, `_claim` + /// reverts `NothingToClaim` and the whole transaction — including the settlement + /// above — rolls back. Settling on someone else's behalf therefore only persists + /// when the caller is also claiming something of their own. Holders must settle + /// their own tokens. + function claim(uint256[] calldata tokenIds) external nonReentrant returns (uint256) { + // Third-party fees arrive as ERC-6909 claims; realise them before paying out or + // _claim's transfer reverts once claims exceed whatever real ETH happens to be held. + _sweepClaims(); + _flushPending(); + + for (uint256 i; i < tokenIds.length; ++i) { + uint256 id = tokenIds[i]; + address owner = IERC721(address(nft)).ownerOf(id); + // Pool-held ids earn nothing; settling one would credit the archive, which has + // no way to claim and would strand the ETH permanently. + if (owner != address(nft)) _settle(id, owner); + } + + return _claim(msg.sender); + } + + /// @notice Pays out the builder's accrued 0.10% share. Beneficiary-only. + function claimBuilderFees() external nonReentrant returns (uint256 amount) { + if (msg.sender != builderFeeRecipient) revert ShardErrorsV1.NotBuilder(); + _sweepClaims(); + amount = builderFeesAccrued; + if (amount == 0) revert ShardErrorsV1.NothingToClaim(); + builderFeesAccrued = 0; // effects before interaction + _sendEth(msg.sender, amount); + emit BuilderFeesClaimed(msg.sender, amount); + } + + /// @notice Pays out Programmable's accrued 0.10% share. Beneficiary-only. + function claimLauncherFees() external nonReentrant returns (uint256 amount) { + if (msg.sender != launcherFeeRecipient) revert ShardErrorsV1.NotLauncher(); + _sweepClaims(); + amount = launcherFeesAccrued; + if (amount == 0) revert ShardErrorsV1.NothingToClaim(); + launcherFeesAccrued = 0; // effects before interaction + _sendEth(msg.sender, amount); + emit LauncherFeesClaimed(msg.sender, amount); + } + + /*////////////////////////////////////////////////////////////// + MARKET SWAPS + //////////////////////////////////////////////////////////////*/ + + function _buySwap(uint256 maxSpend) internal returns (bytes memory) { + BalanceDelta delta = poolManager.swap( + poolKey, + SwapParams({ + zeroForOne: true, + amountSpecified: int256(ShardConstantsV1.SHARDS_PER_NFT), // exact output: 1 SHARD + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + "" + ); + + uint256 ethOwed = uint256(uint128(-delta.amount0())); + if (ethOwed > maxSpend) revert ShardErrorsV1.InsufficientPayment(ethOwed, maxSpend); + + // {buyNFT} mints one NFT on the strength of this swap, so a partial fill would mint an + // NFT the hook has no SHARD to back — breaking + // `balanceOf(hook) == circulatingSupply * 1e18 + seedDust` on a contract that cannot be + // upgraded. The twin of the guards in {_sellSwap} and {_buyExactOutSwap}: unreachable at + // the shipped tick range, asserted anyway so the identity belongs to THIS contract + // rather than to whatever ticks the deployment happened to pass. + uint256 shardsIn = uint256(uint128(delta.amount1())); + if (shardsIn != ShardConstantsV1.SHARDS_PER_NFT) revert ShardErrorsV1.PartialFillNotSupported(); + + _settleCurrency(poolKey.currency0, ethOwed); + poolManager.take(poolKey.currency1, address(this), shardsIn); + + return abi.encode(ethOwed); + } + + function _sellSwap(uint256 shardsIn) internal returns (bytes memory) { + BalanceDelta delta = poolManager.swap( + poolKey, + SwapParams({ + zeroForOne: false, + amountSpecified: -int256(shardsIn), // exact input: the whole batch at once + sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }), + "" + ); + + // The caller has already released `shardsIn / 1e18` NFTs on the strength of this swap, + // so a partial fill would leave the hook holding shards for NFTs no longer in + // circulation — breaking `balanceOf(hook) == circulatingSupply * 1e18 + seedDust` on a + // contract that cannot be upgraded. Unreachable at the shipped tick range; asserted + // anyway so the identity belongs to THIS contract, not to the deployment's ticks. + uint256 shardsSpent = uint256(uint128(-delta.amount1())); + if (shardsSpent != shardsIn) revert ShardErrorsV1.PartialFillNotSupported(); + + _settleCurrency(poolKey.currency1, shardsSpent); + + uint256 ethOut = uint256(uint128(delta.amount0())); + poolManager.take(poolKey.currency0, address(this), ethOut); + + return abi.encode(ethOut); + } + + /// @dev Exact-output: buy exactly `shardsOut` SHARD, spending at most `maxSpend` ETH. + function _buyExactOutSwap(uint256 shardsOut, uint256 maxSpend) internal returns (bytes memory) { + BalanceDelta delta = poolManager.swap( + poolKey, + SwapParams({ + zeroForOne: true, amountSpecified: int256(shardsOut), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + "" + ); + uint256 ethOwed = uint256(uint128(-delta.amount0())); + if (ethOwed > maxSpend) revert ShardErrorsV1.InsufficientPayment(ethOwed, maxSpend); + + // The caller mints `count` NFTs on the strength of this swap, so a partial fill would + // mint NFTs the hook has no SHARD to back. Unreachable at the shipped tick range, but + // the hook is immutable — make the backing identity a property of THIS contract rather + // than of whatever ticks the deployment happened to pass. + uint256 shardsIn = uint256(uint128(delta.amount1())); + if (shardsIn != shardsOut) revert ShardErrorsV1.PartialFillNotSupported(); + + _settleCurrency(poolKey.currency0, ethOwed); + poolManager.take(poolKey.currency1, address(this), shardsIn); + return abi.encode(ethOwed); + } + + /// @dev Exact-input: spend exactly `ethIn` ETH, take whatever SHARD it buys. + function _buyExactInSwap(uint256 ethIn) internal returns (bytes memory) { + BalanceDelta delta = poolManager.swap( + poolKey, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(ethIn), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }), + "" + ); + // Report what the pool ACTUALLY consumed. An exact-input swap can stop early at the + // price limit, and the unconsumed remainder would otherwise sit in the hook forever — + // never distributed as a fee, never sweepable, on an immutable contract. + uint256 ethConsumed = uint256(uint128(-delta.amount0())); + _settleCurrency(poolKey.currency0, ethConsumed); + uint256 shardsOut = uint256(uint128(delta.amount1())); + poolManager.take(poolKey.currency1, address(this), shardsOut); + return abi.encode(shardsOut, ethConsumed); + } + + /*////////////////////////////////////////////////////////////// + WIRING + //////////////////////////////////////////////////////////////*/ + + /// @notice One-shot, deployer-only. A CREATE2 cycle rules out a constructor argument + /// (each address would depend on the other's constructor args), so this gate is + /// what closes the window in which an attacker could bind a malicious NFT and + /// drain the accumulator via settleOnTransfer across all 10,000 ids. + function setNFT(IShardNFTV1 nft_) external { + if (msg.sender != deployer) revert ShardErrorsV1.NotDeployer(); + if (address(nft) != address(0)) revert ShardErrorsV1.AlreadyInitialised(); + if (address(nft_) == address(0)) revert ShardErrorsV1.ZeroAddress(); + bool valid; + bytes4 selector = IShardNFTV1.hook.selector; + assembly ("memory-safe") { + let ptr := mload(0x40) + mstore(ptr, selector) + let ok := staticcall(10000, nft_, ptr, 4, ptr, 32) + valid := and(and(ok, eq(returndatasize(), 32)), eq(mload(ptr), address())) + } + if (!valid) { + revert ShardErrorsV1.WrongNFT(address(nft_), address(this)); + } + nft = nft_; + } + + /*////////////////////////////////////////////////////////////// + ENTROPY + //////////////////////////////////////////////////////////////*/ + + /// @dev This is not secure randomness. Block producers and transaction ordering can + /// influence art seeds, and callers can observe all inputs before inclusion. No + /// security property relies on unpredictability: traits are flat, and a reroll only + /// affects the roller's own draw. The nonce and recipient provide per-acquisition + /// variation while the previous-block hash and timestamp add Ethereum block context. + function _nextSeed(address to) private returns (uint256) { + unchecked { + _seedNonce++; + } + return uint256(keccak256(abi.encodePacked(blockhash(block.number - 1), block.timestamp, to, _seedNonce))); + } + + uint256 private _seedNonce; + + function _sendEth(address to, uint256 amount) private { + if (amount == 0) return; + (bool ok,) = to.call{ value: amount }(""); + if (!ok) revert ShardErrorsV1.EthTransferFailed(); + } + + /// @dev Post-condition shared by every buy path: after the refund, the hook must still hold the + /// ETH it owes fee claimants (`holderEth` from before the buy plus this buy's `fee`). A buy + /// that dipped into claimant ETH would fail here. + function _verifyFeeHeld(uint256 holderEth, uint256 fee) private view { + uint256 expected = holderEth + fee; + if (address(this).balance < expected) revert ShardErrorsV1.FeeEthMissing(expected, address(this).balance); + } + + /// @dev Shared buy head for {buyNFT} and {buyMany}: charge the inclusive 1% fee (carrying its + /// remainder), bound the total against payment and slippage, then distribute the fee. + /// `ethSpent` is the NET curve cost, so the fee is 1% of the total — `ethSpent * 100 / 10_000` + /// would be 0.990% and quietly cheaper than swap-then-redeem. + function _chargeBuy(uint256 ethSpent, uint256 maxEthIn) private returns (uint256 fee, uint256 total) { + fee = _chargeFee(ethSpent, false); + total = ethSpent + fee; + if (total > msg.value) revert ShardErrorsV1.InsufficientPayment(total, msg.value); + if (total > maxEthIn) revert ShardErrorsV1.SlippageExceeded(maxEthIn, total); + _distributeFee(fee); + } +} diff --git a/src/ShardLaunchFactoryV1.sol b/src/ShardLaunchFactoryV1.sol new file mode 100644 index 00000000..d5a6da16 --- /dev/null +++ b/src/ShardLaunchFactoryV1.sol @@ -0,0 +1,281 @@ +// 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 { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; + +import { GeometricRendererV1 } from "./GeometricRendererV1.sol"; +import { ShardConstantsV1 } from "./ShardConstantsV1.sol"; +import { ShardErrorsV1 } from "./ShardErrorsV1.sol"; +import { ShardHookV1 } from "./ShardHookV1.sol"; +import { ShardNFTV1 } from "./ShardNFTV1.sol"; +import { ShardTokenV1 } from "./ShardTokenV1.sol"; +import { IShardNFTV1 } from "./interfaces/IShardNFTV1.sol"; + +/// @title ShardLaunchFactoryV1 +/// @notice Atomically deploys and initialises a Shards collection from hash-pinned hook code. +contract ShardLaunchFactoryV1 { + struct LaunchParams { + int24 tickLower; + int24 tickBand; + int24 tickUpper; + uint160 startSqrtPriceX96; + address builderFeeRecipient; + } + + struct ConfigurationData { + uint256 chainId; + address factory; + address poolManager; + address renderer; + address launcherFeeRecipient; + address builderFeeRecipient; + address shard; + address hook; + address nft; + int24 tickLower; + int24 tickBand; + int24 tickUpper; + uint160 startSqrtPriceX96; + bytes32 tokenSalt; + bytes32 effectiveTokenSalt; + bytes32 hookSalt; + bytes32 hookCreationCodeHash; + } + + struct LaunchAddresses { + address hook; + address shard; + address nft; + } + + error ZeroHookCodeHash(); + error WrongHookCode(bytes32 expected, bytes32 actual); + error InvalidHookFlags(address predictedHook, uint160 expected, uint160 actual); + error AddressOccupied(address predicted); + error TokenTransferFailed(); + error FactoryRetainedShard(uint256 balance); + + 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 + ); + + /// @notice Programmable's fixed 0.10% ("launcher") recipient. Bound immutably to the canonical + /// Programmable address for every launch — the factory cannot route it anywhere else. + address public constant launcherFeeRecipient = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + + IPoolManager public immutable poolManager; + GeometricRendererV1 public immutable renderer; + bytes32 public immutable hookCreationCodeHash; + + mapping(address hook => bytes32 configurationHash) public configurationHashOf; + + event ShardLaunched( + address indexed hook, + address indexed shard, + address indexed nft, + bytes32 tokenSalt, + bytes32 hookSalt, + address builderFeeRecipient, + address renderer, + bytes32 configurationHash + ); + + constructor(IPoolManager poolManager_, bytes32 hookCreationCodeHash_) { + if (address(poolManager_) == address(0)) revert ShardErrorsV1.ZeroAddress(); + if (hookCreationCodeHash_ == bytes32(0)) revert ZeroHookCodeHash(); + poolManager = poolManager_; + hookCreationCodeHash = hookCreationCodeHash_; + renderer = new GeometricRendererV1(); + } + + function effectiveTokenSalt(bytes32 tokenSalt, bytes32 hookSalt, LaunchParams calldata params) + public + pure + returns (bytes32) + { + return keccak256( + abi.encode( + tokenSalt, + hookSalt, + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + params.builderFeeRecipient + ) + ); + } + + function predictToken(bytes32 tokenSalt, bytes32 hookSalt, LaunchParams calldata params) + public + view + returns (address) + { + return Create2.computeAddress( + effectiveTokenSalt(tokenSalt, hookSalt, params), keccak256(type(ShardTokenV1).creationCode) + ); + } + + function hookInitCode(bytes calldata hookCreationCode, address shard, LaunchParams calldata params) + public + view + returns (bytes memory) + { + return bytes.concat( + hookCreationCode, + abi.encode( + poolManager, + ShardTokenV1(shard), + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + address(this), + launcherFeeRecipient, + params.builderFeeRecipient + ) + ); + } + + function hookInitCodeHash(bytes calldata hookCreationCode, address shard, LaunchParams calldata params) + public + view + returns (bytes32) + { + return keccak256(hookInitCode(hookCreationCode, shard, params)); + } + + function predictHook(bytes32 hookSalt, bytes calldata hookCreationCode, address shard, LaunchParams calldata params) + public + view + returns (address) + { + return Create2.computeAddress(hookSalt, hookInitCodeHash(hookCreationCode, shard, params)); + } + + function predictNFT(address hook) public view returns (address) { + return Create2.computeAddress(_nftSalt(hook), keccak256(_nftInitCode(hook))); + } + + function launch(bytes32 tokenSalt, bytes32 hookSalt, bytes calldata hookCreationCode, LaunchParams calldata params) + external + returns (address hook, address shard, address nft) + { + _validateHookCode(hookCreationCode); + _validateParams(params); + LaunchAddresses memory deployed = _deploy(tokenSalt, hookSalt, hookCreationCode, params); + bytes32 configurationHash = + computeConfigurationHash(deployed.hook, deployed.shard, deployed.nft, tokenSalt, hookSalt, params); + configurationHashOf[deployed.hook] = configurationHash; + emit ShardLaunched( + deployed.hook, + deployed.shard, + deployed.nft, + tokenSalt, + hookSalt, + params.builderFeeRecipient, + address(renderer), + configurationHash + ); + return (deployed.hook, deployed.shard, deployed.nft); + } + + function _deploy(bytes32 tokenSalt, bytes32 hookSalt, bytes calldata hookCreationCode, LaunchParams calldata params) + private + returns (LaunchAddresses memory deployed) + { + deployed.shard = predictToken(tokenSalt, hookSalt, params); + deployed.hook = predictHook(hookSalt, hookCreationCode, deployed.shard, params); + deployed.nft = predictNFT(deployed.hook); + uint160 actualFlags = uint160(deployed.hook) & ALL_HOOK_MASK; + if (actualFlags != REQUIRED_HOOK_FLAGS) { + revert InvalidHookFlags(deployed.hook, REQUIRED_HOOK_FLAGS, actualFlags); + } + if (deployed.shard.code.length != 0) revert AddressOccupied(deployed.shard); + if (deployed.hook.code.length != 0) revert AddressOccupied(deployed.hook); + if (deployed.nft.code.length != 0) revert AddressOccupied(deployed.nft); + + deployed.shard = + Create2.deploy(0, effectiveTokenSalt(tokenSalt, hookSalt, params), type(ShardTokenV1).creationCode); + bytes memory initCode = hookInitCode(hookCreationCode, deployed.shard, params); + deployed.hook = Create2.deploy(0, hookSalt, initCode); + deployed.nft = Create2.deploy(0, _nftSalt(deployed.hook), _nftInitCode(deployed.hook)); + + ShardHookV1 deployedHook = ShardHookV1(payable(deployed.hook)); + deployedHook.setNFT(IShardNFTV1(deployed.nft)); + ShardTokenV1 deployedShard = ShardTokenV1(deployed.shard); + if (!deployedShard.transfer(deployed.hook, deployedShard.totalSupply())) revert TokenTransferFailed(); + deployedHook.initialise(); + + uint256 retained = deployedShard.balanceOf(address(this)); + if (retained != 0) revert FactoryRetainedShard(retained); + } + + function _validateHookCode(bytes calldata hookCreationCode) private view { + bytes32 actualHookCodeHash = keccak256(hookCreationCode); + if (actualHookCodeHash != hookCreationCodeHash) { + revert WrongHookCode(hookCreationCodeHash, actualHookCodeHash); + } + } + + function _nftSalt(address hook) private pure returns (bytes32) { + return keccak256(abi.encode(hook)); + } + + function _nftInitCode(address hook) private view returns (bytes memory) { + return bytes.concat(type(ShardNFTV1).creationCode, abi.encode(hook, address(renderer))); + } + + function computeConfigurationHash( + address hook, + address shard, + address nft, + bytes32 tokenSalt, + bytes32 hookSalt, + LaunchParams calldata params + ) public view returns (bytes32) { + ConfigurationData memory data; + data.chainId = block.chainid; + data.factory = address(this); + data.poolManager = address(poolManager); + data.renderer = address(renderer); + data.launcherFeeRecipient = launcherFeeRecipient; + data.builderFeeRecipient = params.builderFeeRecipient; + data.shard = shard; + data.hook = hook; + data.nft = nft; + data.tickLower = params.tickLower; + data.tickBand = params.tickBand; + data.tickUpper = params.tickUpper; + data.startSqrtPriceX96 = params.startSqrtPriceX96; + data.tokenSalt = tokenSalt; + data.effectiveTokenSalt = effectiveTokenSalt(tokenSalt, hookSalt, params); + data.hookSalt = hookSalt; + data.hookCreationCodeHash = hookCreationCodeHash; + return keccak256(abi.encode(data)); + } + + function _validateParams(LaunchParams calldata params) private pure { + if (params.builderFeeRecipient == address(0)) revert ShardErrorsV1.ZeroAddress(); + int24 spacing = ShardConstantsV1.TICK_SPACING; + if ( + params.tickLower < TickMath.minUsableTick(spacing) || params.tickUpper > TickMath.maxUsableTick(spacing) + || params.tickLower >= params.tickBand || params.tickBand >= params.tickUpper + || params.tickLower % spacing != 0 || params.tickBand % spacing != 0 || params.tickUpper % spacing != 0 + ) { + revert ShardErrorsV1.InvalidTickRange(); + } + uint160 start = params.startSqrtPriceX96; + if ( + start < TickMath.MIN_SQRT_PRICE || start >= TickMath.MAX_SQRT_PRICE + || TickMath.getTickAtSqrtPrice(start) < params.tickUpper + ) { + revert ShardErrorsV1.InvalidStartPrice(); + } + } +} diff --git a/src/ShardNFTV1.sol b/src/ShardNFTV1.sol new file mode 100644 index 00000000..3bd16b10 --- /dev/null +++ b/src/ShardNFTV1.sol @@ -0,0 +1,253 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { ERC721 } from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import { Base64 } from "solady/utils/Base64.sol"; +import { LibString } from "solady/utils/LibString.sol"; + +import { IShardNFTV1 } from "./interfaces/IShardNFTV1.sol"; +import { IShardHookV1 } from "./interfaces/IShardHookV1.sol"; +import { IShardRendererV1 } from "./interfaces/IShardRendererV1.sol"; +import { ShardErrorsV1 } from "./ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "./ShardConstantsV1.sol"; + +/// @title ShardNFTV1 +/// @notice A fixed 10,000-piece collection market-made by a Uniswap v4 hook. +/// Tokens live either with users or in this contract's own "archive" +/// (the pool inventory). Art is regenerated on every acquisition from +/// the archive and destroyed on the way back in — the art exists only +/// while you hold it. +contract ShardNFTV1 is ERC721, IShardNFTV1 { + using LibString for uint256; + + uint256 public constant override MAX_SUPPLY = ShardConstantsV1.MAX_NFTS; + + address public immutable override hook; + IShardRendererV1 public immutable renderer; + + /// @dev Bit index == tokenId - 1. Token IDs are 1..10_000, so bit indices + /// are 0..9_999 => words 0..39 (ceil(10000/256) == 40). A set bit + /// means "in the archive" (available to be handed out). + /// Word 39 covers bit indices 9_984..10_239; indices 10_000..10_239 + /// correspond to non-existent token IDs 10_001..10_240 and are left + /// permanently set. That is deliberate: it makes `_advanceLowest` + /// terminate at 10_001 rather than scanning forever, and the + /// `tokenId > MAX_SUPPLY` check in `acquire` then raises PoolExhausted. + mapping(uint256 => uint256) private _heldBits; + + mapping(uint256 => uint256) public override tokenSeed; + + uint256 private _lowestAvailable = 1; + uint256 private _circulating; + + /// @dev Set only for the duration of an archive-side move inside + /// `acquire` / `release`, to bypass the direct-deposit guard in + /// `_update`. Safe ONLY because no external call can occur while set. + bool private _releasing; + + modifier onlyHook() { + if (msg.sender != hook) revert ShardErrorsV1.NotHook(); + _; + } + + constructor(address hook_, address renderer_) ERC721("Shards", "SHARDS") { + if (hook_ == address(0) || renderer_ == address(0)) revert ShardErrorsV1.ZeroAddress(); + hook = hook_; + renderer = IShardRendererV1(renderer_); + // Every ID starts in the archive. + for (uint256 w = 0; w < 40; w++) { + _heldBits[w] = type(uint256).max; + } + } + + // ------------------------------------------------------------------- + // Hook-driven inventory moves + // ------------------------------------------------------------------- + + /// @notice Hand the lowest archived ID to `to` and generate fresh art for it. + /// @dev The seed is supplied by the caller — this contract produces no + /// entropy of its own and is fully deterministic. + function acquire(address to, uint256 seed) external override onlyHook returns (uint256 tokenId) { + tokenId = _lowestAvailable; + if (tokenId == 0 || tokenId > MAX_SUPPLY) revert ShardErrorsV1.PoolExhausted(); + + _markHeld(tokenId, false); + tokenSeed[tokenId] = seed; // always fresh — this IS the art regeneration + _circulating += 1; + + _releasing = true; // archive-side move; bypass the deposit guard in _update + + // HARD RULE: _mint / _transfer, NEVER _safeMint / _safeTransfer. + // + // `_releasing` is only safe because NO external call can occur while it + // is set, and this contract has no reentrancy guard. A safe variant + // fires the receiver's onERC721Received callback while _releasing == + // true; the receiver could then re-enter transferFrom(attacker, + // address(this), otherTokenId) directly on this NFT — outside the + // hook's nonReentrant scope. That token would land in the archive + // without _markHeld, without `_circulating -= 1`, and without the + // hook's accounting, permanently breaking the core invariant + // (shard.balanceOf(hook) == circulatingSupply() * 1e18) on a contract + // with no upgrade path. Do not "improve" these to the safe variants. + if (_ownerOf(tokenId) == address(0)) { + _mint(to, tokenId); // NEVER _safeMint + } else { + _transfer(address(this), to, tokenId); // NEVER _safeTransfer + } + + _releasing = false; + + _advanceLowest(); + } + + /// @notice Return `tokenId` to the archive and destroy its art. + function release(address from, uint256 tokenId) external override onlyHook { + _releasing = true; // archive-side move; bypass the deposit guard in _update + + // HARD RULE: _transfer, NEVER _safeTransfer. See the comment in + // `acquire` — with `_releasing` set and no reentrancy guard, a receiver + // callback here would let the receiver re-enter transferFrom into the + // archive outside the hook's nonReentrant scope, stranding an ID and + // corrupting circulating-supply accounting irreversibly. + _transfer(from, address(this), tokenId); // NEVER _safeTransfer + + _releasing = false; + + tokenSeed[tokenId] = 0; // the art is destroyed + _markHeld(tokenId, true); + _circulating -= 1; + if (tokenId < _lowestAvailable) _lowestAvailable = tokenId; + } + + // ------------------------------------------------------------------- + // Views + // ------------------------------------------------------------------- + + function lowestAvailableId() external view override returns (uint256) { + return _lowestAvailable; + } + + /// @notice True when `tokenId` currently sits in the archive (pool inventory). + function isPoolHeld(uint256 tokenId) public view override returns (bool) { + if (tokenId == 0 || tokenId > MAX_SUPPLY) return false; + uint256 idx = tokenId - 1; // bit index == tokenId - 1 + return (_heldBits[idx >> 8] >> (idx & 255)) & 1 == 1; + } + + function circulatingSupply() external view override returns (uint256) { + return _circulating; + } + + function tokenURI(uint256 tokenId) public view override returns (string memory) { + if (tokenId == 0 || tokenId > MAX_SUPPLY) revert ShardErrorsV1.TokenDoesNotExist(tokenId); + bool dormant = isPoolHeld(tokenId); + string memory svg = dormant ? renderer.generateDormant(tokenId) : renderer.generate(tokenSeed[tokenId]); + string memory json = string.concat( + '{"name":"Shard #', + tokenId.toString(), + '","description":"On-chain art that exists only while you hold it. ', + 'Sell it back and this piece is gone forever.","image":"data:image/svg+xml;base64,', + Base64.encode(bytes(svg)), + '","attributes":', + dormant ? '[{"trait_type":"State","value":"Dormant"}]' : renderer.attributes(tokenSeed[tokenId]), + "}" + ); + return string.concat("data:application/json;base64,", Base64.encode(bytes(json))); + } + + // ------------------------------------------------------------------- + // Internals + // ------------------------------------------------------------------- + + /// @dev The single choke point for every ownership move. Note it does NOT + /// touch `tokenSeed`: art regenerates ONLY on acquisition from the + /// archive, never on a wallet-to-wallet transfer. If it regenerated + /// here, every secondary purchase would be a blind lottery and + /// secondary trading would die. + function _update(address to, uint256 tokenId, address auth) internal override returns (address from) { + // Reject direct deposits. transferFrom does NOT call onERC721Received, so + // guarding there alone leaves a hole that permanently strands an ID. + if (!_releasing && (to == address(this) || to == hook)) revert ShardErrorsV1.DirectTransferRejected(); + + from = super._update(to, tokenId, auth); + + // Settle fees to the outgoing owner — but NOT for mints, and NOT when the + // archive is either side. Archive-side moves are accounted by the hook's + // _acquireAccounting / _releaseAccounting; settling here would credit + // address(this), which cannot claim, stranding ETH forever. + if (from != address(0) && from != address(this) && to != address(this)) { + IShardHookV1(hook).settleOnTransfer(tokenId, from, to); + } + } + + /// @dev bit index == tokenId - 1; word = idx >> 8, position = idx & 255. + function _markHeld(uint256 tokenId, bool held) private { + uint256 idx = tokenId - 1; + uint256 word = idx >> 8; + uint256 mask = 1 << (idx & 255); + if (held) { + _heldBits[word] |= mask; + } else { + _heldBits[word] &= ~mask; + } + } + + /// @dev Scan forward from `_lowestAvailable` for the next set (archived) bit. + /// Terminates because word 39's padding bits (indices 10_000..10_239) + /// are permanently set: a fully drained pool resolves to 10_001, which + /// `acquire` then rejects with PoolExhausted. + function _advanceLowest() private { + uint256 idx = _lowestAvailable - 1; // bit index == tokenId - 1 + uint256 word = idx >> 8; + uint256 masked = _heldBits[word] & (type(uint256).max << (idx & 255)); + + while (masked == 0) { + unchecked { + word += 1; + } + if (word > 39) { + _lowestAvailable = MAX_SUPPLY + 1; + return; + } + masked = _heldBits[word]; + } + + _lowestAvailable = (word << 8) + _ctz(masked) + 1; + } + + /// @dev Count trailing zeros. `x` must be non-zero. + function _ctz(uint256 x) private pure returns (uint256 r) { + x = x & (~x + 1); // isolate the lowest set bit + if (x >> 128 != 0) { + r += 128; + x >>= 128; + } + if (x >> 64 != 0) { + r += 64; + x >>= 64; + } + if (x >> 32 != 0) { + r += 32; + x >>= 32; + } + if (x >> 16 != 0) { + r += 16; + x >>= 16; + } + if (x >> 8 != 0) { + r += 8; + x >>= 8; + } + if (x >> 4 != 0) { + r += 4; + x >>= 4; + } + if (x >> 2 != 0) { + r += 2; + x >>= 2; + } + if (x >> 1 != 0) { + r += 1; + } + } +} diff --git a/src/ShardSwapRouterV1.sol b/src/ShardSwapRouterV1.sol new file mode 100644 index 00000000..54237b86 --- /dev/null +++ b/src/ShardSwapRouterV1.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +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 { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; +import { ReentrancyGuard } from "solady/utils/ReentrancyGuard.sol"; + +/// @title ShardSwapRouterV1 +/// @notice Minimal ETH <-> SHARD router for the SHARDS pool. Holds nothing, owns nothing, and +/// has no privileged access to the hook — swaps through it are ordinary third-party +/// swaps and pay the hook's 1% through its beforeSwap/afterSwap callbacks. +/// @dev Deliberately separate from ShardHookV1: the hook's own swap paths mint and burn NFTs, +/// and its LP position is permanently locked, so its surface is kept minimal. +contract ShardSwapRouterV1 is IUnlockCallback, ReentrancyGuard { + using CurrencyLibrary for Currency; + + error NotPoolManager(); + error Expired(); + error ZeroAmount(); + error InsufficientOutput(uint256 minOut, uint256 actual); + error EthTransferFailed(); + error TokenTransferFailed(); + /// @dev The supplied PoolKey is not the pool this router was deployed for. + error WrongPool(); + + IPoolManager public immutable poolManager; + + /// @dev The one pool this router serves, fixed at deployment. Callers still pass a PoolKey + /// (v4's swap signature needs the struct), but anything else is rejected. Not currently + /// exploitable — payer and recipient are both msg.sender and the router holds nothing — + /// but the pool never changes, so pinning it costs nothing and removes the question. + bytes32 public immutable poolId; + + constructor(IPoolManager _poolManager, PoolKey memory _key) { + poolManager = _poolManager; + poolId = keccak256(abi.encode(_key)); + } + + function _requireCanonicalPool(PoolKey calldata key) internal view { + if (keccak256(abi.encode(key)) != poolId) revert WrongPool(); + } + + /// @dev NO `receive()`, deliberately. This router never legitimately receives a plain ETH + /// transfer: it settles native currency INTO the PoolManager, and takes proceeds + /// straight to the swapper rather than through itself. Accepting stray ETH would only + /// create a balance for the refund path to hand to an unrelated caller. + /// Force-sent ETH (`selfdestruct`) is still possible and is left stuck rather than made + /// claimable — stuck is the safer of the two. + + /// @notice Swap exact ETH for SHARD. Unspent ETH is refunded to the caller. + /// @param key The SHARDS pool key (currency0 = native ETH, currency1 = SHARD). + /// @param minShardOut Minimum SHARD the caller will accept, else `InsufficientOutput`. + /// @param deadline Unix timestamp after which the swap reverts with `Expired`. + /// @return shardOut SHARD delivered to the caller. + function swapEthForShard(PoolKey calldata key, uint256 minShardOut, uint256 deadline) + external + payable + nonReentrant + returns (uint256 shardOut) + { + _requireCanonicalPool(key); + if (block.timestamp > deadline) revert Expired(); + if (msg.value == 0) revert ZeroAmount(); + + BalanceDelta delta = abi.decode( + poolManager.unlock( + abi.encode( + msg.sender, + key, + SwapParams({ + zeroForOne: true, + amountSpecified: -int256(msg.value), + // The hook rejects a partial fill when ETH is the specified currency, + // so use a limit that cannot bind on an ordinary swap. + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ) + ), + (BalanceDelta) + ); + + int128 amount1 = delta.amount1(); + shardOut = amount1 > 0 ? uint256(uint128(amount1)) : 0; + if (shardOut < minShardOut) revert InsufficientOutput(minShardOut, shardOut); + + // Refund THIS swap's own leftover, computed from the delta — never `address(this).balance`. + // The balance is not the same quantity: any ETH that reached this contract by another + // route would be swept to whoever happened to call next, which is a stranger's ETH paid + // out to an arbitrary caller. + // + // `delta.amount0()` is negative and INCLUDES the hook's 1% — v4 subtracts the hook's + // delta from the swapper's before returning it (`Hooks.afterSwap`), so what comes back + // is the caller's total ETH cost, curve plus fee. On a full fill that is exactly + // `-msg.value` and nothing is refunded, which is what + // `test_ethForShardRefundsOnlyItsOwnUnspentEth` pins. Were it the curve cost alone, the + // subtraction below would hand the fee back and the swap would not settle. + // + // Refunded AFTER unlock returns, never inside the callback: a contract caller that + // re-enters on receipt would hit `AlreadyUnlocked` and DoS itself. + int128 amount0 = delta.amount0(); + uint256 ethSpent = amount0 < 0 ? uint256(uint128(-amount0)) : 0; + // Cannot underflow: the swap is exact-input for `msg.value`, so the pool plus the hook + // can never take more than that. Reverting beats refunding a wrong number if it ever did. + uint256 leftover = msg.value - ethSpent; + if (leftover != 0) { + (bool ok,) = msg.sender.call{ value: leftover }(""); + if (!ok) revert EthTransferFailed(); + } + } + + /// @notice Swap exact SHARD for ETH. Caller must have approved this router for `shardIn`. + /// @param key The SHARDS pool key (currency0 = native ETH, currency1 = SHARD). + /// @param shardIn Exact SHARD to sell; pulled from the caller during the swap. + /// @param minEthOut Minimum ETH the caller will accept, else `InsufficientOutput`. + /// @param deadline Unix timestamp after which the swap reverts with `Expired`. + /// @return ethOut ETH delivered to the caller. + function swapShardForEth(PoolKey calldata key, uint256 shardIn, uint256 minEthOut, uint256 deadline) + external + nonReentrant + returns (uint256 ethOut) + { + _requireCanonicalPool(key); + if (block.timestamp > deadline) revert Expired(); + if (shardIn == 0) revert ZeroAmount(); + + BalanceDelta delta = abi.decode( + poolManager.unlock( + abi.encode( + msg.sender, + key, + SwapParams({ + zeroForOne: false, + amountSpecified: -int256(shardIn), + sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }) + ) + ), + (BalanceDelta) + ); + + int128 amount0 = delta.amount0(); + ethOut = amount0 > 0 ? uint256(uint128(amount0)) : 0; + if (ethOut < minEthOut) revert InsufficientOutput(minEthOut, ethOut); + } + + /// @inheritdoc IUnlockCallback + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + if (msg.sender != address(poolManager)) revert NotPoolManager(); + + (address swapper, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + + BalanceDelta delta = poolManager.swap(key, params, ""); + + // ETH owed to the pool is paid from this contract's balance (the caller's msg.value); + // SHARD owed is pulled straight from the swapper. Proceeds go straight to the swapper. + _resolve(key.currency0, delta.amount0(), swapper, swapper); + _resolve(key.currency1, delta.amount1(), swapper, swapper); + + return abi.encode(delta); + } + + /// @dev Negative delta = we owe the pool; positive = the pool owes us. + function _resolve(Currency currency, int128 amount, address payer, address recipient) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + if (!IERC20Minimal(Currency.unwrap(currency)).transferFrom(payer, address(poolManager), owed)) { + revert TokenTransferFailed(); + } + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, recipient, uint256(uint128(amount))); + } + } +} diff --git a/src/ShardTokenV1.sol b/src/ShardTokenV1.sol new file mode 100644 index 00000000..1c8b5718 --- /dev/null +++ b/src/ShardTokenV1.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { ShardConstantsV1 } from "./ShardConstantsV1.sol"; + +/// @notice Fixed-supply fungible unit. 1e18 SHARD == 1 NFT. +/// @dev No mint, no burn, no owner. Supply is immutable from construction — +/// the hook's core invariant depends on it. +contract ShardTokenV1 is ERC20 { + constructor() ERC20("Shard", "SHARD") { + _mint(msg.sender, ShardConstantsV1.MAX_NFTS * ShardConstantsV1.SHARDS_PER_NFT); + } +} diff --git a/src/interfaces/IShardHookV1.sol b/src/interfaces/IShardHookV1.sol new file mode 100644 index 00000000..7346c275 --- /dev/null +++ b/src/interfaces/IShardHookV1.sol @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IShardHookV1 { + function settleOnTransfer(uint256 tokenId, address from, address to) external; + function claimable(address account) external view returns (uint256); +} diff --git a/src/interfaces/IShardNFTV1.sol b/src/interfaces/IShardNFTV1.sol new file mode 100644 index 00000000..111278ef --- /dev/null +++ b/src/interfaces/IShardNFTV1.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IShardNFTV1 { + function hook() external view returns (address); + function MAX_SUPPLY() external view returns (uint256); + function lowestAvailableId() external view returns (uint256); + function isPoolHeld(uint256 tokenId) external view returns (bool); + /// @notice IDs owned by users — the BACKING set. The core invariant + /// `shard.balanceOf(hook) == circulatingSupply() * 1e18` uses THIS, + /// not ShardFeeDistributorV1.circulating (the earning set, which lags a block). + function circulatingSupply() external view returns (uint256); + function acquire(address to, uint256 seed) external returns (uint256 tokenId); + function release(address from, uint256 tokenId) external; + function tokenSeed(uint256 tokenId) external view returns (uint256); +} diff --git a/src/interfaces/IShardRendererV1.sol b/src/interfaces/IShardRendererV1.sol new file mode 100644 index 00000000..039f39fd --- /dev/null +++ b/src/interfaces/IShardRendererV1.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +interface IShardRendererV1 { + function generate(uint256 seed) external pure returns (string memory); + function generateDormant(uint256 tokenId) external pure returns (string memory); + function attributes(uint256 seed) external pure returns (string memory); +} diff --git a/test/GeometricRendererV1.t.sol b/test/GeometricRendererV1.t.sol new file mode 100644 index 00000000..461d7241 --- /dev/null +++ b/test/GeometricRendererV1.t.sol @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; +import { LibString } from "solady/utils/LibString.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; + +contract GeometricRendererV1Test is Test { + using LibString for string; + + GeometricRendererV1 internal renderer; + + function setUp() public { + renderer = new GeometricRendererV1(); + } + + function test_producesWellFormedSvg() public view { + string memory svg = renderer.generate(uint256(keccak256("well-formed"))); + assertTrue(LibString.startsWith(svg, ""), "must end with "); + assertGt(bytes(svg).length, 100, "must be non-trivial"); + } + + /// @dev The namespace is REQUIRED. An SVG loaded as an image (an tag, a CSS + /// background, or OpenSea) is parsed as standalone XML, which has no implied + /// namespace — without this the document is not SVG and renders as nothing. + /// Only inline-in-DOM SVG can omit it, and that is not how this art is consumed. + function test_svgDeclaresTheSvgNamespace() public view { + for (uint256 i = 0; i < 32; ++i) { + string memory svg = renderer.generate(uint256(keccak256(abi.encodePacked("ns", i)))); + assertTrue( + LibString.contains(svg, 'xmlns="http://www.w3.org/2000/svg"'), + "generate() lost the SVG namespace - it will render blank as an image" + ); + } + assertTrue( + LibString.contains(renderer.generateDormant(7), 'xmlns="http://www.w3.org/2000/svg"'), + "generateDormant() lost the SVG namespace" + ); + } + + /// @dev The previous version of this test asserted the SVG contained no "http" at all, + /// as a proxy for "no external dependencies". That banned the namespace declaration + /// above and shipped a blank collection. Check for actual external references + /// instead: a fetched resource is `"), "dormant closes"); + assertTrue(LibString.contains(dormant, "99"), "dormant shows the token id"); + assertTrue( + keccak256(bytes(renderer.generateDormant(99))) != keccak256(bytes(renderer.generateDormant(100))), + "dormant varies by id" + ); + } + + function test_attributesAreValidJsonFragment() public view { + string memory json = renderer.attributes(uint256(keccak256("attrs"))); + assertTrue(LibString.startsWith(json, "["), "starts with ["); + assertTrue(LibString.endsWith(json, "]"), "ends with ]"); + assertTrue(LibString.contains(json, "trait_type"), "has trait_type"); + assertTrue(LibString.contains(json, "Palette"), "has Palette axis"); + assertTrue(LibString.contains(json, "Layout"), "has Layout axis"); + assertTrue(LibString.contains(json, "Density"), "has Density axis"); + assertTrue(LibString.contains(json, "Primitive"), "has Primitive axis"); + assertTrue(LibString.contains(json, "Rotation"), "has Rotation axis"); + assertTrue(LibString.contains(json, "Background"), "has Background axis"); + } + + function test_traitsAreFlat() public view { + uint256 samples = 4000; + uint256[16] memory palettes; + uint256[8] memory layouts; + uint256[6] memory densities; + uint256[6] memory primitives; + uint256[12] memory rotations; + uint256[8] memory backgrounds; + + for (uint256 i = 0; i < samples; ++i) { + uint256 seed = uint256(keccak256(abi.encodePacked("flat", i))); + (uint256 p, uint256 l, uint256 d, uint256 pr, uint256 r, uint256 b) = renderer.axesOf(seed); + palettes[p]++; + layouts[l]++; + densities[d]++; + primitives[pr]++; + rotations[r]++; + backgrounds[b]++; + } + + for (uint256 i = 0; i < 16; ++i) { + assertGt(palettes[i], 0, "every palette index must appear"); + } + for (uint256 i = 0; i < 8; ++i) { + assertGt(layouts[i], 0, "every layout index must appear"); + assertGt(backgrounds[i], 0, "every background index must appear"); + } + for (uint256 i = 0; i < 6; ++i) { + assertGt(densities[i], 0, "every density index must appear"); + assertGt(primitives[i], 0, "every primitive index must appear"); + } + for (uint256 i = 0; i < 12; ++i) { + assertGt(rotations[i], 0, "every rotation index must appear"); + } + + // Flatness: with 4000 samples no bucket should deviate wildly from its + // expected share. Expected for 12 buckets is ~333; allow a wide band that + // a `% 12` bias (which would skew the first 4 buckets by ~33%) would fail. + for (uint256 i = 0; i < 12; ++i) { + assertGt(rotations[i], (samples / 12) * 8 / 10, "rotation bucket too small"); + assertLt(rotations[i], (samples / 12) * 12 / 10, "rotation bucket too large"); + } + } + + function testFuzz_neverReverts(uint256 seed) public view { + string memory svg = renderer.generate(seed); + assertTrue(LibString.startsWith(svg, "")); + string memory json = renderer.attributes(seed); + assertTrue(LibString.startsWith(json, "[")); + assertTrue(LibString.endsWith(json, "]")); + string memory dormant = renderer.generateDormant(seed); + assertTrue(LibString.endsWith(dormant, "")); + } +} diff --git a/test/ShardFeeDistributorV1.t.sol b/test/ShardFeeDistributorV1.t.sol new file mode 100644 index 00000000..a92bc14b --- /dev/null +++ b/test/ShardFeeDistributorV1.t.sol @@ -0,0 +1,430 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; +import { ShardFeeDistributorV1 } from "../src/ShardFeeDistributorV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; + +/// @dev Concrete shim exposing the internal accounting of ShardFeeDistributorV1. +/// Drives `_distribute` directly — the unsplit holder-pool entry point. The +/// builder/launcher carve happens one level up in `_distributeFee`, so every +/// expectation in this file is about the holder accumulator alone. +contract ShardFeeDistributorV1Harness is ShardFeeDistributorV1 { + constructor(address _launcherFeeRecipient, address _builderFeeRecipient) + ShardFeeDistributorV1(_launcherFeeRecipient, _builderFeeRecipient) + { } + + function distribute(uint256 amount) external { + _distribute(amount); + } + + function acquire(uint256 tokenId, address to) external { + _acquireAccounting(tokenId, to); + } + + function release(uint256 tokenId, address from) external { + _releaseAccounting(tokenId, from); + } + + function settle(uint256 tokenId, address owner) external { + _settle(tokenId, owner); + } + + function claim(address account) external returns (uint256) { + return _claim(account); + } + + function flush() external { + _flushPending(); + } + + // IShardHookV1 + function settleOnTransfer(uint256 tokenId, address from, address to) external override { + _settle(tokenId, from); + _releaseAccounting(tokenId, from); + _acquireAccounting(tokenId, to); + } + + receive() external payable { } +} + +contract ShardFeeDistributorV1Test is Test { + uint256 internal constant ACC = ShardConstantsV1.ACC_PRECISION; + + ShardFeeDistributorV1Harness internal d; + + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + address internal carol = address(0xCAC0); + + address internal launcher = makeAddr("launcher"); + address internal builder = makeAddr("builder"); + + function setUp() public { + d = new ShardFeeDistributorV1Harness(launcher, builder); + vm.deal(address(d), 1_000_000 ether); + vm.roll(100); + } + + // ------------------------------------------------------------------ + // escrow + // ------------------------------------------------------------------ + + function test_feesWithNoHoldersGoToEscrow() public { + d.distribute(1 ether); + + assertEq(d.escrowBalance(), 1 ether, "escrow"); + assertEq(d.accFeePerNFT(), 0, "acc untouched"); + assertEq(d.circulating(), 0, "circulating"); + } + + function test_escrowFoldsInOnFirstDistributionWithHolders() public { + d.distribute(1 ether); + assertEq(d.escrowBalance(), 1 ether, "escrowed"); + + // Acquiring does NOT fold the escrow in. + d.acquire(1, alice); + assertEq(d.escrowBalance(), 1 ether, "escrow survives acquire"); + assertEq(d.accFeePerNFT(), 0, "acc still zero"); + + // The fold happens inside _distribute, once something circulates. + vm.roll(block.number + 1); + d.distribute(0); + + assertEq(d.escrowBalance(), 0, "escrow drained"); + assertEq(d.circulating(), 1, "flushed"); + assertEq(d.accFeePerNFT(), 1 ether * ACC, "acc credited with escrow"); + + d.settle(1, alice); + assertEq(d.claimable(alice), 1 ether, "alice paid the escrow"); + } + + // ------------------------------------------------------------------ + // splitting + // ------------------------------------------------------------------ + + function test_evenSplitAcrossHolders() public { + d.acquire(1, alice); + d.acquire(2, bob); + d.acquire(3, carol); + + vm.roll(block.number + 1); + d.distribute(3 ether); + + assertEq(d.circulating(), 3, "circulating"); + + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + + assertEq(d.claimable(alice), 1 ether, "alice"); + assertEq(d.claimable(bob), 1 ether, "bob"); + assertEq(d.claimable(carol), 1 ether, "carol"); + assertEq(d.dustScaled(), 0, "no dust"); + } + + /// @notice The same-block accrual guard: a token acquired in this block is excluded from + /// `circulating`, and `_settle` floors its snapshot at the accumulator recorded + /// when its acquisition block was flushed. Both halves are needed — the count + /// alone would let the pending token claim a rise it was never divided into. + function test_sameBlockAcquisitionEarnsNothing() public { + // alice is already earning + d.acquire(1, alice); + vm.roll(block.number + 1); + d.flush(); + assertEq(d.circulating(), 1, "alice circulating"); + + // bob acquires and a fee lands in the very same block + d.acquire(2, bob); + d.distribute(1 ether); + + d.settle(1, alice); + d.settle(2, bob); + + assertEq(d.claimable(bob), 0, "bob earns nothing in his acquisition block"); + assertEq(d.claimable(alice), 1 ether, "alice takes the whole fee"); + } + + function test_holderEarnsFromNextBlockOnward() public { + d.acquire(1, alice); + vm.roll(block.number + 1); + d.flush(); + + d.acquire(2, bob); + + // block N: bob pending, alice takes everything + d.distribute(1 ether); + + // block N+1: bob is in the earning set + vm.roll(block.number + 1); + d.distribute(2 ether); + + d.settle(1, alice); + d.settle(2, bob); + + assertEq(d.circulating(), 2, "both circulating"); + assertEq(d.claimable(bob), 1 ether, "bob earns from the next block"); + assertEq(d.claimable(alice), 2 ether, "alice: all of #1, half of #2"); + } + + // ------------------------------------------------------------------ + // dust / fractions + // ------------------------------------------------------------------ + + function test_dustIsCarriedNotLost() public { + d.acquire(1, alice); + d.acquire(2, bob); + d.acquire(3, carol); + vm.roll(block.number + 1); + + // 3 x 1 wei across 3 holders: nothing may be stranded. + d.distribute(1); + assertEq(d.dustScaled(), 1, "dust carried in scaled units after #1"); + d.distribute(1); + assertEq(d.dustScaled(), 2, "dust carried in scaled units after #2"); + d.distribute(1); + assertEq(d.dustScaled(), 0, "dust consumed on #3"); + + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + + assertEq(d.claimable(alice), 1, "alice 1 wei"); + assertEq(d.claimable(bob), 1, "bob 1 wei"); + assertEq(d.claimable(carol), 1, "carol 1 wei"); + assertEq(d.claimable(alice) + d.claimable(bob) + d.claimable(carol), 3, "all 3 wei paid out"); + } + + function test_settlePreservesFractionalRemainder() public { + d.acquire(1, alice); + d.acquire(2, bob); + d.acquire(3, carol); + vm.roll(block.number + 1); + + // 300 x 1 wei, force-settling after every single one (as a transfer would). + for (uint256 i = 0; i < 300; ++i) { + d.distribute(1); + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + } + + assertEq(d.claimable(alice), 100, "alice keeps her truncated fractions"); + assertEq(d.claimable(bob), 100, "bob"); + assertEq(d.claimable(carol), 100, "carol"); + assertEq(d.dustScaled(), 0, "dust fully consumed"); + } + + // ------------------------------------------------------------------ + // release + // ------------------------------------------------------------------ + + function test_releaseSettlesToOutgoingOwner() public { + d.acquire(1, alice); + vm.roll(block.number + 1); + d.distribute(1 ether); + + d.release(1, alice); + + assertEq(d.claimable(alice), 1 ether, "outgoing owner settled"); + assertEq(d.circulating(), 0, "left the earning set"); + assertEq(d.pendingCount(), 0, "no pending"); + } + + function test_acquireAndReleaseInSameBlockDoesNotCorruptCount() public { + d.acquire(1, alice); + vm.roll(block.number + 1); + d.flush(); + assertEq(d.circulating(), 1, "baseline"); + + d.acquire(2, bob); + assertEq(d.pendingCount(), 1, "bob pending"); + + d.release(2, bob); + + assertEq(d.pendingCount(), 0, "pending decremented, not circulating"); + assertEq(d.circulating(), 1, "alice untouched"); + + // and the earning set is still sane a block later + vm.roll(block.number + 1); + d.distribute(1 ether); + d.settle(1, alice); + assertEq(d.circulating(), 1, "still one"); + assertEq(d.claimable(alice), 1 ether, "alice takes it all"); + } + + /// @notice Path 3 — the realistic exploit. In production `_settle` is reached through + /// an ERC-721 transfer: `settleOnTransfer` settles `from` before any release. + /// Buy in block N, swap in block N, transfer in block N. ShardFeeDistributorV1 has + /// no ERC-721 of its own, so the transfer's settle leg is called directly here. + function test_transferInAcquisitionBlockEarnsNothing() public { + d.acquire(1, alice); + vm.roll(block.number + 1); + d.flush(); + assertEq(d.circulating(), 1, "alice earning"); + + d.acquire(2, bob); + d.distribute(1 ether); // divided by 1: alice only + + // bob transfers token 2 to carol in his own acquisition block + d.settle(2, bob); + + d.settle(1, alice); + + assertEq(d.claimable(bob), 0, "pending token accrues nothing on transfer"); + assertEq(d.claimable(carol), 0, "carol acquires nothing retroactively"); + assertEq(d.claimable(alice), 1 ether, "alice takes the whole fee"); + assertLe(d.claimable(alice) + d.claimable(bob) + d.claimable(carol), 1 ether, "solvent: claims <= fees"); + } + + /// @notice Path 2: acquired AND released inside one block, with a fee landing between. + /// The token never joined the earning set, so `flushAcc[thatBlock]` is never + /// written and cannot floor anything — release must skip settlement outright. + function test_acquireAndReleaseInSameBlockWithDistributionEarnsNothing() public { + d.acquire(1, alice); + vm.roll(block.number + 1); + d.flush(); + assertEq(d.circulating(), 1, "alice earning"); + + d.acquire(2, bob); + d.distribute(1 ether); // divided by 1: alice only + d.release(2, bob); + + d.settle(1, alice); + + assertEq(d.claimable(bob), 0, "bob never joined: earns nothing"); + assertEq(d.claimable(alice), 1 ether, "alice takes the whole fee"); + assertLe(d.claimable(alice) + d.claimable(bob), 1 ether, "solvent: claims <= fees"); + assertEq(d.circulating(), 1, "count intact"); + assertEq(d.pendingCount(), 0, "pending cleared"); + } + + /// @notice Regression: releasing a token acquired in an EARLIER block while a + /// DIFFERENT token is pending in THIS block must decrement `circulating`, + /// never `pendingCount`. Keying on a global `pendingBlock` would let bob + /// become the sole circulating holder in his own acquisition block and + /// collect the entire fee. + function test_releaseOlderTokenWhileNewerIsPending() public { + // block N + d.acquire(1, alice); + + // block N+1 + vm.roll(block.number + 1); + d.acquire(2, bob); // flush moves token 1 into circulating + assertEq(d.circulating(), 1, "token 1 flushed in"); + assertEq(d.pendingCount(), 1, "token 2 pending"); + + d.release(1, alice); + assertEq(d.pendingCount(), 1, "token 2 must STILL be pending"); + assertEq(d.circulating(), 0, "token 1 left the earning set"); + + d.distribute(10 ether); + + d.settle(2, bob); + assertEq(d.claimable(bob), 0, "bob acquired this very block: earns nothing"); + assertEq(d.claimable(alice), 0, "alice already left"); + assertEq(d.escrowBalance(), 10 ether, "nothing circulating: escrowed"); + assertEq(d.accFeePerNFT(), 0, "acc untouched"); + } + + // ------------------------------------------------------------------ + // conservation + // ------------------------------------------------------------------ + + /// @dev A token's effective snapshot: it may never earn from before it joined the + /// earning set, so the acquire-time snapshot is floored by the accumulator + /// recorded when its acquisition block was flushed. + function _effSnap(uint256 tokenId) internal view returns (uint256) { + uint256 snap = d.feeSnapshot(tokenId); + uint256 floor_ = d.flushAcc(d.acquiredBlock(tokenId)); + return floor_ > snap ? floor_ : snap; + } + + function _paid() internal view returns (uint256) { + return d.claimable(alice) + d.claimable(bob) + d.claimable(carol); + } + + /// @dev THE property that actually matters: the contract can always honour every + /// claim it has issued. Checked after every single step. + function _assertSolvent(uint256 total, string memory tag) internal view { + assertLe(_paid() + d.dustScaled() / ACC + d.escrowBalance(), total, tag); + } + + /// @dev Every wei distributed is either paid out, still escrowed, held as scaled dust, + /// held as an unsettled sub-wei fraction on a token, or discarded by the + /// re-snapshot that `_acquireAccounting` performs on a token settled at release. + /// All terms are accounted in SCALED units so the check is exact. + /// + /// This fuzz distributes FREELY inside acquisition blocks and settles inside them + /// too — no ordering restriction that could hide a same-block double-credit. + function testFuzz_totalDistributedIsConserved(uint96 a1, uint96 a2, uint96 a3, uint96 a4) public { + uint256 total; + + // Block N: fees land with nothing circulating AND acquisitions in the same block. + d.acquire(1, alice); + d.acquire(2, bob); + d.distribute(a1); + total += a1; + _assertSolvent(total, "solvency: escrow block"); + + // Block N+1: carol acquires and a fee lands in her acquisition block. + vm.roll(block.number + 1); + d.acquire(3, carol); + d.distribute(a2); + total += a2; + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + _assertSolvent(total, "solvency: fee in carol's acquisition block"); + + // Block N+2: everyone earning. + vm.roll(block.number + 1); + d.distribute(a3); + total += a3; + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + _assertSolvent(total, "solvency: steady state"); + + // Same block: release and re-acquire token 1, then a fee lands and is settled + // while token 1 is pending again. + d.release(1, alice); + uint256 discardedScaled = d.accFeePerNFT() - _effSnap(1); + d.acquire(1, alice); + d.distribute(a4); + total += a4; + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + _assertSolvent(total, "solvency: fee in token 1's re-acquisition block"); + + // Next block: token 1 rejoins, everything settles out. + vm.roll(block.number + 1); + d.flush(); + d.settle(1, alice); + d.settle(2, bob); + d.settle(3, carol); + _assertSolvent(total, "solvency: final"); + + uint256 acc = d.accFeePerNFT(); + uint256 unsettledScaled = (acc - _effSnap(1)) + (acc - _effSnap(2)) + (acc - _effSnap(3)); + + assertEq(d.circulating(), 3, "all three earning"); + assertEq( + _paid() * ACC + unsettledScaled + discardedScaled + d.dustScaled() + d.escrowBalance() * ACC, + total * ACC, + "no wei created or destroyed" + ); + } + + /// @dev The split lives one level up, in `_distributeFee`. Nothing this suite drives + /// through `_distribute` may ever touch the beneficiary accruals. + function test_distributeNeverAccruesToBuilderOrLauncher() public { + d.acquire(1, alice); + vm.roll(block.number + 1); + d.distribute(1 ether); + + assertEq(d.builderFeesAccrued(), 0, "builder accrued from _distribute"); + assertEq(d.launcherFeesAccrued(), 0, "launcher accrued from _distribute"); + } +} diff --git a/test/ShardFeeDonationV1.t.sol b/test/ShardFeeDonationV1.t.sol new file mode 100644 index 00000000..a2268915 --- /dev/null +++ b/test/ShardFeeDonationV1.t.sol @@ -0,0 +1,329 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { ShardFeeForwarderV1 } from "../src/ShardFeeForwarderV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/// @dev A stand-in for a future launchpad: it only knows how to send plain ETH. +contract PlainSender { + function send(address payable to, uint256 amount) external { + (bool ok,) = to.call{ value: amount }(""); + require(ok, "send failed"); + } + + receive() external payable { } +} + +contract ShardFeeDonationV1Test is Test { + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + uint256 internal constant FAR = 1e18; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + ShardFeeForwarderV1 internal forwarder; + + PoolKey internal key; + uint160 internal startSqrtPriceX96; + + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardFeeDonationV1Test"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + + forwarder = new ShardFeeForwarderV1(payable(address(hook))); + + vm.deal(address(this), 10_000 ether); + vm.deal(alice, 1000 ether); + vm.deal(bob, 1000 ether); + } + + function _buy(address who, uint256 send) internal returns (uint256 tokenId) { + vm.prank(who); + tokenId = hook.buyNFT{ value: send }(type(uint256).max, FAR); + } + + function test_setupUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + /*////////////////////////////////////////////////////////// + DONATE + //////////////////////////////////////////////////////////*/ + + function test_donateRaisesTheAccumulator() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + + uint256 accBefore = hook.accFeePerNFT(); + hook.donate{ value: 1 ether }(); + + assertGt(hook.accFeePerNFT(), accBefore, "a donation did not reach holders"); + } + + /// The whole point: outside ETH must become claimable by NFT holders. + function test_donatedEthIsClaimableByAHolder() public { + uint256 id = _buy(alice, 1 ether); + vm.roll(block.number + 1); + + hook.donate{ value: 1 ether }(); + vm.roll(block.number + 1); + + uint256[] memory ids = new uint256[](1); + ids[0] = id; + uint256 before = alice.balance; + vm.prank(alice); + hook.claim(ids); + + // Alice is the only holder, so she receives the whole donation plus the holder + // share of the buy fee. The donation is never split, so the floor is exact. + assertGe(alice.balance - before, 1 ether, "the donation did not reach the holder"); + } + + function test_donateSplitsAcrossEveryHolder() public { + uint256 a = _buy(alice, 1 ether); + uint256 b = _buy(bob, 1 ether); + vm.roll(block.number + 1); + + hook.donate{ value: 2 ether }(); + vm.roll(block.number + 1); + + uint256[] memory aIds = new uint256[](1); + aIds[0] = a; + uint256[] memory bIds = new uint256[](1); + bIds[0] = b; + + uint256 aBefore = alice.balance; + vm.prank(alice); + hook.claim(aIds); + uint256 bBefore = bob.balance; + vm.prank(bob); + hook.claim(bIds); + + uint256 aGot = alice.balance - aBefore; + uint256 bGot = bob.balance - bBefore; + assertApproxEqRel(aGot, bGot, 0.01e18, "holders did not split the donation evenly"); + assertGe(aGot + bGot, 2 ether, "less than the donation was paid out"); + } + + /// A donation is a gift to holders, not swap volume: it must bypass the + /// builder/launcher carve entirely. + function test_donateIsNeverSplitWithTheBeneficiaries() public { + uint256 builderBefore = hook.builderFeesAccrued(); + uint256 launcherBefore = hook.launcherFeesAccrued(); + + hook.donate{ value: 1 ether }(); + + assertEq(hook.builderFeesAccrued(), builderBefore, "builder took a cut of a donation"); + assertEq(hook.launcherFeesAccrued(), launcherBefore, "launcher took a cut of a donation"); + assertEq(hook.escrowBalance(), 1 ether, "donation did not reach holders whole"); + } + + /// With nothing circulating there is nobody to pay, so it must escrow rather + /// than vanish into the contract balance. + function test_donateEscrowsWhenNothingCirculates() public { + assertEq(nft.circulatingSupply(), 0, "test needs an empty collection"); + + hook.donate{ value: 1 ether }(); + + assertEq(hook.escrowBalance(), 1 ether, "donation was not escrowed"); + } + + /// Escrow is NOT released by the buy that ends the empty period. `_distribute` + /// runs before the buyer joins the earning set, so `circulating` is still zero + /// at that moment and the escrow (plus that buy's own holder share) stays put. + /// It is the NEXT distribution, once someone is circulating, that releases it. + function test_escrowedDonationReleasesOnTheNextDistribution() public { + hook.donate{ value: 1 ether }(); + assertEq(hook.escrowBalance(), 1 ether, "donation was not escrowed"); + + uint256 id = _buy(alice, 1 ether); + // Still escrowed, and now larger: the holder share of this buy's fee joined it + // rather than being paid out. The exact fee is whatever the curve charged, not + // 1% of the ETH sent, because buyNFT refunds the excess. + assertGt(hook.escrowBalance(), 1 ether, "escrow released too early"); + + // Any later fee event does it. A second donation is the simplest one. + vm.roll(block.number + 1); + hook.donate{ value: 0.1 ether }(); + assertEq(hook.escrowBalance(), 0, "escrow never released"); + + vm.roll(block.number + 1); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + uint256 before = alice.balance; + vm.prank(alice); + hook.claim(ids); + + assertGe(alice.balance - before, 1 ether, "escrowed donation never reached the holder"); + } + + function test_donateRevertsOnZeroValue() public { + vm.expectRevert(ShardErrorsV1.ZeroAmount.selector); + hook.donate{ value: 0 }(); + } + + function test_anyoneCanDonate() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + + uint256 accBefore = hook.accFeePerNFT(); + vm.prank(bob); + hook.donate{ value: 0.5 ether }(); + assertGt(hook.accFeePerNFT(), accBefore, "a third party could not donate"); + } + + /// A donation is ETH only. It must not disturb the SHARD backing identity. + function test_donateLeavesTheBackingInvariantIntact() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + hook.donate{ value: 3 ether }(); + + assertEq( + shard.balanceOf(address(hook)), + nft.circulatingSupply() * 1e18 + hook.seedDust(), + "donation moved the SHARD backing" + ); + } + + /*////////////////////////////////////////////////////////// + FEE FORWARDER + //////////////////////////////////////////////////////////*/ + + function test_forwarderAcceptsPlainEth() public { + PlainSender sender = new PlainSender(); + vm.deal(address(sender), 5 ether); + + sender.send(payable(address(forwarder)), 2 ether); + + assertEq(address(forwarder).balance, 2 ether, "forwarder rejected a plain transfer"); + } + + /// The reason the forwarder exists: a contract that can only `transfer` ETH + /// still ends up paying holders, without needing to know the hook's ABI. + function test_flushPushesEverythingIntoTheFeePool() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + + PlainSender sender = new PlainSender(); + vm.deal(address(sender), 5 ether); + sender.send(payable(address(forwarder)), 2 ether); + + uint256 accBefore = hook.accFeePerNFT(); + forwarder.flush(); + + assertEq(address(forwarder).balance, 0, "forwarder kept ETH back"); + assertGt(hook.accFeePerNFT(), accBefore, "flushed ETH never reached holders"); + } + + /// The forwarder routes through `donate`, so flushed ETH is a gift too: no cut. + function test_flushedEthIsNotSplitWithTheBeneficiaries() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + + uint256 builderBefore = hook.builderFeesAccrued(); + uint256 launcherBefore = hook.launcherFeesAccrued(); + + vm.deal(address(forwarder), 4 ether); + forwarder.flush(); + + assertEq(hook.builderFeesAccrued(), builderBefore, "builder took a cut of a flush"); + assertEq(hook.launcherFeesAccrued(), launcherBefore, "launcher took a cut of a flush"); + } + + function test_anyoneCanFlush() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + vm.deal(address(forwarder), 1 ether); + + vm.prank(bob); + forwarder.flush(); + + assertEq(address(forwarder).balance, 0, "a third party could not flush"); + } + + function test_flushRevertsWhenEmpty() public { + vm.expectRevert(ShardFeeForwarderV1.NothingToFlush.selector); + forwarder.flush(); + } + + function test_forwarderRejectsAZeroHook() public { + vm.expectRevert(ShardFeeForwarderV1.ZeroAddress.selector); + new ShardFeeForwarderV1(payable(address(0))); + } + + function test_forwarderHoldsNoEthAfterFlush() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + vm.deal(address(forwarder), 7 ether); + + uint256 hookBefore = address(hook).balance; + forwarder.flush(); + + assertEq(address(forwarder).balance, 0, "forwarder retained a balance"); + assertEq(address(hook).balance - hookBefore, 7 ether, "the hook did not receive it all"); + } + + receive() external payable { } +} diff --git a/test/ShardFeeSplitV1.t.sol b/test/ShardFeeSplitV1.t.sol new file mode 100644 index 00000000..d3234cd5 --- /dev/null +++ b/test/ShardFeeSplitV1.t.sol @@ -0,0 +1,629 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; + +/// @dev Exposes the internal fee entry point so split arithmetic can be pinned with exact +/// wei amounts, independent of curve pricing. +contract FeeSplitHarness is ShardHookV1 { + constructor( + IPoolManager _poolManager, + ShardTokenV1 _shard, + int24 _tickLower, + int24 _tickBand, + int24 _tickUpper, + uint160 _startSqrtPriceX96, + address _deployer, + address _launcherFeeRecipient, + address _builderFeeRecipient + ) + ShardHookV1( + _poolManager, + _shard, + _tickLower, + _tickBand, + _tickUpper, + _startSqrtPriceX96, + _deployer, + _launcherFeeRecipient, + _builderFeeRecipient + ) + { } + + function distributeFee(uint256 amount) external { + _distributeFee(amount); + } + + function chargeFee(uint256 gross, bool exactIn) external returns (uint256) { + return _chargeFee(gross, exactIn); + } + + function outFeeCarry() external view returns (uint256) { + return feeCarryOut; + } +} + +contract SplitSwapRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function swap(PoolKey memory key, SwapParams memory params) external payable returns (BalanceDelta delta) { + delta = abi.decode(poolManager.unlock(abi.encode(msg.sender, key, params)), (BalanceDelta)); + uint256 bal = address(this).balance; + if (bal > 0) { + (bool ok,) = msg.sender.call{ value: bal }(""); + require(ok, "refund failed"); + } + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address sender, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + BalanceDelta delta = poolManager.swap(key, params, ""); + _resolve(key.currency0, delta.amount0(), sender); + _resolve(key.currency1, delta.amount1(), sender); + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount, address sender) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), owed); + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, sender, uint256(uint128(amount))); + } + } +} + +contract RejectShardEth { + receive() external payable { + revert("reject ETH"); + } +} + +contract ShardFeeSplitV1Test is Test { + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + uint256 internal constant UNDER_CAP_ETH = 0.0004 ether; + uint256 internal constant FAR = 1e18; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + FeeSplitHarness internal hook; + ShardNFTV1 internal nft; + SplitSwapRouter internal swapRouter; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal launcher = makeAddr("launcher"); + address internal builder = makeAddr("builder"); + address internal alice = makeAddr("alice"); + + /// @dev buyNFT refunds unspent ETH to the caller; the test contract must accept it. + receive() external payable { } + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + shard = new ShardTokenV1(); + renderer = new GeometricRendererV1(); + swapRouter = new SplitSwapRouter(manager); + + (address expected, bytes32 salt) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(FeeSplitHarness).creationCode, + abi.encode( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, builder + ) + ); + hook = new FeeSplitHarness{ salt: salt }( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, builder + ); + assertEq(address(hook), expected, "hook address mismatch"); + + nft = new ShardNFTV1(address(hook), address(renderer)); + hook.setNFT(IShardNFTV1(address(nft))); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + shard.transfer(address(hook), SEED_AMOUNT); + vm.deal(address(this), 10_000 ether); + shard.approve(address(swapRouter), type(uint256).max); + } + + function _swap(bool zeroForOne, int256 amountSpecified, uint256 value) internal returns (BalanceDelta) { + return swapRouter.swap{ value: value }( + key, + SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amountSpecified, + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }) + ); + } + + /// @dev Total ETH the hook has captured, in whatever form it currently holds it. + function _feesHeld() internal view returns (uint256) { + return manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } + + function _rejectEthAt(address account) internal { + RejectShardEth rejector = new RejectShardEth(); + vm.etch(account, address(rejector).code); + } + + /*////////////////////////////////////////////////////////// + SPLIT ARITHMETIC + //////////////////////////////////////////////////////////*/ + + /// A 1 ether fee splits exactly 0.8 / 0.1 / 0.1. + function test_split_exactTenthsOnRoundAmount() public { + hook.distributeFee(1 ether); + + assertEq(hook.builderFeesAccrued(), 0.1 ether, "builder cut"); + assertEq(hook.launcherFeesAccrued(), 0.1 ether, "launcher cut"); + assertEq(hook.escrowBalance(), 0.8 ether, "holder pool (escrow: nothing circulates)"); + } + + /// The operator cut is the floor of the combined 20%; the odd wei goes to the launcher. + /// 999 wei -> operator floor(1998/10) = 199, split 99 builder / 100 launcher, 800 to holders. + function test_split_roundingRemainderGoesToLauncher() public { + hook.distributeFee(999); + + assertEq(hook.builderFeesAccrued(), 99, "builder cut"); + assertEq(hook.launcherFeesAccrued(), 100, "launcher takes the odd wei"); + assertEq(hook.escrowBalance(), 800, "holders get the remainder"); + } + + /// A tiny fee no longer floors the entitlement away: 9 wei credits 1 wei to the operator + /// (launcher first), 8 to holders — the carried remainder is what makes the cut cumulative. + function test_split_tinyFeeStillCreditsOperator() public { + hook.distributeFee(9); + + assertEq(hook.builderFeesAccrued(), 0, "builder cut"); + assertEq(hook.launcherFeesAccrued(), 1, "launcher takes the floored operator wei"); + assertEq(hook.escrowBalance(), 8, "holder pool"); + } + + /// Conservation: cuts + holder pool always equal the fee, for any single amount. The combined + /// operator (builder+launcher) cut is the floor of the true 20%; the launcher takes the odd wei. + function testFuzz_split_conserves(uint256 amount) public { + amount = bound(amount, 0, 1_000_000 ether); + hook.distributeFee(amount); + + uint256 operator = amount * 2000 / BPS; // floor(20%), taken as one cut then split + assertEq(hook.builderFeesAccrued() + hook.launcherFeesAccrued(), operator, "operator = floor(20%)"); + assertGe(hook.launcherFeesAccrued(), hook.builderFeesAccrued(), "launcher takes the odd wei"); + assertLe(hook.launcherFeesAccrued() - hook.builderFeesAccrued(), 1, "split balanced within a wei"); + assertEq(hook.builderFeesAccrued() + hook.launcherFeesAccrued() + hook.escrowBalance(), amount, "conservation"); + } + + /// The Programmable entitlement is cumulative: a stream of sub-threshold fees accrues the same + /// launcher/builder total as one aggregated fee, so splitting volume into tiny swaps cannot evade + /// the 10 bps cut. Ten 9-wei fees (1% of a 900-wei swap) must match one 90-wei fee (1% of 9,000). + function test_tinyFeesAccumulateToTheSameEntitlement() public { + for (uint256 i = 0; i < 10; i++) { + hook.distributeFee(9); + } + assertEq(hook.launcherFeesAccrued(), 9, "launcher entitlement evaded by fee splitting"); + assertEq(hook.builderFeesAccrued(), 9, "builder entitlement evaded by fee splitting"); + } + + /// Split-invariant across any stream: conservation is exact, and both cuts stay within one wei of + /// the ideal cumulative 10%, with the launcher never shorted below the builder. + function testFuzz_splitIsConservativeAndCumulative(uint256[] memory rawAmounts) public { + vm.assume(rawAmounts.length > 0 && rawAmounts.length <= 64); + uint256 total; + for (uint256 i = 0; i < rawAmounts.length; i++) { + uint256 amount = rawAmounts[i] % 1e18; + total += amount; + hook.distributeFee(amount); + } + uint256 launcher = hook.launcherFeesAccrued(); + uint256 builder = hook.builderFeesAccrued(); + // No NFTs circulate in this harness, so every holder wei lands in escrow. + assertEq(launcher + builder + hook.escrowBalance(), total, "split lost or minted wei"); + assertApproxEqAbs(launcher, (total * 1000) / BPS, 1, "launcher not cumulative-exact"); + assertApproxEqAbs(builder, (total * 1000) / BPS, 1, "builder not cumulative-exact"); + assertGe(launcher, builder, "launcher shorted vs builder"); + assertLe(launcher - builder, 1, "split drifted beyond one wei"); + } + + /*////////////////////////////////////////////////////////// + OUTER (1%) FEE IS CUMULATIVE + //////////////////////////////////////////////////////////*/ + + /// The exact-input 1% fee (beforeSwap/afterSwap for zeroForOne-exactIn and oneForZero-exactIn, + /// and the sellNFT/sellMany paths) carries its sub-wei remainder: eleven 99-wei swaps accrue the + /// same total as one aggregated 1,089-wei swap, instead of each flooring to zero. Regression for + /// the reviewer's fragmented-swap evasion. `hook` starts each test with zeroed carries. + function test_outerFee_exactInFragmentedMatchesAggregated() public { + uint256 fragmented; + for (uint256 i = 0; i < 11; i++) { + fragmented += hook.chargeFee(99, true); + } + assertGt(fragmented, 0, "eleven 99-wei exact-input swaps still floored the fee to zero"); + assertEq(fragmented, (11 * 99 * FEE_BPS) / BPS, "fragmented exact-input fee != cumulative 1%"); + } + + /// The exact-output 1% gross-up (net*100/9900 — the exactOut quadrants and the buyNFT/buyMany/ + /// buyMax market paths) carries the same way: eleven 50-wei nets each floor to zero alone but + /// accrue the aggregated 1% of 550. + function test_outerFee_exactOutFragmentedMatchesAggregated() public { + uint256 fragmented; + for (uint256 i = 0; i < 11; i++) { + fragmented += hook.chargeFee(50, false); + } + assertGt(fragmented, 0, "eleven 50-wei exact-output swaps still floored the fee to zero"); + assertEq(fragmented, (11 * 50 * FEE_BPS) / (BPS - FEE_BPS), "fragmented exact-output fee != cumulative 1%"); + } + + /// Split-invariant across any stream and either basis: the carried fee never over- or + /// under-collects beyond the final sub-wei remainder. + function testFuzz_outerFeeIsCumulative(uint256[] memory raw, bool exactIn) public { + vm.assume(raw.length > 0 && raw.length <= 64); + uint256 denom = exactIn ? BPS : BPS - FEE_BPS; + uint256 total; + uint256 charged; + for (uint256 i = 0; i < raw.length; i++) { + uint256 gross = raw[i] % 1e18; + total += gross; + charged += hook.chargeFee(gross, exactIn); + } + assertEq(charged, (total * FEE_BPS) / denom, "carried fee drifted from the cumulative 1%"); + } + + /// buyMax clamps the inclusive fee to its exact-input reserve so the buyer is never overspent, but + /// the uncollected wei must be CARRIED, not shed once per call. A msg.value of 100_099 wei fully + /// consumes its swap and lands the inclusive fee (1001) one wei above the reserve (1000) — the exact + /// clamp boundary the reviewer hit. Each boundary call must add a whole carried wei to feeCarryOut + /// (>= BPS-FEE_BPS in numerator terms), and Programmable still accrues its share of the collected + /// fee. Without the carry-back the remainder is below one whole wei and the entitlement is lost. + function test_buyMax_clampCarriesUncollectedWeiAndAccruesToProgrammable() public { + hook.initialise(); + uint256 value = 100_099; // 100*1000 + 99: maxFee 1000, ethForSwap 99_099, inclusive fee 1001 + + hook.buyMax{ value: value }(0, block.timestamp + 600); + uint256 carryOne = hook.outFeeCarry(); + uint256 launcherOne = hook.launcherFeesAccrued(); + assertGe(carryOne, BPS - FEE_BPS, "clamped wei shed instead of carried"); + assertGt(launcherOne, 0, "Programmable did not accrue from the collected buyMax fee"); + + hook.buyMax{ value: value }(0, block.timestamp + 600); + assertGt(hook.outFeeCarry(), carryOne, "second boundary call did not carry an additional wei"); + assertGt(hook.launcherFeesAccrued(), launcherOne, "Programmable accrual did not grow across calls"); + } + + /*////////////////////////////////////////////////////////// + REAL FEE PATHS + //////////////////////////////////////////////////////////*/ + + /// A third-party pool swap routes its 1% fee through the split. + function test_poolSwapFeeIsSplit() public { + hook.initialise(); + + uint256 ethIn = UNDER_CAP_ETH; + _swap(true, -int256(ethIn), ethIn); + + uint256 fee = ethIn * FEE_BPS / BPS; + assertEq(_feesHeld(), fee, "total fee captured"); + assertEq(hook.builderFeesAccrued(), fee * 1000 / BPS, "builder cut"); + assertEq(hook.launcherFeesAccrued(), fee * 1000 / BPS, "launcher cut"); + assertEq(hook.escrowBalance(), fee - 2 * (fee * 1000 / BPS), "holder pool"); + } + + /// The hook-market buy path (which bypasses swap callbacks) splits the same way. + function test_buyNFTFeeIsSplit() public { + hook.initialise(); + + uint256 escrowBefore = hook.escrowBalance(); + hook.buyNFT{ value: 1 ether }(1 ether, FAR); + + uint256 builderCut = hook.builderFeesAccrued(); + uint256 launcherCut = hook.launcherFeesAccrued(); + uint256 holderDelta = hook.escrowBalance() - escrowBefore; + + assertGt(builderCut, 0, "builder accrued"); + assertEq(builderCut, launcherCut, "equal 10% cuts"); + // holderDelta = fee - 2 * floor(fee/10); 8 * floor(fee/10) differs by at most fee % 10. + assertApproxEqAbs(holderDelta, 8 * builderCut, 9, "holders get ~80%"); + assertEq(_feesHeld(), builderCut + launcherCut + holderDelta, "conservation vs ETH actually held"); + } + + /// Donations are gifts to holders, not swap fees: never split. + function test_donationIsNotSplit() public { + hook.donate{ value: 1 ether }(); + + assertEq(hook.builderFeesAccrued(), 0, "builder cut on a donation"); + assertEq(hook.launcherFeesAccrued(), 0, "launcher cut on a donation"); + assertEq(hook.escrowBalance(), 1 ether, "donation goes to holders whole"); + } + + /*////////////////////////////////////////////////////////// + CLAIMS + //////////////////////////////////////////////////////////*/ + + function _accrueRealFees() internal returns (uint256 fee) { + hook.initialise(); + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); + fee = UNDER_CAP_ETH * FEE_BPS / BPS; + } + + function test_claimBuilderFees_paysAndZeroes() public { + uint256 fee = _accrueRealFees(); + uint256 cut = fee * 1000 / BPS; + + vm.prank(builder); + uint256 paid = hook.claimBuilderFees(); + + assertEq(paid, cut, "claim amount"); + assertEq(builder.balance, cut, "builder received ETH"); + assertEq(hook.builderFeesAccrued(), 0, "accrual zeroed"); + } + + function test_claimBuilderFees_revertsForStranger() public { + _accrueRealFees(); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NotBuilder.selector); + hook.claimBuilderFees(); + } + + function test_claimBuilderFees_revertsWhenNothingAccrued() public { + vm.prank(builder); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claimBuilderFees(); + } + + function test_claimLauncherFees_paysAndZeroes() public { + uint256 fee = _accrueRealFees(); + uint256 cut = fee * 1000 / BPS; + + vm.prank(launcher); + uint256 paid = hook.claimLauncherFees(); + + assertEq(paid, cut, "claim amount"); + assertEq(launcher.balance, cut, "launcher received ETH"); + assertEq(hook.launcherFeesAccrued(), 0, "accrual zeroed"); + } + + function test_claimLauncherFees_revertsForStranger() public { + _accrueRealFees(); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NotLauncher.selector); + hook.claimLauncherFees(); + } + + /// Holder claims still work alongside the new accruals and never touch them. + function test_holderClaimLeavesCutsIntact() public { + hook.initialise(); + uint256 tokenId = hook.buyNFT{ value: 1 ether }(1 ether, FAR); + vm.roll(block.number + 1); + + // A second fee event accrues to the now-circulating holder. + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); + + uint256 builderBefore = hook.builderFeesAccrued(); + uint256 launcherBefore = hook.launcherFeesAccrued(); + assertGt(builderBefore, 0, "builder accrued"); + + uint256[] memory ids = new uint256[](1); + ids[0] = tokenId; + uint256 paid = hook.claim(ids); + + assertGt(paid, 0, "holder claimed"); + assertEq(hook.builderFeesAccrued(), builderBefore, "builder accrual untouched"); + assertEq(hook.launcherFeesAccrued(), launcherBefore, "launcher accrual untouched"); + } + + function test_buyRefundRevertsAndRollsBackForRejectingRecipient() public { + hook.initialise(); + vm.deal(alice, 1 ether); + _rejectEthAt(alice); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.EthTransferFailed.selector); + hook.buyNFT{ value: 1 ether }(1 ether, FAR); + + assertEq(nft.balanceOf(alice), 0, "acquisition rolled back"); + assertEq(hook.builderFeesAccrued(), 0, "builder accrual rolled back"); + assertEq(hook.launcherFeesAccrued(), 0, "launcher accrual rolled back"); + } + + function test_sellPayoutRevertsAndRollsBackForRejectingRecipient() public { + hook.initialise(); + vm.deal(alice, 1 ether); + vm.prank(alice); + uint256 tokenId = hook.buyNFT{ value: 1 ether }(1 ether, FAR); + uint256 builderBefore = hook.builderFeesAccrued(); + uint256 launcherBefore = hook.launcherFeesAccrued(); + _rejectEthAt(alice); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.EthTransferFailed.selector); + hook.sellNFT(tokenId, 0, FAR); + + assertEq(nft.ownerOf(tokenId), alice, "release rolled back"); + assertEq(hook.builderFeesAccrued(), builderBefore, "builder accrual rolled back"); + assertEq(hook.launcherFeesAccrued(), launcherBefore, "launcher accrual rolled back"); + } + + function test_holderClaimRevertsAndRollsBackForRejectingRecipient() public { + hook.initialise(); + vm.deal(alice, 1 ether); + vm.prank(alice); + uint256 tokenId = hook.buyNFT{ value: 1 ether }(1 ether, FAR); + vm.roll(block.number + 1); + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); + uint256[] memory ids = new uint256[](1); + ids[0] = tokenId; + _rejectEthAt(alice); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.EthTransferFailed.selector); + hook.claim(ids); + + assertEq(hook.claimable(alice), 0, "claim materialisation rolled back"); + } + + function test_builderClaimRevertsAndRestoresAccrualForRejectingRecipient() public { + _accrueRealFees(); + uint256 accrued = hook.builderFeesAccrued(); + _rejectEthAt(builder); + + vm.prank(builder); + vm.expectRevert(ShardErrorsV1.EthTransferFailed.selector); + hook.claimBuilderFees(); + + assertEq(hook.builderFeesAccrued(), accrued, "builder accrual restored"); + } + + function test_launcherClaimRevertsAndRestoresAccrualForRejectingRecipient() public { + _accrueRealFees(); + uint256 accrued = hook.launcherFeesAccrued(); + _rejectEthAt(launcher); + + vm.prank(launcher); + vm.expectRevert(ShardErrorsV1.EthTransferFailed.selector); + hook.claimLauncherFees(); + + assertEq(hook.launcherFeesAccrued(), accrued, "launcher accrual restored"); + } + + /*////////////////////////////////////////////////////////// + PAYOUT ADDRESS CHANGES + //////////////////////////////////////////////////////////*/ + + function test_setBuilderFeeRecipient_transfersClaimRights() public { + uint256 fee = _accrueRealFees(); + uint256 cut = fee * 1000 / BPS; + address successor = makeAddr("successor"); + + vm.prank(builder); + hook.setBuilderFeeRecipient(successor); + assertEq(hook.builderFeeRecipient(), successor, "recipient updated"); + + // Old recipient is locked out. + vm.prank(builder); + vm.expectRevert(ShardErrorsV1.NotBuilder.selector); + hook.claimBuilderFees(); + + // Successor claims the full accrual, including pre-change fees. + vm.prank(successor); + assertEq(hook.claimBuilderFees(), cut, "successor claims"); + } + + function test_setBuilderFeeRecipient_revertsForStranger() public { + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NotBuilder.selector); + hook.setBuilderFeeRecipient(alice); + } + + function test_setBuilderFeeRecipient_revertsOnZero() public { + vm.prank(builder); + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + hook.setBuilderFeeRecipient(address(0)); + } + + /*////////////////////////////////////////////////////////// + CONSTRUCTION + //////////////////////////////////////////////////////////*/ + + /// @dev BaseHook validates the deployment address before recipient checks run, so the + /// hook must sit at a mined address for the ZeroAddress revert to be reachable. + function test_constructor_rejectsZeroRecipients() public { + (, bytes32 saltA) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(ShardHookV1).creationCode, + abi.encode( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), address(0), builder + ) + ); + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + new ShardHookV1{ salt: saltA }( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), address(0), builder + ); + + (, bytes32 saltB) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(ShardHookV1).creationCode, + abi.encode( + manager, + shard, + TICK_LOWER, + TICK_BAND, + TICK_UPPER, + startSqrtPriceX96, + address(this), + launcher, + address(0) + ) + ); + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + new ShardHookV1{ salt: saltB }( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, address(0) + ); + } +} diff --git a/test/ShardHookAttackV1.t.sol b/test/ShardHookAttackV1.t.sol new file mode 100644 index 00000000..54f3e73f --- /dev/null +++ b/test/ShardHookAttackV1.t.sol @@ -0,0 +1,1648 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams, ModifyLiquidityParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/*////////////////////////////////////////////////////////////// + ATTACKER CONTRACTS +//////////////////////////////////////////////////////////////*/ + +/// @dev Generic third-party swap router, also used as the attacker's execution venue. +contract AttackRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function swap(PoolKey memory key, SwapParams memory params) external payable returns (BalanceDelta delta) { + delta = abi.decode(poolManager.unlock(abi.encode(msg.sender, key, params)), (BalanceDelta)); + uint256 bal = address(this).balance; + if (bal > 0) { + (bool ok,) = msg.sender.call{ value: bal }(""); + require(ok, "refund failed"); + } + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address sender, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + BalanceDelta delta = poolManager.swap(key, params, ""); + _resolve(key.currency0, delta.amount0(), sender); + _resolve(key.currency1, delta.amount1(), sender); + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount, address sender) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), owed); + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, sender, uint256(uint128(amount))); + } + } +} + +/// @dev Tries to burn the hook's locked position from outside. v4 keys positions on +/// `msg.sender`, so this addresses an empty position — but the attempt must be +/// made, not assumed away. +contract LiquidityThief is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function steal(PoolKey memory key, int24 tickLower, int24 tickUpper, int256 liquidityDelta) external { + poolManager.unlock(abi.encode(key, tickLower, tickUpper, liquidityDelta)); + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + (PoolKey memory key, int24 tickLower, int24 tickUpper, int256 liquidityDelta) = + abi.decode(rawData, (PoolKey, int24, int24, int256)); + poolManager.modifyLiquidity( + key, + ModifyLiquidityParams({ + tickLower: tickLower, tickUpper: tickUpper, liquidityDelta: liquidityDelta, salt: bytes32(0) + }), + "" + ); + return ""; + } +} + +/// @dev Re-enters {ShardHookV1-claim} from the payout callback. +contract ReentrantFeeClaimer { + ShardHookV1 public hook; + bool public armed; + uint256[] internal ids; + + function arm(ShardHookV1 h, uint256 id) external { + hook = h; + ids.push(id); + armed = true; + } + + function buy(uint256 value) external payable returns (uint256) { + return hook.buyNFT{ value: value }(type(uint256).max, type(uint256).max); + } + + function setHook(ShardHookV1 h) external { + hook = h; + } + + function disarm() external { + armed = false; + } + + function go() external returns (uint256) { + return hook.claim(ids); + } + + receive() external payable { + if (armed) { + armed = false; + hook.claim(ids); + } + } +} + +/// @dev Re-enters {ShardHookV1-buyNFT} from the excess-ETH refund, i.e. from INSIDE +/// the first buy's `nonReentrant` scope. +contract ReentrantBuyer { + ShardHookV1 public hook; + bool public armed; + + constructor(ShardHookV1 h) { + hook = h; + } + + function attack(uint256 value) external { + armed = true; + hook.buyNFT{ value: value }(type(uint256).max, type(uint256).max); + } + + receive() external payable { + if (armed) { + armed = false; + hook.buyNFT{ value: address(this).balance }(type(uint256).max, type(uint256).max); + } + } +} + +/// @dev Re-enters {ShardHookV1-sellNFT} from the sale payout. +contract ReentrantSeller { + ShardHookV1 public hook; + ShardNFTV1 public nft; + uint256 public secondId; + bool public armed; + + constructor(ShardHookV1 h, ShardNFTV1 n) { + hook = h; + nft = n; + } + + function buy(uint256 value) external returns (uint256) { + return hook.buyNFT{ value: value }(type(uint256).max, type(uint256).max); + } + + function attack(uint256 firstId, uint256 secondId_) external { + secondId = secondId_; + armed = true; + hook.sellNFT(firstId, 0, type(uint256).max); + } + + receive() external payable { + if (armed) { + armed = false; + hook.sellNFT(secondId, 0, type(uint256).max); + } + } +} + +/// @dev Watches for an ERC-721 receive callback that must never fire, and if it +/// does, immediately deposits another token straight into the archive while +/// `_releasing` is still set — the exact corruption the HARD RULE prevents. +contract HostileReceiver { + ShardNFTV1 public nft; + ShardHookV1 public hook; + bool public callbackFired; + uint256 public otherId; + bool public depositOnPayout; + + constructor(ShardHookV1 h) { + hook = h; + } + + function setNft(ShardNFTV1 n) external { + nft = n; + } + + function setOtherId(uint256 id) external { + otherId = id; + } + + function armPayoutDeposit() external { + depositOnPayout = true; + } + + function buy(uint256 value) external returns (uint256) { + return hook.buyNFT{ value: value }(type(uint256).max, type(uint256).max); + } + + function sell(uint256 id) external returns (uint256) { + return hook.sellNFT(id, 0, type(uint256).max); + } + + function depositDirect(uint256 id, address to) external { + nft.transferFrom(address(this), to, id); + } + + function onERC721Received(address, address, uint256 tokenId, bytes calldata) external returns (bytes4) { + callbackFired = true; + // If we ever get here, `_releasing` is set: push another id into the + // archive behind the hook's back. + if (otherId != 0 && otherId != tokenId) { + nft.transferFrom(address(this), address(nft), otherId); + } + return this.onERC721Received.selector; + } + + receive() external payable { + if (depositOnPayout && otherId != 0) { + depositOnPayout = false; + nft.transferFrom(address(this), address(nft), otherId); + } + } +} + +/*////////////////////////////////////////////////////////////// + TESTS +//////////////////////////////////////////////////////////////*/ + +/// @title ShardHookAttackV1Test +/// @notice Adversarial suite. Every test here PROVES AN ATTACK FAILS. +/// +/// The security posture of this project is self-audit plus invariant +/// testing, with NO paid audit, and the liquidity position is locked +/// permanently. Nothing found after launch can be fixed. These tests +/// and `test/invariant/` are the entire safety net. +contract ShardHookAttackV1Test is Test { + using StateLibrary for IPoolManager; + using CurrencyLibrary for Currency; + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant ONE_SHARD = 1 ether; + uint256 internal constant FAR = type(uint256).max; + + /// @dev The size of an ordinary third-party swap here. The hook caps ONE third-party swap + /// at `MAX_BATCH` SHARD (50e18), and at TICK_UPPER the price is ~1e-5 ETH per SHARD — + /// so half an ETH would move ~8,000 SHARD and be refused. This buys ~39 SHARD, inside + /// the cap, and still charges a fee far above dust. Where a test needs the price + /// MOVED rather than merely a fee taken, use {_swapShardOutChunked} instead. + uint256 internal constant UNDER_CAP_ETH = 0.0004 ether; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + AttackRouter internal router; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + address internal carol = address(0xCA401); + address internal attacker = address(0xBADBAD); + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + router = new AttackRouter(manager); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardHookAttackV1Test"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + vm.deal(address(this), 100_000 ether); + vm.deal(alice, 10_000 ether); + vm.deal(bob, 10_000 ether); + vm.deal(carol, 10_000 ether); + vm.deal(attacker, 10_000 ether); + vm.roll(block.number + 1); + } + + receive() external payable { } + + /*////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////*/ + + function test_setupUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + function _feeAssets() internal view returns (uint256) { + return address(hook).balance + manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()); + } + + function _positionLiquidity() internal view returns (uint128 liquidity) { + (liquidity,,) = manager.getPositionInfo(poolId, address(hook), TICK_LOWER, TICK_UPPER, bytes32(0)); + } + + function _buy(address who, uint256 send) internal returns (uint256 tokenId, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + tokenId = hook.buyNFT{ value: send }(FAR, FAR); + spent = before - who.balance; + } + + function _buyMany(address who, uint256 count, uint256 send) internal returns (uint256[] memory ids, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + ids = hook.buyMany{ value: send }(count, FAR, FAR); + spent = before - who.balance; + } + + function _buyMax(address who, uint256 send) internal returns (uint256[] memory ids, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + ids = hook.buyMax{ value: send }(0, FAR); + spent = before - who.balance; + } + + function _assertBacking(string memory what) internal view { + assertEq(shard.balanceOf(address(hook)), nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), what); + } + + function _swapEthIn(address who, uint256 amount) internal { + vm.prank(who); + router.swap{ value: amount }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(amount), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + } + + /// @dev Walk the price the way one large swap used to. A single third-party swap may move + /// at most `MAX_BATCH` SHARD, so the same total is taken out of the pool in cap-sized + /// exact-output steps. Exact-output because the SHARD leg is what the cap measures, + /// so specifying it is what makes each step land exactly on the allowed size. + function _swapShardOutChunked(address who, uint256 shardsOut) internal { + uint256 maxPerSwap = hook.MAX_BATCH() * ONE_SHARD; + while (shardsOut > 0) { + uint256 chunk = shardsOut > maxPerSwap ? maxPerSwap : shardsOut; + vm.prank(who); + router.swap{ value: who.balance }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(chunk), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + shardsOut -= chunk; + } + } + + function _swapShardIn(address who, uint256 amount) internal { + vm.startPrank(who); + shard.approve(address(router), type(uint256).max); + router.swap( + key, + SwapParams({ + zeroForOne: false, amountSpecified: -int256(amount), sqrtPriceLimitX96: TickMath.MAX_SQRT_PRICE - 1 + }) + ); + vm.stopPrank(); + } + + /// @dev v4 wraps hook reverts in `Hooks.WrappedError`, so the custom error is + /// nested rather than top-level. Scan the returndata for the selector. + function _revertsWith(bytes memory ret, bytes4 sel) internal pure returns (bool) { + if (ret.length < 4) return false; + for (uint256 i = 0; i + 4 <= ret.length; i++) { + if (ret[i] == sel[0] && ret[i + 1] == sel[1] && ret[i + 2] == sel[2] && ret[i + 3] == sel[3]) return true; + } + return false; + } + + /// @dev A second, deliberately UN-initialised stack, for the pre-seed window. + struct Fresh { + ShardTokenV1 shard; + ShardHookV1 hook; + ShardNFTV1 nft; + PoolKey key; + } + + function _freshStack(bool wireNft) internal returns (Fresh memory f) { + f.shard = new ShardTokenV1(); + (address expected, bytes32 salt) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(ShardHookV1).creationCode, + abi.encode( + manager, f.shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, builder + ) + ); + f.hook = new ShardHookV1{ salt: salt }( + manager, f.shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, builder + ); + assertEq(address(f.hook), expected, "fresh hook address mismatch"); + + if (wireNft) { + f.nft = new ShardNFTV1(address(f.hook), address(renderer)); + f.hook.setNFT(IShardNFTV1(address(f.nft))); + } + + f.key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(f.shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(f.hook)) + }); + + f.shard.transfer(address(f.hook), SEED_AMOUNT); + // NOT initialised on purpose. + } + + /*////////////////////////////////////////////////////////// + 1. THE PERMANENTLY LOCKED LP + //////////////////////////////////////////////////////////*/ + + /// The entire trust model. There must be no reachable path — behind any guard, + /// owner, timelock or "emergency" — that reduces the seed position. If this + /// ever fails there is no upgrade path and the protocol is dead. + function test_ATTACK_cannotWithdrawLiquidity() public { + uint128 seeded = _positionLiquidity(); + assertGt(seeded, 0, "sanity: nothing was seeded"); + + // (a) Probe every plausible withdrawal selector on the hook itself. None + // of these may exist, and none may succeed if they somehow do. + string[16] memory sigs = [ + "withdraw()", + "withdraw(uint256)", + "withdrawLiquidity(uint256)", + "removeLiquidity(uint256)", + "removeLiquidity(int256)", + "emergencyWithdraw()", + "emergencyWithdraw(address)", + "rescue(address,uint256)", + "rescueTokens(address,uint256)", + "sweep(address)", + "sweepTo(address,uint256)", + "collect()", + "collectFees(address)", + "burn(uint256)", + "modifyLiquidity(int256)", + "unseed()" + ]; + for (uint256 i = 0; i < sigs.length; i++) { + bytes4 sel = bytes4(keccak256(bytes(sigs[i]))); + vm.prank(attacker); + (bool ok,) = address(hook).call(abi.encodeWithSelector(sel, uint256(1), uint256(1))); + assertFalse(ok, string.concat("A LIQUIDITY EXIT EXISTS: ", sigs[i])); + } + + // (b) The hook's own unlock callback is the only place modifyLiquidity is + // reachable. It must reject any caller other than the PoolManager, + // including a forged SEED_LIQUIDITY payload. + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotPoolManager.selector); + hook.unlockCallback(abi.encode(uint8(0), SEED_AMOUNT)); + + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotPoolManager.selector); + hook.unlockCallback(abi.encode(uint8(1), uint256(1 ether))); + + // (c) Go at the PoolManager directly. v4 keys positions on msg.sender, so + // the thief addresses an empty position of its own, not the hook's. + LiquidityThief thief = new LiquidityThief(manager); + vm.deal(address(thief), 100 ether); + vm.expectRevert(); + thief.steal(key, TICK_LOWER, TICK_UPPER, -int256(uint256(seeded))); + + // (d) `initialise` is one-shot, so it cannot be replayed into a second + // modifyLiquidity call either. + vm.prank(address(factory)); + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.initialise(); + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.initialise(); + + // (e) Trade hard against the pool, then re-check. Trading must never + // shrink the position. The same ~3,300 SHARD one 0.05 ETH swap used to + // move, now walked in steps of the 50 SHARD swap cap. + _swapShardOutChunked(attacker, 3300 ether); + _buy(alice, 10 ether); + vm.roll(block.number + 1); + vm.prank(alice); + hook.sellNFT(1, 0, FAR); + + assertEq(_positionLiquidity(), seeded, "THE LOCKED POSITION MOVED"); + assertEq(hook.seedLiquidity(), seeded, "seedLiquidity record changed"); + } + + /*////////////////////////////////////////////////////////// + 2. POOL / INITIALISATION GUARDS + //////////////////////////////////////////////////////////*/ + + /// @dev Asserts the pool was rejected BY THE HOOK for being the wrong pool, not + /// incidentally by some unrelated v4 validation. + function _expectWrongPool(PoolKey memory k, string memory what) internal { + vm.prank(attacker); + (bool ok, bytes memory ret) = + address(manager).call(abi.encodeWithSelector(IPoolManager.initialize.selector, k, startSqrtPriceX96)); + assertFalse(ok, string.concat("A FOREIGN POOL WAS BOUND TO THIS HOOK: ", what)); + assertTrue( + _revertsWith(ret, ShardErrorsV1.WrongPool.selector), string.concat("rejected, but not as WrongPool: ", what) + ); + } + + /// A foreign pool bound to this hook would route fees denominated in the WRONG + /// currency into `_distribute`, inflating `accFeePerNFT` against ETH the hook + /// does not hold and permanently bricking `claim` for real holders. + function test_ATTACK_cannotInitialiseForeignPool() public { + Currency usdc = Currency.wrap(address(type(uint160).max)); // sorts above SHARD + + // SHARD / "USDC" against this hook. + PoolKey memory foreign = PoolKey({ + currency0: Currency.wrap(address(shard)), + currency1: usdc, + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + _expectWrongPool(foreign, "SHARD/USDC"); + + // ETH / "USDC" against this hook. + foreign.currency0 = CurrencyLibrary.ADDRESS_ZERO; + foreign.currency1 = usdc; + _expectWrongPool(foreign, "ETH/USDC"); + + // The canonical pair but a different fee tier — a distinct PoolId, so a + // distinct fee stream the hook would mis-attribute. + PoolKey memory wrongFee = key; + wrongFee.fee = 3000; + _expectWrongPool(wrongFee, "wrong fee tier"); + + // The canonical pair but a different tick spacing. + PoolKey memory wrongSpacing = key; + wrongSpacing.tickSpacing = 10; + _expectWrongPool(wrongSpacing, "wrong tick spacing"); + + // And nothing leaked into the accumulator. + assertEq(hook.accFeePerNFT(), 0, "a foreign pool moved the accumulator"); + assertEq(hook.escrowBalance(), 0, "a foreign pool escrowed a fee"); + } + + /// Below `tickUpper` the seed would demand ETH the hook does not have — a + /// permanent, unfixable grief. Far above it, the first buyer collapses the + /// price for free. + function test_ATTACK_cannotFrontRunInitialiseAtDifferentPrice() public { + Fresh memory f = _freshStack(true); + + uint160[4] memory bad = [ + TickMath.getSqrtPriceAtTick(TICK_UPPER - TICK_SPACING), + TickMath.getSqrtPriceAtTick(TICK_UPPER + TICK_SPACING), + TickMath.getSqrtPriceAtTick(0), + TickMath.getSqrtPriceAtTick(TickMath.maxUsableTick(TICK_SPACING)) + ]; + for (uint256 i = 0; i < bad.length; i++) { + vm.prank(attacker); + (bool ok, bytes memory ret) = + address(manager).call(abi.encodeWithSelector(IPoolManager.initialize.selector, f.key, bad[i])); + assertFalse(ok, "A FRONT-RUNNER SET THE START PRICE"); + assertTrue(_revertsWith(ret, ShardErrorsV1.WrongStartPrice.selector), "rejected, but not for the price"); + } + + // Initialising at the CANONICAL price is tolerated by design: the hook + // swallows PoolAlreadyInitialized and seeds anyway. Prove the front-run + // is a no-op rather than a denial of service. + vm.prank(attacker); + manager.initialize(f.key, startSqrtPriceX96); + + uint128 liquidity = f.hook.initialise(); + assertGt(liquidity, 0, "front-run bricked the seed"); + assertEq(f.shard.balanceOf(address(f.hook)), f.hook.seedDust(), "seed did not go in"); + } + + /// The pre-seed window: a front-runner who created the canonical pool must not + /// be able to trade the price away before the hook seeds it. + function test_ATTACK_cannotSwapBeforeInitialise() public { + Fresh memory f = _freshStack(true); + + vm.prank(attacker); + manager.initialize(f.key, startSqrtPriceX96); + + vm.prank(attacker); + (bool ok, bytes memory ret) = address(router).call{ value: 1 ether }( + abi.encodeCall( + AttackRouter.swap, + ( + f.key, + SwapParams({ + zeroForOne: true, + amountSpecified: -int256(uint256(1 ether)), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ) + ) + ); + assertFalse(ok, "THE PRE-SEED POOL WAS TRADEABLE"); + assertTrue(_revertsWith(ret, ShardErrorsV1.NotInitialised.selector), "rejected, but not as NotInitialised"); + + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotInitialised.selector); + f.hook.buyNFT{ value: 1 ether }(FAR, FAR); + + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotInitialised.selector); + f.hook.redeem(); + + // The guard must lift once seeded — otherwise it is itself a permanent grief. + // Sized to the 50 SHARD swap cap: what is asserted is that the swap goes through. + f.hook.initialise(); + vm.prank(attacker); + router.swap{ value: UNDER_CAP_ETH }( + f.key, + SwapParams({ + zeroForOne: true, + amountSpecified: -int256(UNDER_CAP_ETH), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + assertGt(f.shard.balanceOf(attacker), 0, "the guard never lifted"); + } + + /// A malicious NFT bound here could drain the accumulator via + /// `settleOnTransfer` across all 10,000 ids. + function test_ATTACK_cannotHijackSetNft() public { + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.setNFT(IShardNFTV1(address(0xDEAD))); + + // Even the deployer cannot rebind it. + vm.prank(address(factory)); + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.setNFT(IShardNFTV1(address(0xDEAD))); + assertEq(address(hook.nft()), address(nft), "the NFT binding moved"); + + // On an unbound hook the gate is the deployer check, not "first come". + Fresh memory f = _freshStack(false); + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + f.hook.setNFT(IShardNFTV1(address(0xDEAD))); + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + f.hook.setNFT(IShardNFTV1(address(0))); + + // And nothing but the real NFT may drive fee settlement. + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotNFT.selector); + hook.settleOnTransfer(1, attacker, attacker); + } + + /*////////////////////////////////////////////////////////// + 3. FEE-TIMING ATTACKS + //////////////////////////////////////////////////////////*/ + + /// The same-block accrual guard exists for exactly this: buy in, capture the + /// block's fees, leave. The sniper must end DOWN, and the fees must land on + /// the holders who were already there. + function test_ATTACK_sameBlockFeeSnipeEarnsNothing() public { + // A genuine holder, from an earlier block. + (uint256 bobId,) = _buy(bob, 5 ether); + vm.roll(block.number + 1); + + uint256 attackerStart = attacker.balance; + + // --- everything below happens in ONE block --- + (uint256 snipeId,) = _buy(attacker, 5 ether); + + // A large third-party flow, round-tripped so the price ends roughly where + // it started. This isolates FEE capture from price movement. + _swapEthIn(carol, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + _swapShardIn(carol, shard.balanceOf(carol)); + + vm.prank(attacker); + hook.sellNFT(snipeId, 0, FAR); + + uint256[] memory ids = new uint256[](1); + ids[0] = snipeId; + vm.prank(attacker); + try hook.claim(ids) returns (uint256 paid) { + assertEq(paid, 0, "THE SNIPER WAS PAID"); + } catch { } + // --- end of block --- + + assertEq(hook.claimable(attacker), 0, "sniper accrued a claimable balance"); + assertLt(attacker.balance, attackerStart, "THE SAME-BLOCK FEE SNIPE WAS PROFITABLE"); + + // The fees went to the holder who was actually there. + uint256[] memory bobIds = new uint256[](1); + bobIds[0] = bobId; + vm.prank(bob); + uint256 bobPaid = hook.claim(bobIds); + assertGt(bobPaid, 0, "the real holder got nothing"); + } + + /// The variant that routes around `sellNFT`: acquire, then hand the token on + /// with a plain ERC-721 transfer inside the acquisition block. `settleOnTransfer` + /// must credit nothing. + function test_ATTACK_transferInAcquisitionBlockEarnsNothing() public { + (uint256 bobId,) = _buy(bob, 5 ether); + vm.roll(block.number + 1); + + // --- one block --- + (uint256 snipeId,) = _buy(attacker, 5 ether); + _swapEthIn(carol, UNDER_CAP_ETH); // fees for this block; sized to the 50 SHARD swap cap + + vm.prank(attacker); + nft.transferFrom(attacker, alice, snipeId); + // --- end of block --- + + assertEq(hook.claimable(attacker), 0, "TRANSFER-OUT SNIPED THE BLOCK'S FEES"); + assertEq(hook.claimable(alice), 0, "the receiver was credited for a block it did not hold"); + + uint256[] memory ids = new uint256[](2); + ids[0] = bobId; + ids[1] = snipeId; + + // The sniper cannot even settle themselves into a payout. + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claim(ids); + + // The whole block's fee belongs to the holder who was already there. + vm.prank(bob); + uint256 bobPaid = hook.claim(ids); + assertGt(bobPaid, 0, "the pre-existing holder earned nothing"); + assertEq(hook.claimable(attacker), 0, "the sniper accrued after the fact"); + assertEq(hook.claimable(alice), 0, "the receiver accrued for a block it did not hold"); + } + + /*////////////////////////////////////////////////////////// + 4. THE ART-REROLL ECONOMICS + //////////////////////////////////////////////////////////*/ + + /// Art regenerates on every acquisition from the archive. If a round trip were + /// cheap, the collection would be an infinite free reroll and the art means + /// nothing. The 2% round trip is the entire defence — and there is deliberately + /// no NFT-to-SHARD path that would sidestep it. + function test_ATTACK_cannotRerollArtForFree() public { + (uint256 id, uint256 spent) = _buy(attacker, 5 ether); + uint256 seedBefore = nft.tokenSeed(id); + assertGt(seedBefore, 0, "no art was generated"); + + vm.prank(attacker); + uint256 payout = hook.sellNFT(id, 0, FAR); + + uint256 cost = spent - payout; + assertGt(cost, spent * 15 / 1000, "A REROLL COSTS LESS THAN 1.5% - the loop is open"); + assertLt(cost, spent * 30 / 1000, "round trip costs more than 3%"); + + // Rerolling gets you different art, but you paid for it. + (uint256 id2,) = _buy(attacker, 5 ether); + assertEq(id2, id, "the archive did not hand back the same id"); + assertTrue(nft.tokenSeed(id2) != seedBefore, "sanity: the art did not change"); + + // There is no cheaper door. Any NFT-to-SHARD path would be one. + string[6] memory forbidden = [ + "deposit(uint256)", + "depositNFT(uint256)", + "unwrap(uint256)", + "wrap(uint256)", + "burnNFT(uint256)", + "exchange(uint256)" + ]; + for (uint256 i = 0; i < forbidden.length; i++) { + vm.prank(attacker); + (bool ok,) = address(hook).call(abi.encodeWithSelector(bytes4(keccak256(bytes(forbidden[i]))), id2)); + assertFalse(ok, string.concat("AN NFT-TO-SHARD PATH EXISTS: ", forbidden[i])); + } + } + + /// v4 skips a hook's own callbacks, so `buyNFT` must charge the 1% by hand. If + /// it ever stops, buying is free relative to swap-then-redeem and the reroll + /// loop opens from the other side. + function test_ATTACK_buyNftIsNotAFreeFeeBypass() public { + uint256 snap = vm.snapshotState(); + + // Path A: buyNFT. + uint256 f0 = _feeAssets(); + (, uint256 buyCost) = _buy(alice, 5 ether); + uint256 buyFee = _feeAssets() - f0; + + vm.revertToState(snap); + + // Path B: third-party swap for exactly 1 SHARD, then redeem. + uint256 g0 = _feeAssets(); + uint256 before = bob.balance; + vm.startPrank(bob); + router.swap{ value: 5 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(ONE_SHARD), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + uint256 swapCost = before - bob.balance; + shard.approve(address(hook), type(uint256).max); + hook.redeem(); + vm.stopPrank(); + uint256 swapFee = _feeAssets() - g0; + + assertGt(buyFee, 0, "buyNFT IS FREE - the explicit charge is gone"); + assertApproxEqRel(buyFee, swapFee, 0.01e18, "buyNFT is a cheaper door than swap-then-redeem"); + assertApproxEqRel(buyCost, swapCost, 0.01e18, "the two entry paths diverge in total cost"); + assertGe(buyFee, swapFee * 99 / 100, "BUYNFT UNDERCHARGES relative to the swap path"); + } + + /*////////////////////////////////////////////////////////// + 5. NFT CUSTODY AND REENTRANCY + //////////////////////////////////////////////////////////*/ + + /// A plain `transferFrom` into the archive would strand the id: no `_markHeld`, + /// no `_circulating` decrement, no hook accounting — a permanent hole in the + /// core backing invariant on a contract with no upgrade path. + function test_ATTACK_directNftTransferStrandsNothing() public { + (uint256 id,) = _buy(alice, 5 ether); + uint256 circBefore = nft.circulatingSupply(); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.transferFrom(alice, address(nft), id); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.transferFrom(alice, address(hook), id); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.safeTransferFrom(alice, address(nft), id); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.safeTransferFrom(alice, address(hook), id); + + // An approved operator is no better. + vm.prank(alice); + nft.setApprovalForAll(attacker, true); + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.transferFrom(alice, address(nft), id); + + assertEq(nft.ownerOf(id), alice, "the token moved anyway"); + assertEq(nft.circulatingSupply(), circBefore, "circulating supply drifted"); + assertEq( + shard.balanceOf(address(hook)), nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), "SHARD backing broke" + ); + } + + /// `_releasing` bypasses the direct-deposit guard, and ShardNFTV1 has no + /// reentrancy guard. It is safe ONLY because no external call happens while the + /// flag is set — i.e. because acquire/release use `_mint`/`_transfer` and NEVER + /// the `_safe*` variants. This proves the receiver hook never fires. + function test_ATTACK_reentrantTransferDuringReleasingFails() public { + HostileReceiver hostile = new HostileReceiver(hook); + hostile.setNft(nft); + vm.deal(address(hostile), 100 ether); + + uint256 first = hostile.buy(5 ether); + uint256 second = hostile.buy(5 ether); + hostile.setOtherId(second); + + // Acquisition must NOT call onERC721Received. If it did, the receiver could + // deposit `second` into the archive while `_releasing` was still set. + uint256 third = hostile.buy(5 ether); + assertFalse(hostile.callbackFired(), "ACQUIRE USES A _safe* VARIANT - the archive is reentrable"); + assertEq(nft.ownerOf(third), address(hostile), "sanity: the buy failed"); + + // Release must not call it either. + vm.roll(block.number + 1); + hostile.sell(third); + assertFalse(hostile.callbackFired(), "RELEASE USES A _safe* VARIANT"); + + // And the direct route into the archive stays shut for a contract too. + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + hostile.depositDirect(second, address(nft)); + + // Finally: re-enter from the sale payout, the one external call the hook + // does make. The deposit guard must reject it, which unwinds the sale. + hostile.armPayoutDeposit(); + vm.expectRevert(); + hostile.sell(first); + + assertEq(nft.ownerOf(first), address(hostile), "the sale half-completed"); + assertEq(nft.ownerOf(second), address(hostile), "an id was stranded in the archive"); + assertEq( + shard.balanceOf(address(hook)), nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), "SHARD backing broke" + ); + } + + /// The refund at the end of `buyNFT` is an external call to the buyer. It sits + /// inside `nonReentrant` on purpose. + function test_ATTACK_reentrantBuyDuringUnlockFails() public { + ReentrantBuyer buyer = new ReentrantBuyer(hook); + vm.deal(address(buyer), 100 ether); + + vm.expectRevert(); + buyer.attack(50 ether); // huge overpay guarantees a refund, hence a callback + + assertEq(nft.circulatingSupply(), 0, "a reentrant buy minted something"); + assertEq(nft.lowestAvailableId(), 1, "the archive pointer moved"); + } + + /// The same for the sale payout. + function test_ATTACK_reentrantSellDuringUnlockFails() public { + ReentrantSeller seller = new ReentrantSeller(hook, nft); + vm.deal(address(seller), 100 ether); + + uint256 a = seller.buy(5 ether); + uint256 b = seller.buy(5 ether); + vm.roll(block.number + 1); + + vm.expectRevert(); + seller.attack(a, b); + + assertEq(nft.ownerOf(a), address(seller), "the reentrant sale went through"); + assertEq(nft.ownerOf(b), address(seller), "the reentrant sale went through"); + assertEq(nft.circulatingSupply(), 2, "supply accounting drifted"); + } + + /*////////////////////////////////////////////////////////// + 6. CLAIM ATTACKS + //////////////////////////////////////////////////////////*/ + + /// Solvency, not conservation: a same-block double claim once produced 2 ether + /// of claims against 1 ether of fees. Repeat ids in one call, and repeat calls + /// in one block, must both be worthless. + function test_ATTACK_cannotClaimTwice() public { + (uint256 id,) = _buy(alice, 5 ether); + vm.roll(block.number + 1); + + _swapEthIn(carol, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + // Nothing has been paid out yet, so everything the hook holds — in ETH and + // in un-swept ERC-6909 claims — is exactly every fee ever taken. + uint256 feesTaken = _feeAssets(); + assertGt(feesTaken, 0, "sanity: no fee was taken"); + + // Repeat the same id four times in a single call. + uint256[] memory dupes = new uint256[](4); + for (uint256 i = 0; i < 4; i++) { + dupes[i] = id; + } + vm.prank(alice); + uint256 paid = hook.claim(dupes); + assertGt(paid, 0, "sanity: nothing paid"); + assertLe(paid, feesTaken, "DUPLICATE IDS PAID MORE THAN WAS EVER COLLECTED"); + + // Same block, again. + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claim(dupes); + + // Next block, with no new fees, still nothing. + vm.roll(block.number + 1); + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claim(dupes); + + assertLe(paid + hook.claimable(alice), feesTaken, "INSOLVENT: claims exceed fees taken"); + } + + /// Settlement is permissionless by design. It must credit the OWNER, never the + /// caller — otherwise anyone drains every holder by passing their ids. + function test_ATTACK_cannotStealAnotherHoldersFees() public { + (uint256 aliceId,) = _buy(alice, 5 ether); + (uint256 attackerId,) = _buy(attacker, 5 ether); + vm.roll(block.number + 1); + _swapEthIn(carol, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + + // Case 1: an attacker holding NOTHING passes a holder's id. There is no + // payout to take, so the call reverts and no ETH moves. + uint256 carolBefore = carol.balance; + uint256[] memory aliceOnly = new uint256[](1); + aliceOnly[0] = aliceId; + vm.prank(carol); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claim(aliceOnly); + assertEq(carol.balance, carolBefore, "A NON-HOLDER WAS PAID SOMEONE ELSE'S FEES"); + + // Case 2: an attacker who DOES hold something passes everyone's ids, so the + // call succeeds. Settlement must still credit each id's OWNER, and pay the + // caller only their own balance. + uint256[] memory everything = new uint256[](2); + everything[0] = aliceId; + everything[1] = attackerId; + + uint256 attackerBefore = attacker.balance; + vm.prank(attacker); + uint256 paid = hook.claim(everything); + + assertEq(attacker.balance - attackerBefore, paid, "payout mismatch"); + assertGt(hook.claimable(alice), 0, "the rightful owner was NOT credited"); + assertApproxEqAbs(paid, hook.claimable(alice), 2, "THE ATTACKER TOOK MORE THAN THEIR OWN SHARE"); + assertEq(hook.claimable(attacker), 0, "the attacker kept a residual balance"); + + // And alice can still take every wei of hers. + uint256 owed = hook.claimable(alice); + vm.prank(alice); + assertEq(hook.claim(aliceOnly), owed, "the owner's balance was skimmed"); + } + + function test_ATTACK_reentrantClaimFails() public { + ReentrantFeeClaimer claimer = new ReentrantFeeClaimer(); + claimer.setHook(hook); + vm.deal(address(claimer), 100 ether); + + uint256 id = claimer.buy(5 ether); + vm.roll(block.number + 1); + _swapEthIn(carol, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + + uint256 balanceBefore = address(claimer).balance; + uint256 owedBefore = _feeAssets(); + + claimer.arm(hook, id); + vm.expectRevert(); + claimer.go(); + + // Nothing was paid and nothing was zeroed. + assertEq(address(claimer).balance, balanceBefore, "the reentrant claim moved ETH"); + assertEq(_feeAssets(), owedBefore, "the reentrant claim drained the hook"); + // Disarmed, a single honest claim still works — the guard is not a brick. + claimer.disarm(); + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.prank(address(claimer)); + uint256 paid = hook.claim(ids); + assertGt(paid, 0, "the reentrancy guard permanently bricked claiming"); + } + + /*////////////////////////////////////////////////////////// + 7. REDEEM AND SUPPLY LIMITS + //////////////////////////////////////////////////////////*/ + + function test_ATTACK_cannotRedeemWithoutShards() public { + // No balance, no approval. + vm.prank(attacker); + vm.expectRevert(); + hook.redeem(); + + // Approval but no balance. + vm.startPrank(attacker); + shard.approve(address(hook), type(uint256).max); + vm.expectRevert(); + hook.redeem(); + vm.stopPrank(); + + // A partial balance is not enough — the price of an NFT is exactly 1 SHARD. + vm.prank(attacker); + router.swap{ value: 5 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(ONE_SHARD / 2), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + assertEq(shard.balanceOf(attacker), ONE_SHARD / 2, "sanity: wrong balance"); + vm.prank(attacker); + vm.expectRevert(); + hook.redeem(); + + assertEq(nft.circulatingSupply(), 0, "an NFT was minted without a shard"); + assertEq( + shard.balanceOf(address(hook)), nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), "SHARD backing broke" + ); + } + + /// 10,000 is the whole collection. The 10,001st acquisition must revert, and + /// `_advanceLowest` must terminate rather than scanning forever. + function test_ATTACK_cannotMintPastTenThousand() public { + uint256 max = nft.MAX_SUPPLY(); + + vm.startPrank(address(hook)); + for (uint256 i = 1; i <= max; i++) { + uint256 id = nft.acquire(alice, i); + assertEq(id, i, "ids were not handed out in order"); + } + vm.stopPrank(); + + assertEq(nft.circulatingSupply(), max, "circulating supply is wrong"); + assertEq(nft.lowestAvailableId(), max + 1, "_advanceLowest did not terminate at 10_001"); + + vm.prank(address(hook)); + vm.expectRevert(ShardErrorsV1.PoolExhausted.selector); + nft.acquire(attacker, 1); + + // And the market path reverts too, rather than taking money for nothing. + uint256 balanceBefore = attacker.balance; + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.PoolExhausted.selector); + hook.buyNFT{ value: 50 ether }(FAR, FAR); + assertEq(attacker.balance, balanceBefore, "ETH was taken for a mint that could not happen"); + } + + /*////////////////////////////////////////////////////////// + 8. GRIEFING / MEV + //////////////////////////////////////////////////////////*/ + + /// Fees are shared by integer division. Someone spamming sub-wei-per-holder + /// distributions must not be able to strand value: the remainder is carried in + /// SCALED units, so nothing is lost, only deferred. + function test_ATTACK_dustCannotBeGriefedToStrandFunds() public { + address[3] memory holders = [alice, bob, carol]; + uint256[] memory ids = new uint256[](3); + for (uint256 i = 0; i < 3; i++) { + (ids[i],) = _buy(holders[i], 5 ether); + } + vm.roll(block.number + 1); + + // 1-wei and 2-wei distributions: with 3 holders each one divides to zero + // and lands entirely in `dustScaled`. + uint256 landed; + for (uint256 i = 0; i < 60; i++) { + uint256 amount = (i % 2 == 0) ? 100 wei : 199 wei; + vm.prank(attacker); + try router.swap{ value: amount }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(amount), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ) { + landed++; + } catch { } + } + assertGt(landed, 0, "sanity: no dust distributions landed"); + + // Nothing has been claimed yet, so what the hook holds IS every fee ever taken. + uint256 feesTaken = _feeAssets(); + assertGt(feesTaken, 0, "sanity: the grief took no fees"); + + // Everything must still come out. + uint256 paidOut; + for (uint256 i = 0; i < 3; i++) { + vm.prank(holders[i]); + try hook.claim(ids) returns (uint256 paid) { + paidOut += paid; + } catch { } + } + + uint256 stillOwed; + for (uint256 i = 0; i < 3; i++) { + stillOwed += hook.claimable(holders[i]); + } + + // The hook still custodies the builder's and launcher's cuts until they claim, so they + // are part of `feesTaken` but were never destined for the holder pool. + uint256 cuts = hook.builderFeesAccrued() + hook.launcherFeesAccrued(); + + // Solvency first. + assertLe(paidOut + stillOwed + hook.escrowBalance() + cuts, feesTaken, "INSOLVENT after a dust grief"); + + // Then: at most a sub-wei remainder per holder is deferred, and it is + // carried, not lost. + uint256 deferred = feesTaken - paidOut - stillOwed - hook.escrowBalance() - cuts; + assertLe(deferred, hook.circulating(), "DUST WAS STRANDED, not carried"); + assertLt(hook.dustScaled(), hook.circulating(), "dustScaled exceeded a full unit"); + + // A normal-sized fee afterwards still flows, i.e. the accumulator is not wedged. + uint256 accBefore = hook.accFeePerNFT(); + _swapEthIn(attacker, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + assertGt(hook.accFeePerNFT(), accBefore, "the accumulator was wedged by dust"); + } + + /// Third-party fees sit as ERC-6909 claims on the PoolManager, owned by the + /// hook, until a `claim` call sweeps them. That balance is a live pot of ETH + /// held under a token standard with its own transfer and operator surface — + /// and the hook never approves anyone. + function test_ATTACK_cannotStealErc6909FeeClaims() public { + (uint256 id,) = _buy(alice, 5 ether); + vm.roll(block.number + 1); + _swapEthIn(carol, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + + uint256 ethId = CurrencyLibrary.ADDRESS_ZERO.toId(); + uint256 held = manager.balanceOf(address(hook), ethId); + assertGt(held, 0, "sanity: no claims accrued"); + + // Direct transfer of someone else's balance. + vm.prank(attacker); + (bool ok,) = address(manager) + .call( + abi.encodeWithSignature( + "transferFrom(address,address,uint256,uint256)", address(hook), attacker, ethId, held + ) + ); + assertFalse(ok, "AN ATTACKER MOVED THE HOOK'S FEE CLAIMS"); + + // Burning them out from under the hook. + vm.prank(attacker); + (ok,) = + address(manager).call(abi.encodeWithSignature("burn(address,uint256,uint256)", address(hook), ethId, held)); + assertFalse(ok, "AN ATTACKER BURNED THE HOOK'S FEE CLAIMS"); + + // Appointing themselves operator. + vm.prank(attacker); + (ok,) = address(manager).call(abi.encodeWithSignature("setOperator(address,bool)", address(hook), true)); + if (ok) { + vm.prank(attacker); + (ok,) = address(manager) + .call( + abi.encodeWithSignature( + "transferFrom(address,address,uint256,uint256)", address(hook), attacker, ethId, held + ) + ); + assertFalse(ok, "AN ATTACKER BECAME OPERATOR OF THE HOOK'S CLAIMS"); + } + + assertEq(manager.balanceOf(address(hook), ethId), held, "the claim balance moved"); + + // And the rightful holder still gets it all. + uint256[] memory ids = new uint256[](1); + ids[0] = id; + vm.prank(alice); + assertGt(hook.claim(ids), 0, "the holder could not collect"); + assertEq(manager.balanceOf(address(hook), ethId), 0, "claims were not swept"); + } + + /// `buyNFT` walks the curve at whatever price the block leaves it at, so a + /// sandwich CAN raise the cost. `maxEthIn` is the only protection, and it must + /// actually bind. + function test_ATTACK_sandwichBuyNftIsBoundedByMaxEthIn() public { + // Establish the fair cost. + uint256 snap = vm.snapshotState(); + (, uint256 fairCost) = _buy(alice, 50 ether); + vm.revertToState(snap); + + // Attacker front-runs, pushing SHARD up. No single swap may move more than the 50 SHARD + // cap, so the sandwich is four of them — enough to clear the victim's 1% bound below, + // which is the whole point of this leg. + _swapShardOutChunked(attacker, 200 ether); + + // With a 1% bound the victim is refused, not drained. + vm.prank(alice); + vm.expectPartialRevert(ShardErrorsV1.SlippageExceeded.selector); + hook.buyNFT{ value: 50 ether }(fairCost * 101 / 100, FAR); + assertEq(nft.circulatingSupply(), 0, "the bounded buy went through anyway"); + + // And the bound is genuinely load-bearing: unbounded, the victim overpays. + (, uint256 sandwichedCost) = _buy(alice, 50 ether); + assertGt(sandwichedCost, fairCost * 101 / 100, "sanity: the sandwich did not move the price"); + + // The deadline is the other half of the same protection. + vm.warp(block.timestamp + 1); + vm.prank(bob); + vm.expectRevert(ShardErrorsV1.Expired.selector); + hook.buyNFT{ value: 50 ether }(FAR, block.timestamp - 1); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.Expired.selector); + hook.sellNFT(1, 0, block.timestamp - 1); + } + + /*////////////////////////////////////////////////////////// + 9. BATCH BUYING + //////////////////////////////////////////////////////////*/ + + /// v4 SKIPS a hook's own beforeSwap/afterSwap (Hooks.sol:253/:293) because the hook + /// is the swapper, so `buyMany` charges the 1% EXPLICITLY in its own body. Delete + /// that one line and bulk buying becomes FREE while every callback test stays green + /// — this is the test that has to notice. + /// + /// The bar is a RATE, not an amount: N in a batch must cost the same 1% as N single + /// buys and as a third-party swap-then-redeem of the same N shards. + function test_ATTACK_buyManyIsNotAFreeFeeBypass() public { + uint256 n = 5; + uint256 snap = vm.snapshotState(); + + // Path A: one batch. + (uint256[] memory ids, uint256 batchSpent) = _buyMany(alice, n, 5 ether); + uint256 batchFee = _feeAssets(); + assertEq(ids.length, n, "sanity: wrong count minted"); + + vm.revertToState(snap); + + // Path B: the same N as individual buyNFT calls. + uint256 singlesSpent; + for (uint256 i = 0; i < n; i++) { + (, uint256 s) = _buy(bob, 5 ether); + singlesSpent += s; + } + uint256 singlesFee = _feeAssets(); + + vm.revertToState(snap); + + // Path C: a third-party exact-output swap for N shards, then N redeems. This + // path is charged by the CALLBACKS, so it is an independent measurement of the + // same 1% — if the explicit charge in `buyMany` were gone, only A would be zero. + uint256 carolBefore = carol.balance; + vm.startPrank(carol); + router.swap{ value: 50 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(n * ONE_SHARD), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + uint256 swapSpent = carolBefore - carol.balance; + shard.approve(address(hook), type(uint256).max); + for (uint256 i = 0; i < n; i++) { + hook.redeem(); + } + vm.stopPrank(); + uint256 swapFee = _feeAssets(); + + vm.revertToState(snap); + + // 1. The batch is not free. + assertGt(batchFee, 0, "buyMany IS FREE - the explicit 1% charge is gone"); + + // 2. It is charged at the inclusive 1% of the TOTAL paid, exactly once. Twice + // would read ~2%, and the 0.990% (`net * 100 / 10000`) mistake reads low. + assertApproxEqRel(batchFee, batchSpent / 100, 0.001e18, "batch fee is not an inclusive 1% charged ONCE"); + + // 3. Same RATE as N single buys and as the callback-charged swap path. A batch + // that undercharges is a cheaper door into the collection and reopens the + // free-reroll loop from the bulk side. + assertApproxEqRel(batchFee, singlesFee, 0.02e18, "buyMany charges a DIFFERENT RATE than N single buys"); + assertGe(batchFee * 100, singlesFee * 99, "BUYMANY UNDERCHARGES relative to N single buys"); + assertGe(batchFee * 100, swapFee * 99, "BUYMANY UNDERCHARGES relative to swap-then-redeem"); + + // 4. And the ETH cost of the batch is the honest curve cost, not a discount. + assertApproxEqRel(batchSpent, singlesSpent, 0.02e18, "the batch walked a different curve than N singles"); + assertApproxEqRel(batchSpent, swapSpent, 0.02e18, "batch cost diverges from the swap-then-redeem path"); + } + + /// `buyMax` reserves the 1% from `msg.value` BEFORE swapping, so its fee is exactly + /// 1% of what was sent. Same hole, other door: the hook's own swap never reaches the + /// callbacks, so this charge is the only one there is. + function test_ATTACK_buyMaxIsNotAFreeFeeBypass() public { + uint256 sent = 0.0002 ether; + uint256 snap = vm.snapshotState(); + + // Path A: buyMax. + (uint256[] memory ids, uint256 spent) = _buyMax(attacker, sent); + uint256 maxFee = _feeAssets(); + uint256 minted = ids.length; + uint256 leftover = shard.balanceOf(attacker); + assertGt(minted, 0, "sanity: buyMax minted nothing"); + assertEq(spent, sent, "sanity: buyMax did not consume the whole msg.value"); + + vm.revertToState(snap); + + // Path B: the same ETH through a third-party exact-input swap (charged by + // `_beforeSwap`), then redeem the whole shards. + vm.startPrank(carol); + router.swap{ value: sent }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(sent), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + uint256 swapFee = _feeAssets(); + uint256 bought = shard.balanceOf(carol); + shard.approve(address(hook), type(uint256).max); + for (uint256 i = 0; i < bought / ONE_SHARD; i++) { + hook.redeem(); + } + vm.stopPrank(); + + vm.revertToState(snap); + + assertGt(maxFee, 0, "buyMax IS FREE - the explicit 1% charge is gone"); + // Exactly 1% of msg.value, on the exact-input basis. Not 0.990%, not zero. + assertEq(maxFee, sent * 100 / 10_000, "buyMax fee != exactly 1% of the ETH sent"); + assertEq(maxFee, swapFee, "buyMax charges a DIFFERENT RATE than the callback path"); + + // And it did not buy MORE goods for the same money than the fee-paying path. + assertLe(minted * ONE_SHARD + leftover, bought, "BUYMAX GOT MORE SHARD PER ETH THAN THE TAXED PATH"); + } + + /// Each acquire is a fresh storage write, so an unbounded batch would exceed the + /// block gas limit — and a caller who could ask for one would brick their own + /// transaction after the swap had already moved the price. The cap must bind on + /// BOTH entry points, and must take no ETH when it refuses. + function test_ATTACK_buyManyCannotExceedBatchCap() public { + uint256 max = hook.MAX_BATCH(); + uint256 before = attacker.balance; + + uint256[5] memory tooBig = [max + 1, max * 2, 1000, 10_001, type(uint256).max]; + for (uint256 i = 0; i < tooBig.length; i++) { + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, tooBig[i], max)); + hook.buyMany{ value: 100 ether }(tooBig[i], FAR, FAR); + } + + // Zero is the other end of the same guard: a no-op that still charges a fee + // would be a free donation to the accumulator. + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, 0, max)); + hook.buyMany{ value: 1 ether }(0, FAR, FAR); + + assertEq(attacker.balance, before, "ETH WAS TAKEN FOR AN OVERSIZED BATCH"); + assertEq(nft.circulatingSupply(), 0, "an oversized batch minted something"); + assertEq(hook.accFeePerNFT(), 0, "a refused batch moved the accumulator"); + assertEq(hook.escrowBalance(), 0, "a refused batch escrowed a fee"); + + // `buyMax` takes no count at all, so the cap is enforced on the RESULT: sending + // more ETH than MAX_BATCH costs reverts. It used to clamp and return the surplus + // as SHARD, which meant 0.01 ETH bought 50 NFTs plus hundreds of loose tokens. + uint256 attackerEth = attacker.balance; + uint256 attackerShard = shard.balanceOf(attacker); + vm.prank(attacker); + vm.expectRevert(); + hook.buyMax{ value: 0.01 ether }(0, FAR); + + assertEq(attacker.balance, attackerEth, "an oversized buyMax still took ETH"); + assertEq(shard.balanceOf(attacker), attackerShard, "an oversized buyMax still moved SHARD"); + assertEq(nft.circulatingSupply(), 0, "an oversized buyMax still minted"); + _assertBacking("SHARD backing broke on a refused buyMax"); + + // The cap is a bound, not a brick: exactly MAX_BATCH must still go through, and + // buyMax must still work for any amount that lands under it. + vm.roll(block.number + 1); + (uint256[] memory atCap,) = _buyMany(alice, max, 100 ether); + assertEq(atCap.length, max, "a batch of exactly MAX_BATCH was refused"); + _assertBacking("SHARD backing broke on a max-sized batch"); + + vm.roll(block.number + 1); + (uint256[] memory underCap,) = _buyMax(bob, 0.0001 ether); + assertGt(underCap.length, 0, "buyMax stopped working below the cap"); + assertLe(underCap.length, max, "buyMax minted past the cap"); + assertLt(shard.balanceOf(bob), ONE_SHARD, "leftover should be a fraction of one SHARD"); + } + + /// `buyMax` is the ONLY function that ever moves SHARD OUT of the hook. Every wei of + /// that leftover is unbacked ERC-20 until someone redeems it, and it must buy an NFT + /// exactly once — never twice, and never while a copy stays claimable elsewhere. + function test_ATTACK_buyMaxLeftoverShardsCannotBeDoubleSpent() public { + uint256 supply = shard.totalSupply(); + + (uint256[] memory ids,) = _buyMax(attacker, 0.0001 ether); + uint256 leftover = shard.balanceOf(attacker); + assertGt(ids.length, 0, "sanity: nothing minted"); + assertGt(leftover, 0, "sanity: no leftover was returned"); + assertLt(leftover, ONE_SHARD, "leftover should be a fraction of one SHARD"); + _assertBacking("the hook kept SHARD it had already handed out"); + + // A fractional balance buys nothing. The price of an NFT is exactly 1 SHARD. + vm.startPrank(attacker); + shard.approve(address(hook), type(uint256).max); + vm.expectRevert(); + hook.redeem(); + vm.stopPrank(); + + // Accumulate past one whole SHARD, then spend it ONCE. + while (shard.balanceOf(attacker) < ONE_SHARD) { + _buyMax(attacker, 0.0001 ether); + } + uint256 heldBefore = shard.balanceOf(attacker); + uint256 hookBefore = shard.balanceOf(address(hook)); + uint256 circBefore = nft.circulatingSupply(); + + vm.prank(attacker); + hook.redeem(); + + assertEq(shard.balanceOf(attacker), heldBefore - ONE_SHARD, "the redeemed SHARD was not actually spent"); + assertEq(shard.balanceOf(address(hook)) - hookBefore, ONE_SHARD, "the hook minted without taking the backing"); + assertEq(nft.circulatingSupply(), circBefore + 1, "redeem minted the wrong number of NFTs"); + _assertBacking("SHARD backing broke after redeeming leftover"); + + // The same wei cannot be spent again: what is left is once more a fraction. + assertLt(shard.balanceOf(attacker), ONE_SHARD, "sanity: still a whole shard left"); + vm.prank(attacker); + vm.expectRevert(); + hook.redeem(); + + // Nor by handing it to an accomplice and both trying. Only one of the two can + // ever hold it, so only one redemption exists. + while (shard.balanceOf(attacker) < ONE_SHARD) { + _buyMax(attacker, 0.0001 ether); + } + vm.prank(attacker); + shard.transfer(bob, ONE_SHARD); + + vm.startPrank(bob); + shard.approve(address(hook), type(uint256).max); + hook.redeem(); + vm.stopPrank(); + + assertLt(shard.balanceOf(attacker), ONE_SHARD, "the sender kept a copy of the transferred SHARD"); + vm.prank(attacker); + vm.expectRevert(); + hook.redeem(); + + // And every wei of a fixed, unmintable supply is still accounted for: the locked + // position, the hook's backing, or a wallet. + uint256 accounted = shard.balanceOf(address(hook)) + shard.balanceOf(address(manager)) + + shard.balanceOf(attacker) + shard.balanceOf(bob) + shard.balanceOf(alice) + shard.balanceOf(carol) + + shard.balanceOf(address(this)) + shard.balanceOf(address(router)); + assertEq(accounted, supply, "SHARD APPEARED FROM NOWHERE OR WENT MISSING"); + _assertBacking("SHARD backing broke after the double-spend attempts"); + } + + /// Batches are the fattest possible same-block fee snipe: buy 50 NFTs, capture the + /// block's fees against a 50x weight, dump them. The accrual guard is per token and + /// keyed on `acquiredBlock`, so it must apply to every id in a batch — including the + /// ones `buyMax` mints from a single swap. + function test_ATTACK_batchBuyInSameBlockEarnsNoFees() public { + // A genuine holder, from an earlier block. + (uint256 bobId,) = _buy(bob, 5 ether); + vm.roll(block.number + 1); + + uint256 attackerStart = attacker.balance; + uint256 accBefore = hook.accFeePerNFT(); + + // --- everything below happens in ONE block --- + (uint256[] memory manyIds,) = _buyMany(attacker, 10, 5 ether); + (uint256[] memory maxIds,) = _buyMax(attacker, 0.0002 ether); + assertGt(manyIds.length + maxIds.length, 10, "sanity: the batch snipe bought too little to be a test"); + + // Third-party flow, round-tripped so the price ends roughly where it started. + // This isolates FEE capture from price movement. + _swapEthIn(carol, UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + _swapShardIn(carol, shard.balanceOf(carol)); + assertGt(hook.accFeePerNFT(), accBefore, "sanity: no fees accrued during the snipe block"); + + // Dump the whole batch inside the same block. + vm.startPrank(attacker); + for (uint256 i = 0; i < manyIds.length; i++) { + hook.sellNFT(manyIds[i], 0, FAR); + } + for (uint256 i = 0; i < maxIds.length; i++) { + hook.sellNFT(maxIds[i], 0, FAR); + } + vm.stopPrank(); + + uint256[] memory all = new uint256[](manyIds.length + maxIds.length); + for (uint256 i = 0; i < manyIds.length; i++) { + all[i] = manyIds[i]; + } + for (uint256 i = 0; i < maxIds.length; i++) { + all[manyIds.length + i] = maxIds[i]; + } + vm.prank(attacker); + try hook.claim(all) returns (uint256 paid) { + assertEq(paid, 0, "THE BATCH SNIPER WAS PAID"); + } catch { } + // --- end of block --- + + assertEq(hook.claimable(attacker), 0, "the batch sniper accrued a claimable balance"); + assertLt(attacker.balance, attackerStart, "THE SAME-BLOCK BATCH FEE SNIPE WAS PROFITABLE"); + + // Nor after the fact, in a later block, with no new fees. + vm.roll(block.number + 1); + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claim(all); + + // The whole block's fees belong to the holder who was actually there. + uint256[] memory bobIds = new uint256[](1); + bobIds[0] = bobId; + vm.prank(bob); + assertGt(hook.claim(bobIds), 0, "the real holder got nothing while a batch snipe ran"); + _assertBacking("SHARD backing broke across the batch snipe"); + } + + /// The full-range position runs to the minimum tick, so the pool can NEVER run out of + /// SHARD to sell. `_buyExactOutSwap` asserts a full fill and reverts PartialFillNotSupported + /// if it cannot get one — with this shape that must stay unreachable. Prove it by buying + /// hard enough to cross the band edge into the thin tail and checking it still fills. + function test_ATTACK_boundaryCrossingStillFillsCompletely() public { + uint256 max = hook.MAX_BATCH(); + + // Walk to the band edge with third-party swaps. This used to be ONE swap for the whole + // 9,930 SHARD; the hook now caps a single swap at MAX_BATCH SHARD, so it is ~199 of + // them. The TOTAL is unchanged — shrinking it would never reach the boundary, which is + // the only reason this test exists. Walking there through buyMany instead would take + // ~190 batches of hook-side mints and exhaust the test's gas long before the edge. + vm.deal(attacker, 200_000 ether); + _swapShardOutChunked(attacker, 9930 ether); + + // Parked just ABOVE the band edge, so the batch below has to cross it mid-swap. + (, int24 before,,) = manager.getSlot0(poolId); + assertGt(before, hook.tickBand(), "setup put the pool past the edge already"); + + // THE assertion: a full batch that spans the liquidity step must still fill EXACTLY. + // `_buyExactOutSwap` reverts PartialFillNotSupported on a short fill, because a + // partial one would mint NFTs the hook has no SHARD to back. + vm.prank(attacker); + uint256[] memory ids = hook.buyMany{ value: 5000 ether }(max, type(uint256).max, FAR); + assertEq(ids.length, max, "a batch across the boundary did not fill completely"); + + (, int24 after_,,) = manager.getSlot0(poolId); + assertLt(after_, hook.tickBand(), "the batch did not actually cross the band edge"); + + _assertBacking("SHARD backing broke across the band boundary"); + } +} diff --git a/test/ShardHookBatchV1.t.sol b/test/ShardHookBatchV1.t.sol new file mode 100644 index 00000000..cecb8e00 --- /dev/null +++ b/test/ShardHookBatchV1.t.sol @@ -0,0 +1,751 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/// @dev Minimal third-party router, so the callback fee path can be told apart from the +/// explicit fee the batch entry points must charge themselves. +contract BatchSwapRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function swap(PoolKey memory key, SwapParams memory params) external payable returns (BalanceDelta delta) { + delta = abi.decode(poolManager.unlock(abi.encode(msg.sender, key, params)), (BalanceDelta)); + uint256 bal = address(this).balance; + if (bal > 0) { + (bool ok,) = msg.sender.call{ value: bal }(""); + require(ok, "refund failed"); + } + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address sender, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + BalanceDelta delta = poolManager.swap(key, params, ""); + _resolve(key.currency0, delta.amount0(), sender); + _resolve(key.currency1, delta.amount1(), sender); + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount, address sender) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), owed); + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, sender, uint256(uint128(amount))); + } + } +} + +/*////////////////////////////////////////////////////////////// + TESTS +//////////////////////////////////////////////////////////////*/ + +contract ShardHookBatchV1Test is Test { + using StateLibrary for IPoolManager; + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant ONE_SHARD = 1 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + BatchSwapRouter internal swapRouter; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + uint256 internal constant FAR = 1e18; // deadline far in the future + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + swapRouter = new BatchSwapRouter(manager); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardHookBatchV1Test"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + vm.deal(address(this), 10_000 ether); + vm.deal(alice, 1000 ether); + vm.deal(bob, 1000 ether); + } + + /*////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////*/ + + function test_setupUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + function _feesHeld() internal view returns (uint256) { + return manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } + + function _buy(address who, uint256 send) internal returns (uint256 tokenId, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + tokenId = hook.buyNFT{ value: send }(type(uint256).max, FAR); + spent = before - who.balance; + } + + function _buyMany(address who, uint256 count, uint256 send) internal returns (uint256[] memory ids, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + ids = hook.buyMany{ value: send }(count, type(uint256).max, FAR); + spent = before - who.balance; + } + + function _buyMax(address who, uint256 send) internal returns (uint256[] memory ids, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + ids = hook.buyMax{ value: send }(0, FAR); + spent = before - who.balance; + } + + /// @dev The core supply invariant, stated with the seedDust term that + /// getLiquidityForAmount1's rounding makes necessary. + function _assertBacking() internal view { + assertEq( + shard.balanceOf(address(hook)), + nft.circulatingSupply() * 1e18 + hook.seedDust(), + "shard backing != circulating NFTs" + ); + } + + /*////////////////////////////////////////////////////////// + BUY MANY + //////////////////////////////////////////////////////////*/ + + function test_buyManyMintsExactlyCount() public { + (uint256[] memory ids,) = _buyMany(alice, 5, 1 ether); + + assertEq(ids.length, 5, "wrong number of ids returned"); + assertEq(nft.circulatingSupply(), 5, "circulating supply wrong"); + for (uint256 i; i < ids.length; ++i) { + assertEq(nft.ownerOf(ids[i]), alice, "buyer does not own a minted id"); + } + _assertBacking(); + } + + /// THE regression: v4 skips this hook's own callbacks, so buyMany must charge the 1% + /// itself. If this reads 0, bulk buying is free and holder revenue collapses. + function test_buyManyChargesOnePercentInclusiveOnce() public { + (, uint256 spent) = _buyMany(alice, 5, 1 ether); + + uint256 fee = _feesHeld(); + assertGt(fee, 0, "buyMany charged NO fee - bulk buying is free"); + // Inclusive: 1% of the TOTAL paid, charged exactly once (not twice, not 0.990%). + assertApproxEqAbs(fee, spent * FEE_BPS / BPS, 2, "batch fee != inclusive 1% of total"); + } + + function test_buyManyIsCheaperThanLoopingBuyNFT() public { + // Warm the pool/hook storage so neither measurement eats the cold-slot cost. + _buy(address(this), 0.1 ether); + + uint256 gasBefore = gasleft(); + for (uint256 i; i < 5; ++i) { + vm.prank(bob); + hook.buyNFT{ value: 0.1 ether }(type(uint256).max, FAR); + } + uint256 loopGas = gasBefore - gasleft(); + + gasBefore = gasleft(); + vm.prank(alice); + hook.buyMany{ value: 0.1 ether }(5, type(uint256).max, FAR); + uint256 batchGas = gasBefore - gasleft(); + + console2.log("5x buyNFT gas:", loopGas); + console2.log("buyMany(5) gas:", batchGas); + assertLt(batchGas, loopGas, "batch buy is not cheaper than looping buyNFT"); + } + + function test_buyManyRefundsExcess() public { + uint256 before = alice.balance; + (, uint256 spent) = _buyMany(alice, 3, 1 ether); + + assertLt(spent, 1 ether, "excess ETH was not refunded"); + assertEq(alice.balance, before - spent, "refund accounting mismatch"); + // Everything the hook kept is the fee; the curve cost went to the PoolManager and the + // rest went straight back to the buyer. + assertApproxEqAbs(address(hook).balance, spent * FEE_BPS / BPS, 2, "hook kept more than the fee"); + } + + function test_buyManyRespectsMaxEthIn() public { + vm.prank(alice); + vm.expectRevert(); + hook.buyMany{ value: 1 ether }(3, 1, FAR); // maxEthIn of 1 wei + } + + function test_buyManyRespectsDeadline() public { + vm.warp(1000); + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.Expired.selector); + hook.buyMany{ value: 1 ether }(3, type(uint256).max, 999); + } + + function test_buyManyRevertsAboveCap() public { + uint256 max = hook.MAX_BATCH(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, max + 1, max)); + hook.buyMany{ value: 100 ether }(max + 1, type(uint256).max, FAR); + } + + function test_buyManyRevertsOnZeroCount() public { + uint256 max = hook.MAX_BATCH(); + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, 0, max)); + hook.buyMany{ value: 1 ether }(0, type(uint256).max, FAR); + } + + function test_buyManyBackingInvariantHolds() public { + _assertBacking(); + _buyMany(alice, 4, 1 ether); + _assertBacking(); + vm.roll(block.number + 1); + _buyMany(bob, 7, 1 ether); + _assertBacking(); + + (uint256[] memory ids,) = _buyMany(alice, 1, 1 ether); + vm.prank(alice); + hook.sellNFT(ids[0], 0, FAR); + _assertBacking(); + } + + /// @dev A THIRD-PARTY swap may move at most `MAX_BATCH` SHARD — `_afterSwap` reverts + /// `SwapTooLarge` above it. The hook's own batch entry points move exactly that much + /// SHARD in a single swap, and must not be caught by their own cap: v4 skips a hook's + /// beforeSwap/afterSwap when the hook IS the swapper (Hooks.sol:253/:293), so + /// `buyMany(MAX_BATCH)` and `sellMany` of the same ids never reach the check. + /// + /// Get this wrong and the cap silently amputates the largest batch the contract + /// advertises, on a contract that cannot be upgraded — while every third-party test + /// stays green, because they exercise the other side of the same branch. + function test_hookOwnBatchIsNotBoundByTheSwapCap() public { + uint256 max = hook.MAX_BATCH(); + assertEq(max * ONE_SHARD, 50 ether, "sanity: the cap is not where this test assumes"); + + // Exactly MAX_BATCH SHARD out of the pool, in ONE hook-initiated swap. + (uint256[] memory ids,) = _buyMany(alice, max, 100 ether); + assertEq(ids.length, max, "a full batch buy was refused - the swap cap caught the hook"); + assertEq(nft.circulatingSupply(), max, "wrong supply after a full batch"); + _assertBacking(); + + // And the same size back the other way, which is a hook swap too. + vm.roll(block.number + 1); + uint256 payout = _sellMany(alice, ids, 0); + assertGt(payout, 0, "a full batch sell was refused - the swap cap caught the hook"); + assertEq(nft.circulatingSupply(), 0, "the batch did not fully unwind"); + _assertBacking(); + + // The other side of the branch, for contrast: the SAME 50 SHARD asked for as a + // third-party swap is allowed, and one wei more is not. + _acquireShards(bob, max, 100 ether); + vm.prank(bob); + vm.expectRevert(); + swapRouter.swap{ value: 100 ether }( + key, + SwapParams({ + zeroForOne: true, + amountSpecified: int256(max * ONE_SHARD + 1), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + } + + /*////////////////////////////////////////////////////////// + BUY MAX + //////////////////////////////////////////////////////////*/ + + function test_buyMaxSpendsTheEthAndMintsWholeNfts() public { + (uint256[] memory ids, uint256 spent) = _buyMax(alice, 0.0001 ether); + + assertGt(ids.length, 0, "buyMax minted nothing"); + assertEq(spent, 0.0001 ether, "buyMax should consume the whole msg.value"); + assertEq(nft.circulatingSupply(), ids.length, "circulating supply wrong"); + for (uint256 i; i < ids.length; ++i) { + assertEq(nft.ownerOf(ids[i]), alice, "buyer does not own a minted id"); + } + _assertBacking(); + } + + function test_buyMaxReturnsLeftoverShardsToCaller() public { + uint256 before = shard.balanceOf(alice); + _buyMax(alice, 0.0001 ether); + + assertGt(shard.balanceOf(alice) - before, 0, "leftover shards were not returned"); + assertLt(shard.balanceOf(alice) - before, ONE_SHARD, "leftover should be a fraction of one shard"); + _assertBacking(); + } + + function test_buyMaxChargesOnePercentInclusiveOnce() public { + uint256 sent = 0.0001 ether; + _buyMax(alice, sent); + + uint256 fee = _feesHeld(); + assertGt(fee, 0, "buyMax charged NO fee - bulk buying is free"); + assertApproxEqAbs(fee, sent * FEE_BPS / BPS, 2, "buyMax fee != inclusive 1% of ETH sent"); + } + + /// @dev Without this guard a zero-value buyMax either reverts opaquely deep inside v4 or, + /// with minCount 0, succeeds as a confusing no-op that mints nothing. + function test_buyMaxRevertsOnZeroValue() public { + vm.expectRevert(ShardErrorsV1.ZeroAmount.selector); + hook.buyMax{ value: 0 }(0, block.timestamp + 1000); + } + + function test_buyMaxRespectsMinCount() public { + // 0.0001 ETH buys a handful of shards, nowhere near 40 NFTs. + vm.prank(alice); + vm.expectRevert(); + hook.buyMax{ value: 0.0001 ether }(40, FAR); + } + + /// Sending more ETH than MAX_BATCH costs must REVERT, not quietly mint the cap + /// and hand back the rest as SHARD. A buyer asking for art should never be given + /// hundreds of tokens they then have to redeem 50 at a time. + function test_buyMaxRevertsRatherThanExceedingTheCap() public { + uint256 max = hook.MAX_BATCH(); + uint256 shardBefore = shard.balanceOf(alice); + uint256 ethBefore = alice.balance; + + vm.prank(alice); + vm.expectRevert(); + hook.buyMax{ value: 0.002 ether }(0, FAR); + + assertEq(shard.balanceOf(alice), shardBefore, "a rejected buy still moved SHARD"); + assertEq(alice.balance, ethBefore, "a rejected buy still took ETH"); + assertEq(nft.circulatingSupply(), 0, "a rejected buy still minted"); + assertLe(max, max); + _assertBacking(); + } + + /// Just under the cap still works, and still returns only fractional SHARD. + function test_buyMaxAtTheCapBoundaryStillMints() public { + vm.prank(alice); + uint256[] memory ids = hook.buyMax{ value: 0.0002 ether }(0, FAR); + + assertGt(ids.length, 0, "nothing minted below the cap"); + assertLe(ids.length, hook.MAX_BATCH(), "minted above the cap"); + assertLt(shard.balanceOf(alice), ONE_SHARD, "leftover should be a fraction of one SHARD"); + _assertBacking(); + } + + function test_buyMaxBackingInvariantHolds() public { + _assertBacking(); + _buyMax(alice, 0.0001 ether); + _assertBacking(); + vm.roll(block.number + 1); + (uint256[] memory ids,) = _buyMax(bob, 0.0002 ether); + _assertBacking(); + + vm.prank(bob); + hook.sellNFT(ids[0], 0, FAR); + _assertBacking(); + } + + /*////////////////////////////////////////////////////////// + HOLDER FEE ETH + //////////////////////////////////////////////////////////*/ + + /// A batch buy must never dip into ETH already owed to fee claimants. Assert the hook's + /// balance grew by EXACTLY the new fee — a dip shows up as a shortfall. + function test_batchBuysCannotSpendHolderFeeEth() public { + _buy(alice, 0.1 ether); + uint256 holderEth = address(hook).balance; + assertGt(holderEth, 0, "no holder ETH to protect - test would be vacuous"); + + (, uint256 spent) = _buyMany(bob, 4, 0.1 ether); + uint256 gained = address(hook).balance - holderEth; + assertApproxEqAbs(gained, spent * FEE_BPS / BPS, 2, "buyMany did not contribute exactly its own fee"); + + holderEth = address(hook).balance; + uint256 sent = 0.0001 ether; + _buyMax(bob, sent); + gained = address(hook).balance - holderEth; + assertApproxEqAbs(gained, sent * FEE_BPS / BPS, 2, "buyMax did not contribute exactly its own fee"); + } + + /*////////////////////////////////////////////////////////// + SELL MANY + //////////////////////////////////////////////////////////*/ + + function _sellMany(address who, uint256[] memory ids, uint256 minEthOut) internal returns (uint256 payout) { + vm.prank(who); + payout = hook.sellMany(ids, minEthOut, FAR); + } + + function test_sellManyReleasesEveryIdAndPaysTheSeller() public { + (uint256[] memory ids,) = _buyMany(alice, 5, 1 ether); + + uint256 before = alice.balance; + uint256 payout = _sellMany(alice, ids, 0); + + assertGt(payout, 0, "sellMany paid nothing"); + assertEq(alice.balance - before, payout, "reported payout != ETH received"); + assertEq(nft.circulatingSupply(), 0, "ids were not returned to the archive"); + for (uint256 i; i < ids.length; ++i) { + assertEq(nft.ownerOf(ids[i]), address(nft), "id did not go back to the archive"); + assertEq(nft.tokenSeed(ids[i]), 0, "art was not destroyed"); + } + _assertBacking(); + } + + /// The sell-side twin of the buyMany regression: v4 skips this hook's own callbacks, so + /// sellMany must charge the 1% in its own body. If this reads 0, bulk exits are free. + function test_sellManyChargesOnePercentInclusiveOnce() public { + (uint256[] memory ids,) = _buyMany(alice, 5, 1 ether); + + uint256 feesBefore = _feesHeld(); + uint256 payout = _sellMany(alice, ids, 0); + uint256 fee = _feesHeld() - feesBefore; + + assertGt(fee, 0, "sellMany charged NO fee - bulk exits are free"); + // Inclusive: the pool released (payout + fee) and the fee is 1% of that gross. + assertApproxEqAbs(fee, (payout + fee) * FEE_BPS / BPS, 2, "batch exit fee != inclusive 1% of gross"); + } + + function test_sellManyIsCheaperThanLoopingSellNFT() public { + // Warm the shared storage so neither measurement eats the cold-slot cost. + (uint256 warm,) = _buy(address(this), 0.1 ether); + hook.sellNFT(warm, 0, FAR); + + (uint256[] memory bobIds,) = _buyMany(bob, 5, 1 ether); + (uint256[] memory aliceIds,) = _buyMany(alice, 5, 1 ether); + + uint256 gasBefore = gasleft(); + for (uint256 i; i < bobIds.length; ++i) { + vm.prank(bob); + hook.sellNFT(bobIds[i], 0, FAR); + } + uint256 loopGas = gasBefore - gasleft(); + + gasBefore = gasleft(); + vm.prank(alice); + hook.sellMany(aliceIds, 0, FAR); + uint256 batchGas = gasBefore - gasleft(); + + console2.log("5x sellNFT gas:", loopGas); + console2.log("sellMany(5) gas:", batchGas); + assertLt(batchGas, loopGas, "batch sell is not cheaper than looping sellNFT"); + } + + function test_sellManyRespectsMinEthOut() public { + (uint256[] memory ids,) = _buyMany(alice, 3, 1 ether); + + vm.prank(alice); + vm.expectRevert(); + hook.sellMany(ids, 100 ether, FAR); + } + + function test_sellManyRespectsDeadline() public { + (uint256[] memory ids,) = _buyMany(alice, 3, 1 ether); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.Expired.selector); + hook.sellMany(ids, 0, block.timestamp - 1); + } + + function test_sellManyRevertsAboveCap() public { + uint256[] memory ids = new uint256[](hook.MAX_BATCH() + 1); + + vm.prank(alice); + vm.expectRevert( + abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, hook.MAX_BATCH() + 1, hook.MAX_BATCH()) + ); + hook.sellMany(ids, 0, FAR); + } + + function test_sellManyRevertsOnEmptyList() public { + uint256[] memory ids = new uint256[](0); + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, 0, hook.MAX_BATCH())); + hook.sellMany(ids, 0, FAR); + } + + /// Ownership is enforced by `nft.release`'s `_transfer`, which reverts if the caller is + /// not the holder — rolling back the accounting done a line earlier. + function test_sellManyRejectsSomeoneElsesToken() public { + (uint256[] memory aliceIds,) = _buyMany(alice, 2, 1 ether); + + vm.prank(bob); + vm.expectRevert(); + hook.sellMany(aliceIds, 0, FAR); + + assertEq(nft.ownerOf(aliceIds[0]), alice, "alice lost a token to a failed sale"); + } + + /// The second copy hits an id the archive already owns, so `_transfer` reverts and the + /// whole batch rolls back. Without this the hook would swap out shards it does not hold. + function test_sellManyRejectsDuplicateIds() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + + uint256[] memory ids = new uint256[](2); + ids[0] = tokenId; + ids[1] = tokenId; + + vm.prank(alice); + vm.expectRevert(); + hook.sellMany(ids, 0, FAR); + + assertEq(nft.ownerOf(tokenId), alice, "a duplicate batch partially executed"); + _assertBacking(); + } + + /// Every seller must leave the earning set BEFORE their own exit fee is distributed. + /// Bob is the only holder left, so the whole fee must accrue to him. + function test_sellManySellersDoNotEarnFromTheirOwnExitFee() public { + (uint256 bobId,) = _buy(bob, 1 ether); + // A holder never earns from fees taken in their own acquisition block, so bob has to + // be one block old before alice's buy fee can accrue to him at all. + vm.roll(block.number + 1); + (uint256[] memory aliceIds,) = _buyMany(alice, 4, 1 ether); + + uint256[] memory bobIds = new uint256[](1); + bobIds[0] = bobId; + + // Drain what alice's BUY fee already owes bob, so the second claim below measures the + // exit fee alone. Rolling clears the same-block accrual guard each time. + vm.roll(block.number + 1); + vm.prank(bob); + hook.claim(bobIds); + + vm.roll(block.number + 1); + uint256 feesBefore = _feesHeld(); + uint256 payout = _sellMany(alice, aliceIds, 0); + uint256 fee = _feesHeld() - feesBefore; + assertGt(fee, 0, "no exit fee - test would be vacuous"); + + vm.roll(block.number + 1); + uint256 balBefore = bob.balance; + vm.prank(bob); + hook.claim(bobIds); + + // Bob is the sole remaining holder, so he receives the whole HOLDER share of the exit + // fee (less rounding dust carried in the accumulator). Alice earned none of it. The + // builder and launcher each take 10% off the top before the holder pool sees anything, + // so the holder share is `fee - 2 * floor(fee * 1000 / 10000)`. + uint256 holderShare = fee - 2 * (fee * 1000 / BPS); + assertApproxEqAbs(bob.balance - balBefore, holderShare, 4, "exit fee did not go wholly to the remaining holder"); + assertGt(payout, 0, "seller was not paid"); + } + + function test_sellManyBackingInvariantHolds() public { + (uint256[] memory a,) = _buyMany(alice, 7, 2 ether); + _assertBacking(); + + uint256[] memory half = new uint256[](3); + for (uint256 i; i < 3; ++i) { + half[i] = a[i]; + } + _sellMany(alice, half, 0); + _assertBacking(); + assertEq(nft.circulatingSupply(), 4, "wrong supply after a partial batch exit"); + } + + /*////////////////////////////////////////////////////////// + REDEEM MANY + //////////////////////////////////////////////////////////*/ + + /// Buys `count` whole SHARD through a THIRD-PARTY router — the path a user who bought on + /// Uniswap arrives by, and the reason redeemMany exists at all. + function _acquireShards(address who, uint256 count, uint256 value) internal { + vm.prank(who); + swapRouter.swap{ value: value }( + key, + SwapParams({ + zeroForOne: true, + amountSpecified: int256(count * ONE_SHARD), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + } + + function test_redeemManyMintsCountAndBurnsTheShards() public { + _acquireShards(alice, 5, 50 ether); + uint256 shardsBefore = shard.balanceOf(alice); + + vm.startPrank(alice); + shard.approve(address(hook), type(uint256).max); + uint256[] memory ids = hook.redeemMany(5); + vm.stopPrank(); + + assertEq(ids.length, 5, "wrong number of ids returned"); + assertEq(nft.circulatingSupply(), 5, "circulating supply wrong"); + for (uint256 i; i < ids.length; ++i) { + assertEq(nft.ownerOf(ids[i]), alice, "redeemer does not own a minted id"); + assertGt(nft.tokenSeed(ids[i]), 0, "redeemed id has no art"); + } + assertEq(shardsBefore - shard.balanceOf(alice), 5 * ONE_SHARD, "wrong number of shards taken"); + _assertBacking(); + } + + /// Those shards already paid their 1% on the way through the router. Charging again here + /// would make swap-then-redeem strictly worse than buyMany. + function test_redeemManyChargesNoAdditionalFee() public { + _acquireShards(alice, 4, 50 ether); + uint256 feesAfterSwap = _feesHeld(); + uint256 accBefore = hook.accFeePerNFT(); + uint256 escrowBefore = hook.escrowBalance(); + uint256 ethBefore = alice.balance; + + vm.startPrank(alice); + shard.approve(address(hook), type(uint256).max); + hook.redeemMany(4); + vm.stopPrank(); + + assertEq(_feesHeld(), feesAfterSwap, "redeemMany moved ETH into or out of the fee pot"); + assertEq(alice.balance, ethBefore, "redeemMany took ETH from the redeemer"); + // The accumulator is the real detector: _distribute does not move ETH, it reallocates + // it, so a fee charged here would be invisible to a balance check alone. + assertEq(hook.accFeePerNFT(), accBefore, "redeemMany distributed a second fee to holders"); + assertEq(hook.escrowBalance(), escrowBefore, "redeemMany escrowed a second fee"); + } + + function test_redeemManyIsCheaperThanLoopingRedeem() public { + _acquireShards(bob, 5, 50 ether); + _acquireShards(alice, 5, 50 ether); + + vm.prank(bob); + shard.approve(address(hook), type(uint256).max); + vm.prank(alice); + shard.approve(address(hook), type(uint256).max); + + uint256 gasBefore = gasleft(); + for (uint256 i; i < 5; ++i) { + vm.prank(bob); + hook.redeem(); + } + uint256 loopGas = gasBefore - gasleft(); + + gasBefore = gasleft(); + vm.prank(alice); + hook.redeemMany(5); + uint256 batchGas = gasBefore - gasleft(); + + console2.log("5x redeem gas:", loopGas); + console2.log("redeemMany(5) gas:", batchGas); + assertLt(batchGas, loopGas, "batch redeem is not cheaper than looping redeem"); + } + + function test_redeemManyRequiresTheFullAllowance() public { + _acquireShards(alice, 5, 50 ether); + + vm.startPrank(alice); + shard.approve(address(hook), 4 * ONE_SHARD); // one short + vm.expectRevert(); + hook.redeemMany(5); + vm.stopPrank(); + } + + function test_redeemManyRevertsAboveCap() public { + // Cached deliberately: an inline `hook.MAX_BATCH()` in the redeemMany argument would be + // evaluated AFTER expectRevert arms, making that view call the "next call". + uint256 over = hook.MAX_BATCH() + 1; + + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, over, hook.MAX_BATCH())); + hook.redeemMany(over); + } + + function test_redeemManyRevertsOnZeroCount() public { + vm.prank(alice); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.BatchTooLarge.selector, 0, hook.MAX_BATCH())); + hook.redeemMany(0); + } + + function test_redeemManyBackingInvariantHolds() public { + _acquireShards(alice, 6, 50 ether); + _assertBacking(); + + vm.startPrank(alice); + shard.approve(address(hook), type(uint256).max); + hook.redeemMany(6); + vm.stopPrank(); + + _assertBacking(); + assertEq(nft.circulatingSupply(), 6, "wrong supply after a batch redeem"); + } + + receive() external payable { } +} diff --git a/test/ShardHookExhaustionV1.t.sol b/test/ShardHookExhaustionV1.t.sol new file mode 100644 index 00000000..8908992f --- /dev/null +++ b/test/ShardHookExhaustionV1.t.sol @@ -0,0 +1,284 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { BatchSwapRouter } from "./ShardHookBatchV1.t.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/** + * What happens when the curve actually runs out. + * + * The shipped ticks put `tickLower` at `minUsableTick`, which makes exhaustion cost more ETH + * than exists — every guard that fires on a short fill is unreachable there, and so is every + * bug hiding behind one. That is exactly the argument the contract's own comments reject: the + * backing identity has to be a property of THIS contract, not of whatever ticks + * `Deploy.s.sol` happens to pass. + * + * So this fixture deploys the same hook over a DELIBERATELY SHALLOW range — 600 ticks instead + * of ~1.77 million — where the whole 10,000 SHARD seed can be bought out for a few thousand + * test ETH. Nothing else changes. The hook, the NFT and the fee logic are the shipped ones. + */ +contract ShardHookExhaustionV1Test is Test { + using StateLibrary for IPoolManager; + + int24 internal constant TICK_SPACING = 60; + + /// @dev A 600-tick curve. The band covers its upper half, mirroring the shipped shape: + /// at or above `TICK_BAND` both positions are active, below it only the full range. + int24 internal constant TICK_UPPER = 600; + int24 internal constant TICK_BAND = 300; + int24 internal constant TICK_LOWER = 0; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant ONE_SHARD = 1 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + BatchSwapRouter internal swapRouter; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal alice = address(0xA11CE); + address internal whale = address(0xBEEF); + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + uint256 internal constant FAR = 1e18; + + function setUp() public { + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + swapRouter = new BatchSwapRouter(manager); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardHookExhaustionV1Test"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + vm.deal(alice, 100_000 ether); + vm.deal(whale, 1_000_000 ether); + } + + /*////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////*/ + + function test_setupUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + /// @dev Buy SHARD out of the pool as an ordinary third party, leaving the NFT supply + /// untouched. This is how the curve gets walked down without minting anything. + /// + /// EXACT-OUTPUT, deliberately. An exact-input ETH swap makes ETH the specified + /// currency, and the hook rejects those outright once they stop short at the price + /// limit — which is exactly what happens near exhaustion. Specifying the SHARD side + /// instead puts ETH on the unspecified leg, where a short fill is priced rather than + /// refused, so the curve can actually be walked to the bottom. + /// + /// IN CHUNKS, because the hook caps a single third-party swap at `MAX_BATCH` SHARD. + /// The drain is the point of this fixture, so the total is unchanged — it is simply + /// walked there in cap-sized steps rather than in one swap. + function _drainShardExactOut(uint256 shardsOut) internal { + uint256 maxPerSwap = hook.MAX_BATCH() * ONE_SHARD; + while (shardsOut > 0) { + uint256 chunk = shardsOut > maxPerSwap ? maxPerSwap : shardsOut; + vm.prank(whale); + swapRouter.swap{ value: 500_000 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(chunk), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + shardsOut -= chunk; + } + } + + /// @dev Walk the curve down until exactly `keep` SHARD is left for it to sell. + function _drainToRemaining(uint256 keep) internal { + _drainShardExactOut(_poolShard() - keep); + } + + /// @dev SHARD left in the pool, i.e. what the curve can still sell. + function _poolShard() internal view returns (uint256) { + return shard.balanceOf(address(manager)); + } + + function _assertBacking(string memory what) internal view { + assertEq(shard.balanceOf(address(hook)), nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), what); + } + + /*////////////////////////////////////////////////////////// + M1 — buyNFT MUST NOT MINT ON A SHORT FILL + //////////////////////////////////////////////////////////*/ + + /// @dev `_buySwap` used to check only the ETH leg, so at exhaustion `buyNFT` could take + /// LESS than one whole SHARD out of the pool and still mint an NFT — permanently + /// breaking `balanceOf(hook) == circulatingSupply * 1e18 + seedDust` on a contract + /// that cannot be upgraded. Its two siblings, `_sellSwap` and `_buyExactOutSwap`, + /// have always asserted a full fill; this is the one that did not. + function test_buyNftRevertsRatherThanMintingOnAShortFill() public { + // Leave the curve with less than the one whole SHARD `buyNFT` needs, but NOT bone dry: + // drained to the very last wei the price sits exactly on `MIN_SQRT_PRICE + 1` and v4 + // rejects the next swap itself with `PriceLimitAlreadyExceeded`, before the hook is + // ever consulted. Half a SHARD is the case that actually reaches `_buySwap`. + _drainToRemaining(0.5 ether); + assertLt(_poolShard(), ONE_SHARD, "the pool still has a whole SHARD to sell"); + assertGt(_poolShard(), 0, "the pool is bone dry; v4 will reject before the hook runs"); + + uint256 circulatingBefore = nft.circulatingSupply(); + uint256 balanceBefore = alice.balance; + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.PartialFillNotSupported.selector); + hook.buyNFT{ value: 10_000 ether }(type(uint256).max, FAR); + + assertEq(nft.circulatingSupply(), circulatingBefore, "an unbacked NFT was minted"); + assertEq(alice.balance, balanceBefore, "ETH was taken for a mint that could not happen"); + _assertBacking("SHARD backing broke at exhaustion"); + } + + /// @dev The guard must not fire on an ordinary buy. A full fill is the normal case and has + /// to keep working on the same fixture that reaches exhaustion. + function test_buyNftStillWorksWhileTheCurveHasDepth() public { + vm.prank(alice); + uint256 id = hook.buyNFT{ value: 10 ether }(type(uint256).max, FAR); + + assertEq(nft.ownerOf(id), alice, "an ordinary buy was rejected"); + _assertBacking("SHARD backing broke on an ordinary buy"); + } + + /*////////////////////////////////////////////////////////// + M2 — buyMax MUST NOT BILL THE REFUNDED REMAINDER + //////////////////////////////////////////////////////////*/ + + /// @dev The fee used to be fixed at 1% of `msg.value` BEFORE the swap, while only + /// `ethForSwap - ethConsumed` was refunded. Any exact-input swap that stopped early at + /// its price limit therefore paid 1% of the refunded remainder too — send 100 ETH into + /// a curve with 1 ETH of depth left and the fee is 1 ETH on a 1 ETH purchase. That is + /// the same fee-out-of-proportion-to-what-executed failure `_afterSwap` refuses to + /// inflict on third parties. The fee is now billed on what the curve actually took. + function test_buyMaxChargesOnlyOnWhatTheCurveConsumed() public { + // Walk the curve down until only a handful of whole SHARD is left, so a large send + // cannot possibly be consumed and `MAX_BATCH` is not in the way. + _drainToRemaining(30 ether); + uint256 remaining = _poolShard(); + assertGt(remaining, ONE_SHARD, "nothing left to buy"); + assertLt(remaining, hook.MAX_BATCH() * ONE_SHARD, "too much left; buyMax would hit the cap"); + + uint256 sent = 50_000 ether; // vastly more than the remaining depth + uint256 feesBefore = _feesHeld(); + uint256 balanceBefore = alice.balance; + + vm.prank(alice); + uint256[] memory ids = hook.buyMax{ value: sent }(0, FAR); + assertGt(ids.length, 0, "buyMax minted nothing"); + + uint256 charged = alice.balance == balanceBefore ? 0 : balanceBefore - alice.balance; + uint256 fee = _feesHeld() - feesBefore; + assertGt(fee, 0, "buyMax charged no fee at all"); + + // The whole point: the fee is 1% of what was CHARGED, not 1% of what was SENT. + assertApproxEqAbs(fee, charged * FEE_BPS / BPS, 2, "fee was not 1% of the amount charged"); + assertLt(charged, sent, "the swap did not actually stop early - the test proves nothing"); + + // And the old behaviour, stated directly: 1% of `msg.value` would have been enormous. + assertLt(fee, sent * FEE_BPS / BPS, "buyMax billed the refunded remainder"); + + // Everything neither the curve nor the fee took came back. + assertEq(balanceBefore - alice.balance, charged, "refund did not reconcile"); + _assertBacking("SHARD backing broke on a short-filled buyMax"); + } + + /// @dev The common case still bills the full amount: when the curve consumes everything, + /// the inclusive basis and the exact-input basis agree, and nothing is refunded. + function test_buyMaxStillChargesTheWholeSendWhenFullyConsumed() public { + uint256 sent = 10 ether; + uint256 feesBefore = _feesHeld(); + uint256 balanceBefore = alice.balance; + + vm.prank(alice); + hook.buyMax{ value: sent }(0, FAR); + + assertEq(balanceBefore - alice.balance, sent, "buyMax refunded ETH the curve did take"); + assertApproxEqAbs(_feesHeld() - feesBefore, sent * FEE_BPS / BPS, 2, "fee moved off 1%"); + _assertBacking("SHARD backing broke on a fully consumed buyMax"); + } + + /// @dev The clamp is load-bearing. The inclusive form `consumed * 100 / 9900` can land one + /// wei ABOVE the exact-input reserve at full consumption, which would overspend + /// `msg.value` and underflow the refund. Fuzzed across the awkward residues. + function testFuzz_buyMaxNeverOverspendsMsgValue(uint96 raw) public { + uint256 sent = bound(uint256(raw), 1e15, 1000 ether); + vm.deal(alice, sent); + + uint256 feesBefore = _feesHeld(); + + vm.prank(alice); + try hook.buyMax{ value: sent }(0, FAR) { + uint256 charged = sent - alice.balance; + assertLe(charged, sent, "buyMax spent more than msg.value"); + assertLe(_feesHeld() - feesBefore, sent * FEE_BPS / BPS + 1, "fee exceeded the reserve"); + _assertBacking("SHARD backing broke under fuzz"); + } catch { + // BatchTooLarge and InsufficientOutput are legitimate outcomes across this range. + } + } + + function _feesHeld() internal view returns (uint256) { + return manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } +} diff --git a/test/ShardHookFeesV1.t.sol b/test/ShardHookFeesV1.t.sol new file mode 100644 index 00000000..9aed5552 --- /dev/null +++ b/test/ShardHookFeesV1.t.sol @@ -0,0 +1,823 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; + +/*////////////////////////////////////////////////////////////// + HARNESS +//////////////////////////////////////////////////////////////*/ + +/// @dev Exposes the sweep and lets a test fabricate a circulating holder, so the accumulator +/// path can be exercised without wiring the NFT in. +contract FeeHookHarness is ShardHookV1 { + constructor( + IPoolManager _poolManager, + ShardTokenV1 _shard, + int24 _tickLower, + int24 _tickBand, + int24 _tickUpper, + uint160 _startSqrtPriceX96, + address _deployer, + address _launcherFeeRecipient, + address _builderFeeRecipient + ) + ShardHookV1( + _poolManager, + _shard, + _tickLower, + _tickBand, + _tickUpper, + _startSqrtPriceX96, + _deployer, + _launcherFeeRecipient, + _builderFeeRecipient + ) + { } + + uint256 internal constant OP_SWEEP = 0; + uint256 internal constant OP_FABRICATE = 1; + uint256 internal constant OP_SETTLE = 2; + + /// @dev The three test-only hooks are merged into one entry point: as the production hook grew, + /// three separate external wrappers pushed this harness past EIP-170 and broke + /// `forge build --sizes`. One dispatcher keeps the harness deployable. + function harness(uint256 op, uint256 tokenId, address who) external { + if (op == OP_SWEEP) _sweepClaims(); + else if (op == OP_FABRICATE) _acquireAccounting(tokenId, who); + else _settle(tokenId, who); + } +} + +/*////////////////////////////////////////////////////////////// + ROUTER +//////////////////////////////////////////////////////////////*/ + +contract FeeSwapRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function swap(PoolKey memory key, SwapParams memory params) external payable returns (BalanceDelta delta) { + delta = abi.decode(poolManager.unlock(abi.encode(msg.sender, key, params)), (BalanceDelta)); + uint256 bal = address(this).balance; + if (bal > 0) { + (bool ok,) = msg.sender.call{ value: bal }(""); + require(ok, "refund failed"); + } + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address sender, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + + BalanceDelta delta = poolManager.swap(key, params, ""); + + _resolve(key.currency0, delta.amount0(), sender); + _resolve(key.currency1, delta.amount1(), sender); + + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount, address sender) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), owed); + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, sender, uint256(uint128(amount))); + } + } +} + +/*////////////////////////////////////////////////////////////// + TESTS +//////////////////////////////////////////////////////////////*/ + +contract ShardHookFeesV1Test is Test { + using StateLibrary for IPoolManager; + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + + /// @dev The two fixed beneficiary cuts, each 10% of the fee, taken before the remainder + /// reaches the holder pool. The TOTAL fee is unchanged by them. + uint256 internal constant CUT_BPS = 1000; + + /// @dev The size every ordinary third-party swap in this file is written against. A single + /// third-party swap may move at most `MAX_SWAP_SHARD` (see MAX SWAP SIZE below), and at + /// TICK_UPPER the price is ~1e-5 ETH per SHARD — so a whole ETH would move ~99,000 + /// SHARD and be refused. This buys ~39 SHARD, comfortably inside the cap, while its 1% + /// fee (4e12 wei) is still far above the dust where the fee would floor to zero. + uint256 internal constant UNDER_CAP_ETH = 0.0004 ether; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardTokenV1 internal shard; + FeeHookHarness internal hook; + FeeSwapRouter internal swapRouter; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal alice = address(0xA11CE); + + address internal launcher = makeAddr("launcher"); + address internal builder = makeAddr("builder"); + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + shard = new ShardTokenV1(); + swapRouter = new FeeSwapRouter(manager); + hook = _deployHook(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + shard.transfer(address(hook), SEED_AMOUNT); + vm.deal(address(this), 10_000 ether); + shard.approve(address(swapRouter), type(uint256).max); + } + + function _deployHook() internal returns (FeeHookHarness deployed) { + (address expected, bytes32 salt) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(FeeHookHarness).creationCode, + abi.encode( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, builder + ) + ); + deployed = new FeeHookHarness{ salt: salt }( + manager, shard, TICK_LOWER, TICK_BAND, TICK_UPPER, startSqrtPriceX96, address(this), launcher, builder + ); + assertEq(address(deployed), expected, "hook address mismatch"); + } + + /// @dev Total ETH the hook has captured, in whatever form it currently holds it. The + /// builder/launcher carve is an ACCOUNTING split inside the hook, not a payout, so + /// this total is unaffected by it: every wei stays hook-held until someone claims. + function _feesHeld() internal view returns (uint256) { + return manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } + + /// @dev Every wei the hook has accounted for, across all three destinations. + function _feesAccounted() internal view returns (uint256) { + return hook.escrowBalance() + hook.builderFeesAccrued() + hook.launcherFeesAccrued(); + } + + /// @dev The holder pool's exact share of a single fee: total minus the combined operator cut, + /// which is the floor of the full 20% (taken together, not floored per side), so the three + /// parts sum back to `fee`. + function _holderShare(uint256 fee) internal pure returns (uint256) { + uint256 operator = (fee * 2 * CUT_BPS) / BPS; + return fee - operator; + } + + function _swap(bool zeroForOne, int256 amountSpecified, uint256 value) internal returns (BalanceDelta) { + return swapRouter.swap{ value: value }( + key, + SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amountSpecified, + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }) + ); + } + + /// @dev Buy `shardsOut` SHARD so the test contract can exercise the sell direction. + /// Exact-OUTPUT and in chunks of at most `MAX_SWAP_SHARD`, because the hook caps a + /// single third-party swap at that size — one large buy would simply be refused. + function _acquireShards(uint256 shardsOut) internal { + while (shardsOut > 0) { + uint256 chunk = shardsOut > MAX_SWAP_SHARD ? MAX_SWAP_SHARD : shardsOut; + _swap(true, int256(chunk), 100 ether); + shardsOut -= chunk; + } + } + + /*////////////////////////////////////////////////////////// + THE FOUR-CASE MATRIX + //////////////////////////////////////////////////////////*/ + + /// ETH is the SPECIFIED currency -> charged in beforeSwap, inclusive at bps/10000. + /// + /// @dev The TOTAL fee is untouched by the beneficiary split; only its destination + /// changes. UNDER_CAP_ETH * 100 / 10_000 = 4e12 wei, which splits 4e11 / 4e11 / + /// 3.2e12 — the exact integers are pinned below rather than only the formula. + function test_feeIsExactlyOnePercent_zeroForOne_exactIn() public { + hook.initialise(); + + uint256 ethIn = UNDER_CAP_ETH; // sized to the 50 SHARD swap cap, not to the fee + _swap(true, -int256(ethIn), ethIn); + + uint256 fee = (ethIn * FEE_BPS) / BPS; + assertEq(fee, 4e12, "premise: the fee on this swap is 4e12 wei"); + + assertEq(_feesHeld(), fee, "fee != 1% of ETH paid"); + assertEq(_feesAccounted(), fee, "accounted fee != captured fee"); + + assertEq(hook.builderFeesAccrued(), 4e11, "builder cut"); + assertEq(hook.launcherFeesAccrued(), 4e11, "launcher cut"); + assertEq(hook.escrowBalance(), 32e11, "holder share did not reach the accumulator"); + assertEq(hook.escrowBalance(), _holderShare(fee), "holder share disagrees with the split rule"); + } + + /// ETH is UNSPECIFIED (the input the pool computes) -> charged in afterSwap. + /// @dev The router's returned delta is the NET the swapper settles: v4 reassigns + /// `swapperDelta = swapDelta - hookDelta` after afterSwap. For ETH flowing IN that + /// net already INCLUDES the fee (the user pays pool-cost + fee), so the check is + /// total * bps/10000 — even though the hook computed it as poolCost * bps/9900. + /// Both are the same number; they just start from different sides of the same total. + function test_feeIsExactlyOnePercent_zeroForOne_exactOut() public { + hook.initialise(); + + uint256 before = address(this).balance; + BalanceDelta delta = _swap(true, int256(1e18), 10 ether); // want exactly 1 SHARD out + uint256 ethSpent = before - address(this).balance; + + uint256 totalPaid = uint256(uint128(-delta.amount0())); + assertEq(_feesHeld(), (totalPaid * FEE_BPS) / BPS, "fee != 1% of total ETH paid"); + assertEq(ethSpent, totalPaid, "router settled a different amount than reported"); + } + + /// ETH is UNSPECIFIED (the output) -> charged in afterSwap. + /// @dev Here the router's net is what the user RECEIVED, i.e. the pool released + /// `net + fee`. So the inclusive check runs the other way: net * bps/9900. + function test_feeIsExactlyOnePercent_oneForZero_exactIn() public { + hook.initialise(); + _acquireShards(40 ether); // 40 SHARD, so the sell below stays inside the 50 SHARD cap + uint256 feesAfterBuy = _feesHeld(); + + uint256 shardIn = shard.balanceOf(address(this)) / 2; + BalanceDelta delta = _swap(false, -int256(shardIn), 0); + + uint256 netReceived = uint256(uint128(delta.amount0())); + uint256 fee = _feesHeld() - feesAfterBuy; + assertEq(fee, (netReceived * FEE_BPS) / (BPS - FEE_BPS), "sell fee != inclusive 1%"); + // And state the property directly: the fee is 1% of everything the pool released. + assertApproxEqAbs(((netReceived + fee) * FEE_BPS) / BPS, fee, 1, "not 1% of gross"); + } + + /// ETH is the SPECIFIED currency (the exact output) -> charged in beforeSwap at bps/9900. + function test_feeIsExactlyOnePercent_oneForZero_exactOut() public { + hook.initialise(); + _acquireShards(50 ether); + uint256 feesAfterBuy = _feesHeld(); + + // ~0.0001 ETH is ~10 SHARD at this price, so the sell stays under the 50 SHARD cap. + uint256 ethOut = 0.0001 ether; + _swap(false, int256(ethOut), 0); + + assertEq( + _feesHeld() - feesAfterBuy, (ethOut * FEE_BPS) / (BPS - FEE_BPS), "fee != inclusive 1% of exact output" + ); + } + + /// @dev Drives one real swap in each of the four quadrants (zeroForOne/oneForZero × + /// exactIn/exactOut, exact-output using the net*100/9900 gross-up) and checks the split, not + /// just the 1% fee: the combined operator cut is the floor of 20% (within a one-wei carry + /// from earlier swaps), the launcher is never shorted below the builder, and the two stay + /// within a wei. Cumulative-from-zero exactness is pinned in + /// {ShardFeeSplitV1Test-testFuzz_splitIsConservativeAndCumulative}. + function test_operatorSplitHoldsAcrossAllFourQuadrants() public { + hook.initialise(); + _acquireShards(45 ether); // stock SHARD so the oneForZero sells stay inside the 50 SHARD cap + + uint256 builder0 = hook.builderFeesAccrued(); + uint256 launcher0 = hook.launcherFeesAccrued(); + + uint256 totalFee; + totalFee += _assertOperatorSplitOnSwap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); // zeroForOne exactIn + totalFee += _assertOperatorSplitOnSwap(true, int256(1e18), 10 ether); // zeroForOne exactOut + totalFee += _assertOperatorSplitOnSwap(false, -int256(shard.balanceOf(address(this)) / 4), 0); // oneForZero + // exactIn + totalFee += _assertOperatorSplitOnSwap(false, int256(uint256(0.0001 ether)), 0); // oneForZero exactOut + + uint256 builderTotal = hook.builderFeesAccrued() - builder0; + uint256 launcherTotal = hook.launcherFeesAccrued() - launcher0; + assertApproxEqAbs(builderTotal + launcherTotal, (totalFee * 2 * CUT_BPS) / BPS, 1, "operator != cumulative 20%"); + // launcher >= builder is a GLOBAL (from-zero) invariant; over a mid-stream window the parity + // carry can leave either side up to a wei ahead, so the >= check reads the absolute accrual. + assertGe(hook.launcherFeesAccrued(), hook.builderFeesAccrued(), "launcher shorted below builder"); + assertLe(hook.launcherFeesAccrued() - hook.builderFeesAccrued(), 1, "cuts diverged beyond a wei"); + } + + /// @dev Swaps once and returns the fee that swap moved, asserting the operator cut it accrued is + /// the floor of 20% up to a one-wei carry, with the two sides within a wei of each other. + function _assertOperatorSplitOnSwap(bool zeroForOne, int256 amountSpecified, uint256 value) + internal + returns (uint256 fee) + { + uint256 heldBefore = _feesHeld(); + uint256 builderBefore = hook.builderFeesAccrued(); + uint256 launcherBefore = hook.launcherFeesAccrued(); + _swap(zeroForOne, amountSpecified, value); + fee = _feesHeld() - heldBefore; + uint256 builderDelta = hook.builderFeesAccrued() - builderBefore; + uint256 launcherDelta = hook.launcherFeesAccrued() - launcherBefore; + assertApproxEqAbs(builderDelta + launcherDelta, (fee * 2 * CUT_BPS) / BPS, 1, "operator cut off for quadrant"); + assertApproxEqAbs(launcherDelta, builderDelta, 1, "cuts diverged for quadrant"); + } + + /*////////////////////////////////////////////////////////// + PROPERTIES + //////////////////////////////////////////////////////////*/ + + function test_feeIsAlwaysDenominatedInEth() public { + hook.initialise(); + uint256 shardBefore = shard.balanceOf(address(hook)); + + _acquireShards(40 ether); // 40 SHARD; the sell below must stay under the 50 SHARD cap + _swap(false, -int256(shard.balanceOf(address(this)) / 2), 0); + + assertEq(shard.balanceOf(address(hook)), shardBefore, "hook took SHARD as fee"); + assertGt(_feesHeld(), 0, "no ETH fee captured"); + } + + function test_swapperReceivesExpectedAmountAfterFee() public { + hook.initialise(); + + uint256 ethIn = UNDER_CAP_ETH; // sized to the 50 SHARD swap cap + uint256 before = address(this).balance; + _swap(true, -int256(ethIn), ethIn); + + assertEq(before - address(this).balance, ethIn, "swapper paid more than specified"); + assertEq(_feesHeld(), ethIn / 100, "fee not withheld from the input"); + assertGt(shard.balanceOf(address(this)), 0, "swapper got no SHARD"); + } + + function test_firstSwapSucceedsOnFreshPoolManager() public { + // Regression: taking native ETH via poolManager.take in beforeSwap would revert here, + // because the manager holds no other native liquidity. Minting a 6909 claim does not. + hook.initialise(); + assertEq(address(manager).balance, 0, "manager should hold no ETH yet"); + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + assertGt(_feesHeld(), 0, "first swap captured no fee"); + } + + function test_feeGoesToEscrowWhenNothingCirculating() public { + hook.initialise(); + assertEq(hook.circulating(), 0, "expected no holders"); + + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + + // 4e12 fee -> 4e11 builder, 4e11 launcher, 3.2e12 escrowed for holders. + assertEq(hook.escrowBalance(), 32e11, "holder share did not escrow"); + assertEq(hook.builderFeesAccrued(), 4e11, "builder cut"); + assertEq(hook.launcherFeesAccrued(), 4e11, "launcher cut"); + assertEq(hook.accFeePerNFT(), 0, "accumulator moved with no holders"); + } + + function test_feeReachesAccumulatorWhenHoldersExist() public { + hook.initialise(); + hook.harness(1, 1, alice); // OP_FABRICATE + vm.roll(block.number + 1); // the same-block accrual guard + + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + + hook.harness(2, 1, alice); // OP_SETTLE + // The sole holder receives the holder share of the 4e12 fee, not the whole fee. + assertEq(hook.claimable(alice), 32e11, "holder did not receive the holder share"); + assertEq(hook.claimable(alice), _holderShare(UNDER_CAP_ETH / 100), "holder share disagrees with the rule"); + assertEq(hook.builderFeesAccrued() + hook.launcherFeesAccrued(), 8e11, "the two cuts"); + } + + /// @dev The split moves fees between INTERNAL ledgers; custody is unchanged. Everything + /// the hook charged is still hook-held, as 6909 claims before the sweep and as real + /// ETH after it — including the builder and launcher accruals, which are only paid + /// out when those beneficiaries claim. + function test_hookClaimBalancePlusEthCoversFeesTaken() public { + hook.initialise(); + _swap(true, -int256(UNDER_CAP_ETH), UNDER_CAP_ETH); // sized to the 50 SHARD swap cap + + // Before the sweep the value is in 6909 claims, not real ETH. A balance-only + // assertion would fail here — that is the point of counting both. + assertEq(address(hook).balance, 0, "unexpected real ETH before sweep"); + assertEq(_feesHeld(), _feesAccounted(), "claims do not cover accounted fees"); + + hook.harness(0, 0, address(0)); // OP_SWEEP + assertEq(address(hook).balance, _feesAccounted(), "sweep did not realise ETH"); + assertEq(manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()), 0, "claims not fully burned"); + } + + /*////////////////////////////////////////////////////////// + GUARDS + //////////////////////////////////////////////////////////*/ + + function test_swapBeforeInitialiseReverts() public { + // A front-runner may create the canonical pool (at the canonical price, forced by + // _beforeInitialize) but must not be able to move it before the hook seeds. + manager.initialize(key, startSqrtPriceX96); + + vm.expectRevert(); + _swap(true, -int256(uint256(1 ether)), 1 ether); + } + + function test_foreignPoolSwapReverts() public { + hook.initialise(); + + ShardTokenV1 other = new ShardTokenV1(); + PoolKey memory foreign = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(other)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + + // _beforeInitialize rejects it outright — the pool can never exist, so the fee path + // can never be reached with a foreign currency. + vm.expectRevert(); + manager.initialize(foreign, startSqrtPriceX96); + } + + /*////////////////////////////////////////////////////////// + PARTIAL FILLS + //////////////////////////////////////////////////////////*/ + + /// @dev When ETH is the SPECIFIED currency the fee is fixed in `_beforeSwap`, before + /// execution, and therefore on the REQUESTED size. If the swap then stops at its + /// price limit, that fee becomes a huge share of what actually executed — measured + /// at 7,655 bps (76.5%) on a 1 ETH request that filled only 0.013 ETH. It cannot be + /// corrected in `_afterSwap`, whose return value adjusts only the UNSPECIFIED + /// currency while the fee must stay in ETH. So the swap is REJECTED instead. + function test_partialFillIsRejectedNotOvercharged() public { + hook.initialise(); + + // Sized to the 50 SHARD swap cap: UNDER_CAP_ETH walks ~79 ticks if it fills, so a limit + // 60 ticks down binds part-way through and the swap short-fills — while the ~30 SHARD it + // does move stays inside the cap, so the revert here is the partial fill and nothing else. + uint160 limit = TickMath.getSqrtPriceAtTick(TICK_UPPER - 60); + + vm.expectRevert(); + swapRouter.swap{ value: UNDER_CAP_ETH }( + key, SwapParams({ zeroForOne: true, amountSpecified: -int256(UNDER_CAP_ETH), sqrtPriceLimitX96: limit }) + ); + + // Nothing was taken: a rejected swap must not leave a fee behind. + assertEq(_feesHeld(), 0, "a reverted swap still charged a fee"); + assertEq(_feesAccounted(), 0, "a reverted swap still accounted a fee"); + } + + /// @dev The MIRROR of the case above, in the other quadrant where ETH is the specified + /// currency: `!zeroForOne` exact-OUT, i.e. "give me exactly this much ETH for my + /// SHARD". The guard's arithmetic differs per quadrant — exact-in compares against + /// `specified - hookFee` because beforeSwap swaps LESS, exact-out against + /// `specified + hookFee` because it asks the pool for MORE — so covering only the + /// exact-in side leaves half the guard unexercised. Here the fee is fixed in + /// beforeSwap on the requested ETH out; if the limit then binds, the swapper is left + /// paying that fee against a fraction of the ETH, which is the overcharge the guard + /// exists to refuse. + function test_partialFillIsRejectedOnExactOutputEthToo() public { + hook.initialise(); + _acquireShards(50 ether); + uint256 feesBefore = _feesHeld(); + + // Selling SHARD pushes the price UP, so a limit a hair ABOVE spot binds almost + // immediately and the pool cannot find anything like a whole ETH. + (uint160 sqrtNow,,,) = manager.getSlot0(poolId); + uint160 limit = sqrtNow + sqrtNow / 10_000; + + // Bare `expectRevert` because v4 wraps hook reverts in `CustomRevert.WrappedError`, + // which makes the selector unmatchable from here. Verified by trace that the inner + // revert really is `PartialFillNotSupported()` raised in `_afterSwap` — and the + // non-binding twin below is what stops this passing for the wrong reason. + // 0.0001 ETH out rather than a whole one: a complete fill would be ~10 SHARD, inside the + // 50 SHARD swap cap, so the swap is refused for short-filling and not for its size. + vm.expectRevert(); + swapRouter.swap( + key, + SwapParams({ zeroForOne: false, amountSpecified: int256(uint256(0.0001 ether)), sqrtPriceLimitX96: limit }) + ); + + assertEq(_feesHeld(), feesBefore, "a reverted exact-output swap still charged a fee"); + } + + /// @dev And the same quadrant must still succeed when the limit does NOT bind — otherwise + /// the guard above would be indistinguishable from "exact-output sells are broken". + function test_exactOutputEthSucceedsWhenTheLimitDoesNotBind() public { + hook.initialise(); + _acquireShards(50 ether); + uint256 feesBefore = _feesHeld(); + + (uint160 sqrtNow,,,) = manager.getSlot0(poolId); + uint160 limit = sqrtNow + sqrtNow / 4; // far above anything this sell will reach + + // ~10 SHARD in, so the sell stays inside the 50 SHARD swap cap. + uint256 ethOut = 0.0001 ether; + swapRouter.swap( + key, SwapParams({ zeroForOne: false, amountSpecified: int256(ethOut), sqrtPriceLimitX96: limit }) + ); + + assertEq( + _feesHeld() - feesBefore, (ethOut * FEE_BPS) / (BPS - FEE_BPS), "a complete exact-output sell was mispriced" + ); + } + + /// @dev The guard must not break ordinary slippage protection. A limit that exists but + /// never binds is the common routing case and has to keep working. + function test_nonBindingPriceLimitStillWorks() public { + hook.initialise(); + + // Far below anything a swap this size will reach. + uint160 limit = TickMath.getSqrtPriceAtTick(TICK_UPPER - 60_000); + uint256 amountIn = UNDER_CAP_ETH; // sized to the 50 SHARD swap cap + + swapRouter.swap{ value: amountIn }( + key, SwapParams({ zeroForOne: true, amountSpecified: -int256(amountIn), sqrtPriceLimitX96: limit }) + ); + + assertEq(_feesHeld(), (amountIn * FEE_BPS) / BPS, "non-binding limit changed the fee"); + } + + /// @dev A COMPLETE fill that happens to land exactly ON its price limit is not a partial + /// fill and must not be rejected. The guard used to compare the post-swap price to the + /// limit, which cannot tell the two apart — every complete fill that stopped exactly on + /// the boundary was refused. It now measures the delta against what was requested, so + /// the two are distinguishable. + function test_completeFillLandingExactlyOnTheLimitSucceeds() public { + hook.initialise(); + + uint256 amountIn = UNDER_CAP_ETH; // sized to the 50 SHARD swap cap + vm.deal(address(this), amountIn * 4); + + // Run it once with a limit that cannot bind, to learn where the swap lands. + uint256 snap = vm.snapshotState(); + swapRouter.swap{ value: amountIn }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(amountIn), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + (uint160 landedAt,,,) = manager.getSlot0(poolId); + vm.revertToState(snap); + + // Now replay it with the limit set exactly at that price. The swap still fills in full; + // it simply has nothing left to do by the time it touches the boundary. + swapRouter.swap{ value: amountIn }( + key, SwapParams({ zeroForOne: true, amountSpecified: -int256(amountIn), sqrtPriceLimitX96: landedAt }) + ); + + (uint160 nowAt,,,) = manager.getSlot0(poolId); + assertEq(nowAt, landedAt, "the swap did not land on the limit after all"); + assertEq(_feesHeld(), (amountIn * FEE_BPS) / BPS, "a complete fill on the limit was mispriced"); + } + + /*////////////////////////////////////////////////////////// + MAX SWAP SIZE + //////////////////////////////////////////////////////////*/ + + /// @dev The hook's own entry points have always capped a single action at MAX_BATCH NFTs. + /// That cap meant nothing while anyone could bypass it by swapping SHARD directly — + /// through this router, through Uniswap's own interface, or through any aggregator — + /// and then redeeming. The cap now lives on the swap itself, so the limit is a + /// property of the pool rather than of which front end you happened to use. + /// + /// Measured on the SHARD leg of the delta, which is why one check covers all four + /// direction x exactness quadrants: the fee is always taken on the ETH leg, so the + /// SHARD leg is exactly what moved regardless of which side was specified. + uint256 internal constant MAX_SWAP_SHARD = 50 ether; + + /// @dev Asserts the swap reverts specifically with `SwapTooLarge` — not merely that it + /// reverts at all. + /// + /// A bare `vm.expectRevert()` is not good enough here, and that is not theoretical: + /// `test_partialFillIsRejectedNotOvercharged` sat green in this very file while + /// reverting with the WRONG error, because a bare expectRevert accepts any of them. + /// The obvious fix is unavailable — v4 wraps every hook revert in + /// `CustomRevert.WrappedError`, so the selector is nested in the payload and + /// `vm.expectRevert(selector)` cannot match it. So scan the returndata instead, + /// which is exactly what the frontend detector had to do for the same reason. + function _expectSwapTooLarge(bool zeroForOne, int256 amountSpecified, uint256 value) internal { + (bool ok, bytes memory returndata) = address(swapRouter).call{ value: value }( + abi.encodeCall( + FeeSwapRouter.swap, + ( + key, + SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amountSpecified, + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }) + ) + ) + ); + assertFalse(ok, "the swap did not revert at all"); + assertTrue( + _carriesSelector(returndata, ShardErrorsV1.SwapTooLarge.selector), "reverted, but not with SwapTooLarge" + ); + } + + /// @dev Is this 4-byte selector anywhere in the payload? The wrapper puts it in the middle. + function _carriesSelector(bytes memory data, bytes4 sel) internal pure returns (bool) { + if (data.length < 4) return false; + for (uint256 i = 0; i + 4 <= data.length; ++i) { + if (data[i] == sel[0] && data[i + 1] == sel[1] && data[i + 2] == sel[2] && data[i + 3] == sel[3]) { + return true; + } + } + return false; + } + + function test_swapOfExactlyTheCapSucceeds() public { + hook.initialise(); + + // Exact-OUTPUT buy: the specified amount IS the SHARD leg, so this lands on the + // boundary exactly rather than approximately. + _swap(true, int256(MAX_SWAP_SHARD), 100 ether); + + assertEq(shard.balanceOf(address(this)), MAX_SWAP_SHARD, "a swap of exactly the cap was rejected"); + } + + function test_swapOneWeiOverTheCapReverts() public { + hook.initialise(); + + _expectSwapTooLarge(true, int256(MAX_SWAP_SHARD + 1), 100 ether); + + assertEq(shard.balanceOf(address(this)), 0, "an over-cap swap still delivered SHARD"); + assertEq(_feesHeld(), 0, "a rejected swap still charged a fee"); + assertEq(_feesAccounted(), 0, "a rejected swap still accounted a fee"); + } + + /// @dev The cap is symmetric — selling is capped too, so the limit cannot be sidestepped + /// by acquiring through one direction and unwinding through the other. + function test_sellOfExactlyTheCapSucceeds() public { + hook.initialise(); + _acquireShards(60 ether); // buy in under the cap first, in chunks + + uint256 before = shard.balanceOf(address(this)); + assertGe(before, MAX_SWAP_SHARD, "not enough SHARD to test the sell boundary"); + + _swap(false, -int256(MAX_SWAP_SHARD), 0); + + assertEq(shard.balanceOf(address(this)), before - MAX_SWAP_SHARD, "a sell of exactly the cap was rejected"); + } + + function test_sellOneWeiOverTheCapReverts() public { + hook.initialise(); + _acquireShards(60 ether); + + uint256 before = shard.balanceOf(address(this)); + _expectSwapTooLarge(false, -int256(MAX_SWAP_SHARD + 1), 0); + + assertEq(shard.balanceOf(address(this)), before, "an over-cap sell still moved SHARD"); + } + + /// @dev Proves the returndata scan DISCRIMINATES, rather than matching any revert. + /// Without this, `_expectSwapTooLarge` could be quietly vacuous and every cap test + /// above would be worthless — which is exactly the failure mode a bare + /// `vm.expectRevert()` produced in this file once already. + function test_theCapAssertionDoesNotMatchAnUnrelatedRevert() public { + hook.initialise(); + _acquireShards(50 ether); + + // The same scenario as test_partialFillIsRejectedOnExactOutputEthToo: selling + // pushes the price UP, so a limit a hair above spot binds immediately. A complete + // fill here would be ~10 SHARD, well inside the cap, so the ONLY reason this can + // revert is the short fill. + (uint160 sqrtNow,,,) = manager.getSlot0(poolId); + uint160 limit = sqrtNow + sqrtNow / 10_000; + + (bool ok, bytes memory data) = address(swapRouter) + .call( + abi.encodeCall( + FeeSwapRouter.swap, + ( + key, + SwapParams({ + zeroForOne: false, amountSpecified: int256(uint256(0.0001 ether)), sqrtPriceLimitX96: limit + }) + ) + ) + ); + + assertFalse(ok, "the setup swap was supposed to revert"); + assertTrue( + _carriesSelector(data, ShardErrorsV1.PartialFillNotSupported.selector), + "this scenario should revert with PartialFillNotSupported" + ); + assertFalse( + _carriesSelector(data, ShardErrorsV1.SwapTooLarge.selector), + "the scan matched an unrelated revert, so every cap test is vacuous" + ); + } + + /// @dev Exact-INPUT buys specify ETH, not SHARD, so the cap has to be enforced against the + /// delta after execution rather than against the requested amount. Sending far more + /// ETH than 50 SHARD costs must still be refused. + function test_exactInputBuyOverTheCapReverts() public { + hook.initialise(); + + // Price near TICK_UPPER is ~1e-5 ETH per SHARD, so 100 ETH buys far more than 50. + _expectSwapTooLarge(true, -int256(uint256(100 ether)), 100 ether); + + assertEq(shard.balanceOf(address(this)), 0, "an over-cap exact-input buy still delivered SHARD"); + } + + /*////////////////////////////////////////////////////////// + FUZZ + //////////////////////////////////////////////////////////*/ + + function testFuzz_feeNeverExceedsOnePercent(uint96 raw) public { + // Upper bound is the 50 SHARD swap cap, not a fee concern; the lower bound keeps the + // fee above the dust where it would floor to zero. + uint256 ethIn = bound(uint256(raw), 1e12, UNDER_CAP_ETH); + hook.initialise(); + vm.deal(address(this), ethIn + 1 ether); + + _swap(true, -int256(ethIn), ethIn); + + // Inclusive basis: the fee is at most 1% of what the swapper actually paid. + assertLe(_feesHeld(), (ethIn * FEE_BPS) / BPS, "fee exceeded 1%"); + assertGt(_feesHeld(), 0, "no fee taken"); + } + + /// @dev Whatever the fee turns out to be, the three ledgers must add back up to it. The combined + /// operator cut is the floor of the full 20%; the launcher takes the odd wei, so the two + /// sides stay within one wei of each other. + function testFuzz_theSplitConservesEveryFee(uint96 raw) public { + uint256 ethIn = bound(uint256(raw), 1e12, UNDER_CAP_ETH); + hook.initialise(); + vm.deal(address(this), ethIn + 1 ether); + + _swap(true, -int256(ethIn), ethIn); + + uint256 fee = _feesHeld(); + uint256 operator = (fee * 2 * CUT_BPS) / BPS; + assertEq(hook.builderFeesAccrued() + hook.launcherFeesAccrued(), operator, "operator = floor(20%)"); + assertGe(hook.launcherFeesAccrued(), hook.builderFeesAccrued(), "launcher takes the odd wei"); + assertLe(hook.launcherFeesAccrued() - hook.builderFeesAccrued(), 1, "cuts balanced within a wei"); + assertEq(hook.escrowBalance(), _holderShare(fee), "holders get the remainder plus dust"); + assertEq(_feesAccounted(), fee, "the split created or destroyed wei"); + } + + function testFuzz_poolIsNeverLeftWithNegativeDelta(uint96 raw, bool exactIn) public { + uint256 ethIn = bound(uint256(raw), 1e12, UNDER_CAP_ETH); // upper bound: the 50 SHARD swap cap + hook.initialise(); + vm.deal(address(this), ethIn + 100 ether); + + // If the hook's delta accounting were wrong in either direction, unlock would revert + // with CurrencyNotSettled rather than returning. + if (exactIn) { + _swap(true, -int256(ethIn), ethIn); + } else { + _swap(true, int256(ethIn / 1000 + 1), ethIn + 50 ether); + } + + assertGt(_feesHeld(), 0, "no fee captured"); + assertEq(shard.balanceOf(address(hook)), hook.seedDust(), "hook shard balance drifted"); + } + + receive() external payable { } +} diff --git a/test/ShardHookLiquidityV1.t.sol b/test/ShardHookLiquidityV1.t.sol new file mode 100644 index 00000000..35a7438a --- /dev/null +++ b/test/ShardHookLiquidityV1.t.sol @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { CustomRevert } from "@uniswap/v4-core/src/libraries/CustomRevert.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams, ModifyLiquidityParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; + +/*////////////////////////////////////////////////////////////// + HARNESS +//////////////////////////////////////////////////////////////*/ + +/// @dev Exposes the internal `_sweepClaims` only. No behaviour is overridden. +contract ShardHookHarnessV1 is ShardHookV1 { + constructor( + IPoolManager _poolManager, + ShardTokenV1 _shard, + int24 _tickLower, + int24 _tickBand, + int24 _tickUpper, + uint160 _startSqrtPriceX96, + address _deployer, + address _launcherFeeRecipient, + address _builderFeeRecipient + ) + ShardHookV1( + _poolManager, + _shard, + _tickLower, + _tickBand, + _tickUpper, + _startSqrtPriceX96, + _deployer, + _launcherFeeRecipient, + _builderFeeRecipient + ) + { } + + function sweep() external { + _sweepClaims(); + } +} + +/*////////////////////////////////////////////////////////////// + ROUTERS +//////////////////////////////////////////////////////////////*/ + +contract TestSwapRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function swap(PoolKey memory key, SwapParams memory params) external payable returns (BalanceDelta delta) { + delta = abi.decode(poolManager.unlock(abi.encode(msg.sender, key, params)), (BalanceDelta)); + uint256 bal = address(this).balance; + if (bal > 0) { + (bool ok,) = msg.sender.call{ value: bal }(""); + require(ok, "refund failed"); + } + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address sender, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + + BalanceDelta delta = poolManager.swap(key, params, ""); + + _resolve(key.currency0, delta.amount0(), sender); + _resolve(key.currency1, delta.amount1(), sender); + + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount, address sender) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), owed); + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, sender, uint256(uint128(amount))); + } + } +} + +/// @dev Positions key on THIS contract, so it can only ever address an empty position. +contract TestLiquidityRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function modifyLiquidity(PoolKey memory key, ModifyLiquidityParams memory params) + external + payable + returns (BalanceDelta delta) + { + delta = abi.decode(poolManager.unlock(abi.encode(key, params)), (BalanceDelta)); + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (PoolKey memory key, ModifyLiquidityParams memory params) = + abi.decode(rawData, (PoolKey, ModifyLiquidityParams)); + (BalanceDelta delta,) = poolManager.modifyLiquidity(key, params, ""); + return abi.encode(delta); + } +} + +/// @dev Mints ERC-6909 native claims to an arbitrary holder, paid for with real ETH. +contract TestClaimMinter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function mintClaimTo(address to, uint256 amount) external payable { + poolManager.unlock(abi.encode(to, amount)); + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address to, uint256 amount) = abi.decode(rawData, (address, uint256)); + poolManager.mint(to, CurrencyLibrary.ADDRESS_ZERO.toId(), amount); + poolManager.settle{ value: amount }(); + return ""; + } +} + +/*////////////////////////////////////////////////////////////// + TESTS +//////////////////////////////////////////////////////////////*/ + +contract ShardHookLiquidityV1Test is Test { + using StateLibrary for IPoolManager; + + error Boom(); + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + + /// @dev The hook caps a single third-party swap at MAX_BATCH SHARD (50e18). At TICK_UPPER + /// the price is ~1e-5 ETH per SHARD, so a whole ETH would move ~99,000 SHARD and be + /// refused; this buys ~39 SHARD, inside the cap, and still walks the tick ~79 down. + uint256 internal constant UNDER_CAP_ETH = 0.0004 ether; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardTokenV1 internal shard; + ShardHookHarnessV1 internal hook; + TestSwapRouter internal swapRouter; + TestLiquidityRouter internal liquidityRouter; + TestClaimMinter internal claimMinter; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal attacker = address(0xBEEF); + address internal launcher = makeAddr("launcher"); + address internal builder = makeAddr("builder"); + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + assertEq(TICK_LOWER, -887_220, "minUsableTick(60)"); + + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + shard = new ShardTokenV1(); + swapRouter = new TestSwapRouter(manager); + liquidityRouter = new TestLiquidityRouter(manager); + claimMinter = new TestClaimMinter(manager); + + hook = _deployHook(shard, TICK_LOWER, TICK_UPPER, startSqrtPriceX96); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + shard.transfer(address(hook), SEED_AMOUNT); + + vm.deal(address(this), 1000 ether); + vm.deal(attacker, 100 ether); + } + + /*////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////*/ + + function _deployHook(ShardTokenV1 _shard, int24 lower, int24 upper, uint160 startPrice) + internal + returns (ShardHookHarnessV1 deployed) + { + return _deployHook(_shard, lower, TICK_BAND, upper, startPrice); + } + + /// @dev The miner hashes the constructor args, so the `abi.encode` list and the actual + /// `new` call MUST stay identical, argument for argument. + function _deployHook(ShardTokenV1 _shard, int24 lower, int24 band, int24 upper, uint160 startPrice) + internal + returns (ShardHookHarnessV1 deployed) + { + (address expected, bytes32 salt) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(ShardHookHarnessV1).creationCode, + abi.encode(manager, _shard, lower, band, upper, startPrice, address(this), launcher, builder) + ); + deployed = new ShardHookHarnessV1{ salt: salt }( + manager, _shard, lower, band, upper, startPrice, address(this), launcher, builder + ); + assertEq(address(deployed), expected, "hook address mismatch"); + } + + function _positionLiquidityAt(int24 lower, int24 upper) internal view returns (uint128 liquidity) { + (liquidity,,) = manager.getPositionInfo(poolId, address(hook), lower, upper, bytes32(0)); + } + + function _hookPositionLiquidity() internal view returns (uint128 liquidity) { + (liquidity,,) = manager.getPositionInfo(poolId, address(hook), TICK_LOWER, TICK_UPPER, bytes32(0)); + } + + /// @dev v4 wraps a reverting hook call in ERC-7751 `WrappedError`. + function _expectHookRevert(bytes4 hookSelector, bytes memory inner) internal { + vm.expectRevert( + abi.encodeWithSelector( + CustomRevert.WrappedError.selector, + address(hook), + hookSelector, + inner, + abi.encodeWithSelector(Hooks.HookCallFailed.selector) + ) + ); + } + + function _buy(uint256 amountIn) internal { + swapRouter.swap{ value: amountIn }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: -int256(amountIn), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + } + + /*////////////////////////////////////////////////////////// + TESTS + //////////////////////////////////////////////////////////*/ + + function test_hookPermissionsMatchAddressBits() public view { + Hooks.Permissions memory p = hook.getHookPermissions(); + + assertTrue(p.beforeInitialize && p.beforeSwap && p.afterSwap, "action flags"); + assertTrue(p.beforeSwapReturnDelta && p.afterSwapReturnDelta, "delta flags"); + assertFalse(p.afterInitialize, "afterInitialize"); + assertFalse(p.beforeAddLiquidity || p.afterAddLiquidity, "add liquidity"); + assertFalse(p.beforeRemoveLiquidity || p.afterRemoveLiquidity, "remove liquidity"); + assertFalse(p.beforeDonate || p.afterDonate, "donate"); + assertFalse(p.afterAddLiquidityReturnDelta || p.afterRemoveLiquidityReturnDelta, "lp deltas"); + + assertEq(uint160(address(hook)) & Hooks.ALL_HOOK_MASK, HOOK_FLAGS, "address bits != permissions"); + } + + function test_initialiseSeedsAllTenThousandShards() public { + hook.initialise(); + + // Rounding down to whole liquidity units strands a little SHARD in the hook, so the two + // sides only add up — never assert exact equality on the pool side. + assertEq(shard.balanceOf(address(manager)) + hook.seedDust(), SEED_AMOUNT, "SHARD went missing"); + assertApproxEqAbs(shard.balanceOf(address(manager)), SEED_AMOUNT, 1e6, "pool underfunded"); + assertGt(_hookPositionLiquidity(), 0, "no liquidity minted"); + assertEq(_hookPositionLiquidity(), hook.seedLiquidity(), "position/record mismatch"); + assertTrue(hook.initialised(), "not marked initialised"); + } + + function test_initialiseRecordsSeedDust() public { + hook.initialise(); + + uint256 dust = hook.seedDust(); + assertGt(dust, 0, "rounding dust should not be zero"); + assertLt(dust, 1e6, "dust unexpectedly large"); + assertEq(dust, shard.balanceOf(address(hook)), "seedDust != hook SHARD balance"); + + console2.log("seedDust (wei of SHARD):", dust); + } + + function test_initialiseRequiresNoEth() public { + vm.deal(address(hook), 5 ether); + uint256 before = address(hook).balance; + + hook.initialise(); + + assertEq(address(hook).balance, before, "hook spent ETH seeding"); + assertEq(address(manager).balance, 0, "manager received ETH"); + } + + function test_initialiseIsOneShot() public { + hook.initialise(); + + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.initialise(); + } + + function test_initialiseOnlyDeployer() public { + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.initialise(); + } + + function test_initialiseRevertsOnWrongShardBalance() public { + ShardTokenV1 other = new ShardTokenV1(); + ShardHookHarnessV1 lean = _deployHook(other, TICK_LOWER, TICK_UPPER, startSqrtPriceX96); + + other.transfer(address(lean), SEED_AMOUNT - 1); + + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.WrongShardBalance.selector, SEED_AMOUNT, SEED_AMOUNT - 1)); + lean.initialise(); + } + + function test_initialiseSurvivesFrontRunAtSamePrice() public { + // A front-runner initialises the canonical pool first, at the canonical price. + vm.prank(attacker); + manager.initialize(key, startSqrtPriceX96); + + // The hook tolerates it: PoolAlreadyInitialized is caught and seeding proceeds. + hook.initialise(); + + assertGt(_hookPositionLiquidity(), 0, "seed did not happen after front-run"); + assertEq(shard.balanceOf(address(manager)) + hook.seedDust(), SEED_AMOUNT, "SHARD went missing"); + } + + function test_frontRunAtDifferentPriceReverts() public { + uint160 badPrice = TickMath.getSqrtPriceAtTick(TICK_UPPER - TICK_SPACING); + + _expectHookRevert( + IHooks.beforeInitialize.selector, + abi.encodeWithSelector(ShardErrorsV1.WrongStartPrice.selector, startSqrtPriceX96, badPrice) + ); + vm.prank(attacker); + manager.initialize(key, badPrice); + + // And the canonical path still works afterwards. + hook.initialise(); + assertGt(_hookPositionLiquidity(), 0, "hook could not initialise after a failed front-run"); + } + + function test_initialiseRerevertsNonAlreadyInitialisedErrors() public { + // Anything that is NOT PoolAlreadyInitialized must bubble out, never be swallowed — + // otherwise the hook would seed into a pool that does not exist. + vm.mockCallRevert( + address(manager), + abi.encodeWithSelector(IPoolManager.initialize.selector), + abi.encodeWithSelector(Boom.selector) + ); + + vm.expectRevert(Boom.selector); + hook.initialise(); + } + + function test_beforeInitializeRejectsForeignPool() public { + // A SHARD/USDC pool wired to this hook must be rejected outright. + ShardTokenV1 usdc = new ShardTokenV1(); + (Currency c0, Currency c1) = address(usdc) < address(shard) + ? (Currency.wrap(address(usdc)), Currency.wrap(address(shard))) + : (Currency.wrap(address(shard)), Currency.wrap(address(usdc))); + + PoolKey memory foreign = PoolKey({ + currency0: c0, + currency1: c1, + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + + _expectHookRevert(IHooks.beforeInitialize.selector, abi.encodeWithSelector(ShardErrorsV1.WrongPool.selector)); + manager.initialize(foreign, startSqrtPriceX96); + } + + function test_startPriceMustBeAtOrAboveTickUpper() public { + // Start below tickUpper => the range is not entirely below spot => the seed would demand + // currency0 (ETH) the hook does not have. + ShardTokenV1 other = new ShardTokenV1(); + uint160 lowPrice = TickMath.getSqrtPriceAtTick(TICK_UPPER - TICK_SPACING); + ShardHookHarnessV1 low = _deployHook(other, TICK_LOWER, TICK_UPPER, lowPrice); + + other.transfer(address(low), SEED_AMOUNT); + + vm.expectRevert(ShardErrorsV1.InvalidStartPrice.selector); + low.initialise(); + } + + function test_noWithdrawalFunctionExists() public { + hook.initialise(); + uint128 liquidity = _hookPositionLiquidity(); + assertGt(liquidity, 0); + + // (a) No external surface for removal, under any of the usual names. The hook has a + // `receive()` but no fallback, so a call carrying data must revert. + string[6] memory sigs = [ + "withdraw()", + "withdrawLiquidity()", + "removeLiquidity()", + "emergencyWithdraw()", + "modifyLiquidity((address,address,uint24,int24,address),(int24,int24,int256,bytes32),bytes)", + "rescue(address,uint256)" + ]; + for (uint256 i = 0; i < sigs.length; i++) { + (bool ok,) = address(hook).call(abi.encodeWithSignature(sigs[i])); + assertFalse(ok, "an unexpected external function exists"); + } + + // (b) Routing a negative delta through any other contract addresses a DIFFERENT + // (empty) position, because v4 positions key on msg.sender. + vm.prank(attacker); + vm.expectRevert(); + liquidityRouter.modifyLiquidity( + key, + ModifyLiquidityParams({ + tickLower: TICK_LOWER, + tickUpper: TICK_UPPER, + liquidityDelta: -int256(uint256(liquidity)), + salt: bytes32(0) + }) + ); + + assertEq(_hookPositionLiquidity(), liquidity, "hook position was drained"); + assertEq(shard.balanceOf(attacker), 0, "attacker extracted SHARD"); + } + + function test_buyingMovesTickDown() public { + hook.initialise(); + + (, int24 tickBefore,,) = manager.getSlot0(poolId); + uint256 managerEthBefore = address(manager).balance; + + _buy(UNDER_CAP_ETH); // sized to the 50 SHARD swap cap; the direction is what is asserted + + (, int24 tickAfter,,) = manager.getSlot0(poolId); + + assertLt(tickAfter, tickBefore, "tick did not move down"); + assertGt(address(manager).balance, managerEthBefore, "pool did not accumulate ETH"); + assertGt(shard.balanceOf(address(this)), 0, "buyer received no SHARD"); + } + + function test_constructorRejectsUnalignedTicks() public { + int24 badUpper = TICK_UPPER + 1; // not a multiple of 60 + + (, bytes32 salt) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(ShardHookHarnessV1).creationCode, + abi.encode( + manager, shard, TICK_LOWER, TICK_BAND, badUpper, startSqrtPriceX96, address(this), launcher, builder + ) + ); + + // The address is valid, so BaseHook's validateHookAddress passes and the tick check runs. + vm.expectRevert(ShardErrorsV1.InvalidTickRange.selector); + new ShardHookHarnessV1{ salt: salt }( + manager, shard, TICK_LOWER, TICK_BAND, badUpper, startSqrtPriceX96, address(this), launcher, builder + ); + } + + function test_unlockCallbackOnlyPoolManager() public { + vm.prank(attacker); + vm.expectRevert(ShardErrorsV1.NotPoolManager.selector); + hook.unlockCallback(abi.encode(ShardHookV1.Action.SWEEP, uint256(0))); + } + + function test_sweepRedeems6909ClaimsToRealEth() public { + uint256 amount = 3 ether; + uint256 nativeId = CurrencyLibrary.ADDRESS_ZERO.toId(); + assertEq(nativeId, 0, "native ERC-6909 id must be 0"); + + claimMinter.mintClaimTo{ value: amount }(address(hook), amount); + assertEq(manager.balanceOf(address(hook), nativeId), amount, "claim not minted"); + + uint256 hookEthBefore = address(hook).balance; + + hook.sweep(); + + assertEq(manager.balanceOf(address(hook), nativeId), 0, "claim not burned"); + assertEq(address(hook).balance, hookEthBefore + amount, "real ETH did not arrive"); + + // Idempotent: a second sweep with nothing outstanding is a no-op. + hook.sweep(); + assertEq(address(hook).balance, hookEthBefore + amount, "second sweep changed balance"); + } + + /*////////////////////////////////////////////////////////// + TWO OVERLAPPING POSITIONS + //////////////////////////////////////////////////////////*/ + + function test_seedSplitAccountsForTheWholeSupply() public pure { + assertEq( + ShardConstantsV1.SEED_FULL_RANGE + ShardConstantsV1.SEED_BAND, + ShardConstantsV1.MAX_NFTS * ShardConstantsV1.SHARDS_PER_NFT, + "the two positions must seed exactly the whole supply" + ); + } + + /// Both positions must exist after initialise, each holding its own share. + /// NOTE: `setUp` funds the hook but does NOT initialise — each test seeds for itself. + function test_seedsTwoOverlappingPositions() public { + hook.initialise(); + uint128 full = _positionLiquidityAt(hook.tickLower(), hook.tickUpper()); + uint128 band = _positionLiquidityAt(hook.tickBand(), hook.tickUpper()); + + assertGt(full, 0, "full-range position was not seeded"); + assertGt(band, 0, "band position was not seeded"); + assertEq(full, hook.seedLiquidity(), "full-range record does not match the position"); + assertEq(band, hook.seedLiquidityBand(), "band record does not match the position"); + } + + /// The band is stacked ON TOP of the full range, so it must be the denser of the two. + /// If this inverts, the curve is shaped the wrong way round. + function test_bandIsDenserThanTheFullRange() public { + hook.initialise(); + assertGt(hook.seedLiquidityBand(), hook.seedLiquidity(), "band is not denser than the full range"); + } + + /// Below the band edge BOTH positions are active; above it only the full-range one is. + /// That overlap is the entire point of the shape. + /// + /// The pool opens exactly AT `tickUpper`, and a v4 position is NOT active at its own upper + /// bound, so active liquidity is legitimately ZERO until the first trade pushes the tick + /// down into the range. Hence the buy: asserting at the start price would be vacuous. + function test_activeLiquidityIsTheSumInsideTheBand() public { + hook.initialise(); + assertEq(manager.getLiquidity(poolId), 0, "sanity: nothing is in range at the opening tick"); + + _buy(0.0001 ether); + + (, int24 tick,,) = manager.getSlot0(poolId); + assertLt(tick, TICK_UPPER, "the buy did not move the tick into the range"); + assertGt(tick, TICK_BAND, "the buy overshot the band edge - it should still be inside"); + + assertEq( + manager.getLiquidity(poolId), + hook.seedLiquidity() + hook.seedLiquidityBand(), + "inside the band, active liquidity must be BOTH positions summed" + ); + } + + /// Two positions means two roundings, so dust is larger than the single-position 221 wei. + /// The backing invariant is stated RELATIVE to seedDust, so it only has to be recorded. + /// This suite wires no NFT, so nothing circulates and the backing identity reduces to + /// "every SHARD is either in the position or recorded as dust". Two positions means two + /// roundings, so the dust is larger than the single-position 221 wei — it only has to be + /// RECORDED, because the invariant is stated relative to it. + function test_seedDustIsRecordedAndBackingHolds() public { + hook.initialise(); + assertEq(shard.balanceOf(address(hook)), hook.seedDust(), "hook holds SHARD beyond the recorded dust"); + assertEq(shard.balanceOf(address(manager)) + hook.seedDust(), SEED_AMOUNT, "SHARD went missing at seed time"); + assertLt(hook.seedDust(), 1e18, "dust should still be a fraction of one SHARD"); + } + + receive() external payable { } +} diff --git a/test/ShardHookMarketV1.t.sol b/test/ShardHookMarketV1.t.sol new file mode 100644 index 00000000..494414c2 --- /dev/null +++ b/test/ShardHookMarketV1.t.sol @@ -0,0 +1,601 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IUnlockCallback } from "@uniswap/v4-core/src/interfaces/callback/IUnlockCallback.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/*////////////////////////////////////////////////////////////// + ROUTER +//////////////////////////////////////////////////////////////*/ + +contract MarketSwapRouter is IUnlockCallback { + IPoolManager public immutable poolManager; + + constructor(IPoolManager _poolManager) { + poolManager = _poolManager; + } + + receive() external payable { } + + function swap(PoolKey memory key, SwapParams memory params) external payable returns (BalanceDelta delta) { + delta = abi.decode(poolManager.unlock(abi.encode(msg.sender, key, params)), (BalanceDelta)); + uint256 bal = address(this).balance; + if (bal > 0) { + (bool ok,) = msg.sender.call{ value: bal }(""); + require(ok, "refund failed"); + } + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(poolManager), "not pool manager"); + (address sender, PoolKey memory key, SwapParams memory params) = + abi.decode(rawData, (address, PoolKey, SwapParams)); + BalanceDelta delta = poolManager.swap(key, params, ""); + _resolve(key.currency0, delta.amount0(), sender); + _resolve(key.currency1, delta.amount1(), sender); + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount, address sender) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + poolManager.settle{ value: owed }(); + } else { + poolManager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transferFrom(sender, address(poolManager), owed); + poolManager.settle(); + } + } else if (amount > 0) { + poolManager.take(currency, sender, uint256(uint128(amount))); + } + } +} + +/// @dev Re-enters claim() on receipt. Solady's ReentrancyGuard must stop it. +contract ReentrantClaimer { + ShardHookV1 public hook; + bool public armed; + + function arm(ShardHookV1 h) external { + hook = h; + armed = true; + } + + uint256[] internal ids; + + function setIds(uint256 id) external { + ids.push(id); + } + + function go() external returns (uint256) { + return hook.claim(ids); + } + + receive() external payable { + if (armed) { + armed = false; + hook.claim(ids); + } + } +} + +/*////////////////////////////////////////////////////////////// + TESTS +//////////////////////////////////////////////////////////////*/ + +contract ShardHookMarketV1Test is Test { + using StateLibrary for IPoolManager; + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant ONE_SHARD = 1 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + MarketSwapRouter internal swapRouter; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + uint256 internal constant FAR = 1e18; // deadline far in the future + + /// @dev The hook caps a single third-party swap at MAX_BATCH SHARD (50e18). At TICK_UPPER + /// the price is ~1e-5 ETH per SHARD, so third-party flow here is sized in fractions of + /// a milli-ETH: this buys ~39 SHARD, inside the cap, and still leaves a fee above dust. + uint256 internal constant UNDER_CAP_ETH = 0.0004 ether; + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + swapRouter = new MarketSwapRouter(manager); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardHookMarketV1Test"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + vm.deal(address(this), 10_000 ether); + vm.deal(alice, 1000 ether); + vm.deal(bob, 1000 ether); + } + + /*////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////*/ + + function test_setupUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + function _feesHeld() internal view returns (uint256) { + return manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } + + function _price() internal view returns (uint160 sqrtPriceX96) { + (sqrtPriceX96,,,) = manager.getSlot0(poolId); + } + + function _buy(address who, uint256 send) internal returns (uint256 tokenId, uint256 spent) { + uint256 before = who.balance; + vm.prank(who); + tokenId = hook.buyNFT{ value: send }(type(uint256).max, FAR); + spent = before - who.balance; + } + + /// @dev The core supply invariant, stated with the seedDust term that + /// getLiquidityForAmount1's rounding makes necessary. + function _assertBacking() internal view { + assertEq( + shard.balanceOf(address(hook)), + nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), + "shard backing != circulating NFTs" + ); + } + + /*////////////////////////////////////////////////////////// + THE C1 REGRESSION TESTS + //////////////////////////////////////////////////////////*/ + + /// v4 skips a hook's own callbacks, so buyNFT MUST charge explicitly. If this reads 0, + /// buying is free, holder revenue is zero and the art-reroll loop is wide open. + function test_buyNftChargesOnePercentExplicitly() public { + (, uint256 spent) = _buy(alice, 1 ether); + + uint256 fee = _feesHeld(); + assertGt(fee, 0, "buyNFT charged NO fee - the two-path design is broken"); + assertApproxEqAbs(fee, spent * FEE_BPS / BPS, 2, "buy fee != inclusive 1% of total"); + } + + function test_sellNftChargesOnePercentExplicitly() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + uint256 feesAfterBuy = _feesHeld(); + + vm.prank(alice); + uint256 payout = hook.sellNFT(tokenId, 0, FAR); + + uint256 sellFee = _feesHeld() - feesAfterBuy; + assertGt(sellFee, 0, "sellNFT charged NO fee"); + assertApproxEqAbs(sellFee, (payout + sellFee) * FEE_BPS / BPS, 2, "sell fee != 1% of gross released"); + } + + /// The two paths must be mutually exclusive: a hook-initiated swap must not ALSO + /// trigger the callback path. + function test_hookSelfSwapDoesNotDoubleCharge() public { + (, uint256 spent) = _buy(alice, 1 ether); + // Exactly one 1% charge, not two. + assertApproxEqAbs(_feesHeld(), spent * FEE_BPS / BPS, 2, "double-charged"); + } + + function test_feeBasisIsInclusiveOnBuy() public { + (, uint256 spent) = _buy(alice, 1 ether); + uint256 fee = _feesHeld(); + // fee is 1% of the TOTAL paid, not 1% of the curve cost (which would be 0.990%). + assertApproxEqAbs(fee * BPS / spent, FEE_BPS, 1, "not an inclusive 1%"); + } + + /// Both entry paths must cost the same, or one is quietly cheaper and arbitrageable. + function test_buyNftAndSwapThenRedeemCostTheSame() public { + (, uint256 buyCost) = _buy(alice, 1 ether); + + // Now the other way: swap ETH for exactly 1 SHARD via a router, then redeem. + uint256 before = bob.balance; + vm.startPrank(bob); + swapRouter.swap{ value: 1 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(ONE_SHARD), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + uint256 swapCost = before - bob.balance; + shard.approve(address(hook), type(uint256).max); + hook.redeem(); + vm.stopPrank(); + + // Both bought one NFT's worth. The second buy walks slightly further up the curve, + // so allow for curve movement, not for a rate difference. + assertApproxEqRel(swapCost, buyCost, 0.02e18, "entry paths diverge in cost"); + } + + /// A buy/sell round trip must cost ~2%, which is what prices the art-reroll loop out. + function test_buyThenSellRoundTripCostsAboutTwoPercent() public { + (uint256 tokenId, uint256 spent) = _buy(alice, 1 ether); + + vm.prank(alice); + uint256 payout = hook.sellNFT(tokenId, 0, FAR); + + uint256 loss = spent - payout; + assertGt(loss, spent * 15 / 1000, "round trip cost less than 1.5% - reroll is too cheap"); + assertLt(loss, spent * 30 / 1000, "round trip cost more than 3%"); + } + + /*////////////////////////////////////////////////////////// + BUY + //////////////////////////////////////////////////////////*/ + + function test_buyNftMintsAndChargesBondingCurvePrice() public { + (uint256 tokenId, uint256 spent) = _buy(alice, 1 ether); + assertEq(tokenId, 1, "first id should be 1"); + assertEq(nft.ownerOf(1), alice, "buyer does not own it"); + assertGt(spent, 0, "nothing charged"); + assertEq(nft.circulatingSupply(), 1, "circulating not tracked"); + _assertBacking(); + } + + function test_buyNftRefundsExcessEth() public { + (, uint256 spent) = _buy(alice, 500 ether); + assertLt(spent, 1 ether, "excess not refunded"); + } + + function test_buyNftRevertsOnInsufficientEth() public { + vm.prank(alice); + vm.expectRevert(); + hook.buyNFT{ value: 1 }(type(uint256).max, FAR); + } + + function test_buyNftRespectsMaxEthIn() public { + vm.prank(alice); + vm.expectRevert(); + hook.buyNFT{ value: 1 ether }(1, FAR); // maxEthIn of 1 wei + } + + function test_deadlineIsEnforced() public { + vm.warp(1000); + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.Expired.selector); + hook.buyNFT{ value: 1 ether }(type(uint256).max, 999); + } + + /// A buy must never dip into ETH already owed to fee claimants. + /// @dev `assertGe` here would be near-vacuous: the hook's balance always grows by the + /// new buy's fee, so a buy that quietly consumed some holder ETH would still leave + /// the balance higher and the test green. Assert the balance grew by EXACTLY the + /// fee — any dip into holder ETH shows up as a shortfall. + function test_buyNftCannotSpendHolderFeeEth() public { + _buy(alice, 1 ether); + uint256 holderEthBefore = address(hook).balance; + assertGt(holderEthBefore, 0, "no holder ETH to protect - test would be vacuous"); + + (, uint256 spent) = _buy(bob, 1 ether); + + uint256 gained = address(hook).balance - holderEthBefore; + assertApproxEqAbs(gained, spent * FEE_BPS / BPS, 2, "buy did not contribute exactly its own fee"); + } + + function test_priceRisesAsSupplyIsBought() public { + uint160 p0 = _price(); + _buy(alice, 1 ether); + uint160 p1 = _price(); + _buy(bob, 1 ether); + uint160 p2 = _price(); + // Buying SHARD moves the tick DOWN, so sqrtPriceX96 decreases as SHARD gets pricier. + assertLt(p1, p0, "price did not move on first buy"); + assertLt(p2, p1, "price did not move on second buy"); + } + + /*////////////////////////////////////////////////////////// + SELL + //////////////////////////////////////////////////////////*/ + + function test_sellNftReturnsEthToSeller() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + uint256 before = alice.balance; + + vm.prank(alice); + uint256 payout = hook.sellNFT(tokenId, 0, FAR); + + assertEq(alice.balance - before, payout, "payout not delivered"); + assertGt(payout, 0, "no payout"); + } + + function test_sellNftReleasesIdBackToPool() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + vm.prank(alice); + hook.sellNFT(tokenId, 0, FAR); + + assertTrue(nft.isPoolHeld(tokenId), "id not returned to the archive"); + assertEq(nft.ownerOf(tokenId), address(nft), "archive does not hold it"); + assertEq(nft.circulatingSupply(), 0, "still counted as circulating"); + _assertBacking(); + } + + function test_sellNftDestroysArt() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + assertGt(nft.tokenSeed(tokenId), 0, "no seed after buy"); + + vm.prank(alice); + hook.sellNFT(tokenId, 0, FAR); + + assertEq(nft.tokenSeed(tokenId), 0, "art survived the sale"); + } + + function test_sellNftRespectsMinEthOut() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + vm.prank(alice); + vm.expectRevert(); + hook.sellNFT(tokenId, 1000 ether, FAR); + } + + function test_sellNftRevertsIfNotOwner() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + vm.prank(bob); + vm.expectRevert(); + hook.sellNFT(tokenId, 0, FAR); + } + + /// Ordering guarantee: the seller settles OUT before their own exit fee distributes. + function test_sellNftSellerDoesNotEarnFromOwnExitFee() public { + (uint256 tokenIdA,) = _buy(alice, 1 ether); + (uint256 tokenIdB,) = _buy(bob, 1 ether); + vm.roll(block.number + 1); // clear the same-block accrual guard + + vm.prank(alice); + hook.sellNFT(tokenIdA, 0, FAR); + + // Alice is out of the earning set before her fee lands; bob takes all of it. + assertEq(hook.claimable(alice), 0, "seller earned from their own exit fee"); + assertEq(nft.ownerOf(tokenIdB), bob, "sanity: bob still holds his"); + } + + function test_priceFallsAfterSelling() public { + (uint256 tokenId,) = _buy(alice, 1 ether); + uint160 afterBuy = _price(); + + vm.prank(alice); + hook.sellNFT(tokenId, 0, FAR); + + assertGt(_price(), afterBuy, "price did not recover after a sale"); + } + + /*////////////////////////////////////////////////////////// + REDEEM / NO REVERSE + //////////////////////////////////////////////////////////*/ + + function test_redeemConvertsOneShardToNft() public { + vm.startPrank(alice); + swapRouter.swap{ value: 5 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(ONE_SHARD), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + shard.approve(address(hook), type(uint256).max); + uint256 tokenId = hook.redeem(); + vm.stopPrank(); + + assertEq(nft.ownerOf(tokenId), alice, "redeemer does not own it"); + _assertBacking(); + } + + function test_redeemChargesNoAdditionalFee() public { + vm.startPrank(alice); + swapRouter.swap{ value: 5 ether }( + key, + SwapParams({ + zeroForOne: true, amountSpecified: int256(ONE_SHARD), sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + uint256 feesAfterSwap = _feesHeld(); + shard.approve(address(hook), type(uint256).max); + hook.redeem(); + vm.stopPrank(); + + assertEq(_feesHeld(), feesAfterSwap, "redeem charged a second fee"); + } + + function test_thereIsNoNftToShardPath() public view { + // The asymmetry is deliberate: shards can become an NFT, an NFT can only become ETH. + // Without it, deposit-then-redeem would be a free art reroll. + assertEq(address(hook).code.length > 0 ? uint256(1) : uint256(0), 1, "hook has no code"); + bytes4[3] memory forbidden = [ + bytes4(keccak256("deposit(uint256)")), + bytes4(keccak256("depositNFT(uint256)")), + bytes4(keccak256("unwrap(uint256)")) + ]; + for (uint256 i = 0; i < forbidden.length; i++) { + (bool ok,) = address(hook).staticcall(abi.encodeWithSelector(forbidden[i], 1)); + assertFalse(ok, "an NFT-to-SHARD path exists"); + } + } + + /*////////////////////////////////////////////////////////// + CLAIM + //////////////////////////////////////////////////////////*/ + + function test_claimSweepsClaimsThenPays() public { + _buy(alice, 1 ether); + vm.roll(block.number + 1); + + // A third-party swap accrues its fee as an ERC-6909 claim, not real ETH. Sized to the + // 50 SHARD swap cap; what matters here is that a fee accrued at all. + swapRouter.swap{ value: UNDER_CAP_ETH }( + key, + SwapParams({ + zeroForOne: true, + amountSpecified: -int256(UNDER_CAP_ETH), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + assertGt(manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()), 0, "no 6909 claims accrued"); + + uint256[] memory ids = new uint256[](1); + ids[0] = 1; + uint256 before = alice.balance; + vm.prank(alice); + uint256 paid = hook.claim(ids); + + assertGt(paid, 0, "nothing paid"); + assertEq(alice.balance - before, paid, "ETH did not arrive"); + assertEq(manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()), 0, "claims not swept"); + } + + function test_claimRevertsWhenNothingOwed() public { + vm.prank(bob); + vm.expectRevert(ShardErrorsV1.NothingToClaim.selector); + hook.claim(new uint256[](0)); + } + + function test_reentrantClaimReverts() public { + ReentrantClaimer attacker = new ReentrantClaimer(); + vm.deal(address(attacker), 1 ether); + + vm.prank(address(attacker)); + hook.buyNFT{ value: 1 ether }(type(uint256).max, FAR); + vm.roll(block.number + 1); + + // Sized to the 50 SHARD swap cap; this swap only exists to accrue a fee to re-enter on. + swapRouter.swap{ value: UNDER_CAP_ETH }( + key, + SwapParams({ + zeroForOne: true, + amountSpecified: -int256(UNDER_CAP_ETH), + sqrtPriceLimitX96: TickMath.MIN_SQRT_PRICE + 1 + }) + ); + + attacker.setIds(1); + attacker.arm(hook); + vm.expectRevert(); + attacker.go(); + } + + /*////////////////////////////////////////////////////////// + WIRING + //////////////////////////////////////////////////////////*/ + + function test_setNftOnlyDeployerAndOneShot() public { + vm.prank(bob); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.setNFT(IShardNFTV1(address(0xDEAD))); + + vm.prank(address(factory)); + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.setNFT(IShardNFTV1(address(0xDEAD))); + } + + function test_settleOnTransferOnlyCallableByNft() public { + vm.prank(bob); + vm.expectRevert(ShardErrorsV1.NotNFT.selector); + hook.settleOnTransfer(1, bob, alice); + } + + /*////////////////////////////////////////////////////////// + THE INVARIANT + //////////////////////////////////////////////////////////*/ + + function test_shardBackingInvariantHolds() public { + _assertBacking(); + (uint256 a,) = _buy(alice, 1 ether); + _assertBacking(); + _buy(bob, 1 ether); + _assertBacking(); + vm.prank(alice); + hook.sellNFT(a, 0, FAR); + _assertBacking(); + } + + function testFuzz_invariantHoldsUnderRandomSequence(uint8 ops) public { + uint256 n = uint256(ops) % 8 + 1; + uint256[] memory held = new uint256[](n); + uint256 count; + + for (uint256 i = 0; i < n; i++) { + if (count == 0 || i % 3 != 2) { + (uint256 id,) = _buy(alice, 5 ether); + held[count++] = id; + } else { + uint256 id = held[--count]; + vm.prank(alice); + hook.sellNFT(id, 0, FAR); + } + vm.roll(block.number + 1); + _assertBacking(); + } + } + + receive() external payable { } +} diff --git a/test/ShardLaunchFactoryV1.t.sol b/test/ShardLaunchFactoryV1.t.sol new file mode 100644 index 00000000..221bbe61 --- /dev/null +++ b/test/ShardLaunchFactoryV1.t.sol @@ -0,0 +1,531 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; +import { Vm, VmSafe } from "forge-std/Vm.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; + +import { LaunchShardsV1 } from "../script/LaunchShardsV1.s.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +contract FalseTransferConfiguringHook { + Vm private constant VM = Vm(address(uint160(uint256(keccak256("hevm cheat code"))))); + + constructor(IPoolManager, ShardTokenV1 shard, int24, int24, int24, uint160, address, address, address) { + VM.mockCall( + address(shard), + abi.encodeWithSelector(bytes4(keccak256("transfer(address,uint256)")), address(this), 10_000 ether), + abi.encode(false) + ); + } + + function setNFT(IShardNFTV1) external { } + + function initialise() external pure returns (uint128) { + return 1; + } +} + +contract ShardLaunchFactoryV1Test is Test { + event ShardLaunched( + address indexed hook, + address indexed shard, + address indexed nft, + bytes32 tokenSalt, + bytes32 hookSalt, + address builderFeeRecipient, + address renderer, + bytes32 configurationHash + ); + + uint160 internal constant ALL_HOOK_MASK = uint160((1 << 14) - 1); + uint160 internal 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 + ); + int24 internal constant TICK_BAND = 22_980; + int24 internal constant TICK_UPPER = 115_080; + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardLaunchFactoryV1.LaunchParams internal params; + bytes internal hookCreationCode; + bytes32 internal hookCreationCodeHash; + bytes32 internal tokenSalt = keccak256("canonical token salt"); + bytes32 internal hookSalt; + address internal predictedShard; + address internal predictedHook; + /// @dev The launcher recipient is an immutable constant on the factory, so every local + /// configuration-hash reconstruction must use that exact address. + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("factory builder"); + + function setUp() public { + manager = IPoolManager(address(new PoolManager(address(this)))); + hookCreationCode = type(ShardHookV1).creationCode; + hookCreationCodeHash = keccak256(hookCreationCode); + factory = new ShardLaunchFactoryV1(manager, hookCreationCodeHash); + params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TickMath.minUsableTick(60), + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: TickMath.getSqrtPriceAtTick(TICK_UPPER), + builderFeeRecipient: builder + }); + (hookSalt, predictedShard, predictedHook) = + ShardLaunchLib.mine(factory, tokenSalt, bytes32(0), hookCreationCode, params); + } + + function test_constructorPinsReviewedInputsAndDeploysOneRenderer() public view { + assertEq(address(factory.poolManager()), address(manager)); + assertEq(factory.launcherFeeRecipient(), launcher); + assertEq(factory.hookCreationCodeHash(), hookCreationCodeHash); + assertTrue(address(factory.renderer()) != address(0)); + assertGt(address(factory.renderer()).code.length, 0); + } + + /// @dev The launcher (Programmable 0.10%) recipient is bound to the canonical constant and cannot + /// be set through the constructor; every factory routes the launcher share to the same wallet. + function test_launcherRecipientIsBoundToTheProgrammableConstant() public view { + assertEq(factory.launcherFeeRecipient(), 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c); + } + + function test_constructorRejectsZeroPoolManager() public { + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + new ShardLaunchFactoryV1(IPoolManager(address(0)), hookCreationCodeHash); + } + + function test_constructorRejectsZeroHookCodeHash() public { + vm.expectRevert(ShardLaunchFactoryV1.ZeroHookCodeHash.selector); + new ShardLaunchFactoryV1(manager, bytes32(0)); + } + + function test_predictionHelpersAreDeterministicAndUseExactInitCode() public view { + address tokenA = factory.predictToken(tokenSalt, hookSalt, params); + address tokenB = factory.predictToken(tokenSalt, hookSalt, params); + assertEq(tokenA, tokenB); + assertEq(tokenA, predictedShard); + + bytes memory initCodeA = factory.hookInitCode(hookCreationCode, tokenA, params); + bytes memory initCodeB = factory.hookInitCode(hookCreationCode, tokenA, params); + bytes memory expected = bytes.concat( + hookCreationCode, + abi.encode( + manager, + ShardTokenV1(tokenA), + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + address(factory), + launcher, + builder + ) + ); + assertEq(initCodeA, initCodeB); + assertEq(initCodeA, expected); + assertEq(factory.hookInitCodeHash(hookCreationCode, tokenA, params), keccak256(expected)); + assertEq(factory.predictHook(hookSalt, hookCreationCode, tokenA, params), predictedHook); + assertEq(factory.predictHook(hookSalt, hookCreationCode, tokenA, params), predictedHook); + } + + function test_effectiveTokenSaltNamespacesTheBuilderRole() public { + address otherBuilder = makeAddr("other builder namespace"); + bytes32 expected = keccak256( + abi.encode( + tokenSalt, + hookSalt, + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + builder + ) + ); + assertEq(factory.effectiveTokenSalt(tokenSalt, hookSalt, params), expected); + ShardLaunchFactoryV1.LaunchParams memory other = params; + other.builderFeeRecipient = otherBuilder; + assertTrue( + factory.effectiveTokenSalt(tokenSalt, hookSalt, params) + != factory.effectiveTokenSalt(tokenSalt, hookSalt, other) + ); + assertTrue( + factory.predictToken(tokenSalt, hookSalt, params) != factory.predictToken(tokenSalt, hookSalt, other) + ); + } + + function test_effectiveTokenSaltBindsHookSaltAndEveryLaunchParameter() public view { + bytes32 baseline = factory.effectiveTokenSalt(tokenSalt, hookSalt, params); + + assertTrue(baseline != factory.effectiveTokenSalt(bytes32(uint256(tokenSalt) + 1), hookSalt, params)); + assertTrue(baseline != factory.effectiveTokenSalt(tokenSalt, bytes32(uint256(hookSalt) + 1), params)); + + ShardLaunchFactoryV1.LaunchParams memory changed = params; + changed.tickLower += 60; + assertTrue(baseline != factory.effectiveTokenSalt(tokenSalt, hookSalt, changed)); + + changed = params; + changed.tickBand += 60; + assertTrue(baseline != factory.effectiveTokenSalt(tokenSalt, hookSalt, changed)); + + changed = params; + changed.tickUpper += 60; + assertTrue(baseline != factory.effectiveTokenSalt(tokenSalt, hookSalt, changed)); + + changed = params; + changed.startSqrtPriceX96 += 1; + assertTrue(baseline != factory.effectiveTokenSalt(tokenSalt, hookSalt, changed)); + + changed = params; + changed.builderFeeRecipient = address(uint160(uint256(keccak256("changed builder")))); + assertTrue(baseline != factory.effectiveTokenSalt(tokenSalt, hookSalt, changed)); + } + + function test_launchScriptPrecomputesEveryLaunchCommitment() public { + LaunchShardsV1 launchScript = new LaunchShardsV1(); + (bytes32 scriptHookSalt, address scriptShard, address scriptHook, address scriptNft, bytes32 scriptConfig) = + launchScript.predictAndMine(factory, tokenSalt, bytes32(0), params); + + assertEq(scriptHookSalt, hookSalt); + assertEq(scriptShard, predictedShard); + assertEq(scriptHook, predictedHook); + assertEq(scriptNft, factory.predictNFT(scriptHook)); + assertEq( + scriptConfig, + factory.computeConfigurationHash(scriptHook, scriptShard, scriptNft, tokenSalt, scriptHookSalt, params) + ); + } + + function test_deployedAddressesEqualPredictions() public { + address expectedNft = factory.predictNFT(predictedHook); + (address hook, address shard, address nft) = _launch(); + assertEq(shard, predictedShard); + assertEq(hook, predictedHook); + assertEq(nft, expectedNft); + } + + function test_nftPredictionIsStableAcrossUnrelatedLaunches() public { + address expectedNft = factory.predictNFT(predictedHook); + bytes32 unrelatedTokenSalt = keccak256("unrelated token salt"); + (bytes32 unrelatedHookSalt,,) = + ShardLaunchLib.mine(factory, unrelatedTokenSalt, bytes32(0), hookCreationCode, params); + factory.launch(unrelatedTokenSalt, unrelatedHookSalt, hookCreationCode, params); + + assertEq(factory.predictNFT(predictedHook), expectedNft, "unrelated launch changed NFT prediction"); + (,, address nft) = _launch(); + assertEq(nft, expectedNft, "deployed NFT differed from deterministic prediction"); + } + + function test_launchStoresExactConfigurationHashAndEmitsExactEvent() public { + address expectedNft = factory.predictNFT(predictedHook); + bytes32 expectedConfiguration = + _independentConfigurationHash(predictedHook, predictedShard, expectedNft, params); + + vm.expectEmit(true, true, true, true, address(factory)); + emit ShardLaunched( + predictedHook, + predictedShard, + expectedNft, + tokenSalt, + hookSalt, + builder, + address(factory.renderer()), + expectedConfiguration + ); + (address hook,,) = _launch(); + + assertEq(factory.configurationHashOf(hook), expectedConfiguration); + assertEq( + factory.computeConfigurationHash(hook, predictedShard, expectedNft, tokenSalt, hookSalt, params), + expectedConfiguration + ); + } + + function test_launchRejectsWrongCreationCode() public { + bytes memory wrong = type(ShardTokenV1).creationCode; + vm.expectRevert( + abi.encodeWithSelector(ShardLaunchFactoryV1.WrongHookCode.selector, hookCreationCodeHash, keccak256(wrong)) + ); + factory.launch(tokenSalt, hookSalt, wrong, params); + } + + function test_launchRejectsMalformedCreationCodeOfTheSameLength() public { + bytes memory malformed = hookCreationCode; + malformed[malformed.length / 2] = bytes1(uint8(malformed[malformed.length / 2]) ^ 1); + vm.expectRevert( + abi.encodeWithSelector( + ShardLaunchFactoryV1.WrongHookCode.selector, hookCreationCodeHash, keccak256(malformed) + ) + ); + factory.launch(tokenSalt, hookSalt, malformed, params); + } + + function test_launchRejectsHookSaltWithWrongPermissionBitsBeforeDeployment() public { + bytes32 invalidSalt; + address invalidShard = factory.predictToken(tokenSalt, invalidSalt, params); + address invalidHook = factory.predictHook(invalidSalt, hookCreationCode, invalidShard, params); + while (uint160(invalidHook) & ALL_HOOK_MASK == REQUIRED_HOOK_FLAGS) { + invalidSalt = bytes32(uint256(invalidSalt) + 1); + invalidShard = factory.predictToken(tokenSalt, invalidSalt, params); + invalidHook = factory.predictHook(invalidSalt, hookCreationCode, invalidShard, params); + } + uint160 actual = uint160(invalidHook) & ALL_HOOK_MASK; + vm.expectRevert( + abi.encodeWithSelector( + ShardLaunchFactoryV1.InvalidHookFlags.selector, invalidHook, REQUIRED_HOOK_FLAGS, actual + ) + ); + factory.launch(tokenSalt, invalidSalt, hookCreationCode, params); + assertEq(invalidShard.code.length, 0); + } + + function test_launchRejectsOccupiedPredictedNftBeforeDeployingTokenOrHook() public { + address predictedNft = factory.predictNFT(predictedHook); + vm.etch(predictedNft, hex"00"); + + vm.expectRevert(abi.encodeWithSelector(ShardLaunchFactoryV1.AddressOccupied.selector, predictedNft)); + factory.launch(tokenSalt, hookSalt, hookCreationCode, params); + + assertEq(predictedShard.code.length, 0, "token deployed before occupied NFT rejection"); + assertEq(predictedHook.code.length, 0, "hook deployed before occupied NFT rejection"); + } + + function test_launchRejectsZeroBuilder() public { + ShardLaunchFactoryV1.LaunchParams memory invalid = params; + invalid.builderFeeRecipient = address(0); + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + factory.launch(tokenSalt, hookSalt, hookCreationCode, invalid); + } + + function test_launchRejectsInvalidTicks() public { + ShardLaunchFactoryV1.LaunchParams memory invalid = params; + invalid.tickLower = invalid.tickBand; + vm.expectRevert(ShardErrorsV1.InvalidTickRange.selector); + factory.launch(tokenSalt, hookSalt, hookCreationCode, invalid); + } + + function test_launchRejectsInvalidStartPrice() public { + ShardLaunchFactoryV1.LaunchParams memory invalid = params; + invalid.startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER - 60); + vm.expectRevert(ShardErrorsV1.InvalidStartPrice.selector); + factory.launch(tokenSalt, hookSalt, hookCreationCode, invalid); + } + + function test_duplicateTokenSaltForSameBuilderRevertsWithoutChangingFirstLaunch() public { + (address firstHook, address firstShard, address firstNft) = _launch(); + bytes32 firstConfiguration = factory.configurationHashOf(firstHook); + + vm.expectRevert(abi.encodeWithSelector(ShardLaunchFactoryV1.AddressOccupied.selector, predictedShard)); + factory.launch(tokenSalt, hookSalt, hookCreationCode, params); + + assertEq(factory.configurationHashOf(firstHook), firstConfiguration); + assertGt(firstShard.code.length, 0); + assertGt(firstNft.code.length, 0); + } + + function test_sameRawTokenSaltWithDifferentBuilderCreatesIndependentTokenAddress() public { + (address firstHook, address firstShard, address firstNft) = _launch(); + ShardLaunchFactoryV1.LaunchParams memory other = params; + other.builderFeeRecipient = makeAddr("second builder"); + (bytes32 otherHookSalt, address otherShard, address otherHook) = + ShardLaunchLib.mine(factory, tokenSalt, bytes32(uint256(hookSalt) + 1), hookCreationCode, other); + + assertTrue(otherShard != firstShard); + (address launchedHook, address launchedShard, address otherNft) = + factory.launch(tokenSalt, otherHookSalt, hookCreationCode, other); + assertEq(launchedHook, otherHook); + assertEq(launchedShard, otherShard); + assertTrue(launchedHook != firstHook); + assertTrue(otherNft != firstNft); + } + + function test_sameRawSaltAndBuilderWithDifferentConfigurationCannotConsumeIntendedToken() public { + ShardLaunchFactoryV1.LaunchParams memory hostile = params; + hostile.tickBand += 60; + (bytes32 hostileHookSalt, address hostileShard,) = + ShardLaunchLib.mine(factory, tokenSalt, bytes32(uint256(hookSalt) + 1), hookCreationCode, hostile); + + assertTrue(hostileShard != predictedShard, "changed configuration retained the intended token"); + factory.launch(tokenSalt, hostileHookSalt, hookCreationCode, hostile); + assertEq(predictedShard.code.length, 0, "hostile launch consumed the intended token"); + + (address intendedHook, address intendedShard,) = _launch(); + assertEq(intendedHook, predictedHook); + assertEq(intendedShard, predictedShard); + } + + function test_duplicateHookSaltAndConfigurationCannotOverwriteEvidence() public { + (address firstHook,,) = _launch(); + bytes32 firstConfiguration = factory.configurationHashOf(firstHook); + vm.expectRevert(); + factory.launch(tokenSalt, hookSalt, hookCreationCode, params); + assertEq(factory.configurationHashOf(firstHook), firstConfiguration); + } + + function test_failureAfterDeploymentsRollsBackTokenHookNftMappingAndEvent() public { + bytes memory failingCode = type(FalseTransferConfiguringHook).creationCode; + ShardLaunchFactoryV1 failingFactory = new ShardLaunchFactoryV1(manager, keccak256(failingCode)); + (bytes32 failingSalt, address failingShard, address failingHook) = + ShardLaunchLib.mine(failingFactory, tokenSalt, bytes32(0), failingCode, params); + address expectedNft = failingFactory.predictNFT(failingHook); + uint64 nonceBefore = vm.getNonce(address(failingFactory)); + vm.recordLogs(); + vm.expectRevert(ShardLaunchFactoryV1.TokenTransferFailed.selector); + failingFactory.launch(tokenSalt, failingSalt, failingCode, params); + + assertEq(failingShard.code.length, 0); + assertEq(failingHook.code.length, 0); + assertEq(expectedNft.code.length, 0); + assertEq(failingFactory.configurationHashOf(failingHook), bytes32(0)); + assertEq(vm.getNonce(address(failingFactory)), nonceBefore); + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 launchSignature = ShardLaunched.selector; + for (uint256 i; i < logs.length; ++i) { + assertTrue(logs[i].topics.length == 0 || logs[i].topics[0] != launchSignature); + } + } + + function test_factoryHoldsZeroShardAfterSuccess() public { + (, address shard,) = _launch(); + assertEq(ShardTokenV1(shard).balanceOf(address(factory)), 0); + assertEq(ShardTokenV1(shard).totalSupply(), 10_000 ether); + } + + function test_hookDeployerIsFactoryAndBothOneShotPowersAreConsumed() public { + (address hookAddress,, address nftAddress) = _launch(); + ShardHookV1 hook = ShardHookV1(payable(hookAddress)); + assertEq(hook.deployer(), address(factory)); + assertEq(address(hook.nft()), nftAddress); + assertEq(IShardNFTV1(nftAddress).hook(), hookAddress); + assertTrue(hook.initialised()); + + vm.startPrank(address(factory)); + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.setNFT(IShardNFTV1(nftAddress)); + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.initialise(); + vm.stopPrank(); + } + + function test_twoCollectionsShareOneRendererAndRemainIndependent() public { + (address firstHook, address firstShard, address firstNft) = _launch(); + bytes32 secondTokenSalt = keccak256("second token salt"); + (bytes32 secondHookSalt, address secondShard, address secondHook) = + ShardLaunchLib.mine(factory, secondTokenSalt, bytes32(uint256(hookSalt) + 1), hookCreationCode, params); + (address launchedHook, address launchedShard, address secondNft) = + factory.launch(secondTokenSalt, secondHookSalt, hookCreationCode, params); + + assertEq(launchedHook, secondHook); + assertEq(launchedShard, secondShard); + assertTrue(firstHook != secondHook && firstShard != secondShard && firstNft != secondNft); + assertEq(address(ShardNFTV1(firstNft).renderer()), address(factory.renderer())); + assertEq(address(ShardNFTV1(secondNft).renderer()), address(factory.renderer())); + } + + function test_sharedMinerIsDeterministicAndDeploysOnlyOnce() public { + (bytes32 saltA, address shardA, address hookA) = + ShardLaunchLib.mine(factory, tokenSalt, bytes32(0), hookCreationCode, params); + (bytes32 saltB, address shardB, address hookB) = + ShardLaunchLib.mine(factory, tokenSalt, bytes32(0), hookCreationCode, params); + assertEq(saltA, saltB); + assertEq(shardA, shardB); + assertEq(hookA, hookB); + + (address launchedHook, address launchedShard,) = factory.launch(tokenSalt, saltA, hookCreationCode, params); + assertEq(launchedHook, hookA); + assertEq(launchedShard, shardA); + } + + function test_factoryAndHookStayWithinEipSizeLimitsAndFactoryDoesNotEmbedHookBlob() public view { + // `forge coverage` disables the optimizer, so its instrumented artifacts do not represent deployable bytecode. + if (vm.isContext(VmSafe.ForgeContext.Coverage)) return; + + bytes memory factoryCreationCode = type(ShardLaunchFactoryV1).creationCode; + bytes memory factoryInitCode = + bytes.concat(factoryCreationCode, abi.encode(IPoolManager(address(1)), address(2), bytes32(uint256(3)))); + assertLe(address(factory).code.length, 24_576, "factory runtime exceeds EIP-170"); + assertLe(factoryInitCode.length, 49_152, "factory initcode exceeds EIP-3860"); + assertFalse(_contains(factoryCreationCode, type(ShardHookV1).creationCode), "factory embeds complete hook blob"); + } + + function test_logDeploymentAndLaunchGas() public { + uint256 gasBefore = gasleft(); + ShardLaunchFactoryV1 measured = new ShardLaunchFactoryV1(manager, hookCreationCodeHash); + uint256 constructorGas = gasBefore - gasleft(); + (bytes32 measuredSalt,,) = ShardLaunchLib.mine(measured, tokenSalt, bytes32(0), hookCreationCode, params); + gasBefore = gasleft(); + measured.launch(tokenSalt, measuredSalt, hookCreationCode, params); + uint256 launchGas = gasBefore - gasleft(); + console2.log("ShardLaunchFactoryV1 constructor gas", constructorGas); + console2.log("ShardLaunchFactoryV1 launch gas", launchGas); + assertGt(constructorGas, 0); + assertGt(launchGas, 0); + } + + function _launch() internal returns (address hook, address shard, address nft) { + return factory.launch(tokenSalt, hookSalt, hookCreationCode, params); + } + + function _independentConfigurationHash( + address hook, + address shard, + address nft, + ShardLaunchFactoryV1.LaunchParams memory launchParams + ) internal view returns (bytes32) { + ShardLaunchFactoryV1.ConfigurationData memory data; + data.chainId = block.chainid; + data.factory = address(factory); + data.poolManager = address(manager); + data.renderer = address(factory.renderer()); + data.launcherFeeRecipient = launcher; + data.builderFeeRecipient = launchParams.builderFeeRecipient; + data.shard = shard; + data.hook = hook; + data.nft = nft; + data.tickLower = launchParams.tickLower; + data.tickBand = launchParams.tickBand; + data.tickUpper = launchParams.tickUpper; + data.startSqrtPriceX96 = launchParams.startSqrtPriceX96; + data.tokenSalt = tokenSalt; + data.effectiveTokenSalt = factory.effectiveTokenSalt(tokenSalt, hookSalt, launchParams); + data.hookSalt = hookSalt; + data.hookCreationCodeHash = hookCreationCodeHash; + return keccak256(abi.encode(data)); + } + + function _contains(bytes memory haystack, bytes memory needle) internal pure returns (bool) { + if (needle.length == 0 || needle.length > haystack.length) return false; + bytes32 first; + assembly ("memory-safe") { + first := mload(add(needle, 32)) + } + uint256 last = haystack.length - needle.length; + for (uint256 i; i <= last; ++i) { + bytes32 candidate; + assembly ("memory-safe") { + candidate := mload(add(add(haystack, 32), i)) + } + if (candidate != first) continue; + bool match_ = true; + for (uint256 j; j < needle.length; ++j) { + if (haystack[i + j] != needle[j]) { + match_ = false; + break; + } + } + if (match_) return true; + } + return false; + } +} diff --git a/test/ShardLaunchSequenceV1.t.sol b/test/ShardLaunchSequenceV1.t.sol new file mode 100644 index 00000000..0f2d3de4 --- /dev/null +++ b/test/ShardLaunchSequenceV1.t.sol @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/// @notice Atomic factory launch followed by the first batch purchase. +/// +/// @dev These run against the CHEAP TESTNET tick pair, which is the production curve shifted +/// by a constant so the SHAPE is identical and only the scale changes. That matters: +/// a rehearsal on a differently-shaped curve proves nothing about the real one. +contract ShardLaunchSequenceV1Test is Test { + /// @dev The production curve. Price at TICK_UPPER is ~0.001 ETH per NFT; the concentrated + /// band runs from TICK_BAND up to TICK_UPPER. + int24 internal constant TICK_UPPER = 69_060; + int24 internal constant TICK_BAND = 22_980; + + /// @dev The rehearsal curve: the same shape, shifted so a launch costs ~0.000001 ETH per + /// NFT. The tick DISTANCE between the two edges is identical to production's. + int24 internal constant TESTNET_TICK_UPPER = 138_120; + int24 internal constant TESTNET_TICK_BAND = 92_040; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + + address internal buyer = address(0xB0B); + + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + /// @dev Deploys and funds the hook but stops SHORT of setNFT and initialise — exactly the + /// state a deferred launch leaves behind. + function _deployDeferred(int24 tickUpper, int24 tickBand) internal { + int24 tickLower = TickMath.minUsableTick(ShardConstantsV1.TICK_SPACING); + uint160 startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(tickUpper); + + manager = IPoolManager(address(new PoolManager(address(this)))); + shard = new ShardTokenV1(); + renderer = new GeometricRendererV1(); + + (, bytes32 salt) = HookMiner.find( + address(this), + HOOK_FLAGS, + type(ShardHookV1).creationCode, + abi.encode( + manager, shard, tickLower, tickBand, tickUpper, startSqrtPriceX96, address(this), launcher, builder + ) + ); + hook = new ShardHookV1{ salt: salt }( + manager, shard, tickLower, tickBand, tickUpper, startSqrtPriceX96, address(this), launcher, builder + ); + nft = new ShardNFTV1(address(hook), address(renderer)); + + shard.transfer(address(hook), ShardConstantsV1.MAX_NFTS * ShardConstantsV1.SHARDS_PER_NFT); + vm.deal(buyer, 100 ether); + } + + function _launchAtomic(int24 tickUpper, int24 tickBand) internal { + int24 tickLower = TickMath.minUsableTick(ShardConstantsV1.TICK_SPACING); + uint160 startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(tickUpper); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: tickLower, + tickBand: tickBand, + tickUpper: tickUpper, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardLaunchSequenceV1Test"), bytes32(0), params); + renderer = factory.renderer(); + vm.deal(buyer, 100 ether); + } + + /// @dev ETH per whole NFT, in wei, implied by a tick. The pool prices SHARD per ETH, so the + /// NFT price is the RECIPROCAL. Getting this backwards has already happened once here. + function _ethPerNftWei(int24 tick) internal pure returns (uint256) { + uint256 sqrtP = uint256(TickMath.getSqrtPriceAtTick(tick)); + // 1e18 * 2^192 / sqrtP^2 — shardPerEth is (sqrtP/2^96)^2, so invert it. + return ((1e18 * (1 << 96)) / sqrtP) * (1 << 96) / sqrtP; + } + + /*////////////////////////////////////////////////////////// + THE CHEAP TEST CURVE + //////////////////////////////////////////////////////////*/ + + function test_testnetTicksPriceTheLaunchAtAboutOneMillionthOfAnEth() public pure { + uint256 price = _ethPerNftWei(TESTNET_TICK_UPPER); + // 0.000001 ETH = 1e12 wei. Allow 1% either way for the spacing-60 rounding. + assertApproxEqRel(price, 1e12, 0.01e18, "launch price is not ~0.000001 ETH"); + } + + function test_testnetBandEdgeKeepsTheProductionShape() public pure { + // DERIVED from the production pair, not hardcoded at 100x: a hardcoded multiple would + // still pass if production's own span were changed, which is exactly the divergence + // this test exists to catch. + uint256 prodSpan = (_ethPerNftWei(TICK_BAND) * 1e18) / _ethPerNftWei(TICK_UPPER); + uint256 cheapSpan = (_ethPerNftWei(TESTNET_TICK_BAND) * 1e18) / _ethPerNftWei(TESTNET_TICK_UPPER); + assertApproxEqRel(cheapSpan, prodSpan, 0.01e18, "rehearsal curve spans a different range than production"); + } + + function test_cheapCurveIsTheProductionCurveShiftedByAConstant() public pure { + // Same tick DISTANCE between the two edges means the same liquidity split and the same + // shape; only the scale moves. + assertEq( + TESTNET_TICK_UPPER - TESTNET_TICK_BAND, + TICK_UPPER - TICK_BAND, + "cheap curve is a different shape, not just a cheaper one" + ); + } + + /*////////////////////////////////////////////////////////// + THE SEQUENCE + //////////////////////////////////////////////////////////*/ + + function test_buyingIsClosedUntilInitialise() public { + _deployDeferred(TESTNET_TICK_UPPER, TESTNET_TICK_BAND); + hook.setNFT(IShardNFTV1(address(nft))); + + vm.prank(buyer); + vm.expectRevert(ShardErrorsV1.NotInitialised.selector); + hook.buyMany{ value: 1 ether }(50, 1 ether, block.timestamp + 600); + } + + function test_oneTransactionFactoryLaunchMintsTheFirstFiftyIds() public { + _launchAtomic(TESTNET_TICK_UPPER, TESTNET_TICK_BAND); + + assertEq(hook.deployer(), address(factory), "factory is not deployer"); + assertTrue(hook.initialised(), "factory did not initialise atomically"); + + uint256 before = buyer.balance; + vm.prank(buyer); + uint256[] memory ids = hook.buyMany{ value: 1 ether }(50, 1 ether, block.timestamp + 600); + uint256 spent = before - buyer.balance; + + assertEq(ids.length, 50, "wrong count"); + for (uint256 i; i < 50; ++i) { + assertEq(ids[i], i + 1, "ids must be the first fifty, in order"); + assertEq(nft.ownerOf(ids[i]), buyer, "buyer does not hold the token"); + } + assertEq(nft.circulatingSupply(), 50, "circulating supply wrong"); + + // ~50 x 0.000001 ETH plus the inclusive 1%, with room for walking up the curve. The + // builder/launcher carve changes only where that 1% LANDS, never what the buyer pays, + // so this figure is identical to the unsplit curve's. + assertApproxEqRel(spent, 505e11, 0.05e18, "launch batch cost moved unexpectedly"); + console2.log("50 NFTs cost (wei)", spent); + } + + /// @dev The launch fee is charged in full and then routed three ways. The buyer's cost is + /// the whole fee; the ledgers must add back up to it exactly. + function test_launchFeeSplitsWithoutChangingWhatTheBuyerPays() public { + _launchAtomic(TESTNET_TICK_UPPER, TESTNET_TICK_BAND); + + vm.prank(buyer); + hook.buyMany{ value: 1 ether }(50, 1 ether, block.timestamp + 600); + + uint256 builderCut = hook.builderFeesAccrued(); + uint256 launcherCut = hook.launcherFeesAccrued(); + // Nothing circulates while the batch mints, so the holder share escrows in full. + uint256 holderShare = hook.escrowBalance(); + + assertGt(builderCut, 0, "builder accrued nothing on the launch batch"); + assertGe(launcherCut, builderCut, "launcher takes the odd wei"); + assertLe(launcherCut - builderCut, 1, "the two cuts diverged beyond a wei"); + + uint256 fee = builderCut + launcherCut + holderShare; + uint256 operator = (fee * 2000) / 10_000; // combined builder+launcher, floored together + assertEq(builderCut + launcherCut, operator, "operator != floor(20%) of the fee"); + assertEq(holderShare, fee - operator, "holders != the remainder"); + } + + /// @dev Manual construction retains a one-shot binding power even though the production + /// factory consumes it atomically. + function test_setNFTIsOneShotSoABlindRerunWouldBrickTheLaunch() public { + _deployDeferred(TESTNET_TICK_UPPER, TESTNET_TICK_BAND); + hook.setNFT(IShardNFTV1(address(nft))); + + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.setNFT(IShardNFTV1(address(nft))); + + hook.initialise(); + assertTrue(hook.initialised(), "manual launch could not be completed"); + } + + function test_launchIsOneShotSoItCannotBeReplayed() public { + _deployDeferred(TESTNET_TICK_UPPER, TESTNET_TICK_BAND); + hook.setNFT(IShardNFTV1(address(nft))); + hook.initialise(); + + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.initialise(); + } + + function test_onlyTheDeployerCanRunTheLaunch() public { + _deployDeferred(TESTNET_TICK_UPPER, TESTNET_TICK_BAND); + + vm.prank(buyer); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.setNFT(IShardNFTV1(address(nft))); + + vm.prank(buyer); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.initialise(); + } + + /// @dev The production pair must keep working — the cheap one is for rehearsal only. + function test_productionTicksStillLaunchAtAboutAThousandthOfAnEth() public pure { + assertApproxEqRel(_ethPerNftWei(TICK_UPPER), 1e15, 0.01e18, "production launch price moved"); + } +} diff --git a/test/ShardNFTV1.t.sol b/test/ShardNFTV1.t.sol new file mode 100644 index 00000000..2653d3be --- /dev/null +++ b/test/ShardNFTV1.t.sol @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; +import { Base64 } from "solady/utils/Base64.sol"; +import { LibString } from "solady/utils/LibString.sol"; +import { IERC721Errors } from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; + +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { IShardHookV1 } from "../src/interfaces/IShardHookV1.sol"; +import { IShardRendererV1 } from "../src/interfaces/IShardRendererV1.sol"; + +/// @dev Records every settleOnTransfer call so tests can assert exactly when +/// fee settlement fires (wallet moves) and when it must not (archive moves). +contract MockHook is IShardHookV1 { + uint256 public settleCount; + uint256 public lastTokenId; + address public lastFrom; + address public lastTo; + + function settleOnTransfer(uint256 tokenId, address from, address to) external override { + settleCount += 1; + lastTokenId = tokenId; + lastFrom = from; + lastTo = to; + } + + function claimable(address) external pure override returns (uint256) { + return 0; + } +} + +contract MockRenderer is IShardRendererV1 { + using LibString for uint256; + + function generate(uint256 seed) external pure override returns (string memory) { + return string.concat("LIVE:", seed.toString(), ""); + } + + function generateDormant(uint256 tokenId) external pure override returns (string memory) { + return string.concat("DORMANT:", tokenId.toString(), ""); + } + + function attributes(uint256 seed) external pure override returns (string memory) { + return string.concat('[{"trait_type":"Seed","value":"', seed.toString(), '"}]'); + } +} + +/// @dev Flags whether onERC721Received fired. acquire() must NEVER trigger it: +/// the receiver callback under `_releasing == true` is the reentrancy hole +/// this whole design forbids. +contract RecordingReceiver { + bool public callbackFired; + + function onERC721Received(address, address, uint256, bytes calldata) external returns (bytes4) { + callbackFired = true; + return this.onERC721Received.selector; + } +} + +contract ShardNFTV1Test is Test { + using LibString for uint256; + + ShardNFTV1 internal nft; + MockHook internal hook; + MockRenderer internal renderer; + + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + + function setUp() public { + hook = new MockHook(); + renderer = new MockRenderer(); + nft = new ShardNFTV1(address(hook), address(renderer)); + } + + function _acquire(address to, uint256 seed) internal returns (uint256 id) { + vm.prank(address(hook)); + id = nft.acquire(to, seed); + } + + function _release(address from, uint256 tokenId) internal { + vm.prank(address(hook)); + nft.release(from, tokenId); + } + + // ------------------------------------------------------------------ + // Supply / ID allocation + // ------------------------------------------------------------------ + + function test_maxSupplyIsTenThousand() public view { + assertEq(nft.MAX_SUPPLY(), 10_000); + } + + function test_acquireHandsOutLowestAvailableId() public { + assertEq(nft.lowestAvailableId(), 1); + assertEq(_acquire(alice, 111), 1); + assertEq(nft.lowestAvailableId(), 2); + assertEq(_acquire(bob, 222), 2); + assertEq(nft.lowestAvailableId(), 3); + } + + function test_acquireMintsLazily() public { + vm.expectRevert(abi.encodeWithSelector(IERC721Errors.ERC721NonexistentToken.selector, uint256(1))); + nft.ownerOf(1); + + _acquire(alice, 111); + assertEq(nft.ownerOf(1), alice); + } + + function test_releaseReturnsIdToArchiveAndZeroesSeed() public { + uint256 id = _acquire(alice, 12_345); + assertEq(nft.tokenSeed(id), 12_345); + assertFalse(nft.isPoolHeld(id)); + + _release(alice, id); + + assertEq(nft.ownerOf(id), address(nft)); + assertEq(nft.tokenSeed(id), 0); + assertTrue(nft.isPoolHeld(id)); + } + + function test_releasedIdIsHandedOutAgain() public { + _acquire(alice, 1); + _acquire(bob, 2); + assertEq(nft.lowestAvailableId(), 3); + + _release(alice, 1); + assertEq(nft.lowestAvailableId(), 1); + + assertEq(_acquire(bob, 3), 1); + assertEq(nft.lowestAvailableId(), 3); + } + + function test_acquireAlwaysWritesFreshSeed() public { + uint256 id = _acquire(alice, 777); + assertEq(nft.tokenSeed(id), 777); + + _release(alice, id); + assertEq(nft.tokenSeed(id), 0); + + uint256 again = _acquire(bob, 999); + assertEq(again, id); + assertEq(nft.tokenSeed(id), 999); + } + + // ------------------------------------------------------------------ + // The critical rule: art survives wallet-to-wallet trading + // ------------------------------------------------------------------ + + function test_walletTransferDoesNotChangeSeed() public { + uint256 id = _acquire(alice, 424_242); + + vm.prank(alice); + nft.transferFrom(alice, bob, id); + + assertEq(nft.ownerOf(id), bob); + assertEq(nft.tokenSeed(id), 424_242, "secondary trade must not regenerate the art"); + } + + function test_walletTransferCallsSettleOnHook() public { + uint256 id = _acquire(alice, 5); + uint256 before = hook.settleCount(); + + vm.prank(alice); + nft.transferFrom(alice, bob, id); + + assertEq(hook.settleCount(), before + 1); + assertEq(hook.lastTokenId(), id); + assertEq(hook.lastFrom(), alice); + assertEq(hook.lastTo(), bob); + } + + function test_archiveMoveDoesNotCallSettle() public { + uint256 id = _acquire(alice, 5); + assertEq(hook.settleCount(), 0, "mint must not settle"); + + _release(alice, id); + assertEq(hook.settleCount(), 0, "release into archive must not settle"); + + _acquire(bob, 6); + assertEq(hook.settleCount(), 0, "acquire out of archive must not settle"); + } + + // ------------------------------------------------------------------ + // Direct deposit guard + // ------------------------------------------------------------------ + + function test_directTransferFromToArchiveReverts() public { + uint256 id = _acquire(alice, 5); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.transferFrom(alice, address(nft), id); + } + + function test_directTransferFromToHookReverts() public { + uint256 id = _acquire(alice, 5); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.DirectTransferRejected.selector); + nft.transferFrom(alice, address(hook), id); + } + + // ------------------------------------------------------------------ + // Metadata + // ------------------------------------------------------------------ + + function test_tokenUriIsBase64DataUri() public { + uint256 id = _acquire(alice, 31_337); + string memory uri = nft.tokenURI(id); + + string memory expected = string.concat( + "data:application/json;base64,", + Base64.encode( + bytes( + string.concat( + '{"name":"Shard #', + id.toString(), + '","description":"On-chain art that exists only while you hold it. ', + 'Sell it back and this piece is gone forever.","image":"data:image/svg+xml;base64,', + Base64.encode(bytes(renderer.generate(31_337))), + '","attributes":', + renderer.attributes(31_337), + "}" + ) + ) + ) + ); + + assertEq(uri, expected); + assertTrue(LibString.startsWith(uri, "data:application/json;base64,")); + } + + function test_poolHeldIdRendersDormant() public view { + assertTrue(nft.isPoolHeld(5)); + string memory uri = nft.tokenURI(5); + + string memory expected = string.concat( + "data:application/json;base64,", + Base64.encode( + bytes( + string.concat( + '{"name":"Shard #5","description":"On-chain art that exists only while you hold it. ', + 'Sell it back and this piece is gone forever.","image":"data:image/svg+xml;base64,', + Base64.encode(bytes(renderer.generateDormant(5))), + '","attributes":[{"trait_type":"State","value":"Dormant"}]}' + ) + ) + ) + ); + + assertEq(uri, expected); + } + + function test_outOfRangeTokenUriReverts() public { + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.TokenDoesNotExist.selector, uint256(0))); + nft.tokenURI(0); + + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.TokenDoesNotExist.selector, uint256(10_001))); + nft.tokenURI(10_001); + } + + // ------------------------------------------------------------------ + // Access control / accounting + // ------------------------------------------------------------------ + + function test_onlyHookCanAcquire() public { + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NotHook.selector); + nft.acquire(alice, 1); + } + + function test_onlyHookCanRelease() public { + uint256 id = _acquire(alice, 1); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NotHook.selector); + nft.release(alice, id); + } + + function test_circulatingSupplyTracksAcquireAndRelease() public { + assertEq(nft.circulatingSupply(), 0); + + uint256 a = _acquire(alice, 1); + assertEq(nft.circulatingSupply(), 1); + + uint256 b = _acquire(bob, 2); + assertEq(nft.circulatingSupply(), 2); + + _release(alice, a); + assertEq(nft.circulatingSupply(), 1); + + _release(bob, b); + assertEq(nft.circulatingSupply(), 0); + } + + // ------------------------------------------------------------------ + // Regression: the hard rule + // ------------------------------------------------------------------ + + function test_acquireUsesUnsafeMintOnly() public { + RecordingReceiver receiver = new RecordingReceiver(); + + // Lazy mint path. + uint256 id = _acquire(address(receiver), 1); + assertEq(nft.ownerOf(id), address(receiver)); + assertFalse(receiver.callbackFired(), "acquire must use _mint, never _safeMint"); + + // Archive transfer path. + _release(address(receiver), id); + uint256 again = _acquire(address(receiver), 2); + assertEq(again, id); + assertEq(nft.ownerOf(id), address(receiver)); + assertFalse(receiver.callbackFired(), "acquire must use _transfer, never _safeTransfer"); + } + + // ------------------------------------------------------------------ + // Fuzz + // ------------------------------------------------------------------ + + function testFuzz_lowestAvailableIdIsAlwaysUnheld(uint8 acquireCount, uint256 releaseMask) public { + uint256 n = bound(uint256(acquireCount), 1, 16); + + uint256[] memory ids = new uint256[](n); + for (uint256 i; i < n; i++) { + ids[i] = _acquire(alice, i + 1); + } + for (uint256 i; i < n; i++) { + if ((releaseMask >> i) & 1 == 1) { + _release(alice, ids[i]); + } + } + + uint256 low = nft.lowestAvailableId(); + assertTrue(low >= 1 && low <= 10_000); + assertTrue(nft.isPoolHeld(low), "lowestAvailableId must be in the archive"); + for (uint256 id = 1; id < low; id++) { + assertFalse(nft.isPoolHeld(id), "no archived id may sit below lowestAvailableId"); + } + } +} diff --git a/test/ShardScaffoldV1.t.sol b/test/ShardScaffoldV1.t.sol new file mode 100644 index 00000000..a2188aeb --- /dev/null +++ b/test/ShardScaffoldV1.t.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { BaseHook } from "@openzeppelin/uniswap-hooks/src/base/BaseHook.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { Currency } from "@uniswap/v4-core/src/types/Currency.sol"; +import { Base64 } from "solady/utils/Base64.sol"; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; + +contract ShardScaffoldV1Test is Test { + // Reference the BaseHook type so a broken import path is a hard compile + // error rather than an unused-import warning. + function _baseHookTypeName() internal pure returns (string memory) { + return type(BaseHook).name; + } + + function test_baseHookImportResolves() public pure { + assertEq(_baseHookTypeName(), "BaseHook"); + } + + function test_swapParamsImportResolves() public pure { + SwapParams memory params = SwapParams({ zeroForOne: true, amountSpecified: 1, sqrtPriceLimitX96: 0 }); + assertEq(params.amountSpecified, 1); + } + + function test_tickMathConstants() public pure { + assertEq(TickMath.MIN_TICK, -887_272); + assertEq(TickMath.MAX_TICK, 887_272); + } + + function test_currencyWrapUnwrap() public pure { + assertEq(Currency.unwrap(Currency.wrap(address(0))), address(0)); + } + + function test_base64Encode() public pure { + assertEq(Base64.encode(bytes("ab")), "YWI="); + } + + function test_create2PredictionImportResolves() public pure { + address predicted = Create2.computeAddress(bytes32(uint256(1)), keccak256("initcode"), address(0xBEEF)); + assertTrue(predicted != address(0)); + } +} diff --git a/test/ShardSwapRouterV1.t.sol b/test/ShardSwapRouterV1.t.sol new file mode 100644 index 00000000..a36841b6 --- /dev/null +++ b/test/ShardSwapRouterV1.t.sol @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; +import { ShardSwapRouterV1 } from "../src/ShardSwapRouterV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +contract ShardSwapRouterV1Test is Test { + using StateLibrary for IPoolManager; + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant FEE_BPS = 100; + uint256 internal constant BPS = 10_000; + uint256 internal constant FAR = 1e18; // deadline far in the future + + /// @dev The size every swap in this file is written against. The hook caps a single + /// third-party swap at MAX_BATCH SHARD (50e18), and at TICK_UPPER the price is ~1e-5 + /// ETH per SHARD — so even 0.01 ETH would move ~990 SHARD and be refused. This buys + /// ~39 SHARD, inside the cap, and still leaves a 1% fee far above dust. + uint256 internal constant UNDER_CAP_ETH = 0.0004 ether; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + ShardSwapRouterV1 internal router; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + address internal alice = address(0xA11CE); + address internal bob = address(0xB0B); + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = + ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardSwapRouterV1Test"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + // Constructed AFTER `key` is populated: the router pins the pool at deployment, so a + // zero-value key here would make it reject every swap. + router = new ShardSwapRouterV1(manager, key); + + vm.deal(address(this), 1000 ether); + vm.deal(alice, 100 ether); + vm.deal(bob, 100 ether); + } + + /*////////////////////////////////////////////////////////// + HELPERS + //////////////////////////////////////////////////////////*/ + + function test_setupUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + function _feesHeld() internal view returns (uint256) { + return manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } + + function _assertRouterEmpty() internal view { + assertEq(address(router).balance, 0, "router holds ETH"); + assertEq(shard.balanceOf(address(router)), 0, "router holds SHARD"); + } + + /// @dev Give `who` some SHARD by buying through the router. + function _acquireShard(address who, uint256 ethIn) internal returns (uint256 shardOut) { + vm.prank(who); + shardOut = router.swapEthForShard{ value: ethIn }(key, 0, FAR); + } + + /*////////////////////////////////////////////////////////// + SWAPS + //////////////////////////////////////////////////////////*/ + + function test_swapEthForShardDeliversShard() public { + uint256 ethIn = UNDER_CAP_ETH; + uint256 ethBefore = alice.balance; + + vm.prank(alice); + uint256 shardOut = router.swapEthForShard{ value: ethIn }(key, 0, FAR); + + assertGt(shardOut, 0, "no SHARD out"); + assertEq(shard.balanceOf(alice), shardOut, "reported amount != delivered amount"); + assertEq(ethBefore - alice.balance, ethIn, "swapper paid more than msg.value"); + _assertRouterEmpty(); + } + + function test_swapShardForEthDeliversEth() public { + _acquireShard(alice, UNDER_CAP_ETH); + uint256 shardIn = shard.balanceOf(alice) / 2; + assertGt(shardIn, 0, "no SHARD to sell"); + + uint256 ethBefore = alice.balance; + vm.startPrank(alice); + shard.approve(address(router), shardIn); + uint256 ethOut = router.swapShardForEth(key, shardIn, 0, FAR); + vm.stopPrank(); + + assertGt(ethOut, 0, "no ETH out"); + assertEq(alice.balance - ethBefore, ethOut, "reported amount != delivered amount"); + assertEq(shard.balanceOf(address(router)), 0, "router kept SHARD"); + _assertRouterEmpty(); + } + + /// The router has no privileged access, so the hook's beforeSwap/afterSwap callbacks + /// fire and charge the ordinary third-party 1%. + function test_swapChargesTheHookOnePercent() public { + uint256 ethIn = UNDER_CAP_ETH; + + vm.prank(alice); + router.swapEthForShard{ value: ethIn }(key, 0, FAR); + + // ETH is the SPECIFIED currency here -> inclusive 1% of what the swapper paid. + // The 10%/10% builder and launcher cuts are carved out of this fee but stay + // hook-held until claimed, so the total the hook holds is unchanged. + assertEq(_feesHeld(), ethIn * FEE_BPS / BPS, "buy fee != 1%"); + + uint256 feesAfterBuy = _feesHeld(); + uint256 shardIn = shard.balanceOf(alice); + vm.startPrank(alice); + shard.approve(address(router), shardIn); + uint256 ethOut = router.swapShardForEth(key, shardIn, 0, FAR); + vm.stopPrank(); + + // ETH is the UNSPECIFIED currency (the output) -> the fee is 1% of the gross released. + uint256 sellFee = _feesHeld() - feesAfterBuy; + assertGt(sellFee, 0, "sell charged no fee"); + assertApproxEqAbs((ethOut + sellFee) * FEE_BPS / BPS, sellFee, 2, "sell fee != 1% of gross"); + } + + /// Any ETH the router ends up holding after `unlock` goes back to the caller — the + /// router must never keep a wei, whatever the source. + /// @dev The refund is THIS swap's own leftover, computed from the delta — not the router's + /// balance. The hook rejects a partial fill when ETH is the specified currency, so an + /// ordinary swap consumes the whole `msg.value` and there is nothing to refund. + function test_ethForShardRefundsOnlyItsOwnUnspentEth() public { + uint256 ethIn = UNDER_CAP_ETH; + uint256 before = alice.balance; + vm.prank(alice); + router.swapEthForShard{ value: ethIn }(key, 0, FAR); + + assertEq(before - alice.balance, ethIn, "caller was charged the wrong amount"); + _assertRouterEmpty(); + } + + /// @dev Regression: the refund used to pay out `address(this).balance`, so ETH that reached + /// the router by any other route was swept to whoever swapped next — a stranger's ETH + /// handed to an arbitrary caller. Force-sent ETH must stay put. + function test_ethForShardDoesNotSweepStrandedEth() public { + uint256 stranded = 0.005 ether; + vm.deal(address(router), stranded); // force-send; the router has no `receive()` + + uint256 ethIn = UNDER_CAP_ETH; + uint256 before = alice.balance; + vm.prank(alice); + router.swapEthForShard{ value: ethIn }(key, 0, FAR); + + assertEq(before - alice.balance, ethIn, "stranded ETH was swept to the caller"); + assertEq(address(router).balance, stranded, "stranded ETH moved"); + } + + /// @dev The router settles ETH into the PoolManager and takes proceeds straight to the + /// swapper, so it never needs to accept a plain transfer. Keeping `receive()` off is + /// what stops stray ETH accumulating for the refund path to find. + function test_rejectsPlainEthTransfers() public { + vm.deal(alice, 1 ether); + vm.prank(alice); + (bool ok,) = address(router).call{ value: 0.1 ether }(""); + assertFalse(ok, "router accepted a plain ETH transfer"); + } + + /*////////////////////////////////////////////////////////// + GUARDS + //////////////////////////////////////////////////////////*/ + + function test_respectsMinAmountOut() public { + // Ask for far more SHARD than UNDER_CAP_ETH can buy. + vm.prank(alice); + vm.expectPartialRevert(ShardSwapRouterV1.InsufficientOutput.selector); + router.swapEthForShard{ value: UNDER_CAP_ETH }(key, 1_000_000 ether, FAR); + + _acquireShard(bob, UNDER_CAP_ETH); + uint256 shardIn = shard.balanceOf(bob); + vm.startPrank(bob); + shard.approve(address(router), shardIn); + vm.expectPartialRevert(ShardSwapRouterV1.InsufficientOutput.selector); + router.swapShardForEth(key, shardIn, 100 ether, FAR); + vm.stopPrank(); + + _assertRouterEmpty(); + } + + function test_respectsDeadline() public { + vm.warp(1_000_000); + + vm.prank(alice); + vm.expectRevert(ShardSwapRouterV1.Expired.selector); + router.swapEthForShard{ value: 0.01 ether }(key, 0, block.timestamp - 1); + + vm.prank(alice); + vm.expectRevert(ShardSwapRouterV1.Expired.selector); + router.swapShardForEth(key, 1 ether, 0, block.timestamp - 1); + } + + function test_shardForEthRequiresApproval() public { + _acquireShard(alice, UNDER_CAP_ETH); + uint256 shardIn = shard.balanceOf(alice); + + vm.prank(alice); + vm.expectRevert(); + router.swapShardForEth(key, shardIn, 0, FAR); + + // And it works the moment the approval exists. + vm.startPrank(alice); + shard.approve(address(router), shardIn); + uint256 ethOut = router.swapShardForEth(key, shardIn, 0, FAR); + vm.stopPrank(); + assertGt(ethOut, 0, "approved sell produced nothing"); + } + + function test_revertsOnZeroAmount() public { + vm.prank(alice); + vm.expectRevert(ShardSwapRouterV1.ZeroAmount.selector); + router.swapEthForShard{ value: 0 }(key, 0, FAR); + + vm.prank(alice); + vm.expectRevert(ShardSwapRouterV1.ZeroAmount.selector); + router.swapShardForEth(key, 0, 0, FAR); + } + + function test_unlockCallbackOnlyPoolManager() public { + vm.prank(alice); + vm.expectRevert(ShardSwapRouterV1.NotPoolManager.selector); + router.unlockCallback(hex""); + } + + /*////////////////////////////////////////////////////////// + FUZZ + //////////////////////////////////////////////////////////*/ + + /// The pool is thin (~0.04 ETH halves the price), so stay in fractions of an ETH — and + /// under UNDER_CAP_ETH, above which a single swap trips the hook's 50 SHARD cap. + function testFuzz_swapNeverLeavesRouterHoldingFunds(uint96 rawBuy, uint16 sellPct) public { + uint256 ethIn = bound(uint256(rawBuy), 1e12, UNDER_CAP_ETH); + uint256 pct = bound(uint256(sellPct), 1, 100); + + vm.deal(alice, ethIn + 1 ether); + vm.prank(alice); + uint256 shardOut = router.swapEthForShard{ value: ethIn }(key, 0, FAR); + _assertRouterEmpty(); + assertGt(shardOut, 0, "buy produced no SHARD"); + + uint256 shardIn = shardOut * pct / 100; + vm.assume(shardIn > 0); + + vm.startPrank(alice); + shard.approve(address(router), shardIn); + router.swapShardForEth(key, shardIn, 0, FAR); + vm.stopPrank(); + + _assertRouterEmpty(); + } + + receive() external payable { } +} diff --git a/test/ShardTokenV1.t.sol b/test/ShardTokenV1.t.sol new file mode 100644 index 00000000..d05e5f1a --- /dev/null +++ b/test/ShardTokenV1.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; + +contract ShardTokenV1Test is Test { + ShardTokenV1 internal shard; + + function setUp() public { + shard = new ShardTokenV1(); + } + + function test_totalSupplyIsTenThousandWhole() public view { + assertEq(shard.totalSupply(), 10_000 ether); + } + + function test_entireSupplyGoesToDeployer() public view { + assertEq(shard.balanceOf(address(this)), shard.totalSupply()); + } + + function test_oneShardEqualsOneNft() public view { + assertEq(shard.totalSupply() / ShardConstantsV1.SHARDS_PER_NFT, 10_000); + } + + function test_hasNoMintFunction() public { + (bool success,) = address(shard) + .staticcall(abi.encodeWithSelector(bytes4(keccak256("mint(address,uint256)")), address(this), 1 ether)); + assertFalse(success); + } + + function test_hasNoBurnFunction() public { + (bool success,) = address(shard).staticcall(abi.encodeWithSelector(bytes4(keccak256("burn(uint256)")), 1 ether)); + assertFalse(success); + } + + function test_metadata() public view { + assertEq(shard.name(), "Shard"); + assertEq(shard.symbol(), "SHARD"); + assertEq(shard.decimals(), 18); + } +} diff --git a/test/ShardV1MainnetFork.t.sol b/test/ShardV1MainnetFork.t.sol new file mode 100644 index 00000000..d5b04ee1 --- /dev/null +++ b/test/ShardV1MainnetFork.t.sol @@ -0,0 +1,341 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.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 { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { Test, Vm, console2 } from "forge-std/Test.sol"; + +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../src/ShardLaunchFactoryV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { ShardSwapRouterV1 } from "../src/ShardSwapRouterV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardLaunchLib } from "./utils/ShardLaunchLib.sol"; + +/// @notice The Ethereum-Mainnet release gate: the complete Shards lifecycle exercised against the +/// pinned canonical Uniswap v4 `PoolManager` deployment on a Mainnet fork. +/// +/// @dev Every other Shards suite runs against a freshly deployed local `PoolManager`, which proves +/// the model's own logic but says nothing about whether it composes with the real v4 contract +/// the collection would actually market-make on. This suite closes that gap: it mines the hook +/// against the live PoolManager, atomically deploys and wires the token, hook, NFT and shared +/// renderer, seeds and initialises the locked position, then drives a third-party swap, a redeem, a hook-market +/// buy and sell, all three claim paths and a donation — asserting the backing invariant survives +/// every step. It also pins that the fork handed us the genuine PoolManager (by runtime code +/// hash), reproduces CREATE2 predictions, and checks Ethereum-native art-seed inputs. +/// +/// It needs an archive RPC and so is kept out of the default `forge test` run in CI. Unlike +/// {ClassicV3MainnetForkTest}, which is excluded by `verify.yml`, this suite gates itself on +/// `ETHEREUM_RPC_URL`: when that variable is unset it skips, so it stays out of normal CI +/// without editing the shared workflow. Point `ETHEREUM_RPC_URL` at an archive node and run it: +/// +/// ETHEREUM_RPC_URL= forge test --match-contract ShardV1MainnetForkTest +contract ShardV1MainnetForkTest is Test { + using CurrencyLibrary for Currency; + + /// @dev A block well after the canonical v4 PoolManager was deployed. Shared with + /// {ClassicV3MainnetForkTest} so both fork suites pin the same known-good state. + uint256 internal constant SNAPSHOT_BLOCK = 25_639_000; + + address internal constant POOL_MANAGER = 0x000000000004444c5dc75cB358380D2e3dE08A90; + + /// @dev The runtime code hash of the canonical PoolManager at {SNAPSHOT_BLOCK}. If the fork ever + /// returns something else at that address, this suite is testing a stranger, not v4. + bytes32 internal constant POOL_MANAGER_CODE_HASH = + 0x785f1014552b7ce7d5fb7d0c970ca60edee94fd00425d7ca21609acac7ce1293; + + /// @dev The production curve: price at TICK_UPPER is ~0.001 ETH per NFT, the concentrated band + /// runs from TICK_BAND up to TICK_UPPER. Identical to the pair the unit suites launch on. + int24 internal constant TICK_UPPER = 69_060; + int24 internal constant TICK_BAND = 22_980; + + uint256 internal constant SEED_AMOUNT = ShardConstantsV1.MAX_NFTS * ShardConstantsV1.SHARDS_PER_NFT; + uint256 internal constant ONE_SHARD = ShardConstantsV1.SHARDS_PER_NFT; + + IPoolManager internal poolManager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + ShardSwapRouterV1 internal swapRouter; + PoolKey internal key; + + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + address internal buyer = makeAddr("buyer"); + address internal trader = makeAddr("trader"); + + uint256 internal deadline; + bytes32 internal tokenSalt; + bytes32 internal hookSalt; + bytes32 internal expectedConfigurationHash; + bool internal launchEventMatched; + + function setUp() public { + // This fork suite runs only in the dedicated Mainnet-evidence workflow, which sets + // ETHEREUM_RPC_URL. When it is unset — the default `forge test`/`forge snapshot` run — skip + // rather than hit the network, so the suite stays out of normal CI without a + // `--no-match-contract` entry in verify.yml. + string memory rpc = vm.envOr("ETHEREUM_RPC_URL", string("")); + if (bytes(rpc).length == 0) { + vm.skip(true); + return; + } + vm.createSelectFork(rpc, SNAPSHOT_BLOCK); + + deadline = block.timestamp + 1 hours; + poolManager = IPoolManager(POOL_MANAGER); + + int24 tickLower = TickMath.minUsableTick(ShardConstantsV1.TICK_SPACING); + uint160 startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + console2.logBytes32(keccak256(type(ShardHookV1).creationCode)); + + uint256 gasBefore = gasleft(); + factory = new ShardLaunchFactoryV1(poolManager, keccak256(type(ShardHookV1).creationCode)); + console2.log("factory deployment gas", gasBefore - gasleft()); + renderer = factory.renderer(); + + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: tickLower, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + tokenSalt = keccak256("ShardV1MainnetForkTest"); + address predictedShard; + address predictedHook; + (hookSalt, predictedShard, predictedHook) = ShardLaunchLib.mineCanonical(factory, tokenSalt, bytes32(0), params); + (bytes32 repeatedSalt, address repeatedShard, address repeatedHook) = + ShardLaunchLib.mineCanonical(factory, tokenSalt, bytes32(0), params); + assertEq(repeatedSalt, hookSalt, "salt mining was not deterministic"); + assertEq(repeatedShard, predictedShard, "token prediction was not deterministic"); + assertEq(repeatedHook, predictedHook, "hook prediction was not deterministic"); + + vm.recordLogs(); + gasBefore = gasleft(); + (address hookAddress, address shardAddress, address nftAddress) = + factory.launch(tokenSalt, hookSalt, type(ShardHookV1).creationCode, params); + console2.log("atomic launch gas", gasBefore - gasleft()); + assertEq(shardAddress, predictedShard, "deployed token differs from prediction"); + assertEq(hookAddress, predictedHook, "deployed hook differs from prediction"); + hook = ShardHookV1(payable(hookAddress)); + shard = ShardTokenV1(shardAddress); + nft = ShardNFTV1(nftAddress); + + assertEq( + hook.launcherFeeRecipient(), + 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c, + "hook launcher recipient is not the immutable constant" + ); + + ShardLaunchFactoryV1.ConfigurationData memory configuration; + configuration.chainId = block.chainid; + configuration.factory = address(factory); + configuration.poolManager = address(poolManager); + configuration.renderer = address(renderer); + configuration.launcherFeeRecipient = launcher; + configuration.builderFeeRecipient = builder; + configuration.shard = address(shard); + configuration.hook = address(hook); + configuration.nft = address(nft); + configuration.tickLower = tickLower; + configuration.tickBand = TICK_BAND; + configuration.tickUpper = TICK_UPPER; + configuration.startSqrtPriceX96 = startSqrtPriceX96; + configuration.tokenSalt = tokenSalt; + configuration.effectiveTokenSalt = factory.effectiveTokenSalt(tokenSalt, hookSalt, params); + configuration.hookSalt = hookSalt; + configuration.hookCreationCodeHash = keccak256(type(ShardHookV1).creationCode); + expectedConfigurationHash = keccak256(abi.encode(configuration)); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 eventSignature = + keccak256("ShardLaunched(address,address,address,bytes32,bytes32,address,address,bytes32)"); + for (uint256 i; i < logs.length; ++i) { + if (logs[i].emitter != address(factory) || logs[i].topics[0] != eventSignature) continue; + assertEq(address(uint160(uint256(logs[i].topics[1]))), address(hook), "event hook mismatch"); + assertEq(address(uint160(uint256(logs[i].topics[2]))), address(shard), "event token mismatch"); + assertEq(address(uint160(uint256(logs[i].topics[3]))), address(nft), "event NFT mismatch"); + (bytes32 rawSalt, bytes32 minedSalt, address eventBuilder, address eventRenderer, bytes32 configHash) = + abi.decode(logs[i].data, (bytes32, bytes32, address, address, bytes32)); + launchEventMatched = rawSalt == tokenSalt && minedSalt == hookSalt && eventBuilder == builder + && eventRenderer == address(renderer) && configHash == expectedConfigurationHash; + } + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: ShardConstantsV1.TICK_SPACING, + hooks: IHooks(address(hook)) + }); + swapRouter = new ShardSwapRouterV1(poolManager, key); + + vm.deal(buyer, 100 ether); + vm.deal(trader, 100 ether); + } + + /// @dev The core supply invariant: the hook custodies exactly one SHARD per NFT not yet + /// circulating, plus the rounding dust the seed left behind. + function _assertBacking() internal view { + assertEq( + shard.balanceOf(address(hook)), + nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), + "shard backing != circulating NFTs" + ); + } + + /// @dev The pinned check that the fork actually gave us the canonical v4 PoolManager and not an + /// empty account or some other contract at that address. + function test_forkExposesTheRealMainnetPoolManager() public view { + assertGt(POOL_MANAGER.code.length, 0, "no PoolManager code on the fork"); + assertEq(POOL_MANAGER.codehash, POOL_MANAGER_CODE_HASH, "PoolManager runtime code is not the pinned v4"); + assertTrue(hook.initialised(), "locked position never initialised on Mainnet v4"); + } + + function test_factoryPredictionAndConfigurationEvidenceAreReproducible() public view { + assertEq(factory.configurationHashOf(address(hook)), expectedConfigurationHash, "configuration hash mismatch"); + assertTrue(launchEventMatched, "canonical launch event was not emitted"); + assertEq(hook.deployer(), address(factory), "factory is not hook deployer"); + assertEq(shard.balanceOf(address(factory)), 0, "factory retained SHARD"); + } + + /// @notice Deploy, wire, initialise, then run the whole market — third-party swap, redeem, + /// hook-market buy and sell, all three claim paths and a donation — against real v4, + /// asserting the backing invariant survives every step. + function test_fullShardLifecycleAgainstMainnetV4() public { + _assertBacking(); + + // A first-batch buyer takes ten pieces off the curve. + vm.prank(buyer); + uint256[] memory bought = hook.buyMany{ value: 10 ether }(10, 10 ether, deadline); + assertEq(bought.length, 10, "first batch did not mint ten NFTs"); + assertEq(nft.ownerOf(bought[0]), buyer, "buyer does not own the first piece"); + assertGt(_feesHeld(), 0, "the buy charged no fee"); + _assertBacking(); + + // Holders earn from the block AFTER acquisition, so roll one block before fees are charged. + vm.roll(block.number + 1); + + // An ordinary third-party swap through the model's router pays the 1% like any other trade. + // Kept under the model's 50-SHARD per-swap third-party cap, and above the one SHARD a redeem + // needs. + uint256 feesBeforeSwap = _feesHeld(); + vm.prank(trader); + uint256 shardOut = swapRouter.swapEthForShard{ value: 0.04 ether }(key, 0, deadline); + assertGe(shardOut, ONE_SHARD, "swap returned less than one SHARD"); + assertLe(shardOut, 50 * ONE_SHARD, "swap somehow exceeded the third-party cap"); + assertGt(_feesHeld(), feesBeforeSwap, "third-party swap charged no fee"); + + // The swap's SHARD redeems for a fresh NFT, without paying a second fee. + uint256 feesBeforeRedeem = _feesHeld(); + vm.startPrank(trader); + shard.approve(address(hook), type(uint256).max); + uint256 redeemed = hook.redeem(); + vm.stopPrank(); + assertEq(nft.ownerOf(redeemed), trader, "redeemer does not own the piece"); + assertEq(_feesHeld(), feesBeforeRedeem, "redeem charged a fee it should not have"); + _assertBacking(); + + // The buyer exits one piece back into the curve; the sell pays a fee too. + uint256 feesBeforeSell = _feesHeld(); + vm.prank(buyer); + uint256 payout = hook.sellNFT(bought[0], 0, deadline); + assertGt(payout, 0, "sell paid nothing out"); + assertGt(_feesHeld(), feesBeforeSell, "sell charged no fee"); + _assertBacking(); + + // A holder claims their share of everything that accrued while they held. + vm.roll(block.number + 1); + uint256[] memory claimIds = new uint256[](1); + claimIds[0] = bought[1]; + uint256 holderBefore = buyer.balance; + vm.prank(buyer); + uint256 holderClaimed = hook.claim(claimIds); + assertGt(holderClaimed, 0, "holder accrued nothing across the fee-charging blocks"); + assertEq(buyer.balance - holderBefore, holderClaimed, "holder claim did not pay out"); + + // Both beneficiaries claim their fixed 0.10% cuts. + assertGt(hook.builderFeesAccrued(), 0, "builder accrued nothing"); + assertGt(hook.launcherFeesAccrued(), 0, "launcher accrued nothing"); + + // The combined builder+launcher 20% cut is split evenly with a carried remainder, the + // launcher taking the odd wei. Across the whole lifecycle on real v4 the two accruals stay + // within a single wei of each other, and the launcher is never behind. + assertGe( + hook.launcherFeesAccrued(), + hook.builderFeesAccrued(), + "launcher fell behind builder on the cumulative split" + ); + assertLe( + hook.launcherFeesAccrued() - hook.builderFeesAccrued(), + 1, + "even split drifted by more than the carried odd wei" + ); + + uint256 builderBefore = builder.balance; + vm.prank(builder); + uint256 builderClaimed = hook.claimBuilderFees(); + assertEq(builder.balance - builderBefore, builderClaimed, "builder claim did not pay out"); + assertEq(hook.builderFeesAccrued(), 0, "builder accrual not zeroed after claim"); + + uint256 launcherBefore = launcher.balance; + vm.prank(launcher); + uint256 launcherClaimed = hook.claimLauncherFees(); + assertEq(launcher.balance - launcherBefore, launcherClaimed, "launcher claim did not pay out"); + assertEq(hook.launcherFeesAccrued(), 0, "launcher accrual not zeroed after claim"); + + // A donation reaches holders whole and never touches the backing. + uint256 accBefore = hook.accFeePerNFT(); + vm.prank(trader); + hook.donate{ value: 1 ether }(); + assertGt(hook.accFeePerNFT(), accBefore, "donation did not raise the holder accumulator"); + _assertBacking(); + } + + /// @notice Ethereum art seeds vary across recipients, acquisition nonces, and later blocks. + /// @dev This is a uniqueness/regeneration check only. The inputs are public and miner-influenceable; + /// no unpredictability claim is made. + function test_ethereumSeedInputsProduceDistinctRenderedArt() public { + vm.prank(buyer); + uint256 first = hook.buyNFT{ value: 1 ether }(type(uint256).max, deadline); + + vm.prank(trader); + uint256 second = hook.buyNFT{ value: 1 ether }(type(uint256).max, deadline); + + vm.roll(block.number + 1); + vm.warp(block.timestamp + 12); + deadline = block.timestamp + 1 hours; + vm.prank(buyer); + uint256 third = hook.buyNFT{ value: 1 ether }(type(uint256).max, deadline); + + assertEq(nft.ownerOf(first), buyer, "first Mainnet acquisition did not mint"); + assertEq(nft.ownerOf(second), trader, "second Mainnet acquisition did not mint"); + assertEq(nft.ownerOf(third), buyer, "later Mainnet acquisition did not mint"); + assertTrue(nft.tokenSeed(first) != nft.tokenSeed(second), "recipient/nonce did not vary the seed"); + assertTrue(nft.tokenSeed(second) != nft.tokenSeed(third), "later block/nonce did not vary the seed"); + + string memory artFirst = nft.tokenURI(first); + string memory artSecond = nft.tokenURI(second); + string memory artThird = nft.tokenURI(third); + assertGt(bytes(artFirst).length, 0, "no art rendered for the first piece"); + assertGt(bytes(artSecond).length, 0, "no art rendered for the second piece"); + assertGt(bytes(artThird).length, 0, "no art rendered for the later piece"); + assertTrue(keccak256(bytes(artFirst)) != keccak256(bytes(artSecond)), "two acquisitions rendered identical art"); + assertTrue(keccak256(bytes(artSecond)) != keccak256(bytes(artThird)), "later acquisition reused art"); + } + + /// @dev ETH held for the hook, whether sitting as a v4 credit or as a plain balance. + function _feesHeld() internal view returns (uint256) { + return poolManager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()) + address(hook).balance; + } +} diff --git a/test/ShardWiringV1.t.sol b/test/ShardWiringV1.t.sol new file mode 100644 index 00000000..ce2b8e32 --- /dev/null +++ b/test/ShardWiringV1.t.sol @@ -0,0 +1,340 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { GeometricRendererV1 } from "../src/GeometricRendererV1.sol"; +import { ShardErrorsV1 } from "../src/ShardErrorsV1.sol"; +import { ShardHookV1 } from "../src/ShardHookV1.sol"; +import { ShardNFTV1 } from "../src/ShardNFTV1.sol"; +import { ShardSwapRouterV1 } from "../src/ShardSwapRouterV1.sol"; +import { ShardTokenV1 } from "../src/ShardTokenV1.sol"; +import { ShardConstantsV1 } from "../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../src/interfaces/IShardNFTV1.sol"; + +contract MissingHookGetter { } + +contract MalformedHookGetter { + fallback() external { + assembly ("memory-safe") { + mstore(0, caller()) + return(0, 31) + } + } +} + +contract ArbSysOne { + function arbBlockNumber() external pure returns (uint256) { + return 1; + } +} + +contract ArbSysTwo { + function arbBlockNumber() external pure returns (uint256) { + return 2; + } +} + +contract IndependentlyFalseERC20 { + string public constant name = "False Shard"; + string public constant symbol = "FALSE"; + uint8 public constant decimals = 18; + + mapping(address account => uint256 amount) public balanceOf; + mapping(address owner => mapping(address spender => uint256 amount)) public allowance; + + address public falseTransferCaller; + bool public transferFromReturns = true; + + function mint(address to, uint256 amount) external { + balanceOf[to] += amount; + } + + function setFalseTransferCaller(address value) external { + falseTransferCaller = value; + } + + function setTransferFromReturns(bool value) external { + transferFromReturns = value; + } + + function approve(address spender, uint256 amount) external returns (bool) { + allowance[msg.sender][spender] = amount; + return true; + } + + function transfer(address to, uint256 amount) external returns (bool) { + _move(msg.sender, to, amount); + return msg.sender != falseTransferCaller; + } + + function transferFrom(address from, address to, uint256 amount) external returns (bool) { + uint256 allowed = allowance[from][msg.sender]; + if (allowed != type(uint256).max) { + require(allowed >= amount, "allowance"); + allowance[from][msg.sender] = allowed - amount; + } + _move(from, to, amount); + return transferFromReturns; + } + + function _move(address from, address to, uint256 amount) private { + require(balanceOf[from] >= amount, "balance"); + balanceOf[from] -= amount; + balanceOf[to] += amount; + } +} + +contract ShardWiringV1Test is Test { + uint160 internal 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 + ); + + int24 internal constant TICK_BAND = 22_980; + int24 internal constant TICK_UPPER = 115_080; + uint256 internal constant FAR = type(uint256).max; + + IPoolManager internal manager; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal candidate; + int24 internal tickLower; + uint160 internal startSqrtPriceX96; + address internal launcher = makeAddr("launcher"); + address internal builder = makeAddr("builder"); + address internal alice = makeAddr("alice"); + + function setUp() public { + manager = IPoolManager(address(new PoolManager(address(this)))); + shard = new ShardTokenV1(); + renderer = new GeometricRendererV1(); + tickLower = TickMath.minUsableTick(60); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + hook = _deployHook(shard, builder); + candidate = new ShardNFTV1(address(hook), address(renderer)); + } + + function test_correctNftBackReferenceBindsAndMarketRemainsUsable() public { + assertEq(IShardNFTV1(address(candidate)).hook(), address(hook)); + + hook.setNFT(IShardNFTV1(address(candidate))); + assertEq(address(hook.nft()), address(candidate)); + + assertTrue(shard.transfer(address(hook), hook.SEED_AMOUNT())); + hook.initialise(); + vm.deal(alice, 1 ether); + vm.prank(alice); + uint256 tokenId = hook.buyNFT{ value: 1 ether }(type(uint256).max, FAR); + assertEq(candidate.ownerOf(tokenId), alice); + } + + function test_setNftRejectsNftBoundToAnotherValidHook() public { + ShardTokenV1 otherShard = new ShardTokenV1(); + ShardHookV1 otherHook = _deployHook(otherShard, makeAddr("other builder")); + ShardNFTV1 wrong = new ShardNFTV1(address(otherHook), address(renderer)); + + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.WrongNFT.selector, address(wrong), address(hook))); + hook.setNFT(IShardNFTV1(address(wrong))); + } + + function test_setNftNormalizesMissingHookGetter() public { + MissingHookGetter wrong = new MissingHookGetter(); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.WrongNFT.selector, address(wrong), address(hook))); + hook.setNFT(IShardNFTV1(address(wrong))); + } + + function test_setNftNormalizesMalformedHookGetter() public { + MalformedHookGetter wrong = new MalformedHookGetter(); + vm.expectRevert(abi.encodeWithSelector(ShardErrorsV1.WrongNFT.selector, address(wrong), address(hook))); + hook.setNFT(IShardNFTV1(address(wrong))); + } + + function test_setNftRetainsExactAuthorizationAndOneShotErrors() public { + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.NotDeployer.selector); + hook.setNFT(IShardNFTV1(address(candidate))); + + hook.setNFT(IShardNFTV1(address(candidate))); + vm.expectRevert(ShardErrorsV1.AlreadyInitialised.selector); + hook.setNFT(IShardNFTV1(address(candidate))); + } + + function test_setNftRetainsExactZeroAddressError() public { + vm.expectRevert(ShardErrorsV1.ZeroAddress.selector); + hook.setNFT(IShardNFTV1(address(0))); + } + + function test_ethereumArtSeedDoesNotDependOnArbitrumPrecompileAddress() public { + hook.setNFT(IShardNFTV1(address(candidate))); + assertTrue(shard.transfer(address(hook), hook.SEED_AMOUNT())); + hook.initialise(); + vm.deal(alice, 1 ether); + + ArbSysOne one = new ArbSysOne(); + ArbSysTwo two = new ArbSysTwo(); + bytes memory firstCode = address(one).code; + bytes memory secondCode = address(two).code; + uint256 snapshot = vm.snapshotState(); + + vm.etch(address(0x64), firstCode); + vm.prank(alice); + uint256 firstId = hook.buyNFT{ value: 1 ether }(type(uint256).max, FAR); + uint256 firstSeed = candidate.tokenSeed(firstId); + + vm.revertToState(snapshot); + vm.etch(address(0x64), secondCode); + vm.prank(alice); + uint256 secondId = hook.buyNFT{ value: 1 ether }(type(uint256).max, FAR); + uint256 secondSeed = candidate.tokenSeed(secondId); + + assertEq(firstId, secondId); + assertEq(firstSeed, secondSeed, "Ethereum seed depended on code at ArbSys address"); + } + + function _deployHook(ShardTokenV1 token, address builderRecipient) internal returns (ShardHookV1 deployed) { + bytes memory constructorArgs = abi.encode( + manager, + token, + tickLower, + TICK_BAND, + TICK_UPPER, + startSqrtPriceX96, + address(this), + launcher, + builderRecipient + ); + (address predicted, bytes32 salt) = + HookMiner.find(address(this), REQUIRED_HOOK_FLAGS, type(ShardHookV1).creationCode, constructorArgs); + deployed = new ShardHookV1{ salt: salt }( + manager, + token, + tickLower, + TICK_BAND, + TICK_UPPER, + startSqrtPriceX96, + address(this), + launcher, + builderRecipient + ); + assertEq(address(deployed), predicted); + } +} + +contract ShardCheckedTransferV1Test is Test { + uint160 internal 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 + ); + int24 internal constant TICK_BAND = 22_980; + int24 internal constant TICK_UPPER = 115_080; + uint256 internal constant FAR = type(uint256).max; + + IPoolManager internal manager; + IndependentlyFalseERC20 internal token; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + int24 internal tickLower; + uint160 internal startSqrtPriceX96; + address internal launcher = makeAddr("false launcher"); + address internal builder = makeAddr("false builder"); + address internal alice = makeAddr("false alice"); + + function setUp() public { + manager = IPoolManager(address(new PoolManager(address(this)))); + token = new IndependentlyFalseERC20(); + tickLower = TickMath.minUsableTick(60); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + bytes memory constructorArgs = abi.encode( + manager, + ShardTokenV1(address(token)), + tickLower, + TICK_BAND, + TICK_UPPER, + startSqrtPriceX96, + address(this), + launcher, + builder + ); + (address predicted, bytes32 salt) = + HookMiner.find(address(this), REQUIRED_HOOK_FLAGS, type(ShardHookV1).creationCode, constructorArgs); + hook = new ShardHookV1{ salt: salt }( + manager, + ShardTokenV1(address(token)), + tickLower, + TICK_BAND, + TICK_UPPER, + startSqrtPriceX96, + address(this), + launcher, + builder + ); + assertEq(address(hook), predicted); + + GeometricRendererV1 renderer = new GeometricRendererV1(); + nft = new ShardNFTV1(address(hook), address(renderer)); + hook.setNFT(IShardNFTV1(address(nft))); + token.mint(address(hook), hook.SEED_AMOUNT()); + vm.deal(alice, 1 ether); + } + + function test_initialiseRejectsFalseReturnFromLiquiditySettlementTransfer() public { + token.setFalseTransferCaller(address(hook)); + vm.expectRevert(ShardErrorsV1.TokenTransferFailed.selector); + hook.initialise(); + } + + function test_buyMaxRejectsFalseReturnWhenReturningFractionalShard() public { + hook.initialise(); + token.setFalseTransferCaller(address(hook)); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.TokenTransferFailed.selector); + hook.buyMax{ value: 0.0001 ether }(0, FAR); + } + + function test_redeemRejectsFalseReturnFromShardTransferFrom() public { + hook.initialise(); + token.mint(alice, 1 ether); + vm.prank(alice); + token.approve(address(hook), 1 ether); + token.setTransferFromReturns(false); + + vm.prank(alice); + vm.expectRevert(ShardErrorsV1.TokenTransferFailed.selector); + hook.redeem(); + } + + function test_routerRejectsFalseReturnFromShardTransferFrom() public { + hook.initialise(); + PoolKey memory key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(token)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: ShardConstantsV1.TICK_SPACING, + hooks: IHooks(address(hook)) + }); + ShardSwapRouterV1 router = new ShardSwapRouterV1(manager, key); + vm.prank(alice); + hook.buyNFT{ value: 1 ether }(type(uint256).max, FAR); + token.mint(alice, 1 ether); + vm.prank(alice); + token.approve(address(router), 1 ether); + token.setTransferFromReturns(false); + + vm.prank(alice); + vm.expectRevert(ShardSwapRouterV1.TokenTransferFailed.selector); + router.swapShardForEth(key, 1 ether, 0, FAR); + } +} diff --git a/test/invariant/ShardHandlerV1.sol b/test/invariant/ShardHandlerV1.sol new file mode 100644 index 00000000..f8ba33e3 --- /dev/null +++ b/test/invariant/ShardHandlerV1.sol @@ -0,0 +1,664 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.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 { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; +import { BalanceDelta } from "@uniswap/v4-core/src/types/BalanceDelta.sol"; +import { SwapParams } from "@uniswap/v4-core/src/types/PoolOperation.sol"; +import { IERC20Minimal } from "@uniswap/v4-core/src/interfaces/external/IERC20Minimal.sol"; + +import { ShardHookV1 } from "../../src/ShardHookV1.sol"; +import { ShardTokenV1 } from "../../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../../src/ShardNFTV1.sol"; + +/// @title ShardHandlerV1 +/// @notice Bounded random-action driver for the ShardHookV1 invariant suite. +/// +/// @dev The handler doubles as a THIRD-PARTY swapper (it implements +/// `IUnlockCallback` and swaps in its own name), which is the only way to +/// exercise the `_beforeSwap`/`_afterSwap` fee path — v4 skips a hook's own +/// callbacks, so `buyNFT`/`sellNFT` never reach them. +/// +/// Ghost accounting note: every action is wrapped in {track}, which measures +/// the hook's fee assets (`address(hook).balance + poolManager.balanceOf(hook, 0)`) +/// before and after. Every ETH fee the system takes — on either path — lands +/// in exactly one of those two buckets, and the only things that ever leave +/// them are a `claim` payout and the builder/launcher fee claims. So the +/// increase across an action IS the fee taken. +/// +/// Fee split note: each swap/market fee is carved 10% to the builder and 10% to +/// the launcher BEFORE the remainder reaches the holder pool, but both cuts stay +/// hook-held until claimed. `feeAssets()` is therefore still the gross fee take, +/// and the accrued cuts are simply another liability against it. +contract ShardHandlerV1 is Test, IUnlockCallback { + using StateLibrary for IPoolManager; + using CurrencyLibrary for Currency; + + uint256 internal constant ONE_SHARD = 1 ether; + uint256 internal constant FAR = type(uint256).max; + + IPoolManager public immutable manager; + ShardHookV1 public immutable hook; + ShardTokenV1 public immutable shard; + ShardNFTV1 public immutable nft; + + PoolKey internal key; + + /*////////////////////////////////////////////////////////////// + ACTORS + //////////////////////////////////////////////////////////////*/ + + address[] public actors; + + /// @notice The address currently entitled to the builder cut. Kept in step with + /// `hook.builderFeeRecipient()` so claim actions prank the right caller after + /// a rotation. + address public builderRecipient; + /// @notice The launcher payout address. Immutable on the hook, so this never moves. + address public launcherRecipient; + /// @dev The small set the builder payout address rotates between. + address[] public builderCandidates; + + /*////////////////////////////////////////////////////////////// + GHOST VARIABLES + //////////////////////////////////////////////////////////////*/ + + /// @notice Every wei of ETH fee the protocol has ever taken, on either path. + uint256 public totalFeesTaken; + /// @notice Every wei ever paid out by {ShardHookV1-claim}. + uint256 public totalClaimed; + /// @notice Every wei ever paid out by {ShardHookV1-claimBuilderFees}. + uint256 public builderClaimed; + /// @notice Every wei ever paid out by {ShardHookV1-claimLauncherFees}. + uint256 public launcherClaimed; + uint256 public totalBought; + uint256 public totalSold; + uint256 public totalRedeemed; + uint256 public totalThirdPartySwaps; + + /// @notice NFTs minted through {ShardHookV1-buyMany} (also counted in `totalBought`). + uint256 public totalBatchBought; + /// @notice NFTs sold through {ShardHookV1-sellMany} (also counted in `totalSold`). + uint256 public totalBatchSold; + /// @notice Outside ETH paid in through {ShardHookV1-donate}. + uint256 public totalDonated; + /// @notice NFTs minted through {ShardHookV1-buyMax} (also counted in `totalBought`). + uint256 public totalMaxBought; + /// @notice Every wei of SHARD {ShardHookV1-buyMax} has ever handed back to a caller as + /// ERC-20. This is the ONLY path by which SHARD leaves the hook, so the core + /// backing identity `balanceOf(hook) == circulating * 1e18 + seedDust` only + /// survives if every wei of it actually landed on the caller. Each action + /// asserts that locally; this is the running total. + uint256 public totalLeftoverShardOut; + /// @dev Largest single buyMax leftover from a call that did NOT hit MAX_BATCH. Below the + /// cap this must stay under one whole SHARD — more than that should have been minted + /// as an NFT rather than handed back as ERC-20. AT the cap a large leftover is correct + /// and expected, which is why those calls are excluded rather than asserted on. + uint256 public maxSingleLeftoverBelowCap; + + /// @notice Largest `circulatingSupply` ever observed. Bounds the id range that can + /// possibly have been handed out (ids are always issued lowest-first, so the + /// highest id ever issued is at most this + 1). + uint256 public maxCirculatingSeen; + + /// @notice Highest `accFeePerNFT` ever observed at the end of an action. + uint256 public lastAcc; + /// @notice Highest seed-position liquidity ever observed. + uint128 public maxLiquiditySeen; + + /// @notice Highest band-position liquidity ever observed. The band is as unwithdrawable + /// as the full-range position, so it gets the same ratchet. + uint128 public maxBandLiquiditySeen; + + /// @dev Tokens currently held by a user (i.e. NOT in the archive). + uint256[] public liveIds; + mapping(uint256 => uint256) internal _idxPlusOne; + + /// @dev Action call counters, for `--show-metrics` style triage. + mapping(bytes32 => uint256) public calls; + + constructor( + IPoolManager _manager, + ShardHookV1 _hook, + ShardTokenV1 _shard, + ShardNFTV1 _nft, + PoolKey memory _key, + address _launcher, + address _builder + ) { + manager = _manager; + hook = _hook; + shard = _shard; + nft = _nft; + key = _key; + + launcherRecipient = _launcher; + builderRecipient = _builder; + // A small rotating set for `setBuilderFeeRecipient`. All plain EOAs, so a payout + // can never fail for want of a `receive()`. + builderCandidates.push(_builder); + builderCandidates.push(makeAddr("builder.successor.1")); + builderCandidates.push(makeAddr("builder.successor.2")); + + for (uint256 i = 0; i < 4; i++) { + address a = address(uint160(uint256(keccak256(abi.encodePacked("shard.actor", i))))); + actors.push(a); + vm.deal(a, 500 ether); + vm.prank(a); + shard.approve(address(hook), type(uint256).max); + } + + vm.deal(address(this), 100_000 ether); + lastAcc = hook.accFeePerNFT(); + maxLiquiditySeen = _positionLiquidity(); + maxBandLiquiditySeen = _bandLiquidity(); + } + + receive() external payable { } + + /*////////////////////////////////////////////////////////////// + BOOKKEEPING + //////////////////////////////////////////////////////////////*/ + + function actorCount() external view returns (uint256) { + return actors.length; + } + + function liveIdCount() external view returns (uint256) { + return liveIds.length; + } + + /// @notice ETH the hook controls, in either form. The ERC-6909 claim id for + /// native ETH is 0 — third-party fees live there until swept. + function feeAssets() public view returns (uint256) { + return address(hook).balance + manager.balanceOf(address(hook), CurrencyLibrary.ADDRESS_ZERO.toId()); + } + + /// @notice Everything currently owed to a claimant, across every address the + /// handler could ever have credited. + function sumClaimable() external view returns (uint256 total) { + for (uint256 i = 0; i < actors.length; i++) { + total += hook.claimable(actors[i]); + } + total += hook.claimable(address(this)); + total += hook.claimable(address(nft)); + total += hook.claimable(address(hook)); + } + + /// @notice SHARD held by every address this run can possibly have credited. The pool + /// itself (the PoolManager) is the only other holder, so this plus the manager + /// plus the hook must equal total supply — which is what proves the leftover + /// SHARD `buyMax` sends out is not conjured from somewhere. + function sumActorShard() external view returns (uint256 total) { + for (uint256 i = 0; i < actors.length; i++) { + total += shard.balanceOf(actors[i]); + } + total += shard.balanceOf(address(this)); + } + + function positionLiquidity() external view returns (uint128) { + return _positionLiquidity(); + } + + function bandLiquidity() external view returns (uint128) { + return _bandLiquidity(); + } + + function _bandLiquidity() internal view returns (uint128 liquidity) { + (liquidity,,) = + manager.getPositionInfo(key.toId(), address(hook), hook.tickBand(), hook.tickUpper(), bytes32(0)); + } + + function _positionLiquidity() internal view returns (uint128 liquidity) { + (liquidity,,) = + manager.getPositionInfo(key.toId(), address(hook), hook.tickLower(), hook.tickUpper(), bytes32(0)); + } + + modifier track(bytes32 name) { + calls[name]++; + uint256 before = feeAssets(); + _; + uint256 aft = feeAssets(); + // Fees only ever ADD to the two buckets; the only subtractions are a claim + // payout and a builder/launcher fee claim, each of which reconciles itself. + // Counting increases only is exact here and conservative (an undercount + // tightens the solvency invariant rather than loosening it). + if (aft > before) totalFeesTaken += aft - before; + + uint256 acc = hook.accFeePerNFT(); + require(acc >= lastAcc, "GHOST: accFeePerNFT went BACKWARDS"); + lastAcc = acc; + + uint128 liq = _positionLiquidity(); + require(liq >= maxLiquiditySeen, "GHOST: seed liquidity was REDUCED"); + maxLiquiditySeen = liq; + + uint128 bandLiq = _bandLiquidity(); + require(bandLiq >= maxBandLiquiditySeen, "GHOST: band liquidity was REDUCED"); + maxBandLiquiditySeen = bandLiq; + + uint256 circ = nft.circulatingSupply(); + if (circ > maxCirculatingSeen) maxCirculatingSeen = circ; + + // The tracked recipient must never drift from the hook's, or every later claim + // action would be pranked as the wrong caller and quietly become a no-op. + require(builderRecipient == hook.builderFeeRecipient(), "GHOST: builder recipient drifted"); + } + + function _actor(uint256 seed) internal view returns (address) { + return actors[bound(seed, 0, actors.length - 1)]; + } + + function _addLive(uint256 id) internal { + if (_idxPlusOne[id] != 0) return; + liveIds.push(id); + _idxPlusOne[id] = liveIds.length; + } + + function _removeLive(uint256 id) internal { + uint256 ip1 = _idxPlusOne[id]; + if (ip1 == 0) return; + uint256 last = liveIds[liveIds.length - 1]; + liveIds[ip1 - 1] = last; + _idxPlusOne[last] = ip1; + liveIds.pop(); + delete _idxPlusOne[id]; + } + + /*////////////////////////////////////////////////////////////// + ACTIONS + //////////////////////////////////////////////////////////////*/ + + function buyNFT(uint256 actorSeed, uint256 ethSeed) external track("buyNFT") { + address actor = _actor(actorSeed); + uint256 amount = bound(ethSeed, 0.0005 ether, 30 ether); + vm.deal(actor, actor.balance + amount); + + vm.prank(actor); + try hook.buyNFT{ value: amount }(type(uint256).max, FAR) returns (uint256 tokenId) { + totalBought++; + _addLive(tokenId); + } catch { } + } + + /// @dev ONE exact-output swap for `count` whole SHARD, `count` NFTs minted, the 1% + /// charged explicitly in the hook's own body (v4 skips its own callbacks). + /// Exact-output refunds the unused ETH, so an over-generous bound here cannot + /// drain the (very thin) curve — only what the NFTs actually cost is spent. + function buyMany(uint256 actorSeed, uint256 countSeed, uint256 ethSeed) external track("buyMany") { + address actor = _actor(actorSeed); + uint256 max = hook.MAX_BATCH(); + uint256 count = bound(countSeed, 1, max); + uint256 amount = bound(ethSeed, 0.0005 ether, 0.5 ether); + vm.deal(actor, actor.balance + amount); + + uint256 hookShardBefore = shard.balanceOf(address(hook)); + + vm.prank(actor); + try hook.buyMany{ value: amount }(count, FAR, FAR) returns (uint256[] memory ids) { + require(ids.length == count, "GHOST: buyMany minted a different count than asked"); + require(ids.length <= max, "GHOST: buyMany exceeded MAX_BATCH"); + for (uint256 i = 0; i < ids.length; i++) { + require(nft.ownerOf(ids[i]) == actor, "GHOST: buyMany minted to the wrong address"); + _addLive(ids[i]); + } + // One SHARD parked per NFT, nothing more and nothing less. + require( + shard.balanceOf(address(hook)) - hookShardBefore == count * ONE_SHARD, + "GHOST: buyMany moved the wrong amount of SHARD backing" + ); + totalBought += count; + totalBatchBought += count; + } catch { } + } + + /// @dev ONE exact-input swap: the whole `msg.value` is consumed, so this is bounded + /// HARD. The pool is thin enough that ~0.04 ETH halves the price. + function buyMax(uint256 actorSeed, uint256 ethSeed) external track("buyMax") { + address actor = _actor(actorSeed); + uint256 amount = bound(ethSeed, 0.0001 ether, 0.002 ether); + vm.deal(actor, actor.balance + amount); + + uint256 hookShardBefore = shard.balanceOf(address(hook)); + uint256 actorShardBefore = shard.balanceOf(actor); + + vm.prank(actor); + try hook.buyMax{ value: amount }(0, FAR) returns (uint256[] memory ids) { + require(ids.length <= hook.MAX_BATCH(), "GHOST: buyMax exceeded MAX_BATCH"); + for (uint256 i = 0; i < ids.length; i++) { + require(nft.ownerOf(ids[i]) == actor, "GHOST: buyMax minted to the wrong address"); + _addLive(ids[i]); + } + + uint256 leftover = shard.balanceOf(actor) - actorShardBefore; + // Everything the swap bought either became backing for an NFT or was handed to + // the caller. If the hook kept a wei of it, the backing identity is already + // broken and there is no upgrade path to fix it. + require( + shard.balanceOf(address(hook)) - hookShardBefore == ids.length * ONE_SHARD, + "GHOST: buyMax left SHARD stranded in the hook" + ); + // Now UNCONDITIONAL. buyMax reverts above the cap instead of clamping, so a + // successful call can never hand back a whole SHARD: anything worth an NFT was + // minted as one. The old version had to exempt cap-hit calls. + require(leftover < ONE_SHARD, "GHOST: a whole SHARD was returned instead of minted"); + + totalLeftoverShardOut += leftover; + if (leftover > maxSingleLeftoverBelowCap) { + maxSingleLeftoverBelowCap = leftover; + } + totalBought += ids.length; + totalMaxBought += ids.length; + } catch { } + } + + /// @dev Feeds the SHARD `buyMax` handed back into {ShardHookV1-redeem}. This is the + /// only route by which leftover shards re-enter the hook, and it must add + /// exactly one unit of backing per NFT — never mint one for free. + function redeemLeftover(uint256 actorSeed) external track("redeemLeftover") { + address actor = _actor(actorSeed); + if (shard.balanceOf(actor) < ONE_SHARD) return; + + uint256 hookShardBefore = shard.balanceOf(address(hook)); + vm.prank(actor); + try hook.redeem() returns (uint256 tokenId) { + require( + shard.balanceOf(address(hook)) - hookShardBefore == ONE_SHARD, + "GHOST: redeem minted an NFT without one full SHARD of backing" + ); + totalRedeemed++; + _addLive(tokenId); + } catch { } + } + + function sellNFT(uint256 idSeed) external track("sellNFT") { + if (liveIds.length == 0) return; + uint256 id = liveIds[bound(idSeed, 0, liveIds.length - 1)]; + address owner = nft.ownerOf(id); + if (owner == address(nft)) { + _removeLive(id); + return; + } + + vm.prank(owner); + try hook.sellNFT(id, 0, FAR) { + totalSold++; + _removeLive(id); + } catch { } + } + + /// @dev ONE exact-input swap for the whole batch, with the 1% charged explicitly in the + /// hook's own body — the sell-side twin of {buyMany}. Every id must belong to the + /// same seller, so this collects the seed holder's live ids and sells those. + function sellMany(uint256 idSeed, uint256 countSeed) external track("sellMany") { + if (liveIds.length == 0) return; + uint256 id = liveIds[bound(idSeed, 0, liveIds.length - 1)]; + address owner = nft.ownerOf(id); + if (owner == address(nft)) { + _removeLive(id); + return; + } + + uint256 want = bound(countSeed, 1, hook.MAX_BATCH()); + uint256[] memory batch = new uint256[](want); + uint256 n; + for (uint256 i = 0; i < liveIds.length && n < want; i++) { + // Duplicates would revert the whole batch, and liveIds holds each id once. + if (nft.ownerOf(liveIds[i]) == owner) { + batch[n++] = liveIds[i]; + } + } + if (n == 0) return; + + uint256[] memory ids = new uint256[](n); + for (uint256 i = 0; i < n; i++) { + ids[i] = batch[i]; + } + + uint256 hookShardBefore = shard.balanceOf(address(hook)); + + vm.prank(owner); + try hook.sellMany(ids, 0, FAR) { + // One SHARD of backing must leave per NFT — never more, never less. + require( + hookShardBefore - shard.balanceOf(address(hook)) == n * ONE_SHARD, + "GHOST: sellMany moved the wrong amount of SHARD backing" + ); + for (uint256 i = 0; i < n; i++) { + require(nft.ownerOf(ids[i]) == address(nft), "GHOST: sellMany left an id with the seller"); + _removeLive(ids[i]); + } + totalSold += n; + totalBatchSold += n; + } catch { } + } + + /// @dev The batch arrival path for shards bought on a third-party router. One + /// `transferFrom` for the whole batch, so it must add exactly one unit of backing + /// per NFT — never mint one for free. + function redeemMany(uint256 actorSeed, uint256 countSeed) external track("redeemMany") { + uint256 available = shard.balanceOf(address(this)) / ONE_SHARD; + if (available == 0) return; + uint256 count = bound(countSeed, 1, available < hook.MAX_BATCH() ? available : hook.MAX_BATCH()); + + address actor = _actor(actorSeed); + shard.transfer(actor, count * ONE_SHARD); + + uint256 hookShardBefore = shard.balanceOf(address(hook)); + + vm.prank(actor); + try hook.redeemMany(count) returns (uint256[] memory ids) { + require(ids.length == count, "GHOST: redeemMany minted a different count than asked"); + require( + shard.balanceOf(address(hook)) - hookShardBefore == count * ONE_SHARD, + "GHOST: redeemMany minted NFTs without a full SHARD of backing each" + ); + for (uint256 i = 0; i < count; i++) { + require(nft.ownerOf(ids[i]) == actor, "GHOST: redeemMany minted to the wrong address"); + _addLive(ids[i]); + } + totalRedeemed += count; + } catch { + // pull the shards back so the handler's balance stays meaningful + vm.prank(actor); + shard.transfer(address(this), count * ONE_SHARD); + } + } + + /// @dev Only reachable once the handler has bought SHARD on the open market. + function redeem(uint256 actorSeed) external track("redeem") { + if (shard.balanceOf(address(this)) < ONE_SHARD) return; + address actor = _actor(actorSeed); + shard.transfer(actor, ONE_SHARD); + + vm.prank(actor); + try hook.redeem() returns (uint256 tokenId) { + totalRedeemed++; + _addLive(tokenId); + } catch { + // pull the shard back so the handler's balance stays meaningful + vm.prank(actor); + shard.transfer(address(this), ONE_SHARD); + } + } + + /// @dev Outside ETH paid into the fee pool, the launchpad path. It must obey the + /// same solvency rule as a trading fee: every wei donated becomes claimable + /// by holders or sits in escrow, and never inflates claims beyond assets. + /// A donation is a gift to holders, NOT swap volume, so it is never split. + function donate(uint256 amountSeed) external track("donate") { + uint256 amount = bound(amountSeed, 1, 5 ether); + vm.deal(address(this), address(this).balance + amount); + + uint256 before = feeAssets(); + uint256 builderBefore = hook.builderFeesAccrued(); + uint256 launcherBefore = hook.launcherFeesAccrued(); + try hook.donate{ value: amount }() { + require(feeAssets() - before == amount, "GHOST: donation did not land wholly in the fee assets"); + require(hook.builderFeesAccrued() == builderBefore, "GHOST: a donation was split to the builder"); + require(hook.launcherFeesAccrued() == launcherBefore, "GHOST: a donation was split to the launcher"); + totalDonated += amount; + } catch { } + } + + function claim(uint256 actorSeed, uint256 nSeed) external track("claim") { + address actor = _actor(actorSeed); + uint256 n = liveIds.length == 0 ? 0 : bound(nSeed, 0, liveIds.length); + uint256[] memory ids = new uint256[](n); + for (uint256 i = 0; i < n; i++) { + ids[i] = liveIds[i]; + } + + uint256 before = feeAssets(); + vm.prank(actor); + try hook.claim(ids) returns (uint256 paid) { + totalClaimed += paid; + // The payout is the ONLY thing that may leave the fee buckets on this path. + require(before - feeAssets() == paid, "GHOST: claim moved more than it paid"); + } catch { } + } + + /*////////////////////////////////////////////////////////////// + BUILDER / LAUNCHER CUTS + //////////////////////////////////////////////////////////////*/ + + /// @dev Pranked as the CURRENT recipient, which `setBuilderFeeRecipient` keeps in step. + /// `NothingToClaim` is an ordinary no-op, so a zero accrual is caught and ignored. + function claimBuilderFees() external track("claimBuilderFees") { + uint256 accrued = hook.builderFeesAccrued(); + uint256 before = feeAssets(); + + vm.prank(builderRecipient); + try hook.claimBuilderFees() returns (uint256 paid) { + require(paid == accrued, "GHOST: builder claim paid something other than the accrual"); + require(hook.builderFeesAccrued() == 0, "GHOST: builder accrual survived a claim"); + require(before - feeAssets() == paid, "GHOST: builder claim moved more than it paid"); + builderClaimed += paid; + } catch { } + } + + function claimLauncherFees() external track("claimLauncherFees") { + uint256 accrued = hook.launcherFeesAccrued(); + uint256 before = feeAssets(); + + vm.prank(launcherRecipient); + try hook.claimLauncherFees() returns (uint256 paid) { + require(paid == accrued, "GHOST: launcher claim paid something other than the accrual"); + require(hook.launcherFeesAccrued() == 0, "GHOST: launcher accrual survived a claim"); + require(before - feeAssets() == paid, "GHOST: launcher claim moved more than it paid"); + launcherClaimed += paid; + } catch { } + } + + /// @dev Rotates the builder payout address around a fixed set of EOAs. The tracked + /// recipient moves with it, so later claims still prank a caller the hook accepts. + function setBuilderFeeRecipient(uint256 seed) external track("setBuilderFeeRecipient") { + address next = builderCandidates[bound(seed, 0, builderCandidates.length - 1)]; + if (next == builderRecipient) return; + + vm.prank(builderRecipient); + try hook.setBuilderFeeRecipient(next) { + builderRecipient = next; + } catch { } + } + + /*////////////////////////////////////////////////////////////// + THIRD-PARTY SWAP PATH + //////////////////////////////////////////////////////////////*/ + + function swapEthForShard(uint256 amountSeed, uint256 exactSeed) external track("swapEthForShard") { + uint256 amount = bound(amountSeed, 1000 wei, 25 ether); + bool exactIn = exactSeed % 2 == 0; + int256 specified = exactIn ? -int256(amount) : int256(bound(amountSeed, 1000 wei, 50 ether)); + + try this.doSwap(true, specified) { + totalThirdPartySwaps++; + } catch { } + } + + function swapShardForEth(uint256 amountSeed, uint256 exactSeed) external track("swapShardForEth") { + uint256 held = shard.balanceOf(address(this)); + if (held == 0) return; + uint256 amount = bound(amountSeed, 1, held); + bool exactIn = exactSeed % 2 == 0; + // exactOut here specifies ETH out, which the handler cannot size safely, + // so keep it small relative to what the pool plausibly holds. + int256 specified = exactIn ? -int256(amount) : int256(bound(amountSeed, 1 wei, 0.05 ether)); + + try this.doSwap(false, specified) { + totalThirdPartySwaps++; + } catch { } + } + + /// @dev External so the handler can `try` it and roll back a failed swap + /// without aborting the whole action. + function doSwap(bool zeroForOne, int256 amountSpecified) external returns (BalanceDelta delta) { + require(msg.sender == address(this), "self only"); + delta = abi.decode(manager.unlock(abi.encode(zeroForOne, amountSpecified)), (BalanceDelta)); + } + + function unlockCallback(bytes calldata rawData) external override returns (bytes memory) { + require(msg.sender == address(manager), "not pool manager"); + (bool zeroForOne, int256 amountSpecified) = abi.decode(rawData, (bool, int256)); + + BalanceDelta delta = manager.swap( + key, + SwapParams({ + zeroForOne: zeroForOne, + amountSpecified: amountSpecified, + sqrtPriceLimitX96: zeroForOne ? TickMath.MIN_SQRT_PRICE + 1 : TickMath.MAX_SQRT_PRICE - 1 + }), + "" + ); + + _resolve(key.currency0, delta.amount0()); + _resolve(key.currency1, delta.amount1()); + return abi.encode(delta); + } + + function _resolve(Currency currency, int128 amount) internal { + if (amount < 0) { + uint256 owed = uint256(uint128(-amount)); + if (currency.isAddressZero()) { + manager.settle{ value: owed }(); + } else { + manager.sync(currency); + IERC20Minimal(Currency.unwrap(currency)).transfer(address(manager), owed); + manager.settle(); + } + } else if (amount > 0) { + manager.take(currency, address(this), uint256(uint128(amount))); + } + } + + /*////////////////////////////////////////////////////////////// + TRANSFERS AND TIME + //////////////////////////////////////////////////////////////*/ + + function transferNFT(uint256 idSeed, uint256 actorSeed) external track("transferNFT") { + if (liveIds.length == 0) return; + uint256 id = liveIds[bound(idSeed, 0, liveIds.length - 1)]; + address owner = nft.ownerOf(id); + if (owner == address(nft)) { + _removeLive(id); + return; + } + address to = _actor(actorSeed); + if (to == owner) return; + + vm.prank(owner); + try nft.transferFrom(owner, to, id) { } catch { } + } + + function warpBlock(uint256 seed) external track("warpBlock") { + uint256 n = bound(seed, 1, 3); + vm.roll(block.number + n); + vm.warp(block.timestamp + n * 2); + } +} diff --git a/test/invariant/ShardV1.t.sol b/test/invariant/ShardV1.t.sol new file mode 100644 index 00000000..65402e79 --- /dev/null +++ b/test/invariant/ShardV1.t.sol @@ -0,0 +1,389 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Test, console2 } from "forge-std/Test.sol"; + +import { PoolManager } from "@uniswap/v4-core/src/PoolManager.sol"; +import { IPoolManager } from "@uniswap/v4-core/src/interfaces/IPoolManager.sol"; +import { IHooks } from "@uniswap/v4-core/src/interfaces/IHooks.sol"; +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { TickMath } from "@uniswap/v4-core/src/libraries/TickMath.sol"; +import { StateLibrary } from "@uniswap/v4-core/src/libraries/StateLibrary.sol"; +import { PoolKey } from "@uniswap/v4-core/src/types/PoolKey.sol"; +import { PoolId } from "@uniswap/v4-core/src/types/PoolId.sol"; +import { Currency, CurrencyLibrary } from "@uniswap/v4-core/src/types/Currency.sol"; + +import { HookMiner } from "@uniswap/v4-periphery/src/utils/HookMiner.sol"; + +import { ShardHookV1 } from "../../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../../src/ShardLaunchFactoryV1.sol"; +import { ShardTokenV1 } from "../../src/ShardTokenV1.sol"; +import { ShardNFTV1 } from "../../src/ShardNFTV1.sol"; +import { GeometricRendererV1 } from "../../src/GeometricRendererV1.sol"; +import { ShardConstantsV1 } from "../../src/ShardConstantsV1.sol"; +import { IShardNFTV1 } from "../../src/interfaces/IShardNFTV1.sol"; +import { ShardLaunchLib } from "../utils/ShardLaunchLib.sol"; + +import { ShardHandlerV1 } from "./ShardHandlerV1.sol"; + +/// @title ShardV1Invariants +/// @notice The primary safety net. There is no paid audit and the liquidity +/// position is locked forever, so anything these do not catch cannot be +/// fixed later. Assertions are stated as SOLVENCY where possible, not as +/// conservation — a conservation-only property let a same-block +/// double-claim (2 ETH of claims against 1 ETH of fees) through cleanly. +contract ShardV1Invariants is Test { + using StateLibrary for IPoolManager; + using CurrencyLibrary for Currency; + + int24 internal constant TICK_SPACING = 60; + int24 internal constant TICK_UPPER = 115_080; + int24 internal constant TICK_BAND = 22_980; // ~0.1 ETH per NFT, the concentrated band edge + int24 internal TICK_LOWER; + + uint256 internal constant SEED_AMOUNT = 10_000 ether; + uint256 internal constant ONE_SHARD = 1 ether; + uint256 internal constant ACC_PRECISION = 1e18; + + /// @dev Floor for the audited id range. Batch buys mint up to `MAX_BATCH` ids in a + /// single action, so a FIXED window is no longer safe — the window is derived + /// from the run itself in {_idWindow} and this is only its lower bound. + uint256 internal constant ID_WINDOW = 128; + + uint160 internal constant 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 + ); + + IPoolManager internal manager; + ShardLaunchFactoryV1 internal factory; + ShardTokenV1 internal shard; + GeometricRendererV1 internal renderer; + ShardHookV1 internal hook; + ShardNFTV1 internal nft; + ShardHandlerV1 internal handler; + + PoolKey internal key; + PoolId internal poolId; + uint160 internal startSqrtPriceX96; + + address internal constant launcher = 0x4957f49620AFf3Adbbe8195a4f633E49cc93376c; + address internal builder = makeAddr("builder"); + + function setUp() public { + TICK_LOWER = TickMath.minUsableTick(TICK_SPACING); + startSqrtPriceX96 = TickMath.getSqrtPriceAtTick(TICK_UPPER); + + manager = IPoolManager(address(new PoolManager(address(this)))); + factory = new ShardLaunchFactoryV1(manager, keccak256(type(ShardHookV1).creationCode)); + ShardLaunchFactoryV1.LaunchParams memory params = ShardLaunchFactoryV1.LaunchParams({ + tickLower: TICK_LOWER, + tickBand: TICK_BAND, + tickUpper: TICK_UPPER, + startSqrtPriceX96: startSqrtPriceX96, + builderFeeRecipient: builder + }); + (hook, shard, nft,) = ShardLaunchLib.mineAndLaunch(factory, keccak256("ShardV1Invariants"), bytes32(0), params); + renderer = factory.renderer(); + + key = PoolKey({ + currency0: CurrencyLibrary.ADDRESS_ZERO, + currency1: Currency.wrap(address(shard)), + fee: ShardConstantsV1.POOL_FEE, + tickSpacing: TICK_SPACING, + hooks: IHooks(address(hook)) + }); + poolId = key.toId(); + + handler = new ShardHandlerV1(manager, hook, shard, nft, key, launcher, builder); + + bytes4[] memory selectors = new bytes4[](17); + selectors[0] = ShardHandlerV1.buyNFT.selector; + selectors[1] = ShardHandlerV1.sellNFT.selector; + selectors[2] = ShardHandlerV1.redeem.selector; + selectors[3] = ShardHandlerV1.claim.selector; + selectors[4] = ShardHandlerV1.swapEthForShard.selector; + selectors[5] = ShardHandlerV1.swapShardForEth.selector; + selectors[6] = ShardHandlerV1.transferNFT.selector; + selectors[7] = ShardHandlerV1.warpBlock.selector; + selectors[8] = ShardHandlerV1.buyMany.selector; + selectors[9] = ShardHandlerV1.buyMax.selector; + selectors[10] = ShardHandlerV1.redeemLeftover.selector; + selectors[11] = ShardHandlerV1.sellMany.selector; + selectors[12] = ShardHandlerV1.redeemMany.selector; + selectors[13] = ShardHandlerV1.donate.selector; + selectors[14] = ShardHandlerV1.claimBuilderFees.selector; + selectors[15] = ShardHandlerV1.claimLauncherFees.selector; + selectors[16] = ShardHandlerV1.setBuilderFeeRecipient.selector; + + targetSelector(FuzzSelector({ addr: address(handler), selectors: selectors })); + targetContract(address(handler)); + + // Nothing but the handler may originate calls. + excludeSender(address(hook)); + excludeSender(address(nft)); + excludeSender(address(manager)); + excludeSender(address(shard)); + } + + /*////////////////////////////////////////////////////////////// + BACKING AND SUPPLY + //////////////////////////////////////////////////////////////*/ + + function invariant_launchUsesAtomicFactory() public view { + assertEq(hook.deployer(), address(factory)); + } + + /// Every circulating NFT is backed by exactly 1 SHARD parked in the hook. + /// `seedDust` is load-bearing: `LiquidityAmounts.getLiquidityForAmount1` + /// rounds down, so ~221 wei of SHARD never entered the position and sits in + /// the hook forever. Without the term this is false from deployment. + function invariant_shardBackingMatchesNftCirculating() public view { + assertEq( + shard.balanceOf(address(hook)), + nft.circulatingSupply() * ONE_SHARD + hook.seedDust(), + "SHARD backing != circulating NFTs" + ); + } + + /// SHARD is a fixed, unmintable, unburnable supply. `buyMax` is the ONLY function + /// that ever sends SHARD out of the hook, so this is what proves the leftover it + /// hands back is real supply moving between holders rather than backing quietly + /// leaving the system. Every wei must sit in exactly one of four places: the locked + /// position (the PoolManager), the hook's backing, a caller, or the handler. + function invariant_shardSupplyIsConserved() public view { + uint256 accounted = shard.balanceOf(address(hook)) + shard.balanceOf(address(manager)) + handler.sumActorShard() + + shard.balanceOf(address(this)) + shard.balanceOf(address(nft)); + assertEq(accounted, shard.totalSupply(), "SHARD went missing or appeared from nowhere"); + } + + /// Every wei of SHARD `buyMax` handed back left the hook and landed on a caller — + /// the handler asserts that per call, and it must never have handed back more than + /// @dev Leftover SHARD from `buyMax` is a cumulative FLOW, so comparing it to total supply + /// (a stock) could never fail — that assertion was vacuous. The property that actually + /// bites: below the MAX_BATCH cap, a leftover of a whole SHARD or more means an NFT + /// that should have been minted was handed back as ERC-20 instead. At the cap a large + /// leftover is correct by design, so those calls are excluded at the handler. + /// (Conservation of the units that left is covered by invariant_shardSupplyIsConserved.) + /// Unconditional now: buyMax reverts above MAX_BATCH rather than clamping, so no + /// successful call can ever return a whole SHARD in place of an NFT. + function invariant_buyMaxNeverReturnsAWholeShard() public view { + assertLt( + handler.maxSingleLeftoverBelowCap(), + 1e18, + "buyMax returned a whole SHARD - it should have minted an NFT or reverted" + ); + } + + /// The EARNING set (`circulating`, which lags acquisitions by a block) plus + /// the not-yet-joined set must always equal the BACKING set. If these drift, + /// fees are being divided by the wrong denominator. + function invariant_earningSetMatchesBackingSet() public view { + assertEq( + hook.circulating() + hook.pendingCount(), nft.circulatingSupply(), "earning set + pending != backing set" + ); + } + + function invariant_circulatingNeverExceedsMaxSupply() public view { + assertLe(nft.circulatingSupply(), nft.MAX_SUPPLY(), "circulating > MAX_SUPPLY"); + assertLe(hook.circulating(), nft.MAX_SUPPLY(), "earning set > MAX_SUPPLY"); + } + + /// Every id is in exactly one of two places: a user's wallet or the archive. + /// None can be stranded in a third state (which is what a direct ERC-721 + /// transfer into the archive would produce). + /// @dev Ids are always issued LOWEST-FIRST, so at the moment the highest id `H` ever + /// handed out was issued, ids 1..H-1 were all circulating. Hence + /// `H <= maxCirculatingEver + 1` and every id above that is provably unminted + /// (and therefore pool-held). That is the window; the fixed 128 is kept as a + /// floor so the check never audits a NARROWER range than it used to. + function _idWindow() internal view returns (uint256 w) { + w = handler.maxCirculatingSeen() + 1; + if (w < ID_WINDOW) w = ID_WINDOW; + if (w > nft.MAX_SUPPLY()) w = nft.MAX_SUPPLY(); + } + + function invariant_poolHeldPlusCirculatingEqualsTenThousand() public view { + uint256 window = _idWindow(); + assertLe(nft.lowestAvailableId(), window + 1, "an id above the audited window was handed out"); + + uint256 held; + for (uint256 id = 1; id <= window; id++) { + if (nft.isPoolHeld(id)) held++; + } + // Ids above the window are provably all archived (asserted above), so + // held + circulating == 10_000 reduces to this. + assertEq(held + nft.circulatingSupply(), window, "archived + circulating != total supply"); + } + + function invariant_lowestAvailableIdIsActuallyAvailable() public view { + uint256 lowest = nft.lowestAvailableId(); + if (lowest > nft.MAX_SUPPLY()) return; // exhausted pool is a valid terminal state + assertTrue(nft.isPoolHeld(lowest), "lowestAvailableId is NOT archived"); + // An archived id is either unminted (never handed out) or owned by the + // archive. It must never be sitting in a user's wallet. + if (nft.tokenSeed(lowest) != 0 || _exists(lowest)) { + assertEq(nft.ownerOf(lowest), address(nft), "an archived id is in a user wallet"); + } + for (uint256 id = 1; id < lowest; id++) { + assertFalse(nft.isPoolHeld(id), "a lower id was archived and skipped"); + } + } + + /// @dev ERC-721 has no public existence check; `ownerOf` reverts for an id + /// that was never minted (ids start life unminted, not owned). + function _exists(uint256 id) internal view returns (bool) { + (bool ok,) = address(nft).staticcall(abi.encodeWithSignature("ownerOf(uint256)", id)); + return ok; + } + + /*////////////////////////////////////////////////////////////// + SOLVENCY + //////////////////////////////////////////////////////////////*/ + + /// THE ONE THAT MATTERS. Everything the protocol has promised — materialised + /// balances, the unshared scaled dust, escrow, the builder and launcher cuts + /// (accrued or already paid), and everything already paid out — must never + /// exceed what it actually took in fees. + /// + /// Conservation (`in == out`) is NOT enough: it passed a same-block + /// double-claim that produced 2 ether of claims against 1 ether of fees. + /// Solvency is the property that catches it. + function invariant_claimsNeverExceedFeesTaken() public view { + // NOTE: `dustScaled / ACC_PRECISION` was previously a term here and was DEAD — + // dustScaled is bounded by `circulating` (< 10,000), so dividing by 1e18 is + // identically zero. It contributes nothing to wei-level solvency, so it is dropped + // here and checked on its own bound below instead of being silently carried. + uint256 promised = handler.sumClaimable() + hook.escrowBalance() + handler.totalClaimed() + + hook.builderFeesAccrued() + hook.launcherFeesAccrued() + handler.builderClaimed() + + handler.launcherClaimed(); + + assertLe(promised, handler.totalFeesTaken(), "INSOLVENT: promised more than was ever taken in fees"); + } + + /// @dev The scaled dust must stay strictly below `circulating`, which is what makes it + /// sub-wei and therefore irrelevant to solvency. If it ever exceeded that, the + /// accumulator would be carrying real unshared value and the assertion above would + /// be understating what the protocol owes. + /// @dev Bounded by ACC_PRECISION, NOT by `circulating`. `_distribute` sets + /// `dustScaled = totalScaled % circulating`, so it is below the holder count at the + /// MOMENT of distribution — but holders then sell and `circulating` falls while the + /// carried dust does not. The standing property is the one the name claims: divided + /// by ACC_PRECISION the carry is less than a single wei, so nothing material is + /// sitting undistributed. It is carried into the next distribution, never lost. + function invariant_dustStaysSubWei() public view { + assertLt(hook.dustScaled(), ACC_PRECISION, "carried dust reached a whole wei"); + } + + /// And the protocol must actually be holding the ETH behind those promises. + /// The ERC-6909 term is mandatory — third-party fees live as claims (id 0) + /// until a `claim` call sweeps them, so an ETH-balance-only assertion would + /// fail spuriously. The builder and launcher cuts are carved out of the fee but + /// stay hook-held until claimed, so they are liabilities against the same assets. + function invariant_hookAssetsCoverAllClaims() public view { + uint256 owed = + handler.sumClaimable() + hook.escrowBalance() + hook.builderFeesAccrued() + hook.launcherFeesAccrued(); + assertGe(handler.feeAssets(), owed, "INSOLVENT: hook cannot cover what it owes"); + } + + /// The builder and launcher take the SAME 10% of the SAME fee stream, on every split path. The + /// combined operator cut is carried and split evenly with the launcher taking the odd wei, so + /// their lifetime totals (still accrued plus already claimed) stay within a single wei of each + /// other, launcher never below builder. A fee that reached one carve-out but not the other — or + /// a claim that paid more than was accrued — breaks this immediately. + function invariant_builderAndLauncherCutsMatch() public view { + uint256 launcherTotal = hook.launcherFeesAccrued() + handler.launcherClaimed(); + uint256 builderTotal = hook.builderFeesAccrued() + handler.builderClaimed(); + assertGe(launcherTotal, builderTotal, "launcher shorted below builder"); + assertLe(launcherTotal - builderTotal, 1, "builder and launcher cuts diverged beyond a wei"); + } + + /// An accrued builder cut must always be payable, right now, by whoever the payout + /// address currently is. If it is not, the cut is stranded: `builderFeesAccrued` is + /// bookkeeping the hook cannot honour with the ETH it holds. + /// @dev State-changing on purpose — the claim is really executed and then rolled back, + /// so it proves payability rather than restating the accounting. + function invariant_builderFeesAreAlwaysClaimable() public { + uint256 accrued = hook.builderFeesAccrued(); + if (accrued == 0) return; + + uint256 snap = vm.snapshotState(); + + uint256 paid; + bool ok; + vm.prank(handler.builderRecipient()); + try hook.claimBuilderFees() returns (uint256 amount) { + paid = amount; + ok = true; + } catch { } + + assertTrue(ok, "STRANDED: accrued builder fees could not be claimed"); + assertEq(paid, accrued, "builder claim paid something other than the accrual"); + + vm.revertToState(snap); + } + + /// The accumulator is a running total. A decrease means someone's snapshot + /// arithmetic underflows into a free claim. + function invariant_accumulatorNeverDecreases() public view { + assertGe(hook.accFeePerNFT(), handler.lastAcc(), "accFeePerNFT decreased"); + } + + /// Dust is carried in SCALED units. It can never exceed the largest holder count the + /// protocol has ever had, because every `_distribute` reduces it modulo `circulating` + /// and `circulating` can never exceed MAX_NFTS. + function invariant_dustNeverExceedsMaxSupply() public view { + assertLt(hook.dustScaled(), ShardConstantsV1.MAX_NFTS, "dustScaled exceeded the largest possible divisor"); + } + + /// The band position is stacked on top of the full-range one and is just as permanent. + /// If it could shrink, the curve would silently steepen under holders. + function invariant_bandPositionIsNeverReduced() public view { + (uint128 liquidity,,) = + manager.getPositionInfo(poolId, address(hook), hook.tickBand(), hook.tickUpper(), bytes32(0)); + assertGe(liquidity, hook.seedLiquidityBand(), "band position liquidity was REDUCED"); + assertGe(liquidity, handler.maxBandLiquiditySeen(), "band position liquidity went down mid-run"); + } + + /// The shape itself is an invariant: the band must stay denser than the full range, or + /// the curve is upside down and early buyers pay tail prices. + function invariant_bandStaysDenserThanTheFullRange() public view { + assertGt(hook.seedLiquidityBand(), hook.seedLiquidity(), "band is no longer the denser position"); + } + + /*////////////////////////////////////////////////////////////// + THE LOCKED POSITION + //////////////////////////////////////////////////////////////*/ + + /// THE LIQUIDITY IS LOCKED FOREVER. There is exactly one `modifyLiquidity` + /// call site and it is always positive. If this ever fails, the trust model + /// of the whole protocol is gone and there is no upgrade path to fix it. + function invariant_liquidityPositionIsNeverReduced() public view { + (uint128 liquidity,,) = + manager.getPositionInfo(poolId, address(hook), hook.tickLower(), hook.tickUpper(), bytes32(0)); + assertGe(liquidity, hook.seedLiquidity(), "seed position liquidity was REDUCED"); + assertGe(liquidity, handler.maxLiquiditySeen(), "seed position liquidity went down mid-run"); + } + + /*////////////////////////////////////////////////////////////// + CALL LOG + //////////////////////////////////////////////////////////////*/ + + function invariant_callSummary() public view { + console2.log("buys ", handler.totalBought()); + console2.log(" via buyMany ", handler.totalBatchBought()); + console2.log(" via sellMany", handler.totalBatchSold()); + console2.log(" donated wei ", handler.totalDonated()); + console2.log(" via buyMax ", handler.totalMaxBought()); + console2.log("leftover SHARD", handler.totalLeftoverShardOut()); + console2.log("sells ", handler.totalSold()); + console2.log("redeems ", handler.totalRedeemed()); + console2.log("3p swaps ", handler.totalThirdPartySwaps()); + console2.log("fees taken ", handler.totalFeesTaken()); + console2.log("claimed ", handler.totalClaimed()); + console2.log("builder held ", hook.builderFeesAccrued()); + console2.log("builder paid ", handler.builderClaimed()); + console2.log("launcher held ", hook.launcherFeesAccrued()); + console2.log("launcher paid ", handler.launcherClaimed()); + } +} diff --git a/test/utils/ShardLaunchLib.sol b/test/utils/ShardLaunchLib.sol new file mode 100644 index 00000000..94b238af --- /dev/null +++ b/test/utils/ShardLaunchLib.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity 0.8.26; + +import { Hooks } from "@uniswap/v4-core/src/libraries/Hooks.sol"; +import { Create2 } from "@openzeppelin/contracts/utils/Create2.sol"; + +import { ShardHookV1 } from "../../src/ShardHookV1.sol"; +import { ShardLaunchFactoryV1 } from "../../src/ShardLaunchFactoryV1.sol"; +import { ShardNFTV1 } from "../../src/ShardNFTV1.sol"; +import { ShardTokenV1 } from "../../src/ShardTokenV1.sol"; + +library ShardLaunchLib { + uint160 internal constant ALL_HOOK_MASK = uint160((1 << 14) - 1); + uint160 internal 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 + ); + + function mine( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + bytes32 hookSaltStart, + bytes memory hookCreationCode, + ShardLaunchFactoryV1.LaunchParams memory params + ) internal view returns (bytes32 hookSalt, address predictedShard, address predictedHook) { + bytes32 tokenCreationCodeHash = keccak256(type(ShardTokenV1).creationCode); + bytes memory initCode = _hookInitCodeTemplate(factory, hookCreationCode, params); + uint256 shardWord = hookCreationCode.length + 64; + // Preallocate the effective-token-salt preimage once (the seven abi.encode words) and rewrite + // only the hook-salt word in place each iteration, so the search does not allocate — and thus + // grow EVM memory — per candidate. The layout matches {ShardLaunchFactoryV1.effectiveTokenSalt}: + // word 0 tokenSalt, word 1 hookSalt, then the four tick/price fields and the builder recipient. + bytes memory saltPreimage = abi.encode( + tokenSalt, + bytes32(0), + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + params.builderFeeRecipient + ); + uint256 candidate = uint256(hookSaltStart); + while (true) { + hookSalt = bytes32(candidate); + predictedShard = Create2.computeAddress( + _effectiveSaltFromPreimage(saltPreimage, hookSalt), tokenCreationCodeHash, address(factory) + ); + assembly ("memory-safe") { + mstore(add(initCode, shardWord), predictedShard) + } + predictedHook = Create2.computeAddress(hookSalt, keccak256(initCode), address(factory)); + if (uint160(predictedHook) & ALL_HOOK_MASK == REQUIRED_HOOK_FLAGS) { + return (hookSalt, predictedShard, predictedHook); + } + unchecked { + candidate++; + } + } + } + + /// @dev Rewrites the hook-salt word of a preallocated effective-token-salt preimage in place and + /// hashes it, so the mining loop never allocates. Equal to + /// {ShardLaunchFactoryV1.effectiveTokenSalt} for the same inputs. + function _effectiveSaltFromPreimage(bytes memory preimage, bytes32 hookSalt) private pure returns (bytes32 out) { + assembly ("memory-safe") { + mstore(add(preimage, 0x40), hookSalt) // second encoded word == hookSalt + out := keccak256(add(preimage, 0x20), mload(preimage)) + } + } + + function _hookInitCodeTemplate( + ShardLaunchFactoryV1 factory, + bytes memory hookCreationCode, + ShardLaunchFactoryV1.LaunchParams memory params + ) private view returns (bytes memory) { + return bytes.concat( + hookCreationCode, + abi.encode( + factory.poolManager(), + ShardTokenV1(address(0)), + params.tickLower, + params.tickBand, + params.tickUpper, + params.startSqrtPriceX96, + address(factory), + factory.launcherFeeRecipient(), + params.builderFeeRecipient + ) + ); + } + + function mineCanonical( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + bytes32 hookSaltStart, + ShardLaunchFactoryV1.LaunchParams memory params + ) internal view returns (bytes32 hookSalt, address predictedShard, address predictedHook) { + return mine(factory, tokenSalt, hookSaltStart, type(ShardHookV1).creationCode, params); + } + + function mineAndLaunch( + ShardLaunchFactoryV1 factory, + bytes32 tokenSalt, + bytes32 hookSaltStart, + ShardLaunchFactoryV1.LaunchParams memory params + ) internal returns (ShardHookV1 hook, ShardTokenV1 shard, ShardNFTV1 nft, bytes32 hookSalt) { + bytes memory hookCreationCode = type(ShardHookV1).creationCode; + address predictedShard; + address predictedHook; + (hookSalt, predictedShard, predictedHook) = mine(factory, tokenSalt, hookSaltStart, hookCreationCode, params); + (address hookAddress, address shardAddress, address nftAddress) = + factory.launch(tokenSalt, hookSalt, hookCreationCode, params); + assert(hookAddress == predictedHook && shardAddress == predictedShard); + hook = ShardHookV1(payable(hookAddress)); + shard = ShardTokenV1(shardAddress); + nft = ShardNFTV1(nftAddress); + } +}