@bananapus/core-v6 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +112 -0
- package/SKILLS.md +151 -0
- package/docs/book.css +13 -0
- package/docs/book.toml +12 -0
- package/docs/solidity.min.js +74 -0
- package/docs/src/README.md +703 -0
- package/docs/src/SUMMARY.md +94 -0
- package/docs/src/src/JBChainlinkV3PriceFeed.sol/contract.JBChainlinkV3PriceFeed.md +83 -0
- package/docs/src/src/JBChainlinkV3SequencerPriceFeed.sol/contract.JBChainlinkV3SequencerPriceFeed.md +88 -0
- package/docs/src/src/JBController.sol/contract.JBController.md +1121 -0
- package/docs/src/src/JBDeadline.sol/contract.JBDeadline.md +84 -0
- package/docs/src/src/JBDirectory.sol/contract.JBDirectory.md +294 -0
- package/docs/src/src/JBERC20.sol/contract.JBERC20.md +190 -0
- package/docs/src/src/JBFeelessAddresses.sol/contract.JBFeelessAddresses.md +80 -0
- package/docs/src/src/JBFundAccessLimits.sol/contract.JBFundAccessLimits.md +253 -0
- package/docs/src/src/JBMultiTerminal.sol/contract.JBMultiTerminal.md +1472 -0
- package/docs/src/src/JBPermissions.sol/contract.JBPermissions.md +199 -0
- package/docs/src/src/JBPrices.sol/contract.JBPrices.md +154 -0
- package/docs/src/src/JBProjects.sol/contract.JBProjects.md +131 -0
- package/docs/src/src/JBRulesets.sol/contract.JBRulesets.md +677 -0
- package/docs/src/src/JBSplits.sol/contract.JBSplits.md +237 -0
- package/docs/src/src/JBTerminalStore.sol/contract.JBTerminalStore.md +591 -0
- package/docs/src/src/JBTokens.sol/contract.JBTokens.md +353 -0
- package/docs/src/src/README.md +25 -0
- package/docs/src/src/abstract/JBControlled.sol/abstract.JBControlled.md +64 -0
- package/docs/src/src/abstract/JBPermissioned.sol/abstract.JBPermissioned.md +84 -0
- package/docs/src/src/abstract/README.md +5 -0
- package/docs/src/src/enums/JBApprovalStatus.sol/enum.JBApprovalStatus.md +17 -0
- package/docs/src/src/enums/README.md +4 -0
- package/docs/src/src/interfaces/IJBCashOutHook.sol/interface.IJBCashOutHook.md +29 -0
- package/docs/src/src/interfaces/IJBCashOutTerminal.sol/interface.IJBCashOutTerminal.md +57 -0
- package/docs/src/src/interfaces/IJBControlled.sol/interface.IJBControlled.md +12 -0
- package/docs/src/src/interfaces/IJBController.sol/interface.IJBController.md +334 -0
- package/docs/src/src/interfaces/IJBDirectory.sol/interface.IJBDirectory.md +108 -0
- package/docs/src/src/interfaces/IJBDirectoryAccessControl.sol/interface.IJBDirectoryAccessControl.md +19 -0
- package/docs/src/src/interfaces/IJBFeeTerminal.sol/interface.IJBFeeTerminal.md +91 -0
- package/docs/src/src/interfaces/IJBFeelessAddresses.sol/interface.IJBFeelessAddresses.md +26 -0
- package/docs/src/src/interfaces/IJBFundAccessLimits.sol/interface.IJBFundAccessLimits.md +88 -0
- package/docs/src/src/interfaces/IJBMigratable.sol/interface.IJBMigratable.md +29 -0
- package/docs/src/src/interfaces/IJBMultiTerminal.sol/interface.IJBMultiTerminal.md +50 -0
- package/docs/src/src/interfaces/IJBPayHook.sol/interface.IJBPayHook.md +28 -0
- package/docs/src/src/interfaces/IJBPayoutTerminal.sol/interface.IJBPayoutTerminal.md +105 -0
- package/docs/src/src/interfaces/IJBPermissioned.sol/interface.IJBPermissioned.md +12 -0
- package/docs/src/src/interfaces/IJBPermissions.sol/interface.IJBPermissions.md +74 -0
- package/docs/src/src/interfaces/IJBPermitTerminal.sol/interface.IJBPermitTerminal.md +15 -0
- package/docs/src/src/interfaces/IJBPriceFeed.sol/interface.IJBPriceFeed.md +12 -0
- package/docs/src/src/interfaces/IJBPrices.sol/interface.IJBPrices.md +74 -0
- package/docs/src/src/interfaces/IJBProjectUriRegistry.sol/interface.IJBProjectUriRegistry.md +19 -0
- package/docs/src/src/interfaces/IJBProjects.sol/interface.IJBProjects.md +49 -0
- package/docs/src/src/interfaces/IJBRulesetApprovalHook.sol/interface.IJBRulesetApprovalHook.md +35 -0
- package/docs/src/src/interfaces/IJBRulesetDataHook.sol/interface.IJBRulesetDataHook.md +97 -0
- package/docs/src/src/interfaces/IJBRulesets.sol/interface.IJBRulesets.md +165 -0
- package/docs/src/src/interfaces/IJBSplitHook.sol/interface.IJBSplitHook.md +31 -0
- package/docs/src/src/interfaces/IJBSplits.sol/interface.IJBSplits.md +35 -0
- package/docs/src/src/interfaces/IJBTerminal.sol/interface.IJBTerminal.md +141 -0
- package/docs/src/src/interfaces/IJBTerminalStore.sol/interface.IJBTerminalStore.md +198 -0
- package/docs/src/src/interfaces/IJBToken.sol/interface.IJBToken.md +54 -0
- package/docs/src/src/interfaces/IJBTokenUriResolver.sol/interface.IJBTokenUriResolver.md +12 -0
- package/docs/src/src/interfaces/IJBTokens.sol/interface.IJBTokens.md +151 -0
- package/docs/src/src/interfaces/README.md +33 -0
- package/docs/src/src/libraries/JBCashOuts.sol/library.JBCashOuts.md +40 -0
- package/docs/src/src/libraries/JBConstants.sol/library.JBConstants.md +52 -0
- package/docs/src/src/libraries/JBCurrencyIds.sol/library.JBCurrencyIds.md +19 -0
- package/docs/src/src/libraries/JBFees.sol/library.JBFees.md +52 -0
- package/docs/src/src/libraries/JBFixedPointNumber.sol/library.JBFixedPointNumber.md +12 -0
- package/docs/src/src/libraries/JBMetadataResolver.sol/library.JBMetadataResolver.md +242 -0
- package/docs/src/src/libraries/JBRulesetMetadataResolver.sol/library.JBRulesetMetadataResolver.md +180 -0
- package/docs/src/src/libraries/JBSplitGroupIds.sol/library.JBSplitGroupIds.md +14 -0
- package/docs/src/src/libraries/JBSurplus.sol/library.JBSurplus.md +44 -0
- package/docs/src/src/libraries/README.md +12 -0
- package/docs/src/src/periphery/JBDeadline1Day.sol/contract.JBDeadline1Day.md +15 -0
- package/docs/src/src/periphery/JBDeadline3Days.sol/contract.JBDeadline3Days.md +15 -0
- package/docs/src/src/periphery/JBDeadline3Hours.sol/contract.JBDeadline3Hours.md +15 -0
- package/docs/src/src/periphery/JBDeadline7Days.sol/contract.JBDeadline7Days.md +15 -0
- package/docs/src/src/periphery/JBMatchingPriceFeed.sol/contract.JBMatchingPriceFeed.md +22 -0
- package/docs/src/src/periphery/README.md +8 -0
- package/docs/src/src/structs/JBAccountingContext.sol/struct.JBAccountingContext.md +20 -0
- package/docs/src/src/structs/JBAfterCashOutRecordedContext.sol/struct.JBAfterCashOutRecordedContext.md +43 -0
- package/docs/src/src/structs/JBAfterPayRecordedContext.sol/struct.JBAfterPayRecordedContext.md +42 -0
- package/docs/src/src/structs/JBBeforeCashOutRecordedContext.sol/struct.JBBeforeCashOutRecordedContext.md +45 -0
- package/docs/src/src/structs/JBBeforePayRecordedContext.sol/struct.JBBeforePayRecordedContext.md +41 -0
- package/docs/src/src/structs/JBCashOutHookSpecification.sol/struct.JBCashOutHookSpecification.md +22 -0
- package/docs/src/src/structs/JBCurrencyAmount.sol/struct.JBCurrencyAmount.md +17 -0
- package/docs/src/src/structs/JBFee.sol/struct.JBFee.md +20 -0
- package/docs/src/src/structs/JBFundAccessLimitGroup.sol/struct.JBFundAccessLimitGroup.md +39 -0
- package/docs/src/src/structs/JBPayHookSpecification.sol/struct.JBPayHookSpecification.md +22 -0
- package/docs/src/src/structs/JBPermissionsData.sol/struct.JBPermissionsData.md +21 -0
- package/docs/src/src/structs/JBRuleset.sol/struct.JBRuleset.md +55 -0
- package/docs/src/src/structs/JBRulesetConfig.sol/struct.JBRulesetConfig.md +51 -0
- package/docs/src/src/structs/JBRulesetMetadata.sol/struct.JBRulesetMetadata.md +79 -0
- package/docs/src/src/structs/JBRulesetWeightCache.sol/struct.JBRulesetWeightCache.md +16 -0
- package/docs/src/src/structs/JBRulesetWithMetadata.sol/struct.JBRulesetWithMetadata.md +16 -0
- package/docs/src/src/structs/JBSingleAllowance.sol/struct.JBSingleAllowance.md +26 -0
- package/docs/src/src/structs/JBSplit.sol/struct.JBSplit.md +49 -0
- package/docs/src/src/structs/JBSplitGroup.sol/struct.JBSplitGroup.md +17 -0
- package/docs/src/src/structs/JBSplitHookContext.sol/struct.JBSplitHookContext.md +29 -0
- package/docs/src/src/structs/JBTerminalConfig.sol/struct.JBTerminalConfig.md +16 -0
- package/docs/src/src/structs/JBTokenAmount.sol/struct.JBTokenAmount.md +23 -0
- package/docs/src/src/structs/README.md +25 -0
- package/foundry.lock +11 -0
- package/foundry.toml +41 -0
- package/package.json +38 -0
- package/remappings.txt +1 -0
- package/script/Deploy.s.sol +111 -0
- package/script/DeployPeriphery.s.sol +287 -0
- package/script/helpers/CoreDeploymentLib.sol +121 -0
- package/slither-ci.config.json +10 -0
- package/sphinx.lock +507 -0
- package/src/JBChainlinkV3PriceFeed.sol +77 -0
- package/src/JBChainlinkV3SequencerPriceFeed.sol +75 -0
- package/src/JBController.sol +1186 -0
- package/src/JBDeadline.sol +73 -0
- package/src/JBDirectory.sol +343 -0
- package/src/JBERC20.sol +131 -0
- package/src/JBFeelessAddresses.sol +54 -0
- package/src/JBFundAccessLimits.sol +308 -0
- package/src/JBMultiTerminal.sol +2024 -0
- package/src/JBPermissions.sol +252 -0
- package/src/JBPrices.sol +227 -0
- package/src/JBProjects.sol +126 -0
- package/src/JBRulesets.sol +1093 -0
- package/src/JBSplits.sol +324 -0
- package/src/JBTerminalStore.sol +908 -0
- package/src/JBTokens.sol +376 -0
- package/src/abstract/JBControlled.sol +48 -0
- package/src/abstract/JBPermissioned.sol +77 -0
- package/src/enums/JBApprovalStatus.sol +12 -0
- package/src/interfaces/IJBCashOutHook.sol +15 -0
- package/src/interfaces/IJBCashOutTerminal.sol +51 -0
- package/src/interfaces/IJBControlled.sol +10 -0
- package/src/interfaces/IJBController.sol +280 -0
- package/src/interfaces/IJBDirectory.sol +69 -0
- package/src/interfaces/IJBDirectoryAccessControl.sol +15 -0
- package/src/interfaces/IJBFeeTerminal.sol +61 -0
- package/src/interfaces/IJBFeelessAddresses.sol +17 -0
- package/src/interfaces/IJBFundAccessLimits.sol +94 -0
- package/src/interfaces/IJBMigratable.sol +24 -0
- package/src/interfaces/IJBMultiTerminal.sol +36 -0
- package/src/interfaces/IJBPayHook.sol +14 -0
- package/src/interfaces/IJBPayoutTerminal.sol +92 -0
- package/src/interfaces/IJBPermissioned.sol +10 -0
- package/src/interfaces/IJBPermissions.sol +71 -0
- package/src/interfaces/IJBPermitTerminal.sol +14 -0
- package/src/interfaces/IJBPriceFeed.sol +10 -0
- package/src/interfaces/IJBPrices.sol +65 -0
- package/src/interfaces/IJBProjectUriRegistry.sol +15 -0
- package/src/interfaces/IJBProjects.sol +27 -0
- package/src/interfaces/IJBRulesetApprovalHook.sol +21 -0
- package/src/interfaces/IJBRulesetDataHook.sol +56 -0
- package/src/interfaces/IJBRulesets.sol +151 -0
- package/src/interfaces/IJBSplitHook.sol +16 -0
- package/src/interfaces/IJBSplits.sol +28 -0
- package/src/interfaces/IJBTerminal.sol +120 -0
- package/src/interfaces/IJBTerminalStore.sol +225 -0
- package/src/interfaces/IJBToken.sol +39 -0
- package/src/interfaces/IJBTokenUriResolver.sol +10 -0
- package/src/interfaces/IJBTokens.sol +113 -0
- package/src/libraries/JBCashOuts.sol +120 -0
- package/src/libraries/JBConstants.sol +14 -0
- package/src/libraries/JBCurrencyIds.sol +7 -0
- package/src/libraries/JBFees.sol +28 -0
- package/src/libraries/JBFixedPointNumber.sol +12 -0
- package/src/libraries/JBMetadataResolver.sol +306 -0
- package/src/libraries/JBRulesetMetadataResolver.sol +160 -0
- package/src/libraries/JBSplitGroupIds.sol +7 -0
- package/src/libraries/JBSurplus.sol +40 -0
- package/src/periphery/JBDeadline1Day.sol +8 -0
- package/src/periphery/JBDeadline3Days.sol +8 -0
- package/src/periphery/JBDeadline3Hours.sol +8 -0
- package/src/periphery/JBDeadline7Days.sol +8 -0
- package/src/periphery/JBMatchingPriceFeed.sol +13 -0
- package/src/structs/JBAccountingContext.sol +12 -0
- package/src/structs/JBAfterCashOutRecordedContext.sol +30 -0
- package/src/structs/JBAfterPayRecordedContext.sol +29 -0
- package/src/structs/JBBeforeCashOutRecordedContext.sol +31 -0
- package/src/structs/JBBeforePayRecordedContext.sol +28 -0
- package/src/structs/JBCashOutHookSpecification.sol +15 -0
- package/src/structs/JBCurrencyAmount.sol +10 -0
- package/src/structs/JBFee.sol +12 -0
- package/src/structs/JBFundAccessLimitGroup.sol +28 -0
- package/src/structs/JBPayHookSpecification.sol +15 -0
- package/src/structs/JBPermissionsData.sol +13 -0
- package/src/structs/JBRuleset.sol +42 -0
- package/src/structs/JBRulesetConfig.sol +43 -0
- package/src/structs/JBRulesetMetadata.sol +56 -0
- package/src/structs/JBRulesetWeightCache.sol +9 -0
- package/src/structs/JBRulesetWithMetadata.sol +12 -0
- package/src/structs/JBSingleAllowance.sol +16 -0
- package/src/structs/JBSplit.sol +37 -0
- package/src/structs/JBSplitGroup.sol +12 -0
- package/src/structs/JBSplitHookContext.sol +20 -0
- package/src/structs/JBTerminalConfig.sol +12 -0
- package/src/structs/JBTokenAmount.sol +14 -0
- package/test/AuditExploits.t.sol +2710 -0
- package/test/ComprehensiveInvariant.t.sol +298 -0
- package/test/EconomicSimulation.t.sol +340 -0
- package/test/EntryPointPermutations.t.sol +671 -0
- package/test/FlashLoanAttacks.t.sol +792 -0
- package/test/PermissionEscalation.t.sol +679 -0
- package/test/RulesetTransitions.t.sol +699 -0
- package/test/SplitLoopTests.t.sol +731 -0
- package/test/TestAccessToFunds.sol +2644 -0
- package/test/TestCashOut.sol +185 -0
- package/test/TestCashOutCountFor.sol +272 -0
- package/test/TestCashOutHooks.sol +317 -0
- package/test/TestCashOutTimingEdge.sol +229 -0
- package/test/TestDurationUnderflow.sol +220 -0
- package/test/TestFeeProcessingFailure.sol +208 -0
- package/test/TestFees.sol +604 -0
- package/test/TestInterfaceSupport.sol +62 -0
- package/test/TestJBERC20Inheritance.sol +91 -0
- package/test/TestLaunchProject.sol +176 -0
- package/test/TestMetaTx.sol +203 -0
- package/test/TestMetadataParserLib.sol +438 -0
- package/test/TestMigrationHeldFees.sol +249 -0
- package/test/TestMintTokensOf.sol +172 -0
- package/test/TestMultiTokenSurplus.sol +206 -0
- package/test/TestMultipleAccessLimits.sol +642 -0
- package/test/TestPayBurnRedeemFlow.sol +180 -0
- package/test/TestPayHooks.sol +190 -0
- package/test/TestPermissions.sol +305 -0
- package/test/TestPermissionsEdge.sol +286 -0
- package/test/TestPermit2Terminal.sol +339 -0
- package/test/TestRulesetQueueing.sol +1001 -0
- package/test/TestRulesetQueuingStress.sol +778 -0
- package/test/TestRulesetWeightCaching.sol +177 -0
- package/test/TestSplits.sol +369 -0
- package/test/TestTerminalMigration.sol +167 -0
- package/test/TestTokenFlow.sol +174 -0
- package/test/WeirdTokenTests.t.sol +764 -0
- package/test/formal/BondingCurveProperties.t.sol +411 -0
- package/test/formal/FeeProperties.t.sol +246 -0
- package/test/helpers/JBTest.sol +129 -0
- package/test/helpers/MetadataResolverHelper.sol +116 -0
- package/test/helpers/TestBaseWorkflow.sol +317 -0
- package/test/invariants/Phase3DeepInvariant.t.sol +404 -0
- package/test/invariants/RulesetsInvariant.t.sol +115 -0
- package/test/invariants/TerminalStoreInvariant.t.sol +220 -0
- package/test/invariants/TokensInvariant.t.sol +184 -0
- package/test/invariants/handlers/ComprehensiveHandler.sol +285 -0
- package/test/invariants/handlers/EconomicHandler.sol +347 -0
- package/test/invariants/handlers/Phase3Handler.sol +414 -0
- package/test/invariants/handlers/RulesetsHandler.sol +111 -0
- package/test/invariants/handlers/TerminalStoreHandler.sol +146 -0
- package/test/invariants/handlers/TokensHandler.sol +127 -0
- package/test/mock/ERC2771ForwarderMock.sol +37 -0
- package/test/mock/MockERC20.sol +18 -0
- package/test/mock/MockMaliciousBeneficiary.sol +67 -0
- package/test/mock/MockMaliciousSplitHook.sol +42 -0
- package/test/mock/MockPriceFeed.sol +20 -0
- package/test/trees/JBController/burnTokensOf.tree +9 -0
- package/test/trees/JBController/claimTokensFor.tree +5 -0
- package/test/trees/JBController/deployERC20For.tree +5 -0
- package/test/trees/JBController/getRulesetOf.tree +5 -0
- package/test/trees/JBController/launchProjectFor.tree +12 -0
- package/test/trees/JBController/launchRulesetsFor.tree +8 -0
- package/test/trees/JBController/migrateController.tree +12 -0
- package/test/trees/JBController/mintTokensOf.tree +12 -0
- package/test/trees/JBController/payReservedTokenToTerminal.tree +8 -0
- package/test/trees/JBController/receiveMigrationFrom.tree +4 -0
- package/test/trees/JBController/sendReservedTokensToSplitsOf.tree +12 -0
- package/test/trees/JBController/setMetadataOf.tree +5 -0
- package/test/trees/JBController/setSplitGroupsOf.tree +5 -0
- package/test/trees/JBController/setTokenFor.tree +5 -0
- package/test/trees/JBController/transferCreditsFrom.tree +8 -0
- package/test/trees/JBDirectory/primaryTerminalOf.tree +8 -0
- package/test/trees/JBDirectory/setControllerOf.tree +11 -0
- package/test/trees/JBDirectory/setPrimaryTerminalOf.tree +15 -0
- package/test/trees/JBDirectory/setTerminalsOf.tree +11 -0
- package/test/trees/JBERC20/initialize.tree +7 -0
- package/test/trees/JBERC20/name.tree +5 -0
- package/test/trees/JBERC20/nonces.tree +5 -0
- package/test/trees/JBERC20/symbol.tree +5 -0
- package/test/trees/JBFeelessAddresses/setFeelessAddress.tree +5 -0
- package/test/trees/JBFeelessAddresses/supportsInterface.tree +5 -0
- package/test/trees/JBFundAccessLimits/payoutLimitOf.tree +5 -0
- package/test/trees/JBFundAccessLimits/payoutLimitsOf.tree +8 -0
- package/test/trees/JBFundAccessLimits/setFundAccessLimitsFor.tree +18 -0
- package/test/trees/JBFundAccessLimits/surplusAllowanceOf.tree +5 -0
- package/test/trees/JBFundAccessLimits/surplusAllowancesOf.tree +8 -0
- package/test/trees/JBMetadataResolver/getDataFor.tree +8 -0
- package/test/trees/JBMultiTerminal/accountingContextsOf.tree +5 -0
- package/test/trees/JBMultiTerminal/addAccountingContextsFor.tree +10 -0
- package/test/trees/JBMultiTerminal/addToBalanceOf.tree +23 -0
- package/test/trees/JBMultiTerminal/cashOutTokensOf.tree +23 -0
- package/test/trees/JBMultiTerminal/executePayout.tree +32 -0
- package/test/trees/JBMultiTerminal/executeProcessFee.tree +14 -0
- package/test/trees/JBMultiTerminal/migrateBalanceOf.tree +12 -0
- package/test/trees/JBMultiTerminal/pay.tree +23 -0
- package/test/trees/JBMultiTerminal/processHeldFeesOf.tree +8 -0
- package/test/trees/JBMultiTerminal/sendPayoutsOf.tree +34 -0
- package/test/trees/JBMultiTerminal/useAllowanceOf.tree +16 -0
- package/test/trees/JBPermissions/hasPermission.tree +8 -0
- package/test/trees/JBPermissions/hasPermissions.tree +8 -0
- package/test/trees/JBPermissions/setPermissionsFor.tree +5 -0
- package/test/trees/JBPrices/addPriceFeedFor.tree +14 -0
- package/test/trees/JBPrices/pricePerUnitOf.tree +11 -0
- package/test/trees/JBProjects/createFor.tree +11 -0
- package/test/trees/JBProjects/setTokenUriResolver.tree +5 -0
- package/test/trees/JBProjects/supportsInterface.tree +9 -0
- package/test/trees/JBProjects/tokenURI.tree +5 -0
- package/test/trees/JBRulesets/currentApprovalStatusForLatestRulesetOf.tree +8 -0
- package/test/trees/JBRulesets/currentOf.tree +12 -0
- package/test/trees/JBRulesets/getRulesetOf.tree +5 -0
- package/test/trees/JBRulesets/latestQueuedRulesetOf.tree +10 -0
- package/test/trees/JBRulesets/rulesetsOf.tree +11 -0
- package/test/trees/JBRulesets/upcomingRulesetOf.tree +20 -0
- package/test/trees/JBRulesets/updateRulesetWeightCache.tree +5 -0
- package/test/trees/JBSplits/setSplitGroupsOf.tree +17 -0
- package/test/trees/JBSplits/splitsOf.tree +5 -0
- package/test/trees/JBTerminalStore/currentReclaimableSurplusOf.tree +16 -0
- package/test/trees/JBTerminalStore/currentSurplusOf.tree +25 -0
- package/test/trees/JBTerminalStore/currentTotalSurplusOf.tree +5 -0
- package/test/trees/JBTerminalStore/recordCashOutsFor.tree +16 -0
- package/test/trees/JBTerminalStore/recordPaymentFrom.tree +14 -0
- package/test/trees/JBTerminalStore/recordPayoutFor.tree +10 -0
- package/test/trees/JBTerminalStore/recordTerminalMigration.tree +5 -0
- package/test/trees/JBTerminalStore/recordUsedAllowanceOf.tree +10 -0
- package/test/trees/JBTokens/burnFrom.tree +10 -0
- package/test/trees/JBTokens/claimTokensFor.tree +10 -0
- package/test/trees/JBTokens/deployERC20For.tree +12 -0
- package/test/trees/JBTokens/mintFor.tree +10 -0
- package/test/trees/JBTokens/setTokenFor.tree +11 -0
- package/test/trees/JBTokens/totalBalanceOf.tree +5 -0
- package/test/trees/JBTokens/totalSupplyOf.tree +5 -0
- package/test/trees/JBTokens/transferCreditsFrom.tree +8 -0
- package/test/trees/mintTokensOf.tree +12 -0
- package/test/units/static/JBChainlinkV3PriceFeed/TestPriceFeed.sol +220 -0
- package/test/units/static/JBController/JBControllerSetup.sol +40 -0
- package/test/units/static/JBController/TestBurnTokensOf.sol +107 -0
- package/test/units/static/JBController/TestClaimTokensFor.sol +60 -0
- package/test/units/static/JBController/TestDeployErc20For.sol +80 -0
- package/test/units/static/JBController/TestLaunchProjectFor.sol +282 -0
- package/test/units/static/JBController/TestLaunchRulesetsFor.sol +322 -0
- package/test/units/static/JBController/TestMigrateController.sol +148 -0
- package/test/units/static/JBController/TestMintTokensOfUnits.sol +102 -0
- package/test/units/static/JBController/TestPayReservedTokenToTerminal.sol +71 -0
- package/test/units/static/JBController/TestReceiveMigrationFrom.sol +95 -0
- package/test/units/static/JBController/TestRulesetViews.sol +219 -0
- package/test/units/static/JBController/TestSendReservedTokensToSplitsOf.sol +595 -0
- package/test/units/static/JBController/TestSetSplitGroupsOf.sol +63 -0
- package/test/units/static/JBController/TestSetTokenFor.sol +227 -0
- package/test/units/static/JBController/TestSetUriOf.sol +53 -0
- package/test/units/static/JBController/TestTransferCreditsFrom.sol +159 -0
- package/test/units/static/JBDeadline/TestDeadlineFuzz.sol +194 -0
- package/test/units/static/JBDirectory/JBDirectorySetup.sol +22 -0
- package/test/units/static/JBDirectory/TestPrimaryTerminalOf.sol +122 -0
- package/test/units/static/JBDirectory/TestSetControllerOf.sol +173 -0
- package/test/units/static/JBDirectory/TestSetControllerOfMigrationOrder.sol +98 -0
- package/test/units/static/JBDirectory/TestSetPrimaryTerminalOf.sol +169 -0
- package/test/units/static/JBDirectory/TestSetTerminalsOf.sol +128 -0
- package/test/units/static/JBERC20/JBERC20Setup.sol +20 -0
- package/test/units/static/JBERC20/SigUtils.sol +34 -0
- package/test/units/static/JBERC20/TestInitialize.sol +54 -0
- package/test/units/static/JBERC20/TestName.sol +30 -0
- package/test/units/static/JBERC20/TestNonces.sol +59 -0
- package/test/units/static/JBERC20/TestSymbol.sol +31 -0
- package/test/units/static/JBFeelessAdresses/JBFeelessSetup.sol +20 -0
- package/test/units/static/JBFeelessAdresses/TestInterfaces.sol +29 -0
- package/test/units/static/JBFeelessAdresses/TestSetFeelessAddress.sol +35 -0
- package/test/units/static/JBFees/TestFeesFuzz.sol +78 -0
- package/test/units/static/JBFixedPointNumber/TestAdjustDecimals.sol +16 -0
- package/test/units/static/JBFixedPointNumber/TestAdjustDecimalsFuzz.sol +71 -0
- package/test/units/static/JBFundAccessLimits/JBFundAccessSetup.sol +21 -0
- package/test/units/static/JBFundAccessLimits/TestFundAccessLimitsEdge.sol +159 -0
- package/test/units/static/JBFundAccessLimits/TestPayoutLimitOf.sol +56 -0
- package/test/units/static/JBFundAccessLimits/TestPayoutLimitsOf.sol +94 -0
- package/test/units/static/JBFundAccessLimits/TestSetFundAccessLimitsFor.sol +182 -0
- package/test/units/static/JBFundAccessLimits/TestSurplusAllowanceOf.sol +61 -0
- package/test/units/static/JBFundAccessLimits/TestSurplusAllowancesOf.sol +96 -0
- package/test/units/static/JBMetadataResolver/TestGetDataFor.sol +89 -0
- package/test/units/static/JBMetadataResolver/TestMetadataResolverFuzz.sol +227 -0
- package/test/units/static/JBMetadataResolver/TestMetadataResolverM20M21.sol +245 -0
- package/test/units/static/JBMultiTerminal/JBMultiTerminalSetup.sol +39 -0
- package/test/units/static/JBMultiTerminal/TestAccountingContextsOf.sol +65 -0
- package/test/units/static/JBMultiTerminal/TestAddAccountingContextsFor.sol +313 -0
- package/test/units/static/JBMultiTerminal/TestAddToBalanceOf.sol +432 -0
- package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +478 -0
- package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +577 -0
- package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +176 -0
- package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +190 -0
- package/test/units/static/JBMultiTerminal/TestPay.sol +514 -0
- package/test/units/static/JBMultiTerminal/TestProcessHeldFeesOf.sol +29 -0
- package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +243 -0
- package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +310 -0
- package/test/units/static/JBPermissions/JBPermissionsSetup.sol +18 -0
- package/test/units/static/JBPermissions/TestHasPermission.sol +50 -0
- package/test/units/static/JBPermissions/TestHasPermissions.sol +93 -0
- package/test/units/static/JBPermissions/TestSetPermissionsFor.sol +62 -0
- package/test/units/static/JBPrices/JBPricesSetup.sol +26 -0
- package/test/units/static/JBPrices/TestAddPriceFeedFor.sol +102 -0
- package/test/units/static/JBPrices/TestPricePerUnitOf.sol +129 -0
- package/test/units/static/JBPrices/TestPrices.sol +262 -0
- package/test/units/static/JBProjects/JBProjectsSetup.sol +20 -0
- package/test/units/static/JBProjects/TestCreateFor.sol +69 -0
- package/test/units/static/JBProjects/TestInitialProject.sol +19 -0
- package/test/units/static/JBProjects/TestInterfaces.sol +27 -0
- package/test/units/static/JBProjects/TestSetResolver.sol +36 -0
- package/test/units/static/JBProjects/TestTokenUri.sol +38 -0
- package/test/units/static/JBRulesetMetadataResolver/TestSetCashOutTaxRateTo.sol +99 -0
- package/test/units/static/JBRulesets/JBRulesetsSetup.sol +21 -0
- package/test/units/static/JBRulesets/TestCurrentApprovalStatusForLatestRulesetOf.sol +257 -0
- package/test/units/static/JBRulesets/TestCurrentOf.sol +231 -0
- package/test/units/static/JBRulesets/TestGetRulesetOf.sol +94 -0
- package/test/units/static/JBRulesets/TestLatestQueuedRulesetOf.sol +252 -0
- package/test/units/static/JBRulesets/TestRulesets.sol +617 -0
- package/test/units/static/JBRulesets/TestRulesetsOf.sol +37 -0
- package/test/units/static/JBRulesets/TestUpcomingRulesetOf.sol +526 -0
- package/test/units/static/JBRulesets/TestUpdateRulesetWeightCache.sol +91 -0
- package/test/units/static/JBSplits/JBSplitsSetup.sol +23 -0
- package/test/units/static/JBSplits/TestSelfManagedSplitGroups.sol +502 -0
- package/test/units/static/JBSplits/TestSetSplitGroupsOf.sol +370 -0
- package/test/units/static/JBSplits/TestSplitsLockedEdge.sol +262 -0
- package/test/units/static/JBSplits/TestSplitsOf.sol +24 -0
- package/test/units/static/JBSplits/TestSplitsPacking.sol +33 -0
- package/test/units/static/JBSurplus/TestSurplusFuzz.sol +125 -0
- package/test/units/static/JBTerminalStore/JBTerminalStoreSetup.sol +23 -0
- package/test/units/static/JBTerminalStore/TestCurrentReclaimableSurplusOf.sol +434 -0
- package/test/units/static/JBTerminalStore/TestCurrentSurplusOf.sol +428 -0
- package/test/units/static/JBTerminalStore/TestCurrentTotalSurplusOf.sol +65 -0
- package/test/units/static/JBTerminalStore/TestRecordCashOutsFor.sol +479 -0
- package/test/units/static/JBTerminalStore/TestRecordPaymentFrom.sol +508 -0
- package/test/units/static/JBTerminalStore/TestRecordPayoutFor.sol +257 -0
- package/test/units/static/JBTerminalStore/TestRecordTerminalMigration.sol +131 -0
- package/test/units/static/JBTerminalStore/TestRecordUsedAllowanceOf.sol +390 -0
- package/test/units/static/JBTerminalStore/TestUint224Overflow.sol +187 -0
- package/test/units/static/JBTokens/JBTokensSetup.sol +23 -0
- package/test/units/static/JBTokens/TestBurnFrom.sol +104 -0
- package/test/units/static/JBTokens/TestClaimTokensFor.sol +107 -0
- package/test/units/static/JBTokens/TestDeployERC20ForUnits.sol +89 -0
- package/test/units/static/JBTokens/TestMintFor.sol +97 -0
- package/test/units/static/JBTokens/TestSetTokenFor.sol +95 -0
- package/test/units/static/JBTokens/TestTotalBalanceOf.sol +65 -0
- package/test/units/static/JBTokens/TestTotalSupplyOf.sol +56 -0
- package/test/units/static/JBTokens/TestTransferCreditsFrom.sol +54 -0
|
@@ -0,0 +1,2710 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity ^0.8.6;
|
|
3
|
+
|
|
4
|
+
import /* {*} from */ "./helpers/TestBaseWorkflow.sol";
|
|
5
|
+
import {JBCashOuts} from "../src/libraries/JBCashOuts.sol";
|
|
6
|
+
|
|
7
|
+
/// @notice Exploit PoC tests for critical vulnerability scenarios in Juicebox V5.
|
|
8
|
+
/// @dev These tests target specific edge cases and potential vulnerabilities identified during audit.
|
|
9
|
+
contract AuditExploits_Local is TestBaseWorkflow {
|
|
10
|
+
//*********************************************************************//
|
|
11
|
+
// ----------------------------- storage ----------------------------- //
|
|
12
|
+
//*********************************************************************//
|
|
13
|
+
|
|
14
|
+
IJBController private _controller;
|
|
15
|
+
IJBMultiTerminal private _terminal;
|
|
16
|
+
JBTokens private _tokens;
|
|
17
|
+
MetadataResolverHelper private _metadataHelper;
|
|
18
|
+
uint112 private _weight;
|
|
19
|
+
uint256 private _projectId;
|
|
20
|
+
address private _projectOwner;
|
|
21
|
+
address private _beneficiary;
|
|
22
|
+
|
|
23
|
+
//*********************************************************************//
|
|
24
|
+
// ------------------------------ setup ------------------------------ //
|
|
25
|
+
//*********************************************************************//
|
|
26
|
+
|
|
27
|
+
function setUp() public override {
|
|
28
|
+
super.setUp();
|
|
29
|
+
|
|
30
|
+
_projectOwner = multisig();
|
|
31
|
+
_beneficiary = beneficiary();
|
|
32
|
+
_controller = jbController();
|
|
33
|
+
_terminal = jbMultiTerminal();
|
|
34
|
+
_tokens = jbTokens();
|
|
35
|
+
_metadataHelper = metadataHelper();
|
|
36
|
+
_weight = 1000 * 10 ** 18;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
//*********************************************************************//
|
|
40
|
+
// ----------- helpers: project launch with various configs ---------- //
|
|
41
|
+
//*********************************************************************//
|
|
42
|
+
|
|
43
|
+
/// @notice Launches a project with a given cashOutTaxRate and reservedPercent, no payout limits.
|
|
44
|
+
function _launchProject(uint16 cashOutTaxRate, uint16 reservedPercent) internal returns (uint256 projectId) {
|
|
45
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
46
|
+
reservedPercent: reservedPercent,
|
|
47
|
+
cashOutTaxRate: cashOutTaxRate,
|
|
48
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
49
|
+
pausePay: false,
|
|
50
|
+
pauseCreditTransfers: false,
|
|
51
|
+
allowOwnerMinting: true,
|
|
52
|
+
allowSetCustomToken: true,
|
|
53
|
+
allowTerminalMigration: false,
|
|
54
|
+
allowSetTerminals: false,
|
|
55
|
+
ownerMustSendPayouts: false,
|
|
56
|
+
allowSetController: false,
|
|
57
|
+
allowAddAccountingContext: true,
|
|
58
|
+
allowAddPriceFeed: false,
|
|
59
|
+
holdFees: false,
|
|
60
|
+
useTotalSurplusForCashOuts: false,
|
|
61
|
+
useDataHookForPay: false,
|
|
62
|
+
useDataHookForCashOut: false,
|
|
63
|
+
dataHook: address(0),
|
|
64
|
+
metadata: 0
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
68
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
69
|
+
rulesetConfig[0].duration = 0;
|
|
70
|
+
rulesetConfig[0].weight = _weight;
|
|
71
|
+
rulesetConfig[0].weightCutPercent = 0;
|
|
72
|
+
rulesetConfig[0].approvalHook = IJBRulesetApprovalHook(address(0));
|
|
73
|
+
rulesetConfig[0].metadata = metadata;
|
|
74
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
75
|
+
rulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
76
|
+
|
|
77
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
78
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
79
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
80
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
81
|
+
});
|
|
82
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
83
|
+
|
|
84
|
+
// Create project #1 to collect fees (if it does not exist yet).
|
|
85
|
+
_controller.launchProjectFor({
|
|
86
|
+
owner: address(420),
|
|
87
|
+
projectUri: "feeCollector",
|
|
88
|
+
rulesetConfigurations: rulesetConfig,
|
|
89
|
+
terminalConfigurations: terminalConfigurations,
|
|
90
|
+
memo: ""
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Create the actual test project.
|
|
94
|
+
projectId = _controller.launchProjectFor({
|
|
95
|
+
owner: _projectOwner,
|
|
96
|
+
projectUri: "testProject",
|
|
97
|
+
rulesetConfigurations: rulesetConfig,
|
|
98
|
+
terminalConfigurations: terminalConfigurations,
|
|
99
|
+
memo: ""
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// @notice Launches a project with payout limits set.
|
|
104
|
+
function _launchProjectWithPayoutLimit(
|
|
105
|
+
uint16 cashOutTaxRate,
|
|
106
|
+
uint224 payoutLimit
|
|
107
|
+
)
|
|
108
|
+
internal
|
|
109
|
+
returns (uint256 projectId)
|
|
110
|
+
{
|
|
111
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
112
|
+
reservedPercent: 0,
|
|
113
|
+
cashOutTaxRate: cashOutTaxRate,
|
|
114
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
115
|
+
pausePay: false,
|
|
116
|
+
pauseCreditTransfers: false,
|
|
117
|
+
allowOwnerMinting: true,
|
|
118
|
+
allowSetCustomToken: true,
|
|
119
|
+
allowTerminalMigration: false,
|
|
120
|
+
allowSetTerminals: false,
|
|
121
|
+
ownerMustSendPayouts: false,
|
|
122
|
+
allowSetController: false,
|
|
123
|
+
allowAddAccountingContext: true,
|
|
124
|
+
allowAddPriceFeed: false,
|
|
125
|
+
holdFees: false,
|
|
126
|
+
useTotalSurplusForCashOuts: false,
|
|
127
|
+
useDataHookForPay: false,
|
|
128
|
+
useDataHookForCashOut: false,
|
|
129
|
+
dataHook: address(0),
|
|
130
|
+
metadata: 0
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
|
|
134
|
+
payoutLimits[0] = JBCurrencyAmount({amount: payoutLimit, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
135
|
+
|
|
136
|
+
JBFundAccessLimitGroup[] memory fundAccessLimitGroup = new JBFundAccessLimitGroup[](1);
|
|
137
|
+
fundAccessLimitGroup[0] = JBFundAccessLimitGroup({
|
|
138
|
+
terminal: address(_terminal),
|
|
139
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
140
|
+
payoutLimits: payoutLimits,
|
|
141
|
+
surplusAllowances: new JBCurrencyAmount[](0)
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
145
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
146
|
+
rulesetConfig[0].duration = 0;
|
|
147
|
+
rulesetConfig[0].weight = _weight;
|
|
148
|
+
rulesetConfig[0].weightCutPercent = 0;
|
|
149
|
+
rulesetConfig[0].approvalHook = IJBRulesetApprovalHook(address(0));
|
|
150
|
+
rulesetConfig[0].metadata = metadata;
|
|
151
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
152
|
+
rulesetConfig[0].fundAccessLimitGroups = fundAccessLimitGroup;
|
|
153
|
+
|
|
154
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
155
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
156
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
157
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
158
|
+
});
|
|
159
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
160
|
+
|
|
161
|
+
// Create project #1 to collect fees (if it does not exist yet).
|
|
162
|
+
_controller.launchProjectFor({
|
|
163
|
+
owner: address(420),
|
|
164
|
+
projectUri: "feeCollector",
|
|
165
|
+
rulesetConfigurations: rulesetConfig,
|
|
166
|
+
terminalConfigurations: terminalConfigurations,
|
|
167
|
+
memo: ""
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Create the actual test project.
|
|
171
|
+
projectId = _controller.launchProjectFor({
|
|
172
|
+
owner: _projectOwner,
|
|
173
|
+
projectUri: "testProject",
|
|
174
|
+
rulesetConfigurations: rulesetConfig,
|
|
175
|
+
terminalConfigurations: terminalConfigurations,
|
|
176
|
+
memo: ""
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
//*********************************************************************//
|
|
181
|
+
// -- Test 1: _sliceBytes Memory Corruption (JBMetadataResolver) ---- //
|
|
182
|
+
//*********************************************************************//
|
|
183
|
+
|
|
184
|
+
/// @notice Tests the _sliceBytes bug where `lt(i, end)` is used instead of `lt(i, length)`.
|
|
185
|
+
/// @dev When start > 0, the loop copies `end` bytes instead of `end - start` bytes (the actual
|
|
186
|
+
/// length), writing past the allocated memory region. This test demonstrates that the
|
|
187
|
+
/// returned data is still correct (Solidity's ABI decoder reads based on `length`, not
|
|
188
|
+
/// allocation size), but the over-copy corrupts the free memory pointer region.
|
|
189
|
+
function test_sliceBytes_memorySafety() public view {
|
|
190
|
+
// Create metadata with 3 entries to force non-trivial offsets.
|
|
191
|
+
bytes4[] memory ids = new bytes4[](3);
|
|
192
|
+
bytes[] memory datas = new bytes[](3);
|
|
193
|
+
|
|
194
|
+
ids[0] = bytes4(0xAAAAAAAA);
|
|
195
|
+
ids[1] = bytes4(0xBBBBBBBB);
|
|
196
|
+
ids[2] = bytes4(0xCCCCCCCC);
|
|
197
|
+
|
|
198
|
+
// Each data entry is 32 bytes (one word).
|
|
199
|
+
datas[0] = abi.encode(uint256(111));
|
|
200
|
+
datas[1] = abi.encode(uint256(222));
|
|
201
|
+
datas[2] = abi.encode(uint256(333));
|
|
202
|
+
|
|
203
|
+
bytes memory metadata = _metadataHelper.createMetadata(ids, datas);
|
|
204
|
+
|
|
205
|
+
// Retrieve each entry and verify correctness. Even though _sliceBytes over-copies, the
|
|
206
|
+
// returned bytes have the correct `length` field, so abi.decode reads the right data.
|
|
207
|
+
(bool found1, bytes memory data1) = _metadataHelper.getDataFor(ids[0], metadata);
|
|
208
|
+
(bool found2, bytes memory data2) = _metadataHelper.getDataFor(ids[1], metadata);
|
|
209
|
+
(bool found3, bytes memory data3) = _metadataHelper.getDataFor(ids[2], metadata);
|
|
210
|
+
|
|
211
|
+
assertTrue(found1, "ID 0xAAAAAAAA not found");
|
|
212
|
+
assertTrue(found2, "ID 0xBBBBBBBB not found");
|
|
213
|
+
assertTrue(found3, "ID 0xCCCCCCCC not found");
|
|
214
|
+
|
|
215
|
+
assertEq(abi.decode(data1, (uint256)), 111, "data1 mismatch");
|
|
216
|
+
assertEq(abi.decode(data2, (uint256)), 222, "data2 mismatch");
|
|
217
|
+
assertEq(abi.decode(data3, (uint256)), 333, "data3 mismatch");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/// @notice Demonstrates the _sliceBytes over-copy with large offsets.
|
|
221
|
+
/// @dev When slicing from a non-zero start with end >> start, the loop iterates `end` times
|
|
222
|
+
/// (not `end - start` times), copying extra bytes beyond the allocated output buffer.
|
|
223
|
+
/// This test creates metadata where data for the last ID starts at a high offset,
|
|
224
|
+
/// exercising the worst case of the over-copy.
|
|
225
|
+
function test_sliceBytes_overcopy_with_large_offset() public view {
|
|
226
|
+
// Create metadata with entries of increasing data sizes to push offsets higher.
|
|
227
|
+
bytes4[] memory ids = new bytes4[](3);
|
|
228
|
+
bytes[] memory datas = new bytes[](3);
|
|
229
|
+
|
|
230
|
+
ids[0] = bytes4(0x11111111);
|
|
231
|
+
ids[1] = bytes4(0x22222222);
|
|
232
|
+
ids[2] = bytes4(0x33333333);
|
|
233
|
+
|
|
234
|
+
// First data: 3 words (96 bytes)
|
|
235
|
+
datas[0] = abi.encode(uint256(1), uint256(2), uint256(3));
|
|
236
|
+
// Second data: 3 words (96 bytes)
|
|
237
|
+
datas[1] = abi.encode(uint256(4), uint256(5), uint256(6));
|
|
238
|
+
// Third data: 1 word (32 bytes) -- but starts at a high offset
|
|
239
|
+
datas[2] = abi.encode(uint256(7));
|
|
240
|
+
|
|
241
|
+
bytes memory metadata = _metadataHelper.createMetadata(ids, datas);
|
|
242
|
+
|
|
243
|
+
// The third entry starts at a high offset. When getDataFor calls _sliceBytes(metadata, offset*32, end),
|
|
244
|
+
// the loop runs for `end` iterations (not `end - offset*32`), over-copying.
|
|
245
|
+
(bool found, bytes memory data) = _metadataHelper.getDataFor(ids[2], metadata);
|
|
246
|
+
assertTrue(found, "ID 0x33333333 not found");
|
|
247
|
+
assertEq(abi.decode(data, (uint256)), 7, "data for id3 mismatch");
|
|
248
|
+
|
|
249
|
+
// Verify earlier entries are also correct.
|
|
250
|
+
(bool found0, bytes memory data0) = _metadataHelper.getDataFor(ids[0], metadata);
|
|
251
|
+
assertTrue(found0, "ID 0x11111111 not found");
|
|
252
|
+
(uint256 a, uint256 b, uint256 c) = abi.decode(data0, (uint256, uint256, uint256));
|
|
253
|
+
assertEq(a, 1);
|
|
254
|
+
assertEq(b, 2);
|
|
255
|
+
assertEq(c, 3);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/// @notice Test that _sliceBytes memory over-copy does not corrupt subsequent allocations
|
|
259
|
+
/// when the returned data is used alongside new allocations.
|
|
260
|
+
/// @dev This is an indirect demonstration. After calling getDataFor (which internally calls
|
|
261
|
+
/// _sliceBytes with the over-copy), we allocate new memory and verify it is not corrupted
|
|
262
|
+
/// by the over-written bytes. Because the free memory pointer is advanced by `length`
|
|
263
|
+
/// (the correct amount), the next allocation starts at the end of the intended region,
|
|
264
|
+
/// which may have been overwritten by the over-copy.
|
|
265
|
+
function test_sliceBytes_no_corruption_of_subsequent_alloc() public view {
|
|
266
|
+
bytes4[] memory ids = new bytes4[](2);
|
|
267
|
+
bytes[] memory datas = new bytes[](2);
|
|
268
|
+
|
|
269
|
+
ids[0] = bytes4(0xDEADBEEF);
|
|
270
|
+
ids[1] = bytes4(0xCAFEBABE);
|
|
271
|
+
|
|
272
|
+
// 2-word data for each.
|
|
273
|
+
datas[0] = abi.encode(uint256(0xDEAD), uint256(0xBEEF));
|
|
274
|
+
datas[1] = abi.encode(uint256(0xCAFE), uint256(0xBABE));
|
|
275
|
+
|
|
276
|
+
bytes memory metadata = _metadataHelper.createMetadata(ids, datas);
|
|
277
|
+
|
|
278
|
+
// Retrieve second entry (which has start > 0, triggering the over-copy).
|
|
279
|
+
(bool found, bytes memory data) = _metadataHelper.getDataFor(ids[1], metadata);
|
|
280
|
+
assertTrue(found);
|
|
281
|
+
|
|
282
|
+
(uint256 val1, uint256 val2) = abi.decode(data, (uint256, uint256));
|
|
283
|
+
assertEq(val1, 0xCAFE, "val1 mismatch after getDataFor");
|
|
284
|
+
assertEq(val2, 0xBABE, "val2 mismatch after getDataFor");
|
|
285
|
+
|
|
286
|
+
// Now allocate new memory and check it is clean. In Solidity, new allocations should
|
|
287
|
+
// start with zeroed memory. If _sliceBytes over-wrote past the free pointer, this could
|
|
288
|
+
// contain stale data from the copy loop.
|
|
289
|
+
uint256[] memory freshAlloc = new uint256[](2);
|
|
290
|
+
assertEq(freshAlloc[0], 0, "fresh allocation[0] should be zero");
|
|
291
|
+
assertEq(freshAlloc[1], 0, "fresh allocation[1] should be zero");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
//*********************************************************************//
|
|
295
|
+
// --- Test 2: REVDeployer beforePayRecordedWith OOB (C-2) ---------- //
|
|
296
|
+
//*********************************************************************//
|
|
297
|
+
|
|
298
|
+
/// @notice Demonstrates that REVDeployer.beforePayRecordedWith writes to index [1] of the
|
|
299
|
+
/// hookSpecifications array even when the array has size 1 (no tiered721Hook, but
|
|
300
|
+
/// buybackHook is present).
|
|
301
|
+
/// @dev We cannot directly test REVDeployer here (it's in a separate package), but we can
|
|
302
|
+
/// demonstrate the array indexing pattern that causes the OOB revert.
|
|
303
|
+
/// This test mirrors the exact logic from REVDeployer lines 248-258.
|
|
304
|
+
function test_revDeployer_hookSpecifications_oob_pattern() public {
|
|
305
|
+
// Simulate the condition: usesBuybackHook=true, usesTiered721Hook=false.
|
|
306
|
+
bool usesBuybackHook = true;
|
|
307
|
+
bool usesTiered721Hook = false;
|
|
308
|
+
|
|
309
|
+
// This is the exact allocation from REVDeployer line 249:
|
|
310
|
+
// hookSpecifications = new JBPayHookSpecification[]((usesTiered721Hook ? 1 : 0) + (usesBuybackHook ? 1 : 0));
|
|
311
|
+
uint256 arraySize = (usesTiered721Hook ? 1 : 0) + (usesBuybackHook ? 1 : 0);
|
|
312
|
+
|
|
313
|
+
// Array has size 1.
|
|
314
|
+
assertEq(arraySize, 1, "array should be size 1");
|
|
315
|
+
|
|
316
|
+
JBPayHookSpecification[] memory hookSpecifications = new JBPayHookSpecification[](arraySize);
|
|
317
|
+
|
|
318
|
+
// Line 253: if (usesTiered721Hook) hookSpecifications[0] = ... -- SKIPPED (false)
|
|
319
|
+
|
|
320
|
+
// Line 258: if (usesBuybackHook) hookSpecifications[1] = buybackHookSpecifications[0];
|
|
321
|
+
// This writes to index 1 of a size-1 array. In Solidity 0.8.x, this reverts with
|
|
322
|
+
// a panic (index out of bounds).
|
|
323
|
+
if (usesBuybackHook) {
|
|
324
|
+
// This WILL revert with an index out of bounds panic.
|
|
325
|
+
vm.expectRevert();
|
|
326
|
+
// We need to trigger the revert in a way forge can catch. Use a low-level call to
|
|
327
|
+
// ourselves with a helper function.
|
|
328
|
+
this.externalWriteToIndex1(hookSpecifications);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/// @notice External helper to trigger the array OOB write. Forge's vm.expectRevert()
|
|
333
|
+
/// requires a call boundary to catch the panic.
|
|
334
|
+
function externalWriteToIndex1(JBPayHookSpecification[] memory specs) external pure {
|
|
335
|
+
// This will revert with Panic(0x32) -- array index out of bounds.
|
|
336
|
+
specs[1] = JBPayHookSpecification({hook: IJBPayHook(address(0)), amount: 0, metadata: ""});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/// @notice Verify that when BOTH hooks are present, the array size is 2 and no OOB occurs.
|
|
340
|
+
function test_revDeployer_hookSpecifications_no_oob_when_both_present() public pure {
|
|
341
|
+
bool usesBuybackHook = true;
|
|
342
|
+
bool usesTiered721Hook = true;
|
|
343
|
+
|
|
344
|
+
uint256 arraySize = (usesTiered721Hook ? 1 : 0) + (usesBuybackHook ? 1 : 0);
|
|
345
|
+
assertEq(arraySize, 2, "array should be size 2 when both hooks present");
|
|
346
|
+
|
|
347
|
+
JBPayHookSpecification[] memory hookSpecifications = new JBPayHookSpecification[](arraySize);
|
|
348
|
+
|
|
349
|
+
// Both writes are valid.
|
|
350
|
+
hookSpecifications[0] = JBPayHookSpecification({hook: IJBPayHook(address(0)), amount: 0, metadata: ""});
|
|
351
|
+
hookSpecifications[1] = JBPayHookSpecification({hook: IJBPayHook(address(0)), amount: 0, metadata: ""});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/// @notice Verify that when neither hook is present, no write occurs.
|
|
355
|
+
function test_revDeployer_hookSpecifications_no_hooks() public pure {
|
|
356
|
+
bool usesBuybackHook = false;
|
|
357
|
+
bool usesTiered721Hook = false;
|
|
358
|
+
|
|
359
|
+
uint256 arraySize = (usesTiered721Hook ? 1 : 0) + (usesBuybackHook ? 1 : 0);
|
|
360
|
+
assertEq(arraySize, 0, "array should be size 0 when no hooks");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//*********************************************************************//
|
|
364
|
+
// --------- Test 3: JBCashOuts bonding curve edge cases ------------ //
|
|
365
|
+
//*********************************************************************//
|
|
366
|
+
|
|
367
|
+
/// @notice When cashOutCount == totalSupply, the entire surplus should be returned regardless
|
|
368
|
+
/// of the cashOutTaxRate.
|
|
369
|
+
function test_cashOuts_fullSupplyCashOut_returnsEntireSurplus() public pure {
|
|
370
|
+
uint256 surplus = 100 ether;
|
|
371
|
+
uint256 totalSupply = 1000e18;
|
|
372
|
+
uint256 cashOutCount = totalSupply; // Cashing out everything.
|
|
373
|
+
|
|
374
|
+
// Even with a high tax rate, cashing out the full supply returns the full surplus.
|
|
375
|
+
uint256 reclaimable = JBCashOuts.cashOutFrom({
|
|
376
|
+
surplus: surplus,
|
|
377
|
+
cashOutCount: cashOutCount,
|
|
378
|
+
totalSupply: totalSupply,
|
|
379
|
+
cashOutTaxRate: JBConstants.MAX_CASH_OUT_TAX_RATE / 2 // 50% tax
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
assertEq(reclaimable, surplus, "full supply cash out should return entire surplus");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/// @notice When cashOutTaxRate == MAX_CASH_OUT_TAX_RATE (10_000), the function should return 0.
|
|
386
|
+
function test_cashOuts_maxTaxRate_returnsZero() public pure {
|
|
387
|
+
uint256 surplus = 100 ether;
|
|
388
|
+
uint256 totalSupply = 1000e18;
|
|
389
|
+
uint256 cashOutCount = 500e18;
|
|
390
|
+
|
|
391
|
+
uint256 reclaimable = JBCashOuts.cashOutFrom({
|
|
392
|
+
surplus: surplus,
|
|
393
|
+
cashOutCount: cashOutCount,
|
|
394
|
+
totalSupply: totalSupply,
|
|
395
|
+
cashOutTaxRate: JBConstants.MAX_CASH_OUT_TAX_RATE // 100% tax
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
assertEq(reclaimable, 0, "max tax rate should return 0");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/// @notice When cashOutTaxRate == 0, the full linear proportion should be returned.
|
|
402
|
+
function test_cashOuts_zeroTaxRate_returnsLinear() public pure {
|
|
403
|
+
uint256 surplus = 100 ether;
|
|
404
|
+
uint256 totalSupply = 1000e18;
|
|
405
|
+
uint256 cashOutCount = 250e18; // 25% of supply.
|
|
406
|
+
|
|
407
|
+
uint256 reclaimable = JBCashOuts.cashOutFrom({
|
|
408
|
+
surplus: surplus, cashOutCount: cashOutCount, totalSupply: totalSupply, cashOutTaxRate: 0
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Linear: surplus * cashOutCount / totalSupply = 100 * 250 / 1000 = 25 ether.
|
|
412
|
+
assertEq(reclaimable, 25 ether, "zero tax should return linear proportion");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/// @notice Dust attack: very small cashOutCount that rounds to 0 reclaim.
|
|
416
|
+
function test_cashOuts_dustAttack_roundsToZero() public pure {
|
|
417
|
+
uint256 surplus = 1 ether; // 1e18
|
|
418
|
+
uint256 totalSupply = type(uint208).max; // Huge supply.
|
|
419
|
+
uint256 cashOutCount = 1; // Single token unit.
|
|
420
|
+
|
|
421
|
+
uint256 reclaimable = JBCashOuts.cashOutFrom({
|
|
422
|
+
surplus: surplus,
|
|
423
|
+
cashOutCount: cashOutCount,
|
|
424
|
+
totalSupply: totalSupply,
|
|
425
|
+
cashOutTaxRate: JBConstants.MAX_CASH_OUT_TAX_RATE / 2
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// With huge totalSupply and tiny cashOutCount, the reclaim rounds to 0.
|
|
429
|
+
assertEq(reclaimable, 0, "dust amount should round to 0");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/// @notice Bonding curve invariant: sequential cash outs should never yield more total value
|
|
433
|
+
/// than a single equivalent cash out, and vice versa (for the same total tokens).
|
|
434
|
+
/// @dev With a non-zero tax rate, the bonding curve penalizes early exiters relative to a
|
|
435
|
+
/// single large exit. Verify that splitting a cash out into two parts returns <= the
|
|
436
|
+
/// amount from a single combined cash out.
|
|
437
|
+
function test_cashOuts_sequentialVsSingle_invariant() public pure {
|
|
438
|
+
uint256 surplus = 100 ether;
|
|
439
|
+
uint256 totalSupply = 1000e18;
|
|
440
|
+
uint256 cashOutTaxRate = 5000; // 50%
|
|
441
|
+
|
|
442
|
+
// Single cash out of 500 tokens.
|
|
443
|
+
uint256 singleReclaim = JBCashOuts.cashOutFrom({
|
|
444
|
+
surplus: surplus, cashOutCount: 500e18, totalSupply: totalSupply, cashOutTaxRate: cashOutTaxRate
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// Sequential: first cash out 250 tokens.
|
|
448
|
+
uint256 firstReclaim = JBCashOuts.cashOutFrom({
|
|
449
|
+
surplus: surplus, cashOutCount: 250e18, totalSupply: totalSupply, cashOutTaxRate: cashOutTaxRate
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// After the first cash out, surplus and supply are reduced.
|
|
453
|
+
uint256 newSurplus = surplus - firstReclaim;
|
|
454
|
+
uint256 newTotalSupply = totalSupply - 250e18;
|
|
455
|
+
|
|
456
|
+
// Second cash out of remaining 250 tokens.
|
|
457
|
+
uint256 secondReclaim = JBCashOuts.cashOutFrom({
|
|
458
|
+
surplus: newSurplus, cashOutCount: 250e18, totalSupply: newTotalSupply, cashOutTaxRate: cashOutTaxRate
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
uint256 totalSequentialReclaim = firstReclaim + secondReclaim;
|
|
462
|
+
|
|
463
|
+
// The bonding curve with tax rate > 0 should give less to sequential cash outs than a
|
|
464
|
+
// single large one (or at most equal). This is because the first casher-out "leaves"
|
|
465
|
+
// some value in the pool for the remaining holders.
|
|
466
|
+
assertLe(
|
|
467
|
+
totalSequentialReclaim, singleReclaim, "sequential cash outs should not yield more than single equivalent"
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/// @notice Fuzz the bonding curve: verify key properties across random inputs.
|
|
472
|
+
function testFuzz_cashOuts_properties(
|
|
473
|
+
uint256 surplus,
|
|
474
|
+
uint256 cashOutCount,
|
|
475
|
+
uint256 totalSupply,
|
|
476
|
+
uint16 cashOutTaxRate
|
|
477
|
+
)
|
|
478
|
+
public
|
|
479
|
+
pure
|
|
480
|
+
{
|
|
481
|
+
// Bound inputs to reasonable ranges.
|
|
482
|
+
surplus = bound(surplus, 1, 1e30);
|
|
483
|
+
totalSupply = bound(totalSupply, 1, type(uint208).max);
|
|
484
|
+
cashOutCount = bound(cashOutCount, 1, totalSupply);
|
|
485
|
+
cashOutTaxRate = uint16(bound(uint256(cashOutTaxRate), 0, JBConstants.MAX_CASH_OUT_TAX_RATE));
|
|
486
|
+
|
|
487
|
+
uint256 reclaimable = JBCashOuts.cashOutFrom({
|
|
488
|
+
surplus: surplus, cashOutCount: cashOutCount, totalSupply: totalSupply, cashOutTaxRate: cashOutTaxRate
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// Property 1: reclaim should never exceed surplus.
|
|
492
|
+
assertLe(reclaimable, surplus, "reclaimable must not exceed surplus");
|
|
493
|
+
|
|
494
|
+
// Property 2: if cashOutCount == totalSupply and taxRate != MAX, reclaim == surplus.
|
|
495
|
+
if (cashOutCount == totalSupply && cashOutTaxRate != JBConstants.MAX_CASH_OUT_TAX_RATE) {
|
|
496
|
+
assertEq(reclaimable, surplus, "full redemption should return full surplus");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Property 3: if cashOutTaxRate == MAX, reclaim == 0.
|
|
500
|
+
if (cashOutTaxRate == JBConstants.MAX_CASH_OUT_TAX_RATE) {
|
|
501
|
+
assertEq(reclaimable, 0, "max tax rate should always return 0");
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
//*********************************************************************//
|
|
506
|
+
// -------- Test 4: JBMultiTerminal balance invariants --------------- //
|
|
507
|
+
//*********************************************************************//
|
|
508
|
+
|
|
509
|
+
/// @notice After pay + cashOut, the terminal balance + amounts sent out should equal the
|
|
510
|
+
/// original amount paid in (accounting for fees).
|
|
511
|
+
function test_terminal_balanceInvariant_payAndCashOut() public {
|
|
512
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 5000, reservedPercent: 0});
|
|
513
|
+
|
|
514
|
+
vm.prank(_projectOwner);
|
|
515
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
516
|
+
|
|
517
|
+
uint112 payAmount = 10 ether;
|
|
518
|
+
vm.deal(_beneficiary, payAmount);
|
|
519
|
+
|
|
520
|
+
// Pay the project.
|
|
521
|
+
vm.prank(_beneficiary);
|
|
522
|
+
_terminal.pay{value: payAmount}({
|
|
523
|
+
projectId: projectId,
|
|
524
|
+
amount: payAmount,
|
|
525
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
526
|
+
beneficiary: _beneficiary,
|
|
527
|
+
minReturnedTokens: 0,
|
|
528
|
+
memo: "test",
|
|
529
|
+
metadata: new bytes(0)
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// Verify terminal balance equals the payment amount.
|
|
533
|
+
uint256 terminalBalance = jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
534
|
+
assertEq(terminalBalance, payAmount, "terminal balance should equal pay amount");
|
|
535
|
+
|
|
536
|
+
// Get beneficiary token balance.
|
|
537
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
538
|
+
assertGt(tokenBalance, 0, "beneficiary should have tokens");
|
|
539
|
+
|
|
540
|
+
// Record beneficiary ETH balance before cash out.
|
|
541
|
+
uint256 ethBefore = _beneficiary.balance;
|
|
542
|
+
|
|
543
|
+
// Cash out all tokens.
|
|
544
|
+
vm.prank(_beneficiary);
|
|
545
|
+
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
546
|
+
holder: _beneficiary,
|
|
547
|
+
projectId: projectId,
|
|
548
|
+
cashOutCount: tokenBalance,
|
|
549
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
550
|
+
minTokensReclaimed: 0,
|
|
551
|
+
beneficiary: payable(_beneficiary),
|
|
552
|
+
metadata: new bytes(0)
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
uint256 ethReceived = _beneficiary.balance - ethBefore;
|
|
556
|
+
assertEq(ethReceived, reclaimAmount, "ETH received should match reclaim amount");
|
|
557
|
+
|
|
558
|
+
// After cash out, the terminal balance should be reduced by the gross reclaim amount
|
|
559
|
+
// (which includes the fee portion that stays in the terminal for the fee project).
|
|
560
|
+
uint256 terminalBalanceAfter =
|
|
561
|
+
jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
562
|
+
|
|
563
|
+
// Balance invariant: terminal balance after + gross reclaim == terminal balance before.
|
|
564
|
+
// The gross reclaim includes the fee, which goes to the fee project's balance in the same
|
|
565
|
+
// terminal. So we check: terminalBalanceAfter + reclaimAmount + fee == payAmount.
|
|
566
|
+
// Since fee = grossReclaim - netReclaim, and grossReclaim is deducted from balance:
|
|
567
|
+
// terminalBalanceAfter <= payAmount (always).
|
|
568
|
+
assertLe(terminalBalanceAfter, payAmount, "balance after should not exceed pay amount");
|
|
569
|
+
|
|
570
|
+
// When cashing out 100% of supply, the bonding curve returns the full surplus
|
|
571
|
+
// regardless of tax rate. The fee is taken from the reclaim, so the project's
|
|
572
|
+
// balance goes to 0, and the fee project receives the fee portion.
|
|
573
|
+
// terminalBalanceAfter == 0 for the project is expected when cashing out full supply.
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/// @notice Verify that surplus calculation correctly excludes payout limits.
|
|
577
|
+
function test_terminal_surplusExcludesPayoutLimit() public {
|
|
578
|
+
uint224 payoutLimit = 5 ether;
|
|
579
|
+
uint256 projectId = _launchProjectWithPayoutLimit({cashOutTaxRate: 0, payoutLimit: payoutLimit});
|
|
580
|
+
|
|
581
|
+
vm.prank(_projectOwner);
|
|
582
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
583
|
+
|
|
584
|
+
uint112 payAmount = 20 ether;
|
|
585
|
+
vm.deal(_beneficiary, payAmount);
|
|
586
|
+
|
|
587
|
+
// Pay the project.
|
|
588
|
+
vm.prank(_beneficiary);
|
|
589
|
+
_terminal.pay{value: payAmount}({
|
|
590
|
+
projectId: projectId,
|
|
591
|
+
amount: payAmount,
|
|
592
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
593
|
+
beneficiary: _beneficiary,
|
|
594
|
+
minReturnedTokens: 0,
|
|
595
|
+
memo: "test",
|
|
596
|
+
metadata: new bytes(0)
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
// The surplus should be payAmount - payoutLimit = 15 ether.
|
|
600
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
601
|
+
contexts[0] = JBAccountingContext({
|
|
602
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
uint256 surplus = jbTerminalStore()
|
|
606
|
+
.currentSurplusOf({
|
|
607
|
+
terminal: address(_terminal),
|
|
608
|
+
projectId: projectId,
|
|
609
|
+
accountingContexts: contexts,
|
|
610
|
+
decimals: 18,
|
|
611
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
assertEq(surplus, payAmount - payoutLimit, "surplus should be pay amount minus payout limit");
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/// @notice Verify that hook specifications cannot exceed the payment amount.
|
|
618
|
+
/// @dev The terminal store checks that the sum of hook specification amounts does not exceed
|
|
619
|
+
/// the payment value. We test this by verifying the error exists in the logic.
|
|
620
|
+
function test_terminal_hookSpecsCannotExceedPayment() public pure {
|
|
621
|
+
// This test verifies the check at JBTerminalStore line 679:
|
|
622
|
+
// `if (specifiedAmount > balanceDiff) revert JBTerminalStore_InvalidAmountToForwardHook(...)`
|
|
623
|
+
//
|
|
624
|
+
// The invariant is that the sum of all hook spec amounts cannot exceed the original payment.
|
|
625
|
+
// This is enforced by the terminal store, not by the terminal itself.
|
|
626
|
+
//
|
|
627
|
+
// We verify this property by checking the JBCashOuts library directly: the reclaimable
|
|
628
|
+
// amount is always bounded by the surplus.
|
|
629
|
+
uint256 surplus = 10 ether;
|
|
630
|
+
uint256 reclaimable =
|
|
631
|
+
JBCashOuts.cashOutFrom({surplus: surplus, cashOutCount: 500e18, totalSupply: 1000e18, cashOutTaxRate: 0});
|
|
632
|
+
|
|
633
|
+
// With 0 tax rate and 50% of supply, should get 50% of surplus.
|
|
634
|
+
assertEq(reclaimable, 5 ether, "should get 50% of surplus");
|
|
635
|
+
assertLe(reclaimable, surplus, "reclaimable should never exceed surplus");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/// @notice Full integration: pay, send payouts (up to limit), then cash out remaining surplus.
|
|
639
|
+
function test_terminal_payPayoutCashOut_fullCycle() public {
|
|
640
|
+
uint224 payoutLimit = 3 ether;
|
|
641
|
+
uint256 projectId = _launchProjectWithPayoutLimit({
|
|
642
|
+
cashOutTaxRate: 0, // No tax so we can verify exact amounts.
|
|
643
|
+
payoutLimit: payoutLimit
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
vm.prank(_projectOwner);
|
|
647
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
648
|
+
|
|
649
|
+
uint112 payAmount = 10 ether;
|
|
650
|
+
vm.deal(_beneficiary, payAmount);
|
|
651
|
+
|
|
652
|
+
// Pay the project.
|
|
653
|
+
vm.prank(_beneficiary);
|
|
654
|
+
_terminal.pay{value: payAmount}({
|
|
655
|
+
projectId: projectId,
|
|
656
|
+
amount: payAmount,
|
|
657
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
658
|
+
beneficiary: _beneficiary,
|
|
659
|
+
minReturnedTokens: 0,
|
|
660
|
+
memo: "pay",
|
|
661
|
+
metadata: new bytes(0)
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
// Send payouts (up to the 3 ETH limit).
|
|
665
|
+
vm.prank(_projectOwner);
|
|
666
|
+
uint256 amountPaidOut = _terminal.sendPayoutsOf({
|
|
667
|
+
projectId: projectId,
|
|
668
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
669
|
+
amount: payoutLimit,
|
|
670
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
671
|
+
minTokensPaidOut: 0
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Payouts should be approximately the payout limit (minus fees paid to the fee project).
|
|
675
|
+
assertGt(amountPaidOut, 0, "should have paid out something");
|
|
676
|
+
|
|
677
|
+
// Terminal balance should have decreased.
|
|
678
|
+
uint256 terminalBalanceAfterPayout =
|
|
679
|
+
jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
680
|
+
assertLt(terminalBalanceAfterPayout, payAmount, "balance should decrease after payout");
|
|
681
|
+
|
|
682
|
+
// Cash out all remaining tokens.
|
|
683
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
684
|
+
vm.prank(_beneficiary);
|
|
685
|
+
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
686
|
+
holder: _beneficiary,
|
|
687
|
+
projectId: projectId,
|
|
688
|
+
cashOutCount: tokenBalance,
|
|
689
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
690
|
+
minTokensReclaimed: 0,
|
|
691
|
+
beneficiary: payable(_beneficiary),
|
|
692
|
+
metadata: new bytes(0)
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// After full cash out (0 tax rate, cashing out 100% of supply), the entire remaining
|
|
696
|
+
// surplus should be returned.
|
|
697
|
+
uint256 terminalBalanceAfterCashOut =
|
|
698
|
+
jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
699
|
+
|
|
700
|
+
// With 0 tax and full supply cash out, project balance should be 0 (all surplus returned).
|
|
701
|
+
assertEq(terminalBalanceAfterCashOut, 0, "project balance should be 0 after full cashout");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
//*********************************************************************//
|
|
705
|
+
// -------------- Test 5: JBTokens supply invariant ----------------- //
|
|
706
|
+
//*********************************************************************//
|
|
707
|
+
|
|
708
|
+
/// @notice Verify that totalSupplyOf == totalCreditSupplyOf + token.totalSupply().
|
|
709
|
+
function test_tokens_supplyInvariant_afterPay() public {
|
|
710
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
711
|
+
|
|
712
|
+
vm.prank(_projectOwner);
|
|
713
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
714
|
+
|
|
715
|
+
uint112 payAmount = 5 ether;
|
|
716
|
+
vm.deal(_beneficiary, payAmount);
|
|
717
|
+
|
|
718
|
+
vm.prank(_beneficiary);
|
|
719
|
+
_terminal.pay{value: payAmount}({
|
|
720
|
+
projectId: projectId,
|
|
721
|
+
amount: payAmount,
|
|
722
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
723
|
+
beneficiary: _beneficiary,
|
|
724
|
+
minReturnedTokens: 0,
|
|
725
|
+
memo: "supply test",
|
|
726
|
+
metadata: new bytes(0)
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
// After payment, tokens are minted. If ERC20 is deployed, they are minted as ERC20 (not credits).
|
|
730
|
+
uint256 totalSupply = _tokens.totalSupplyOf(projectId);
|
|
731
|
+
uint256 totalCreditSupply = _tokens.totalCreditSupplyOf(projectId);
|
|
732
|
+
IJBToken token = _tokens.tokenOf(projectId);
|
|
733
|
+
|
|
734
|
+
uint256 erc20Supply = token != IJBToken(address(0)) ? token.totalSupply() : 0;
|
|
735
|
+
|
|
736
|
+
assertEq(
|
|
737
|
+
totalSupply,
|
|
738
|
+
totalCreditSupply + erc20Supply,
|
|
739
|
+
"totalSupplyOf must equal totalCreditSupplyOf + token.totalSupply()"
|
|
740
|
+
);
|
|
741
|
+
|
|
742
|
+
// Also verify beneficiary balance consistency.
|
|
743
|
+
uint256 beneficiaryTotal = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
744
|
+
uint256 beneficiaryCredits = _tokens.creditBalanceOf(_beneficiary, projectId);
|
|
745
|
+
uint256 beneficiaryErc20 = token != IJBToken(address(0)) ? token.balanceOf(_beneficiary) : 0;
|
|
746
|
+
|
|
747
|
+
assertEq(
|
|
748
|
+
beneficiaryTotal, beneficiaryCredits + beneficiaryErc20, "totalBalanceOf must equal credits + ERC20 balance"
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/// @notice Verify supply invariant when paying BEFORE deploying ERC20 (credits only).
|
|
753
|
+
function test_tokens_supplyInvariant_creditsOnly() public {
|
|
754
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
755
|
+
|
|
756
|
+
// Do NOT deploy ERC20 yet -- tokens will be minted as credits.
|
|
757
|
+
|
|
758
|
+
uint112 payAmount = 5 ether;
|
|
759
|
+
vm.deal(_beneficiary, payAmount);
|
|
760
|
+
|
|
761
|
+
vm.prank(_beneficiary);
|
|
762
|
+
_terminal.pay{value: payAmount}({
|
|
763
|
+
projectId: projectId,
|
|
764
|
+
amount: payAmount,
|
|
765
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
766
|
+
beneficiary: _beneficiary,
|
|
767
|
+
minReturnedTokens: 0,
|
|
768
|
+
memo: "credits test",
|
|
769
|
+
metadata: new bytes(0)
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
// Since no ERC20 is deployed, all tokens should be credits.
|
|
773
|
+
uint256 totalSupply = _tokens.totalSupplyOf(projectId);
|
|
774
|
+
uint256 totalCreditSupply = _tokens.totalCreditSupplyOf(projectId);
|
|
775
|
+
|
|
776
|
+
assertEq(totalSupply, totalCreditSupply, "with no ERC20, total supply should equal credit supply");
|
|
777
|
+
assertEq(
|
|
778
|
+
_tokens.creditBalanceOf(_beneficiary, projectId), totalCreditSupply, "all credits belong to beneficiary"
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/// @notice Verify that claimTokensFor does not create or destroy tokens: it converts credits
|
|
783
|
+
/// to ERC20 tokens 1:1, maintaining the total supply invariant.
|
|
784
|
+
function test_tokens_claimTokens_preservesSupply() public {
|
|
785
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
786
|
+
|
|
787
|
+
// Pay first (without ERC20 deployed, so credits are minted).
|
|
788
|
+
uint112 payAmount = 5 ether;
|
|
789
|
+
vm.deal(_beneficiary, payAmount);
|
|
790
|
+
|
|
791
|
+
vm.prank(_beneficiary);
|
|
792
|
+
_terminal.pay{value: payAmount}({
|
|
793
|
+
projectId: projectId,
|
|
794
|
+
amount: payAmount,
|
|
795
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
796
|
+
beneficiary: _beneficiary,
|
|
797
|
+
minReturnedTokens: 0,
|
|
798
|
+
memo: "claim test",
|
|
799
|
+
metadata: new bytes(0)
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
uint256 totalSupplyBefore = _tokens.totalSupplyOf(projectId);
|
|
803
|
+
uint256 creditsBefore = _tokens.creditBalanceOf(_beneficiary, projectId);
|
|
804
|
+
assertGt(creditsBefore, 0, "beneficiary should have credits");
|
|
805
|
+
|
|
806
|
+
// Now deploy ERC20.
|
|
807
|
+
vm.prank(_projectOwner);
|
|
808
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
809
|
+
|
|
810
|
+
// Claim all credits as ERC20 tokens.
|
|
811
|
+
vm.prank(_beneficiary);
|
|
812
|
+
_controller.claimTokensFor({
|
|
813
|
+
holder: _beneficiary, projectId: projectId, tokenCount: creditsBefore, beneficiary: _beneficiary
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
// After claiming, credits should be 0 and ERC20 balance should equal former credits.
|
|
817
|
+
uint256 creditsAfter = _tokens.creditBalanceOf(_beneficiary, projectId);
|
|
818
|
+
assertEq(creditsAfter, 0, "credits should be 0 after claiming");
|
|
819
|
+
|
|
820
|
+
IJBToken token = _tokens.tokenOf(projectId);
|
|
821
|
+
uint256 erc20Balance = token.balanceOf(_beneficiary);
|
|
822
|
+
assertEq(erc20Balance, creditsBefore, "ERC20 balance should equal former credits");
|
|
823
|
+
|
|
824
|
+
// Total supply should be unchanged.
|
|
825
|
+
uint256 totalSupplyAfter = _tokens.totalSupplyOf(projectId);
|
|
826
|
+
assertEq(totalSupplyAfter, totalSupplyBefore, "total supply must not change after claiming");
|
|
827
|
+
|
|
828
|
+
// Credit supply should be 0.
|
|
829
|
+
uint256 creditSupplyAfter = _tokens.totalCreditSupplyOf(projectId);
|
|
830
|
+
assertEq(creditSupplyAfter, 0, "credit supply should be 0 after claiming all");
|
|
831
|
+
|
|
832
|
+
// Final invariant check.
|
|
833
|
+
assertEq(totalSupplyAfter, creditSupplyAfter + token.totalSupply(), "totalSupply == creditSupply + erc20Supply");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/// @notice Verify supply invariant with multiple holders.
|
|
837
|
+
function test_tokens_supplyInvariant_multipleHolders() public {
|
|
838
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
839
|
+
|
|
840
|
+
vm.prank(_projectOwner);
|
|
841
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
842
|
+
|
|
843
|
+
address holder1 = address(0x1001);
|
|
844
|
+
address holder2 = address(0x1002);
|
|
845
|
+
address holder3 = address(0x1003);
|
|
846
|
+
|
|
847
|
+
vm.deal(holder1, 3 ether);
|
|
848
|
+
vm.deal(holder2, 5 ether);
|
|
849
|
+
vm.deal(holder3, 7 ether);
|
|
850
|
+
|
|
851
|
+
// Three payments from different holders.
|
|
852
|
+
vm.prank(holder1);
|
|
853
|
+
_terminal.pay{value: 3 ether}({
|
|
854
|
+
projectId: projectId,
|
|
855
|
+
amount: 3 ether,
|
|
856
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
857
|
+
beneficiary: holder1,
|
|
858
|
+
minReturnedTokens: 0,
|
|
859
|
+
memo: "",
|
|
860
|
+
metadata: new bytes(0)
|
|
861
|
+
});
|
|
862
|
+
|
|
863
|
+
vm.prank(holder2);
|
|
864
|
+
_terminal.pay{value: 5 ether}({
|
|
865
|
+
projectId: projectId,
|
|
866
|
+
amount: 5 ether,
|
|
867
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
868
|
+
beneficiary: holder2,
|
|
869
|
+
minReturnedTokens: 0,
|
|
870
|
+
memo: "",
|
|
871
|
+
metadata: new bytes(0)
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
vm.prank(holder3);
|
|
875
|
+
_terminal.pay{value: 7 ether}({
|
|
876
|
+
projectId: projectId,
|
|
877
|
+
amount: 7 ether,
|
|
878
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
879
|
+
beneficiary: holder3,
|
|
880
|
+
minReturnedTokens: 0,
|
|
881
|
+
memo: "",
|
|
882
|
+
metadata: new bytes(0)
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
// Verify that the sum of all individual balances equals the total supply.
|
|
886
|
+
uint256 bal1 = _tokens.totalBalanceOf(holder1, projectId);
|
|
887
|
+
uint256 bal2 = _tokens.totalBalanceOf(holder2, projectId);
|
|
888
|
+
uint256 bal3 = _tokens.totalBalanceOf(holder3, projectId);
|
|
889
|
+
uint256 totalSupply = _tokens.totalSupplyOf(projectId);
|
|
890
|
+
|
|
891
|
+
assertEq(bal1 + bal2 + bal3, totalSupply, "sum of balances must equal total supply");
|
|
892
|
+
|
|
893
|
+
// Verify the invariant.
|
|
894
|
+
uint256 creditSupply = _tokens.totalCreditSupplyOf(projectId);
|
|
895
|
+
IJBToken token = _tokens.tokenOf(projectId);
|
|
896
|
+
uint256 erc20Supply = token.totalSupply();
|
|
897
|
+
|
|
898
|
+
assertEq(totalSupply, creditSupply + erc20Supply, "totalSupply == creditSupply + erc20Supply");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/// @notice Burn tokens and verify supply invariant is maintained.
|
|
902
|
+
function test_tokens_supplyInvariant_afterBurn() public {
|
|
903
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
904
|
+
|
|
905
|
+
vm.prank(_projectOwner);
|
|
906
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
907
|
+
|
|
908
|
+
uint112 payAmount = 10 ether;
|
|
909
|
+
vm.deal(_beneficiary, payAmount);
|
|
910
|
+
|
|
911
|
+
vm.prank(_beneficiary);
|
|
912
|
+
_terminal.pay{value: payAmount}({
|
|
913
|
+
projectId: projectId,
|
|
914
|
+
amount: payAmount,
|
|
915
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
916
|
+
beneficiary: _beneficiary,
|
|
917
|
+
minReturnedTokens: 0,
|
|
918
|
+
memo: "burn test",
|
|
919
|
+
metadata: new bytes(0)
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
uint256 totalSupplyBefore = _tokens.totalSupplyOf(projectId);
|
|
923
|
+
uint256 burnAmount = totalSupplyBefore / 3;
|
|
924
|
+
|
|
925
|
+
// Burn some tokens.
|
|
926
|
+
vm.prank(_beneficiary);
|
|
927
|
+
_controller.burnTokensOf({holder: _beneficiary, projectId: projectId, tokenCount: burnAmount, memo: "burning"});
|
|
928
|
+
|
|
929
|
+
uint256 totalSupplyAfter = _tokens.totalSupplyOf(projectId);
|
|
930
|
+
assertEq(totalSupplyAfter, totalSupplyBefore - burnAmount, "supply should decrease by burn amount");
|
|
931
|
+
|
|
932
|
+
// Invariant still holds.
|
|
933
|
+
uint256 creditSupply = _tokens.totalCreditSupplyOf(projectId);
|
|
934
|
+
IJBToken token = _tokens.tokenOf(projectId);
|
|
935
|
+
uint256 erc20Supply = token.totalSupply();
|
|
936
|
+
assertEq(totalSupplyAfter, creditSupply + erc20Supply, "invariant: totalSupply == creditSupply + erc20Supply");
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
//*********************************************************************//
|
|
940
|
+
// ====== CRITICAL EXPLOIT PoC TESTS ================================ //
|
|
941
|
+
//*********************************************************************//
|
|
942
|
+
|
|
943
|
+
//*********************************************************************//
|
|
944
|
+
// --- [C-5] Zero-Supply Cash Out Drains Entire Surplus ------------- //
|
|
945
|
+
//*********************************************************************//
|
|
946
|
+
|
|
947
|
+
/// @notice PoC: When totalSupply == 0 and surplus > 0, calling cashOutTokensOf with
|
|
948
|
+
/// cashOutCount = 0 returns the ENTIRE surplus without burning any tokens.
|
|
949
|
+
/// @dev Attack flow:
|
|
950
|
+
/// 1. Project is funded via addToBalanceOf (no tokens minted)
|
|
951
|
+
/// 2. Attacker calls cashOutTokensOf with cashOutCount=0
|
|
952
|
+
/// 3. JBCashOuts.cashOutFrom: 0 >= 0 → true → returns full surplus
|
|
953
|
+
/// 4. JBMultiTerminal: cashOutCount != 0 is false → no burn
|
|
954
|
+
/// 5. Attacker receives full surplus minus fee
|
|
955
|
+
function test_C5_zeroSupplyCashOut_drainsEntireSurplus() public {
|
|
956
|
+
// --- Setup: create a project with 0% cash out tax ---
|
|
957
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
958
|
+
|
|
959
|
+
// --- Fund the project via addToBalanceOf (this does NOT mint tokens) ---
|
|
960
|
+
uint256 fundAmount = 10 ether;
|
|
961
|
+
vm.deal(address(this), fundAmount);
|
|
962
|
+
|
|
963
|
+
_terminal.addToBalanceOf{value: fundAmount}({
|
|
964
|
+
projectId: projectId,
|
|
965
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
966
|
+
amount: fundAmount,
|
|
967
|
+
shouldReturnHeldFees: false,
|
|
968
|
+
memo: "Funding treasury without minting tokens",
|
|
969
|
+
metadata: new bytes(0)
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
// --- Verify preconditions ---
|
|
973
|
+
// Terminal balance == fundAmount
|
|
974
|
+
uint256 terminalBalance = jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
975
|
+
assertEq(terminalBalance, fundAmount, "terminal should hold the funded amount");
|
|
976
|
+
|
|
977
|
+
// Total supply == 0 (no tokens minted via addToBalanceOf)
|
|
978
|
+
uint256 totalSupply = _tokens.totalSupplyOf(projectId);
|
|
979
|
+
assertEq(totalSupply, 0, "total supply should be 0 - no tokens were minted");
|
|
980
|
+
|
|
981
|
+
// --- Verify the library-level fix ---
|
|
982
|
+
// After fix: cashOutCount == 0 → early return 0
|
|
983
|
+
uint256 reclaimFromLibrary =
|
|
984
|
+
JBCashOuts.cashOutFrom({surplus: fundAmount, cashOutCount: 0, totalSupply: 0, cashOutTaxRate: 0});
|
|
985
|
+
assertEq(reclaimFromLibrary, 0, "FIX CONFIRMED: cashOutFrom(surplus, 0, 0, rate) returns 0");
|
|
986
|
+
|
|
987
|
+
// --- Verify the exploit is now blocked ---
|
|
988
|
+
address attacker = makeAddr("attacker");
|
|
989
|
+
|
|
990
|
+
uint256 attackerEthBefore = attacker.balance;
|
|
991
|
+
|
|
992
|
+
// Attacker calls cashOutTokensOf with cashOutCount=0.
|
|
993
|
+
// After fix: they burn 0 tokens and receive nothing.
|
|
994
|
+
vm.prank(attacker);
|
|
995
|
+
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
996
|
+
holder: attacker,
|
|
997
|
+
projectId: projectId,
|
|
998
|
+
cashOutCount: 0,
|
|
999
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
1000
|
+
minTokensReclaimed: 0,
|
|
1001
|
+
beneficiary: payable(attacker),
|
|
1002
|
+
metadata: new bytes(0)
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
uint256 attackerEthAfter = attacker.balance;
|
|
1006
|
+
uint256 ethReceived = attackerEthAfter - attackerEthBefore;
|
|
1007
|
+
|
|
1008
|
+
// --- Verify the exploit is blocked ---
|
|
1009
|
+
assertEq(reclaimAmount, 0, "FIX: attacker receives nothing when cashing out 0 tokens");
|
|
1010
|
+
assertEq(ethReceived, 0, "FIX: no ETH transferred to attacker");
|
|
1011
|
+
|
|
1012
|
+
// Terminal balance should be unchanged — not drained.
|
|
1013
|
+
uint256 terminalBalanceAfter =
|
|
1014
|
+
jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
1015
|
+
assertEq(terminalBalanceAfter, fundAmount, "FIX: terminal balance preserved");
|
|
1016
|
+
|
|
1017
|
+
// Attacker never had any tokens.
|
|
1018
|
+
uint256 attackerTokens = _tokens.totalBalanceOf(attacker, projectId);
|
|
1019
|
+
assertEq(attackerTokens, 0, "attacker never held any tokens");
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/// @notice PoC variant: Same attack but with a cash out tax rate.
|
|
1023
|
+
/// Even with a tax, the attacker drains surplus (fee goes to project #1).
|
|
1024
|
+
function test_C5_zeroSupplyCashOut_withTaxRate() public {
|
|
1025
|
+
// 50% cash out tax rate
|
|
1026
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 5000, reservedPercent: 0});
|
|
1027
|
+
|
|
1028
|
+
uint256 fundAmount = 10 ether;
|
|
1029
|
+
vm.deal(address(this), fundAmount);
|
|
1030
|
+
|
|
1031
|
+
_terminal.addToBalanceOf{value: fundAmount}({
|
|
1032
|
+
projectId: projectId,
|
|
1033
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1034
|
+
amount: fundAmount,
|
|
1035
|
+
shouldReturnHeldFees: false,
|
|
1036
|
+
memo: "Funding treasury",
|
|
1037
|
+
metadata: new bytes(0)
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
// Verify: total supply is 0.
|
|
1041
|
+
assertEq(_tokens.totalSupplyOf(projectId), 0, "total supply should be 0");
|
|
1042
|
+
|
|
1043
|
+
// After fix: cashOutCount == 0 → early return 0, regardless of tax rate.
|
|
1044
|
+
uint256 reclaimFromLibrary =
|
|
1045
|
+
JBCashOuts.cashOutFrom({surplus: fundAmount, cashOutCount: 0, totalSupply: 0, cashOutTaxRate: 5000});
|
|
1046
|
+
assertEq(reclaimFromLibrary, 0, "FIX: 0 returned even with 50% tax when cashOutCount=0");
|
|
1047
|
+
|
|
1048
|
+
// Verify exploit is blocked.
|
|
1049
|
+
address attacker = makeAddr("attacker");
|
|
1050
|
+
uint256 attackerEthBefore = attacker.balance;
|
|
1051
|
+
|
|
1052
|
+
vm.prank(attacker);
|
|
1053
|
+
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
1054
|
+
holder: attacker,
|
|
1055
|
+
projectId: projectId,
|
|
1056
|
+
cashOutCount: 0,
|
|
1057
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
1058
|
+
minTokensReclaimed: 0,
|
|
1059
|
+
beneficiary: payable(attacker),
|
|
1060
|
+
metadata: new bytes(0)
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
uint256 ethReceived = attacker.balance - attackerEthBefore;
|
|
1064
|
+
|
|
1065
|
+
// After fix: attacker gets nothing.
|
|
1066
|
+
assertEq(ethReceived, 0, "FIX: attacker receives nothing with 0 tokens burned");
|
|
1067
|
+
assertEq(reclaimAmount, 0, "FIX: reclaim is 0");
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/// @notice PoC variant: Repeatable attack. After all tokens are burned, attacker drains surplus.
|
|
1071
|
+
function test_C5_zeroSupplyCashOut_afterAllTokensBurned() public {
|
|
1072
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 0});
|
|
1073
|
+
|
|
1074
|
+
vm.prank(_projectOwner);
|
|
1075
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
1076
|
+
|
|
1077
|
+
// Legitimate user pays 10 ETH, gets tokens.
|
|
1078
|
+
uint112 payAmount = 10 ether;
|
|
1079
|
+
vm.deal(_beneficiary, payAmount);
|
|
1080
|
+
|
|
1081
|
+
vm.prank(_beneficiary);
|
|
1082
|
+
_terminal.pay{value: payAmount}({
|
|
1083
|
+
projectId: projectId,
|
|
1084
|
+
amount: payAmount,
|
|
1085
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1086
|
+
beneficiary: _beneficiary,
|
|
1087
|
+
minReturnedTokens: 0,
|
|
1088
|
+
memo: "legitimate payment",
|
|
1089
|
+
metadata: new bytes(0)
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
1093
|
+
assertGt(tokenBalance, 0, "beneficiary should have tokens");
|
|
1094
|
+
|
|
1095
|
+
// Beneficiary burns ALL their tokens (not cashing out — just burning).
|
|
1096
|
+
vm.prank(_beneficiary);
|
|
1097
|
+
_controller.burnTokensOf({
|
|
1098
|
+
holder: _beneficiary, projectId: projectId, tokenCount: tokenBalance, memo: "burning all"
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
// Now: totalSupply == 0, but terminal still has 10 ETH balance.
|
|
1102
|
+
assertEq(_tokens.totalSupplyOf(projectId), 0, "total supply should be 0 after burn");
|
|
1103
|
+
|
|
1104
|
+
uint256 terminalBalance = jbTerminalStore().balanceOf(address(_terminal), projectId, JBConstants.NATIVE_TOKEN);
|
|
1105
|
+
assertEq(terminalBalance, payAmount, "terminal should still hold 10 ETH");
|
|
1106
|
+
|
|
1107
|
+
// Attacker tries to exploit the zero-supply state.
|
|
1108
|
+
address attacker = makeAddr("attacker");
|
|
1109
|
+
|
|
1110
|
+
vm.prank(attacker);
|
|
1111
|
+
uint256 stolen = _terminal.cashOutTokensOf({
|
|
1112
|
+
holder: attacker,
|
|
1113
|
+
projectId: projectId,
|
|
1114
|
+
cashOutCount: 0,
|
|
1115
|
+
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
1116
|
+
minTokensReclaimed: 0,
|
|
1117
|
+
beneficiary: payable(attacker),
|
|
1118
|
+
metadata: new bytes(0)
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// After fix: cashing out 0 tokens returns 0, treasury is safe.
|
|
1122
|
+
assertEq(stolen, 0, "FIX: attacker cannot steal funds after tokens were burned");
|
|
1123
|
+
assertEq(attacker.balance, 0, "FIX: attacker receives no ETH");
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
//*********************************************************************//
|
|
1127
|
+
// --- [C-2] REVDeployer.beforePayRecordedWith Array OOB (Enhanced)-- //
|
|
1128
|
+
//*********************************************************************//
|
|
1129
|
+
|
|
1130
|
+
/// @notice PoC: Demonstrates the exact Solidity panic that occurs in REVDeployer when
|
|
1131
|
+
/// usesTiered721Hook=false and usesBuybackHook=true.
|
|
1132
|
+
/// This mirrors REVDeployer.sol lines 248-258 exactly.
|
|
1133
|
+
function test_C2_beforePayRecordedWith_fullOOBPattern() public {
|
|
1134
|
+
// Simulate all 4 combinations and verify only the problematic one reverts.
|
|
1135
|
+
bool[4] memory tiered = [false, true, false, true];
|
|
1136
|
+
bool[4] memory buyback = [false, false, true, true];
|
|
1137
|
+
bool[4] memory shouldRevert = [false, false, true, false];
|
|
1138
|
+
|
|
1139
|
+
for (uint256 i; i < 4; i++) {
|
|
1140
|
+
bool usesT = tiered[i];
|
|
1141
|
+
bool usesB = buyback[i];
|
|
1142
|
+
|
|
1143
|
+
uint256 size = (usesT ? 1 : 0) + (usesB ? 1 : 0);
|
|
1144
|
+
JBPayHookSpecification[] memory specs = new JBPayHookSpecification[](size);
|
|
1145
|
+
|
|
1146
|
+
// Replicate REVDeployer logic exactly:
|
|
1147
|
+
// if (usesTiered721Hook) hookSpecifications[0] = ...
|
|
1148
|
+
if (usesT) {
|
|
1149
|
+
specs[0] = JBPayHookSpecification({hook: IJBPayHook(address(0xdead)), amount: 1 ether, metadata: ""});
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// if (usesBuybackHook) hookSpecifications[1] = ... // ALWAYS index 1!
|
|
1153
|
+
if (usesB) {
|
|
1154
|
+
if (shouldRevert[i]) {
|
|
1155
|
+
// This case: usesT=false, usesB=true → size=1, writing to [1] → PANIC
|
|
1156
|
+
vm.expectRevert();
|
|
1157
|
+
this.externalWriteToIndex1(specs);
|
|
1158
|
+
} else {
|
|
1159
|
+
// usesT=true, usesB=true → size=2, writing to [1] → OK
|
|
1160
|
+
specs[1] =
|
|
1161
|
+
JBPayHookSpecification({hook: IJBPayHook(address(0xbeef)), amount: 2 ether, metadata: ""});
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
//*********************************************************************//
|
|
1168
|
+
// --- [C-4] REVDeployer.hasMintPermissionFor address(0) call ------- //
|
|
1169
|
+
//*********************************************************************//
|
|
1170
|
+
|
|
1171
|
+
/// @notice PoC: Demonstrates that calling a function on address(0) reverts.
|
|
1172
|
+
/// This mirrors REVDeployer.sol line 353 where buybackHook.hasMintPermissionFor(...)
|
|
1173
|
+
/// is called when buybackHookOf[revnetId] == address(0).
|
|
1174
|
+
function test_C4_hasMintPermissionFor_callOnAddressZero() public {
|
|
1175
|
+
// The bug is in the short-circuit evaluation of:
|
|
1176
|
+
// addr == loansOf[revnetId] // false (addr is some real address)
|
|
1177
|
+
// || addr == address(buybackHook) // false (buybackHook is address(0), addr is not)
|
|
1178
|
+
// || buybackHook.hasMintPermissionFor(...) // REVERTS — calling on address(0)
|
|
1179
|
+
// || _isSuckerOf(...) // never reached
|
|
1180
|
+
|
|
1181
|
+
// Demonstrate that calling an interface method on address(0) reverts.
|
|
1182
|
+
address zeroAddress = address(0);
|
|
1183
|
+
|
|
1184
|
+
// In Solidity, calling a function on address(0) makes a call to the zero address.
|
|
1185
|
+
// Since there's no contract deployed there, the call returns empty data.
|
|
1186
|
+
// For an external view call expecting a bool return, Solidity's ABI decoder reverts
|
|
1187
|
+
// on empty return data.
|
|
1188
|
+
vm.expectRevert();
|
|
1189
|
+
this.externalCallHasMintPermissionOnZero(zeroAddress);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/// @notice External helper to trigger the address(0) call.
|
|
1193
|
+
function externalCallHasMintPermissionOnZero(address target) external view {
|
|
1194
|
+
// This simulates buybackHook.hasMintPermissionFor(revnetId, ruleset, addr)
|
|
1195
|
+
// where buybackHook == address(0).
|
|
1196
|
+
|
|
1197
|
+
// Construct the call data for IJBRulesetDataHook.hasMintPermissionFor
|
|
1198
|
+
JBRuleset memory dummyRuleset;
|
|
1199
|
+
dummyRuleset.id = 1;
|
|
1200
|
+
|
|
1201
|
+
// Low-level call to address(0) — will return empty bytes, causing decode to fail.
|
|
1202
|
+
(bool success, bytes memory data) = target.staticcall(
|
|
1203
|
+
abi.encodeWithSelector(IJBRulesetDataHook.hasMintPermissionFor.selector, 1, dummyRuleset, address(0x1234))
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
// The call succeeds (returns false from empty contract) but has no return data.
|
|
1207
|
+
// Solidity's interface-based call would revert because it expects abi-encoded bool.
|
|
1208
|
+
if (success && data.length < 32) {
|
|
1209
|
+
revert("C-4 confirmed: call to address(0) returns empty data, ABI decode reverts");
|
|
1210
|
+
}
|
|
1211
|
+
if (!success) {
|
|
1212
|
+
revert("C-4 confirmed: call to address(0) reverted directly");
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
//*********************************************************************//
|
|
1217
|
+
// --- [C-1] REVLoans uint112 Truncation Pattern -------------------- //
|
|
1218
|
+
//*********************************************************************//
|
|
1219
|
+
|
|
1220
|
+
/// @notice PoC: Demonstrates silent uint112 truncation.
|
|
1221
|
+
/// This mirrors REVLoans.sol lines 922-923 where:
|
|
1222
|
+
/// loan.amount = uint112(newBorrowAmount);
|
|
1223
|
+
/// loan.collateral = uint112(newCollateralCount);
|
|
1224
|
+
function test_C1_uint112SilentTruncation() public pure {
|
|
1225
|
+
// Values that exceed uint112.max
|
|
1226
|
+
uint256 hugeCollateral = uint256(type(uint112).max) + 1000 ether;
|
|
1227
|
+
uint256 hugeBorrow = uint256(type(uint112).max) + 500 ether;
|
|
1228
|
+
|
|
1229
|
+
// Silent truncation — Solidity 0.8.x does NOT revert on explicit downcasts!
|
|
1230
|
+
uint112 truncatedCollateral = uint112(hugeCollateral);
|
|
1231
|
+
uint112 truncatedBorrow = uint112(hugeBorrow);
|
|
1232
|
+
|
|
1233
|
+
// The truncated values are dramatically smaller.
|
|
1234
|
+
assertLt(uint256(truncatedCollateral), hugeCollateral, "collateral was silently truncated");
|
|
1235
|
+
assertLt(uint256(truncatedBorrow), hugeBorrow, "borrow amount was silently truncated");
|
|
1236
|
+
|
|
1237
|
+
// Quantify the loss: the attacker deposited hugeCollateral but the loan only records
|
|
1238
|
+
// the truncated amount. The difference is permanently lost from the attacker's perspective,
|
|
1239
|
+
// but the attacker already received the full borrow amount.
|
|
1240
|
+
uint256 collateralLoss = hugeCollateral - uint256(truncatedCollateral);
|
|
1241
|
+
uint256 borrowProfit = hugeBorrow - uint256(truncatedBorrow);
|
|
1242
|
+
|
|
1243
|
+
// The attacker profits by (hugeBorrow - truncatedBorrow) in surplus extraction:
|
|
1244
|
+
// They received `hugeBorrow` worth of tokens but only need to repay `truncatedBorrow`.
|
|
1245
|
+
assertGt(borrowProfit, 0, "attacker profits from truncation");
|
|
1246
|
+
assertGt(collateralLoss, 0, "collateral accounting is corrupted");
|
|
1247
|
+
|
|
1248
|
+
// Demonstrate the exploit math:
|
|
1249
|
+
// If attacker repays truncatedBorrow (tiny), they get back truncatedCollateral (tiny).
|
|
1250
|
+
// But they already received hugeBorrow worth of funds from the surplus.
|
|
1251
|
+
// Net theft = hugeBorrow - truncatedBorrow
|
|
1252
|
+
assertGt(borrowProfit, 999 ether, "EXPLOIT: attacker steals >999 ETH from truncation of 1000 ETH overflow");
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
//*********************************************************************//
|
|
1256
|
+
// --- [C-3] REVLoans Reentrancy Pattern ----------------------------- //
|
|
1257
|
+
//*********************************************************************//
|
|
1258
|
+
|
|
1259
|
+
/// @notice PoC: Demonstrates the CEI violation pattern in REVLoans._adjust.
|
|
1260
|
+
/// The contract makes external calls (useAllowanceOf, feeTerminal.pay, _transferFrom)
|
|
1261
|
+
/// BEFORE writing loan.amount and loan.collateral at lines 921-923.
|
|
1262
|
+
///
|
|
1263
|
+
/// This test demonstrates the reentrancy window using a mock contract.
|
|
1264
|
+
///
|
|
1265
|
+
/// @dev The actual exploit requires a full REVLoans deployment which is in revnet-core-v5.
|
|
1266
|
+
/// This test demonstrates the PATTERN: external call before state write.
|
|
1267
|
+
function test_C3_reentrancyPattern_externalCallBeforeStateWrite() public {
|
|
1268
|
+
// Deploy a mock "victim" contract that follows the vulnerable pattern.
|
|
1269
|
+
ReentrancyVictim victim = new ReentrancyVictim();
|
|
1270
|
+
|
|
1271
|
+
// Fund the victim.
|
|
1272
|
+
vm.deal(address(victim), 20 ether);
|
|
1273
|
+
|
|
1274
|
+
// Deploy the attacker.
|
|
1275
|
+
ReentrancyAttacker attacker = new ReentrancyAttacker(victim);
|
|
1276
|
+
|
|
1277
|
+
// The attacker borrows. The victim sends ETH before updating state.
|
|
1278
|
+
// The attacker re-enters during the ETH transfer and borrows again
|
|
1279
|
+
// against the stale state.
|
|
1280
|
+
attacker.attack();
|
|
1281
|
+
|
|
1282
|
+
// The attacker extracted more than they should have.
|
|
1283
|
+
assertGt(
|
|
1284
|
+
address(attacker).balance,
|
|
1285
|
+
10 ether,
|
|
1286
|
+
"EXPLOIT: attacker extracted more than single borrow allows (re-entered)"
|
|
1287
|
+
);
|
|
1288
|
+
|
|
1289
|
+
// The victim was drained below expected.
|
|
1290
|
+
assertLt(address(victim).balance, 10 ether, "victim was over-drained");
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
//*********************************************************************//
|
|
1294
|
+
// --- [M-1] Held Fee Reentrancy ----------------------------------- //
|
|
1295
|
+
//*********************************************************************//
|
|
1296
|
+
|
|
1297
|
+
/// @notice PoC: Verifies that the M-1 held fee reentrancy fix works correctly.
|
|
1298
|
+
/// A reentrant fee terminal calls back into processHeldFeesOf during pay().
|
|
1299
|
+
/// Before the fix, the same fee would be processed twice. After the fix,
|
|
1300
|
+
/// the index is advanced before the external call (CEI pattern), so the
|
|
1301
|
+
/// reentrant call processes zero fees.
|
|
1302
|
+
function test_M1_heldFeeReentrancy_blocked() public {
|
|
1303
|
+
// --- Step 1: Launch fee project (project #1) with the standard terminal ---
|
|
1304
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1305
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
1306
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
1307
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1308
|
+
});
|
|
1309
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
1310
|
+
|
|
1311
|
+
JBRulesetConfig[] memory feeRulesetConfig = new JBRulesetConfig[](1);
|
|
1312
|
+
feeRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1313
|
+
feeRulesetConfig[0].duration = 0;
|
|
1314
|
+
feeRulesetConfig[0].weight = _weight;
|
|
1315
|
+
feeRulesetConfig[0].metadata = JBRulesetMetadata({
|
|
1316
|
+
reservedPercent: 0,
|
|
1317
|
+
cashOutTaxRate: 0,
|
|
1318
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1319
|
+
pausePay: false,
|
|
1320
|
+
pauseCreditTransfers: false,
|
|
1321
|
+
allowOwnerMinting: false,
|
|
1322
|
+
allowSetCustomToken: false,
|
|
1323
|
+
allowTerminalMigration: false,
|
|
1324
|
+
allowSetTerminals: true,
|
|
1325
|
+
ownerMustSendPayouts: false,
|
|
1326
|
+
allowSetController: false,
|
|
1327
|
+
allowAddAccountingContext: true,
|
|
1328
|
+
allowAddPriceFeed: false,
|
|
1329
|
+
holdFees: false,
|
|
1330
|
+
useTotalSurplusForCashOuts: false,
|
|
1331
|
+
useDataHookForPay: false,
|
|
1332
|
+
useDataHookForCashOut: false,
|
|
1333
|
+
dataHook: address(0),
|
|
1334
|
+
metadata: 0
|
|
1335
|
+
});
|
|
1336
|
+
feeRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1337
|
+
feeRulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
1338
|
+
|
|
1339
|
+
address feeProjectOwner = makeAddr("feeProjectOwner");
|
|
1340
|
+
uint256 feeProjectId = _controller.launchProjectFor({
|
|
1341
|
+
owner: feeProjectOwner,
|
|
1342
|
+
projectUri: "feeProject",
|
|
1343
|
+
rulesetConfigurations: feeRulesetConfig,
|
|
1344
|
+
terminalConfigurations: terminalConfigurations,
|
|
1345
|
+
memo: ""
|
|
1346
|
+
});
|
|
1347
|
+
assertEq(feeProjectId, 1, "fee project should be #1");
|
|
1348
|
+
|
|
1349
|
+
// --- Step 2: Launch test project with holdFees=true and surplus allowance ---
|
|
1350
|
+
JBRulesetMetadata memory testMetadata = JBRulesetMetadata({
|
|
1351
|
+
reservedPercent: 0,
|
|
1352
|
+
cashOutTaxRate: 0,
|
|
1353
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1354
|
+
pausePay: false,
|
|
1355
|
+
pauseCreditTransfers: false,
|
|
1356
|
+
allowOwnerMinting: true,
|
|
1357
|
+
allowSetCustomToken: false,
|
|
1358
|
+
allowTerminalMigration: false,
|
|
1359
|
+
allowSetTerminals: false,
|
|
1360
|
+
ownerMustSendPayouts: false,
|
|
1361
|
+
allowSetController: false,
|
|
1362
|
+
allowAddAccountingContext: true,
|
|
1363
|
+
allowAddPriceFeed: false,
|
|
1364
|
+
holdFees: true, // KEY: hold fees so we can process them later
|
|
1365
|
+
useTotalSurplusForCashOuts: false,
|
|
1366
|
+
useDataHookForPay: false,
|
|
1367
|
+
useDataHookForCashOut: false,
|
|
1368
|
+
dataHook: address(0),
|
|
1369
|
+
metadata: 0
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
JBCurrencyAmount[] memory surplusAllowances = new JBCurrencyAmount[](1);
|
|
1373
|
+
surplusAllowances[0] = JBCurrencyAmount({amount: 10 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
1374
|
+
|
|
1375
|
+
JBFundAccessLimitGroup[] memory fundAccessLimits = new JBFundAccessLimitGroup[](1);
|
|
1376
|
+
fundAccessLimits[0] = JBFundAccessLimitGroup({
|
|
1377
|
+
terminal: address(_terminal),
|
|
1378
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1379
|
+
payoutLimits: new JBCurrencyAmount[](0),
|
|
1380
|
+
surplusAllowances: surplusAllowances
|
|
1381
|
+
});
|
|
1382
|
+
|
|
1383
|
+
JBRulesetConfig[] memory testRulesetConfig = new JBRulesetConfig[](1);
|
|
1384
|
+
testRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1385
|
+
testRulesetConfig[0].duration = 0;
|
|
1386
|
+
testRulesetConfig[0].weight = _weight;
|
|
1387
|
+
testRulesetConfig[0].metadata = testMetadata;
|
|
1388
|
+
testRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1389
|
+
testRulesetConfig[0].fundAccessLimitGroups = fundAccessLimits;
|
|
1390
|
+
|
|
1391
|
+
uint256 projectId = _controller.launchProjectFor({
|
|
1392
|
+
owner: _projectOwner,
|
|
1393
|
+
projectUri: "testProject",
|
|
1394
|
+
rulesetConfigurations: testRulesetConfig,
|
|
1395
|
+
terminalConfigurations: terminalConfigurations,
|
|
1396
|
+
memo: ""
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
// --- Step 3: Fund the project and use allowance twice to create 2 held fees ---
|
|
1400
|
+
vm.deal(address(this), 10 ether);
|
|
1401
|
+
_terminal.pay{value: 10 ether}({
|
|
1402
|
+
projectId: projectId,
|
|
1403
|
+
amount: 10 ether,
|
|
1404
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1405
|
+
beneficiary: _beneficiary,
|
|
1406
|
+
minReturnedTokens: 0,
|
|
1407
|
+
memo: "fund",
|
|
1408
|
+
metadata: new bytes(0)
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
// Use allowance to create held fees (holdFees=true means fee is held, not processed immediately).
|
|
1412
|
+
vm.startPrank(_projectOwner);
|
|
1413
|
+
_terminal.useAllowanceOf({
|
|
1414
|
+
projectId: projectId,
|
|
1415
|
+
amount: 1 ether,
|
|
1416
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1417
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1418
|
+
minTokensPaidOut: 0,
|
|
1419
|
+
beneficiary: payable(_projectOwner),
|
|
1420
|
+
feeBeneficiary: payable(_projectOwner),
|
|
1421
|
+
memo: "allowance1"
|
|
1422
|
+
});
|
|
1423
|
+
_terminal.useAllowanceOf({
|
|
1424
|
+
projectId: projectId,
|
|
1425
|
+
amount: 1 ether,
|
|
1426
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1427
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1428
|
+
minTokensPaidOut: 0,
|
|
1429
|
+
beneficiary: payable(_projectOwner),
|
|
1430
|
+
feeBeneficiary: payable(_projectOwner),
|
|
1431
|
+
memo: "allowance2"
|
|
1432
|
+
});
|
|
1433
|
+
vm.stopPrank();
|
|
1434
|
+
|
|
1435
|
+
// Verify 2 held fees exist.
|
|
1436
|
+
JBFee[] memory heldFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1437
|
+
assertEq(heldFees.length, 2, "should have 2 held fees");
|
|
1438
|
+
|
|
1439
|
+
// --- Step 4: Deploy reentrant fee terminal and set it as primary for fee project ---
|
|
1440
|
+
ReentrantFeeTerminal reentrantTerminal =
|
|
1441
|
+
new ReentrantFeeTerminal(_terminal, projectId, JBConstants.NATIVE_TOKEN);
|
|
1442
|
+
|
|
1443
|
+
// Set the reentrant terminal as a terminal + primary terminal for fee project.
|
|
1444
|
+
IJBTerminal[] memory newTerminals = new IJBTerminal[](2);
|
|
1445
|
+
newTerminals[0] = _terminal; // Keep original
|
|
1446
|
+
newTerminals[1] = IJBTerminal(address(reentrantTerminal));
|
|
1447
|
+
|
|
1448
|
+
vm.startPrank(feeProjectOwner);
|
|
1449
|
+
jbDirectory().setTerminalsOf(feeProjectId, newTerminals);
|
|
1450
|
+
jbDirectory()
|
|
1451
|
+
.setPrimaryTerminalOf(feeProjectId, JBConstants.NATIVE_TOKEN, IJBTerminal(address(reentrantTerminal)));
|
|
1452
|
+
vm.stopPrank();
|
|
1453
|
+
|
|
1454
|
+
// --- Step 5: Warp past fee holding period and process ---
|
|
1455
|
+
vm.warp(block.timestamp + 2_419_200 + 1); // 28 days + 1 second
|
|
1456
|
+
|
|
1457
|
+
// Record terminal ETH before processing.
|
|
1458
|
+
uint256 terminalBalanceBefore = address(_terminal).balance;
|
|
1459
|
+
|
|
1460
|
+
// Process both held fees.
|
|
1461
|
+
_terminal.processHeldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1462
|
+
|
|
1463
|
+
// --- Step 6: Verify the fix ---
|
|
1464
|
+
// The reentrant terminal's pay() was called for each fee.
|
|
1465
|
+
// On the first call, it reentered processHeldFeesOf.
|
|
1466
|
+
// With the fix (CEI): the reentrant call sees the index already advanced,
|
|
1467
|
+
// so it only processes the remaining fees (not the same fee again).
|
|
1468
|
+
// Total pay calls should be exactly 2 (one per held fee), not 3+ (which would indicate double-processing).
|
|
1469
|
+
assertEq(
|
|
1470
|
+
reentrantTerminal.payCallCount(), 2, "M-1 FIX: exactly 2 pay calls (no double-processing from reentrancy)"
|
|
1471
|
+
);
|
|
1472
|
+
|
|
1473
|
+
// Held fees should all be consumed.
|
|
1474
|
+
JBFee[] memory remainingFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1475
|
+
assertEq(remainingFees.length, 0, "all held fees should be processed");
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/// @notice Verify processHeldFeesOf correctly handles partial unlock (some locked, some unlocked).
|
|
1479
|
+
function test_M1_partialLockedFees() public {
|
|
1480
|
+
// Launch fee project.
|
|
1481
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1482
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
1483
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
1484
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1485
|
+
});
|
|
1486
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
1487
|
+
|
|
1488
|
+
JBRulesetConfig[] memory feeRulesetConfig = new JBRulesetConfig[](1);
|
|
1489
|
+
feeRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1490
|
+
feeRulesetConfig[0].duration = 0;
|
|
1491
|
+
feeRulesetConfig[0].weight = _weight;
|
|
1492
|
+
feeRulesetConfig[0].metadata = JBRulesetMetadata({
|
|
1493
|
+
reservedPercent: 0,
|
|
1494
|
+
cashOutTaxRate: 0,
|
|
1495
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1496
|
+
pausePay: false,
|
|
1497
|
+
pauseCreditTransfers: false,
|
|
1498
|
+
allowOwnerMinting: false,
|
|
1499
|
+
allowSetCustomToken: false,
|
|
1500
|
+
allowTerminalMigration: false,
|
|
1501
|
+
allowSetTerminals: false,
|
|
1502
|
+
ownerMustSendPayouts: false,
|
|
1503
|
+
allowSetController: false,
|
|
1504
|
+
allowAddAccountingContext: true,
|
|
1505
|
+
allowAddPriceFeed: false,
|
|
1506
|
+
holdFees: false,
|
|
1507
|
+
useTotalSurplusForCashOuts: false,
|
|
1508
|
+
useDataHookForPay: false,
|
|
1509
|
+
useDataHookForCashOut: false,
|
|
1510
|
+
dataHook: address(0),
|
|
1511
|
+
metadata: 0
|
|
1512
|
+
});
|
|
1513
|
+
feeRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1514
|
+
feeRulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
1515
|
+
|
|
1516
|
+
_controller.launchProjectFor({
|
|
1517
|
+
owner: makeAddr("feeOwner"),
|
|
1518
|
+
projectUri: "fee",
|
|
1519
|
+
rulesetConfigurations: feeRulesetConfig,
|
|
1520
|
+
terminalConfigurations: terminalConfigurations,
|
|
1521
|
+
memo: ""
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
// Launch test project with holdFees.
|
|
1525
|
+
JBRulesetMetadata memory testMetadata = JBRulesetMetadata({
|
|
1526
|
+
reservedPercent: 0,
|
|
1527
|
+
cashOutTaxRate: 0,
|
|
1528
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1529
|
+
pausePay: false,
|
|
1530
|
+
pauseCreditTransfers: false,
|
|
1531
|
+
allowOwnerMinting: true,
|
|
1532
|
+
allowSetCustomToken: false,
|
|
1533
|
+
allowTerminalMigration: false,
|
|
1534
|
+
allowSetTerminals: false,
|
|
1535
|
+
ownerMustSendPayouts: false,
|
|
1536
|
+
allowSetController: false,
|
|
1537
|
+
allowAddAccountingContext: true,
|
|
1538
|
+
allowAddPriceFeed: false,
|
|
1539
|
+
holdFees: true,
|
|
1540
|
+
useTotalSurplusForCashOuts: false,
|
|
1541
|
+
useDataHookForPay: false,
|
|
1542
|
+
useDataHookForCashOut: false,
|
|
1543
|
+
dataHook: address(0),
|
|
1544
|
+
metadata: 0
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
JBCurrencyAmount[] memory surplusAllowances = new JBCurrencyAmount[](1);
|
|
1548
|
+
surplusAllowances[0] = JBCurrencyAmount({amount: 10 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
1549
|
+
|
|
1550
|
+
JBFundAccessLimitGroup[] memory fundAccessLimits = new JBFundAccessLimitGroup[](1);
|
|
1551
|
+
fundAccessLimits[0] = JBFundAccessLimitGroup({
|
|
1552
|
+
terminal: address(_terminal),
|
|
1553
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1554
|
+
payoutLimits: new JBCurrencyAmount[](0),
|
|
1555
|
+
surplusAllowances: surplusAllowances
|
|
1556
|
+
});
|
|
1557
|
+
|
|
1558
|
+
JBRulesetConfig[] memory testRulesetConfig = new JBRulesetConfig[](1);
|
|
1559
|
+
testRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1560
|
+
testRulesetConfig[0].duration = 0;
|
|
1561
|
+
testRulesetConfig[0].weight = _weight;
|
|
1562
|
+
testRulesetConfig[0].metadata = testMetadata;
|
|
1563
|
+
testRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1564
|
+
testRulesetConfig[0].fundAccessLimitGroups = fundAccessLimits;
|
|
1565
|
+
|
|
1566
|
+
uint256 projectId = _controller.launchProjectFor({
|
|
1567
|
+
owner: _projectOwner,
|
|
1568
|
+
projectUri: "testProject",
|
|
1569
|
+
rulesetConfigurations: testRulesetConfig,
|
|
1570
|
+
terminalConfigurations: terminalConfigurations,
|
|
1571
|
+
memo: ""
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
// Fund and create first held fee.
|
|
1575
|
+
vm.deal(address(this), 10 ether);
|
|
1576
|
+
_terminal.pay{value: 10 ether}({
|
|
1577
|
+
projectId: projectId,
|
|
1578
|
+
amount: 10 ether,
|
|
1579
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1580
|
+
beneficiary: _beneficiary,
|
|
1581
|
+
minReturnedTokens: 0,
|
|
1582
|
+
memo: "fund",
|
|
1583
|
+
metadata: new bytes(0)
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
vm.prank(_projectOwner);
|
|
1587
|
+
_terminal.useAllowanceOf({
|
|
1588
|
+
projectId: projectId,
|
|
1589
|
+
amount: 1 ether,
|
|
1590
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1591
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1592
|
+
minTokensPaidOut: 0,
|
|
1593
|
+
beneficiary: payable(_projectOwner),
|
|
1594
|
+
feeBeneficiary: payable(_projectOwner),
|
|
1595
|
+
memo: "allowance1"
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
// Warp 14 days (halfway through holding period).
|
|
1599
|
+
vm.warp(block.timestamp + 14 days);
|
|
1600
|
+
|
|
1601
|
+
// Create second held fee (this one will be locked when we process at day 28).
|
|
1602
|
+
vm.prank(_projectOwner);
|
|
1603
|
+
_terminal.useAllowanceOf({
|
|
1604
|
+
projectId: projectId,
|
|
1605
|
+
amount: 1 ether,
|
|
1606
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1607
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1608
|
+
minTokensPaidOut: 0,
|
|
1609
|
+
beneficiary: payable(_projectOwner),
|
|
1610
|
+
feeBeneficiary: payable(_projectOwner),
|
|
1611
|
+
memo: "allowance2"
|
|
1612
|
+
});
|
|
1613
|
+
|
|
1614
|
+
// Verify 2 held fees.
|
|
1615
|
+
JBFee[] memory heldFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1616
|
+
assertEq(heldFees.length, 2, "should have 2 held fees");
|
|
1617
|
+
|
|
1618
|
+
// Warp to 28 days + 1 from the start (first fee unlocked, second still locked 14 more days).
|
|
1619
|
+
vm.warp(block.timestamp + 14 days + 1);
|
|
1620
|
+
|
|
1621
|
+
// Process all — should only process the first (unlocked) fee and stop at the second (locked).
|
|
1622
|
+
_terminal.processHeldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1623
|
+
|
|
1624
|
+
// Only 1 fee should remain (the locked one).
|
|
1625
|
+
JBFee[] memory remainingFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1626
|
+
assertEq(remainingFees.length, 1, "one fee should still be locked");
|
|
1627
|
+
|
|
1628
|
+
// Warp past second fee's unlock.
|
|
1629
|
+
vm.warp(block.timestamp + 14 days + 1);
|
|
1630
|
+
|
|
1631
|
+
// Process the remaining fee.
|
|
1632
|
+
_terminal.processHeldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1633
|
+
|
|
1634
|
+
// All fees processed.
|
|
1635
|
+
JBFee[] memory finalFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1636
|
+
assertEq(finalFees.length, 0, "all fees should be processed");
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
//*********************************************************************//
|
|
1640
|
+
// --- [H-3] Approval Hook Revert DoS (NEW - no test existed) ------- //
|
|
1641
|
+
//*********************************************************************//
|
|
1642
|
+
|
|
1643
|
+
/// @notice PoC: A malicious approval hook that reverts on approvalStatusOf() causes
|
|
1644
|
+
/// currentOf() to revert when the NEXT ruleset (queued or auto-cycled) needs
|
|
1645
|
+
/// approval from the hook. This permanently freezes the project.
|
|
1646
|
+
/// Affects JBRulesets (NOT fixed).
|
|
1647
|
+
function test_H3_approvalHookRevertDoS() public {
|
|
1648
|
+
// Deploy a malicious approval hook that always reverts
|
|
1649
|
+
MaliciousApprovalHook maliciousHook = new MaliciousApprovalHook();
|
|
1650
|
+
|
|
1651
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1652
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
1653
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
1654
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1655
|
+
});
|
|
1656
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
1657
|
+
|
|
1658
|
+
// Launch fee collector
|
|
1659
|
+
JBRulesetConfig[] memory feeConfig = new JBRulesetConfig[](1);
|
|
1660
|
+
feeConfig[0].mustStartAtOrAfter = 0;
|
|
1661
|
+
feeConfig[0].duration = 0;
|
|
1662
|
+
feeConfig[0].weight = 1000e18;
|
|
1663
|
+
feeConfig[0].metadata = JBRulesetMetadata({
|
|
1664
|
+
reservedPercent: 0,
|
|
1665
|
+
cashOutTaxRate: 0,
|
|
1666
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1667
|
+
pausePay: false,
|
|
1668
|
+
pauseCreditTransfers: false,
|
|
1669
|
+
allowOwnerMinting: false,
|
|
1670
|
+
allowSetCustomToken: false,
|
|
1671
|
+
allowTerminalMigration: false,
|
|
1672
|
+
allowSetTerminals: false,
|
|
1673
|
+
ownerMustSendPayouts: false,
|
|
1674
|
+
allowSetController: false,
|
|
1675
|
+
allowAddAccountingContext: true,
|
|
1676
|
+
allowAddPriceFeed: false,
|
|
1677
|
+
holdFees: false,
|
|
1678
|
+
useTotalSurplusForCashOuts: false,
|
|
1679
|
+
useDataHookForPay: false,
|
|
1680
|
+
useDataHookForCashOut: false,
|
|
1681
|
+
dataHook: address(0),
|
|
1682
|
+
metadata: 0
|
|
1683
|
+
});
|
|
1684
|
+
feeConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1685
|
+
feeConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
1686
|
+
|
|
1687
|
+
_controller.launchProjectFor({
|
|
1688
|
+
owner: address(420),
|
|
1689
|
+
projectUri: "feeCollector",
|
|
1690
|
+
rulesetConfigurations: feeConfig,
|
|
1691
|
+
terminalConfigurations: terminalConfigurations,
|
|
1692
|
+
memo: ""
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
// Launch project with FIRST ruleset that has the malicious hook (30-day duration).
|
|
1696
|
+
// The hook is on the FIRST ruleset - it gates approval of any NEXT ruleset.
|
|
1697
|
+
// When the first ruleset expires and auto-cycles, _approvalStatusOf is called
|
|
1698
|
+
// on the auto-cycled ruleset, which queries the FIRST ruleset's approval hook.
|
|
1699
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
1700
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1701
|
+
rulesetConfig[0].duration = 30 days;
|
|
1702
|
+
rulesetConfig[0].weight = _weight;
|
|
1703
|
+
rulesetConfig[0].weightCutPercent = 0;
|
|
1704
|
+
rulesetConfig[0].approvalHook = IJBRulesetApprovalHook(address(maliciousHook));
|
|
1705
|
+
rulesetConfig[0].metadata = JBRulesetMetadata({
|
|
1706
|
+
reservedPercent: 0,
|
|
1707
|
+
cashOutTaxRate: 0,
|
|
1708
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1709
|
+
pausePay: false,
|
|
1710
|
+
pauseCreditTransfers: false,
|
|
1711
|
+
allowOwnerMinting: true,
|
|
1712
|
+
allowSetCustomToken: true,
|
|
1713
|
+
allowTerminalMigration: false,
|
|
1714
|
+
allowSetTerminals: false,
|
|
1715
|
+
ownerMustSendPayouts: false,
|
|
1716
|
+
allowSetController: false,
|
|
1717
|
+
allowAddAccountingContext: true,
|
|
1718
|
+
allowAddPriceFeed: false,
|
|
1719
|
+
holdFees: false,
|
|
1720
|
+
useTotalSurplusForCashOuts: false,
|
|
1721
|
+
useDataHookForPay: false,
|
|
1722
|
+
useDataHookForCashOut: false,
|
|
1723
|
+
dataHook: address(0),
|
|
1724
|
+
metadata: 0
|
|
1725
|
+
});
|
|
1726
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1727
|
+
rulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
1728
|
+
|
|
1729
|
+
uint256 projectId = _controller.launchProjectFor({
|
|
1730
|
+
owner: _projectOwner,
|
|
1731
|
+
projectUri: "doSProject",
|
|
1732
|
+
rulesetConfigurations: rulesetConfig,
|
|
1733
|
+
terminalConfigurations: terminalConfigurations,
|
|
1734
|
+
memo: ""
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
// During the first ruleset, currentOf works fine (basedOnId == 0 -> Empty status, no hook call)
|
|
1738
|
+
JBRuleset memory initialRuleset = jbRulesets().currentOf(projectId);
|
|
1739
|
+
assertGt(initialRuleset.id, 0, "project should have active ruleset initially");
|
|
1740
|
+
|
|
1741
|
+
// Queue a second ruleset (which will need approval from the first ruleset's hook)
|
|
1742
|
+
JBRulesetConfig[] memory queuedConfig = new JBRulesetConfig[](1);
|
|
1743
|
+
queuedConfig[0].mustStartAtOrAfter = 0;
|
|
1744
|
+
queuedConfig[0].duration = 30 days;
|
|
1745
|
+
queuedConfig[0].weight = _weight / 2;
|
|
1746
|
+
queuedConfig[0].weightCutPercent = 0;
|
|
1747
|
+
queuedConfig[0].approvalHook = IJBRulesetApprovalHook(address(0));
|
|
1748
|
+
queuedConfig[0].metadata = rulesetConfig[0].metadata;
|
|
1749
|
+
queuedConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1750
|
+
queuedConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
1751
|
+
|
|
1752
|
+
vm.prank(_projectOwner);
|
|
1753
|
+
_controller.queueRulesetsOf({projectId: projectId, rulesetConfigurations: queuedConfig, memo: ""});
|
|
1754
|
+
|
|
1755
|
+
// Warp past the first ruleset's duration + approval hook's DURATION
|
|
1756
|
+
vm.warp(block.timestamp + 31 days + 1 days + 1);
|
|
1757
|
+
|
|
1758
|
+
// After fix: the malicious hook's revert is caught by try/catch.
|
|
1759
|
+
// currentOf no longer reverts — it treats the approval as Failed and falls back
|
|
1760
|
+
// to the most recent approved ruleset. The project is NOT frozen.
|
|
1761
|
+
JBRuleset memory currentRuleset = jbRulesets().currentOf(projectId);
|
|
1762
|
+
assertGt(currentRuleset.id, 0, "H-3 FIX: project is not frozen, currentOf succeeds");
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
//*********************************************************************//
|
|
1766
|
+
// --- [C-5] V5.1 Regression: Verify Fix Works ---------------------- //
|
|
1767
|
+
//*********************************************************************//
|
|
1768
|
+
|
|
1769
|
+
/// @notice Verify that the C-5 zero-supply cash-out exploit is still present in V5.0
|
|
1770
|
+
/// but the V5.1 rulesets should handle rulesets correctly (C-5 is a different
|
|
1771
|
+
/// bug from the rulesets fix - C-5 is in JBCashOuts library).
|
|
1772
|
+
/// @dev Confirms the C-5 fix: cashing out 0 tokens returns 0, not the full surplus.
|
|
1773
|
+
function test_C5_v51_zeroSupplyCashOut_fixedInLibrary() public pure {
|
|
1774
|
+
// C-5 was in JBCashOuts.cashOutFrom: when cashOutCount=0 and totalSupply=0,
|
|
1775
|
+
// the function returned the full surplus instead of 0.
|
|
1776
|
+
// The fix adds an early return of 0 when cashOutCount == 0.
|
|
1777
|
+
uint256 reclaimFromLibrary =
|
|
1778
|
+
JBCashOuts.cashOutFrom({surplus: 10 ether, cashOutCount: 0, totalSupply: 0, cashOutTaxRate: 0});
|
|
1779
|
+
|
|
1780
|
+
// Cashing out 0 tokens should return 0, not the full surplus.
|
|
1781
|
+
assertEq(reclaimFromLibrary, 0, "C-5 FIX: cashOutFrom(surplus, 0, 0, rate) should return 0");
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/// @notice Verify C-5 does not manifest when totalSupply > 0 (normal operation).
|
|
1785
|
+
function test_C5_v51_normalOperation_cashOut_safe() public pure {
|
|
1786
|
+
// With non-zero supply and non-zero cashOutCount, the bonding curve works correctly
|
|
1787
|
+
uint256 reclaimable =
|
|
1788
|
+
JBCashOuts.cashOutFrom({surplus: 10 ether, cashOutCount: 500e18, totalSupply: 1000e18, cashOutTaxRate: 0});
|
|
1789
|
+
|
|
1790
|
+
// With 0% tax rate and 50% of supply, should get 50% of surplus
|
|
1791
|
+
assertEq(reclaimable, 5 ether, "normal cash out should work correctly");
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
//*********************************************************************//
|
|
1795
|
+
// --- [L-5] Held Fees Storage Cleanup ------------------------------ //
|
|
1796
|
+
//*********************************************************************//
|
|
1797
|
+
|
|
1798
|
+
/// @notice Verifies that processHeldFeesOf deletes processed entries and resets array+index when done.
|
|
1799
|
+
function test_L5_heldFeesStorageCleanup() public {
|
|
1800
|
+
// Launch fee project.
|
|
1801
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1802
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
1803
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
1804
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1805
|
+
});
|
|
1806
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
1807
|
+
|
|
1808
|
+
JBRulesetConfig[] memory feeRulesetConfig = new JBRulesetConfig[](1);
|
|
1809
|
+
feeRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1810
|
+
feeRulesetConfig[0].duration = 0;
|
|
1811
|
+
feeRulesetConfig[0].weight = _weight;
|
|
1812
|
+
feeRulesetConfig[0].metadata = JBRulesetMetadata({
|
|
1813
|
+
reservedPercent: 0,
|
|
1814
|
+
cashOutTaxRate: 0,
|
|
1815
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1816
|
+
pausePay: false,
|
|
1817
|
+
pauseCreditTransfers: false,
|
|
1818
|
+
allowOwnerMinting: false,
|
|
1819
|
+
allowSetCustomToken: false,
|
|
1820
|
+
allowTerminalMigration: false,
|
|
1821
|
+
allowSetTerminals: false,
|
|
1822
|
+
ownerMustSendPayouts: false,
|
|
1823
|
+
allowSetController: false,
|
|
1824
|
+
allowAddAccountingContext: true,
|
|
1825
|
+
allowAddPriceFeed: false,
|
|
1826
|
+
holdFees: false,
|
|
1827
|
+
useTotalSurplusForCashOuts: false,
|
|
1828
|
+
useDataHookForPay: false,
|
|
1829
|
+
useDataHookForCashOut: false,
|
|
1830
|
+
dataHook: address(0),
|
|
1831
|
+
metadata: 0
|
|
1832
|
+
});
|
|
1833
|
+
feeRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1834
|
+
feeRulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
1835
|
+
|
|
1836
|
+
_controller.launchProjectFor({
|
|
1837
|
+
owner: makeAddr("feeOwner"),
|
|
1838
|
+
projectUri: "fee",
|
|
1839
|
+
rulesetConfigurations: feeRulesetConfig,
|
|
1840
|
+
terminalConfigurations: terminalConfigurations,
|
|
1841
|
+
memo: ""
|
|
1842
|
+
});
|
|
1843
|
+
|
|
1844
|
+
// Launch project with holdFees.
|
|
1845
|
+
JBRulesetMetadata memory testMetadata = JBRulesetMetadata({
|
|
1846
|
+
reservedPercent: 0,
|
|
1847
|
+
cashOutTaxRate: 0,
|
|
1848
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1849
|
+
pausePay: false,
|
|
1850
|
+
pauseCreditTransfers: false,
|
|
1851
|
+
allowOwnerMinting: true,
|
|
1852
|
+
allowSetCustomToken: false,
|
|
1853
|
+
allowTerminalMigration: false,
|
|
1854
|
+
allowSetTerminals: false,
|
|
1855
|
+
ownerMustSendPayouts: false,
|
|
1856
|
+
allowSetController: false,
|
|
1857
|
+
allowAddAccountingContext: true,
|
|
1858
|
+
allowAddPriceFeed: false,
|
|
1859
|
+
holdFees: true,
|
|
1860
|
+
useTotalSurplusForCashOuts: false,
|
|
1861
|
+
useDataHookForPay: false,
|
|
1862
|
+
useDataHookForCashOut: false,
|
|
1863
|
+
dataHook: address(0),
|
|
1864
|
+
metadata: 0
|
|
1865
|
+
});
|
|
1866
|
+
|
|
1867
|
+
JBCurrencyAmount[] memory surplusAllowances = new JBCurrencyAmount[](1);
|
|
1868
|
+
surplusAllowances[0] = JBCurrencyAmount({amount: 10 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
1869
|
+
|
|
1870
|
+
JBFundAccessLimitGroup[] memory fundAccessLimits = new JBFundAccessLimitGroup[](1);
|
|
1871
|
+
fundAccessLimits[0] = JBFundAccessLimitGroup({
|
|
1872
|
+
terminal: address(_terminal),
|
|
1873
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1874
|
+
payoutLimits: new JBCurrencyAmount[](0),
|
|
1875
|
+
surplusAllowances: surplusAllowances
|
|
1876
|
+
});
|
|
1877
|
+
|
|
1878
|
+
JBRulesetConfig[] memory testRulesetConfig = new JBRulesetConfig[](1);
|
|
1879
|
+
testRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1880
|
+
testRulesetConfig[0].duration = 0;
|
|
1881
|
+
testRulesetConfig[0].weight = _weight;
|
|
1882
|
+
testRulesetConfig[0].metadata = testMetadata;
|
|
1883
|
+
testRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
1884
|
+
testRulesetConfig[0].fundAccessLimitGroups = fundAccessLimits;
|
|
1885
|
+
|
|
1886
|
+
uint256 projectId = _controller.launchProjectFor({
|
|
1887
|
+
owner: _projectOwner,
|
|
1888
|
+
projectUri: "testProject",
|
|
1889
|
+
rulesetConfigurations: testRulesetConfig,
|
|
1890
|
+
terminalConfigurations: terminalConfigurations,
|
|
1891
|
+
memo: ""
|
|
1892
|
+
});
|
|
1893
|
+
|
|
1894
|
+
// Fund and create 3 held fees.
|
|
1895
|
+
vm.deal(address(this), 10 ether);
|
|
1896
|
+
_terminal.pay{value: 10 ether}({
|
|
1897
|
+
projectId: projectId,
|
|
1898
|
+
amount: 10 ether,
|
|
1899
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1900
|
+
beneficiary: _beneficiary,
|
|
1901
|
+
minReturnedTokens: 0,
|
|
1902
|
+
memo: "fund",
|
|
1903
|
+
metadata: new bytes(0)
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
vm.startPrank(_projectOwner);
|
|
1907
|
+
for (uint256 i; i < 3; i++) {
|
|
1908
|
+
_terminal.useAllowanceOf({
|
|
1909
|
+
projectId: projectId,
|
|
1910
|
+
amount: 1 ether,
|
|
1911
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1912
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1913
|
+
minTokensPaidOut: 0,
|
|
1914
|
+
beneficiary: payable(_projectOwner),
|
|
1915
|
+
feeBeneficiary: payable(_projectOwner),
|
|
1916
|
+
memo: "allowance"
|
|
1917
|
+
});
|
|
1918
|
+
}
|
|
1919
|
+
vm.stopPrank();
|
|
1920
|
+
|
|
1921
|
+
// Verify 3 held fees.
|
|
1922
|
+
JBFee[] memory fees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1923
|
+
assertEq(fees.length, 3, "should have 3 held fees");
|
|
1924
|
+
|
|
1925
|
+
// Warp past unlock.
|
|
1926
|
+
vm.warp(block.timestamp + 2_419_200 + 1);
|
|
1927
|
+
|
|
1928
|
+
// Process all 3.
|
|
1929
|
+
_terminal.processHeldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1930
|
+
|
|
1931
|
+
// After processing all, the array should be fully reset (not just emptied).
|
|
1932
|
+
JBFee[] memory remaining = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1933
|
+
assertEq(remaining.length, 0, "L-5: all fees processed and array cleaned");
|
|
1934
|
+
|
|
1935
|
+
// Creating new fees after cleanup should work from index 0 (array was reset).
|
|
1936
|
+
vm.deal(address(this), 2 ether);
|
|
1937
|
+
_terminal.pay{value: 2 ether}({
|
|
1938
|
+
projectId: projectId,
|
|
1939
|
+
amount: 2 ether,
|
|
1940
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1941
|
+
beneficiary: _beneficiary,
|
|
1942
|
+
minReturnedTokens: 0,
|
|
1943
|
+
memo: "fund2",
|
|
1944
|
+
metadata: new bytes(0)
|
|
1945
|
+
});
|
|
1946
|
+
vm.prank(_projectOwner);
|
|
1947
|
+
_terminal.useAllowanceOf({
|
|
1948
|
+
projectId: projectId,
|
|
1949
|
+
amount: 1 ether,
|
|
1950
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1951
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
1952
|
+
minTokensPaidOut: 0,
|
|
1953
|
+
beneficiary: payable(_projectOwner),
|
|
1954
|
+
feeBeneficiary: payable(_projectOwner),
|
|
1955
|
+
memo: "allowance-after-cleanup"
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
// Should have 1 new fee (not 4 = 3 deleted + 1 new).
|
|
1959
|
+
JBFee[] memory newFees = _terminal.heldFeesOf(projectId, JBConstants.NATIVE_TOKEN, 100);
|
|
1960
|
+
assertEq(newFees.length, 1, "L-5: new fee after array reset works correctly");
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
//*********************************************************************//
|
|
1964
|
+
// --- [L-8] Ruleset Start Time After Base Ruleset ------------------ //
|
|
1965
|
+
//*********************************************************************//
|
|
1966
|
+
|
|
1967
|
+
/// @notice Verifies that a queued ruleset's start time is bumped to at least
|
|
1968
|
+
/// the base ruleset's start time (L-8 fix).
|
|
1969
|
+
function test_L8_rulesetStartsAfterBaseRuleset() public {
|
|
1970
|
+
// Launch fee project.
|
|
1971
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
1972
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
1973
|
+
tokensToAccept[0] = JBAccountingContext({
|
|
1974
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
1975
|
+
});
|
|
1976
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
1977
|
+
|
|
1978
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
1979
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
1980
|
+
rulesetConfig[0].duration = 30 days;
|
|
1981
|
+
rulesetConfig[0].weight = _weight;
|
|
1982
|
+
rulesetConfig[0].metadata = JBRulesetMetadata({
|
|
1983
|
+
reservedPercent: 0,
|
|
1984
|
+
cashOutTaxRate: 0,
|
|
1985
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
1986
|
+
pausePay: false,
|
|
1987
|
+
pauseCreditTransfers: false,
|
|
1988
|
+
allowOwnerMinting: true,
|
|
1989
|
+
allowSetCustomToken: false,
|
|
1990
|
+
allowTerminalMigration: false,
|
|
1991
|
+
allowSetTerminals: false,
|
|
1992
|
+
ownerMustSendPayouts: false,
|
|
1993
|
+
allowSetController: false,
|
|
1994
|
+
allowAddAccountingContext: true,
|
|
1995
|
+
allowAddPriceFeed: false,
|
|
1996
|
+
holdFees: false,
|
|
1997
|
+
useTotalSurplusForCashOuts: false,
|
|
1998
|
+
useDataHookForPay: false,
|
|
1999
|
+
useDataHookForCashOut: false,
|
|
2000
|
+
dataHook: address(0),
|
|
2001
|
+
metadata: 0
|
|
2002
|
+
});
|
|
2003
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
2004
|
+
rulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
2005
|
+
|
|
2006
|
+
_controller.launchProjectFor({
|
|
2007
|
+
owner: makeAddr("feeOwner"),
|
|
2008
|
+
projectUri: "fee",
|
|
2009
|
+
rulesetConfigurations: rulesetConfig,
|
|
2010
|
+
terminalConfigurations: terminalConfigurations,
|
|
2011
|
+
memo: ""
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
// Launch project with 30-day duration ruleset.
|
|
2015
|
+
uint256 projectId = _controller.launchProjectFor({
|
|
2016
|
+
owner: _projectOwner,
|
|
2017
|
+
projectUri: "testProject",
|
|
2018
|
+
rulesetConfigurations: rulesetConfig,
|
|
2019
|
+
terminalConfigurations: terminalConfigurations,
|
|
2020
|
+
memo: ""
|
|
2021
|
+
});
|
|
2022
|
+
|
|
2023
|
+
// Get the first ruleset's start time.
|
|
2024
|
+
JBRuleset memory firstRuleset = jbRulesets().currentOf(projectId);
|
|
2025
|
+
uint256 firstStart = firstRuleset.start;
|
|
2026
|
+
assertGt(firstStart, 0, "first ruleset should have a start time");
|
|
2027
|
+
|
|
2028
|
+
// Queue a second ruleset with mustStartAtOrAfter=0 (meaning "as soon as possible").
|
|
2029
|
+
// Without the L-8 fix, this could theoretically derive a start before the base ruleset.
|
|
2030
|
+
JBRulesetConfig[] memory secondConfig = new JBRulesetConfig[](1);
|
|
2031
|
+
secondConfig[0].mustStartAtOrAfter = 0; // Key: asking for earliest possible
|
|
2032
|
+
secondConfig[0].duration = 30 days;
|
|
2033
|
+
secondConfig[0].weight = _weight / 2;
|
|
2034
|
+
secondConfig[0].metadata = rulesetConfig[0].metadata;
|
|
2035
|
+
secondConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
2036
|
+
secondConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
2037
|
+
|
|
2038
|
+
vm.prank(_projectOwner);
|
|
2039
|
+
_controller.queueRulesetsOf({projectId: projectId, rulesetConfigurations: secondConfig, memo: ""});
|
|
2040
|
+
|
|
2041
|
+
// Get the queued ruleset.
|
|
2042
|
+
(JBRuleset memory queued,) = jbRulesets().latestQueuedOf(projectId);
|
|
2043
|
+
|
|
2044
|
+
// L-8 fix: the queued ruleset must start at or after the base ruleset's start.
|
|
2045
|
+
assertGe(queued.start, firstStart, "L-8: queued ruleset must start at or after base ruleset");
|
|
2046
|
+
|
|
2047
|
+
// It should start at the next cycle boundary (firstStart + duration).
|
|
2048
|
+
assertEq(queued.start, firstStart + 30 days, "queued ruleset starts at next cycle boundary");
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
//*********************************************************************//
|
|
2052
|
+
// --- [H-4] Pending Reserves Inflate cashOutWeight (property test)-- //
|
|
2053
|
+
//*********************************************************************//
|
|
2054
|
+
|
|
2055
|
+
//*********************************************************************//
|
|
2056
|
+
// ====== CROSS-CHAIN COMPATIBILITY (Celo/Tempo) =================== //
|
|
2057
|
+
//*********************************************************************//
|
|
2058
|
+
|
|
2059
|
+
/// @notice Helper: launches a project with only MockERC20 (6 decimals, simulating USDC on Tempo).
|
|
2060
|
+
/// No NATIVE_TOKEN accounting context is registered.
|
|
2061
|
+
function _launchProjectERC20(uint16 cashOutTaxRate, uint16 reservedPercent) internal returns (uint256 projectId) {
|
|
2062
|
+
MockERC20 token = usdcToken();
|
|
2063
|
+
|
|
2064
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
2065
|
+
reservedPercent: reservedPercent,
|
|
2066
|
+
cashOutTaxRate: cashOutTaxRate,
|
|
2067
|
+
baseCurrency: uint32(uint160(address(token))),
|
|
2068
|
+
pausePay: false,
|
|
2069
|
+
pauseCreditTransfers: false,
|
|
2070
|
+
allowOwnerMinting: true,
|
|
2071
|
+
allowSetCustomToken: true,
|
|
2072
|
+
allowTerminalMigration: false,
|
|
2073
|
+
allowSetTerminals: false,
|
|
2074
|
+
ownerMustSendPayouts: false,
|
|
2075
|
+
allowSetController: false,
|
|
2076
|
+
allowAddAccountingContext: true,
|
|
2077
|
+
allowAddPriceFeed: false,
|
|
2078
|
+
holdFees: false,
|
|
2079
|
+
useTotalSurplusForCashOuts: false,
|
|
2080
|
+
useDataHookForPay: false,
|
|
2081
|
+
useDataHookForCashOut: false,
|
|
2082
|
+
dataHook: address(0),
|
|
2083
|
+
metadata: 0
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
2087
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
2088
|
+
rulesetConfig[0].duration = 0;
|
|
2089
|
+
rulesetConfig[0].weight = _weight;
|
|
2090
|
+
rulesetConfig[0].weightCutPercent = 0;
|
|
2091
|
+
rulesetConfig[0].approvalHook = IJBRulesetApprovalHook(address(0));
|
|
2092
|
+
rulesetConfig[0].metadata = metadata;
|
|
2093
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
2094
|
+
rulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
2095
|
+
|
|
2096
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
2097
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
2098
|
+
tokensToAccept[0] =
|
|
2099
|
+
JBAccountingContext({token: address(token), decimals: 6, currency: uint32(uint160(address(token)))});
|
|
2100
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
2101
|
+
|
|
2102
|
+
// Create project #1 to collect fees (if it does not exist yet).
|
|
2103
|
+
// Fee project also uses ERC-20 only (but a different terminal config for simplicity).
|
|
2104
|
+
JBTerminalConfig[] memory feeTermConfigs = new JBTerminalConfig[](1);
|
|
2105
|
+
JBAccountingContext[] memory feeTokens = new JBAccountingContext[](1);
|
|
2106
|
+
feeTokens[0] = JBAccountingContext({
|
|
2107
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2108
|
+
});
|
|
2109
|
+
feeTermConfigs[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: feeTokens});
|
|
2110
|
+
|
|
2111
|
+
_controller.launchProjectFor({
|
|
2112
|
+
owner: address(420),
|
|
2113
|
+
projectUri: "feeCollector",
|
|
2114
|
+
rulesetConfigurations: rulesetConfig,
|
|
2115
|
+
terminalConfigurations: feeTermConfigs,
|
|
2116
|
+
memo: ""
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
// Create the actual test project with ERC-20 only.
|
|
2120
|
+
projectId = _controller.launchProjectFor({
|
|
2121
|
+
owner: _projectOwner,
|
|
2122
|
+
projectUri: "erc20OnlyProject",
|
|
2123
|
+
rulesetConfigurations: rulesetConfig,
|
|
2124
|
+
terminalConfigurations: terminalConfigurations,
|
|
2125
|
+
memo: ""
|
|
2126
|
+
});
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
/// @notice Helper: launches an ERC-20-only project with payout limits.
|
|
2130
|
+
function _launchProjectERC20WithPayoutLimit(
|
|
2131
|
+
uint16 cashOutTaxRate,
|
|
2132
|
+
uint224 payoutLimit
|
|
2133
|
+
)
|
|
2134
|
+
internal
|
|
2135
|
+
returns (uint256 projectId)
|
|
2136
|
+
{
|
|
2137
|
+
MockERC20 token = usdcToken();
|
|
2138
|
+
|
|
2139
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
2140
|
+
reservedPercent: 0,
|
|
2141
|
+
cashOutTaxRate: cashOutTaxRate,
|
|
2142
|
+
baseCurrency: uint32(uint160(address(token))),
|
|
2143
|
+
pausePay: false,
|
|
2144
|
+
pauseCreditTransfers: false,
|
|
2145
|
+
allowOwnerMinting: true,
|
|
2146
|
+
allowSetCustomToken: true,
|
|
2147
|
+
allowTerminalMigration: false,
|
|
2148
|
+
allowSetTerminals: false,
|
|
2149
|
+
ownerMustSendPayouts: false,
|
|
2150
|
+
allowSetController: false,
|
|
2151
|
+
allowAddAccountingContext: true,
|
|
2152
|
+
allowAddPriceFeed: false,
|
|
2153
|
+
holdFees: false,
|
|
2154
|
+
useTotalSurplusForCashOuts: false,
|
|
2155
|
+
useDataHookForPay: false,
|
|
2156
|
+
useDataHookForCashOut: false,
|
|
2157
|
+
dataHook: address(0),
|
|
2158
|
+
metadata: 0
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
|
|
2162
|
+
payoutLimits[0] = JBCurrencyAmount({amount: payoutLimit, currency: uint32(uint160(address(token)))});
|
|
2163
|
+
|
|
2164
|
+
JBFundAccessLimitGroup[] memory fundAccessLimitGroup = new JBFundAccessLimitGroup[](1);
|
|
2165
|
+
fundAccessLimitGroup[0] = JBFundAccessLimitGroup({
|
|
2166
|
+
terminal: address(_terminal),
|
|
2167
|
+
token: address(token),
|
|
2168
|
+
payoutLimits: payoutLimits,
|
|
2169
|
+
surplusAllowances: new JBCurrencyAmount[](0)
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
2173
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
2174
|
+
rulesetConfig[0].duration = 0;
|
|
2175
|
+
rulesetConfig[0].weight = _weight;
|
|
2176
|
+
rulesetConfig[0].weightCutPercent = 0;
|
|
2177
|
+
rulesetConfig[0].approvalHook = IJBRulesetApprovalHook(address(0));
|
|
2178
|
+
rulesetConfig[0].metadata = metadata;
|
|
2179
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
2180
|
+
rulesetConfig[0].fundAccessLimitGroups = fundAccessLimitGroup;
|
|
2181
|
+
|
|
2182
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
2183
|
+
JBAccountingContext[] memory tokensToAccept = new JBAccountingContext[](1);
|
|
2184
|
+
tokensToAccept[0] =
|
|
2185
|
+
JBAccountingContext({token: address(token), decimals: 6, currency: uint32(uint160(address(token)))});
|
|
2186
|
+
terminalConfigurations[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: tokensToAccept});
|
|
2187
|
+
|
|
2188
|
+
// Fee project.
|
|
2189
|
+
JBTerminalConfig[] memory feeTermConfigs = new JBTerminalConfig[](1);
|
|
2190
|
+
JBAccountingContext[] memory feeTokens = new JBAccountingContext[](1);
|
|
2191
|
+
feeTokens[0] = JBAccountingContext({
|
|
2192
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2193
|
+
});
|
|
2194
|
+
feeTermConfigs[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: feeTokens});
|
|
2195
|
+
|
|
2196
|
+
_controller.launchProjectFor({
|
|
2197
|
+
owner: address(420),
|
|
2198
|
+
projectUri: "feeCollector",
|
|
2199
|
+
rulesetConfigurations: rulesetConfig,
|
|
2200
|
+
terminalConfigurations: feeTermConfigs,
|
|
2201
|
+
memo: ""
|
|
2202
|
+
});
|
|
2203
|
+
|
|
2204
|
+
projectId = _controller.launchProjectFor({
|
|
2205
|
+
owner: _projectOwner,
|
|
2206
|
+
projectUri: "erc20OnlyProject",
|
|
2207
|
+
rulesetConfigurations: rulesetConfig,
|
|
2208
|
+
terminalConfigurations: terminalConfigurations,
|
|
2209
|
+
memo: ""
|
|
2210
|
+
});
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
//*********************************************************************//
|
|
2214
|
+
// --- [COMPAT] ERC-20-Only Project Tests (Tempo Compatibility) ----- //
|
|
2215
|
+
//*********************************************************************//
|
|
2216
|
+
|
|
2217
|
+
/// @notice Full lifecycle: pay with ERC-20, verify token minting, cash out, verify ERC-20 returned.
|
|
2218
|
+
/// Simulates a project on Tempo where only stablecoins are used.
|
|
2219
|
+
function test_erc20OnlyProject_payAndCashOut() public {
|
|
2220
|
+
uint256 projectId = _launchProjectERC20({cashOutTaxRate: 0, reservedPercent: 0});
|
|
2221
|
+
MockERC20 token = usdcToken();
|
|
2222
|
+
|
|
2223
|
+
vm.prank(_projectOwner);
|
|
2224
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
2225
|
+
|
|
2226
|
+
// Mint USDC to beneficiary and approve terminal.
|
|
2227
|
+
uint256 payAmount = 1000e6; // 1000 USDC (6 decimals)
|
|
2228
|
+
token.mint(_beneficiary, payAmount);
|
|
2229
|
+
vm.prank(_beneficiary);
|
|
2230
|
+
token.approve(address(_terminal), payAmount);
|
|
2231
|
+
|
|
2232
|
+
// Pay the project with ERC-20 (msg.value = 0).
|
|
2233
|
+
vm.prank(_beneficiary);
|
|
2234
|
+
_terminal.pay{value: 0}({
|
|
2235
|
+
projectId: projectId,
|
|
2236
|
+
amount: payAmount,
|
|
2237
|
+
token: address(token),
|
|
2238
|
+
beneficiary: _beneficiary,
|
|
2239
|
+
minReturnedTokens: 0,
|
|
2240
|
+
memo: "erc20 pay",
|
|
2241
|
+
metadata: new bytes(0)
|
|
2242
|
+
});
|
|
2243
|
+
|
|
2244
|
+
// Verify terminal balance.
|
|
2245
|
+
uint256 terminalBalance = jbTerminalStore().balanceOf(address(_terminal), projectId, address(token));
|
|
2246
|
+
assertEq(terminalBalance, payAmount, "terminal should hold the ERC-20 payment");
|
|
2247
|
+
|
|
2248
|
+
// Verify tokens were minted.
|
|
2249
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
2250
|
+
assertGt(tokenBalance, 0, "beneficiary should have project tokens after ERC-20 pay");
|
|
2251
|
+
|
|
2252
|
+
// Cash out all tokens — should receive ERC-20 back.
|
|
2253
|
+
uint256 usdcBefore = token.balanceOf(_beneficiary);
|
|
2254
|
+
|
|
2255
|
+
vm.prank(_beneficiary);
|
|
2256
|
+
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
2257
|
+
holder: _beneficiary,
|
|
2258
|
+
projectId: projectId,
|
|
2259
|
+
cashOutCount: tokenBalance,
|
|
2260
|
+
tokenToReclaim: address(token),
|
|
2261
|
+
minTokensReclaimed: 0,
|
|
2262
|
+
beneficiary: payable(_beneficiary),
|
|
2263
|
+
metadata: new bytes(0)
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
uint256 usdcReceived = token.balanceOf(_beneficiary) - usdcBefore;
|
|
2267
|
+
assertEq(usdcReceived, reclaimAmount, "USDC received should match reclaim amount");
|
|
2268
|
+
|
|
2269
|
+
// With 0% tax and full supply cash out, should get full amount back.
|
|
2270
|
+
assertEq(reclaimAmount, payAmount, "full cash out should return full ERC-20 amount");
|
|
2271
|
+
}
|
|
2272
|
+
|
|
2273
|
+
/// @notice Confirm msg.value=0 with ERC-20 doesn't trigger the NoMsgValueAllowed revert.
|
|
2274
|
+
function test_erc20Payment_zeroMsgValue_succeeds() public {
|
|
2275
|
+
uint256 projectId = _launchProjectERC20({cashOutTaxRate: 0, reservedPercent: 0});
|
|
2276
|
+
MockERC20 token = usdcToken();
|
|
2277
|
+
|
|
2278
|
+
uint256 payAmount = 500e6;
|
|
2279
|
+
token.mint(_beneficiary, payAmount);
|
|
2280
|
+
vm.prank(_beneficiary);
|
|
2281
|
+
token.approve(address(_terminal), payAmount);
|
|
2282
|
+
|
|
2283
|
+
// Pay with msg.value = 0 — should succeed.
|
|
2284
|
+
vm.prank(_beneficiary);
|
|
2285
|
+
_terminal.pay{value: 0}({
|
|
2286
|
+
projectId: projectId,
|
|
2287
|
+
amount: payAmount,
|
|
2288
|
+
token: address(token),
|
|
2289
|
+
beneficiary: _beneficiary,
|
|
2290
|
+
minReturnedTokens: 0,
|
|
2291
|
+
memo: "zero msg.value",
|
|
2292
|
+
metadata: new bytes(0)
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
2296
|
+
assertGt(tokenBalance, 0, "payment with 0 msg.value should succeed for ERC-20");
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/// @notice Confirm msg.value > 0 with ERC-20 correctly reverts with NoMsgValueAllowed.
|
|
2300
|
+
function test_erc20Payment_nonZeroMsgValue_reverts() public {
|
|
2301
|
+
uint256 projectId = _launchProjectERC20({cashOutTaxRate: 0, reservedPercent: 0});
|
|
2302
|
+
MockERC20 token = usdcToken();
|
|
2303
|
+
|
|
2304
|
+
uint256 payAmount = 500e6;
|
|
2305
|
+
token.mint(_beneficiary, payAmount);
|
|
2306
|
+
vm.prank(_beneficiary);
|
|
2307
|
+
token.approve(address(_terminal), payAmount);
|
|
2308
|
+
|
|
2309
|
+
// Pay with msg.value > 0 for an ERC-20 token — should revert.
|
|
2310
|
+
vm.deal(_beneficiary, 1 ether);
|
|
2311
|
+
vm.prank(_beneficiary);
|
|
2312
|
+
vm.expectRevert(abi.encodeWithSelector(JBMultiTerminal.JBMultiTerminal_NoMsgValueAllowed.selector, 1 ether));
|
|
2313
|
+
_terminal.pay{value: 1 ether}({
|
|
2314
|
+
projectId: projectId,
|
|
2315
|
+
amount: payAmount,
|
|
2316
|
+
token: address(token),
|
|
2317
|
+
beneficiary: _beneficiary,
|
|
2318
|
+
minReturnedTokens: 0,
|
|
2319
|
+
memo: "should revert",
|
|
2320
|
+
metadata: new bytes(0)
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
/// @notice Payouts distributed in ERC-20 to split beneficiaries.
|
|
2325
|
+
function test_erc20OnlyProject_sendPayouts() public {
|
|
2326
|
+
uint224 payoutLimit = 300e6; // 300 USDC
|
|
2327
|
+
uint256 projectId = _launchProjectERC20WithPayoutLimit({cashOutTaxRate: 0, payoutLimit: payoutLimit});
|
|
2328
|
+
MockERC20 token = usdcToken();
|
|
2329
|
+
|
|
2330
|
+
// Fund the project.
|
|
2331
|
+
uint256 payAmount = 1000e6;
|
|
2332
|
+
token.mint(_beneficiary, payAmount);
|
|
2333
|
+
vm.prank(_beneficiary);
|
|
2334
|
+
token.approve(address(_terminal), payAmount);
|
|
2335
|
+
|
|
2336
|
+
vm.prank(_beneficiary);
|
|
2337
|
+
_terminal.pay{value: 0}({
|
|
2338
|
+
projectId: projectId,
|
|
2339
|
+
amount: payAmount,
|
|
2340
|
+
token: address(token),
|
|
2341
|
+
beneficiary: _beneficiary,
|
|
2342
|
+
minReturnedTokens: 0,
|
|
2343
|
+
memo: "fund for payouts",
|
|
2344
|
+
metadata: new bytes(0)
|
|
2345
|
+
});
|
|
2346
|
+
|
|
2347
|
+
// Verify terminal balance.
|
|
2348
|
+
uint256 balanceBefore = jbTerminalStore().balanceOf(address(_terminal), projectId, address(token));
|
|
2349
|
+
assertEq(balanceBefore, payAmount, "terminal should hold full payment");
|
|
2350
|
+
|
|
2351
|
+
// Send payouts. Since no splits are configured, payouts go to the project owner.
|
|
2352
|
+
// The fee terminal for project #1 does NOT accept this ERC-20, so the fee
|
|
2353
|
+
// processing will fail in try/catch and the fee stays with the project.
|
|
2354
|
+
vm.prank(_projectOwner);
|
|
2355
|
+
uint256 amountPaidOut = _terminal.sendPayoutsOf({
|
|
2356
|
+
projectId: projectId,
|
|
2357
|
+
token: address(token),
|
|
2358
|
+
amount: payoutLimit,
|
|
2359
|
+
currency: uint32(uint160(address(token))),
|
|
2360
|
+
minTokensPaidOut: 0
|
|
2361
|
+
});
|
|
2362
|
+
|
|
2363
|
+
assertGt(amountPaidOut, 0, "should have paid out ERC-20 tokens");
|
|
2364
|
+
|
|
2365
|
+
// Terminal balance should have decreased.
|
|
2366
|
+
uint256 balanceAfter = jbTerminalStore().balanceOf(address(_terminal), projectId, address(token));
|
|
2367
|
+
assertLt(balanceAfter, balanceBefore, "balance should decrease after payout");
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/// @notice Confirm NATIVE_TOKEN with decimals != 18 is rejected by addAccountingContextsFor.
|
|
2371
|
+
function test_nativeToken_wrongDecimals_reverts() public {
|
|
2372
|
+
// Launch a minimal project first.
|
|
2373
|
+
JBRulesetMetadata memory metadata = JBRulesetMetadata({
|
|
2374
|
+
reservedPercent: 0,
|
|
2375
|
+
cashOutTaxRate: 0,
|
|
2376
|
+
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
2377
|
+
pausePay: false,
|
|
2378
|
+
pauseCreditTransfers: false,
|
|
2379
|
+
allowOwnerMinting: true,
|
|
2380
|
+
allowSetCustomToken: true,
|
|
2381
|
+
allowTerminalMigration: false,
|
|
2382
|
+
allowSetTerminals: false,
|
|
2383
|
+
ownerMustSendPayouts: false,
|
|
2384
|
+
allowSetController: false,
|
|
2385
|
+
allowAddAccountingContext: true,
|
|
2386
|
+
allowAddPriceFeed: false,
|
|
2387
|
+
holdFees: false,
|
|
2388
|
+
useTotalSurplusForCashOuts: false,
|
|
2389
|
+
useDataHookForPay: false,
|
|
2390
|
+
useDataHookForCashOut: false,
|
|
2391
|
+
dataHook: address(0),
|
|
2392
|
+
metadata: 0
|
|
2393
|
+
});
|
|
2394
|
+
|
|
2395
|
+
JBRulesetConfig[] memory rulesetConfig = new JBRulesetConfig[](1);
|
|
2396
|
+
rulesetConfig[0].mustStartAtOrAfter = 0;
|
|
2397
|
+
rulesetConfig[0].duration = 0;
|
|
2398
|
+
rulesetConfig[0].weight = _weight;
|
|
2399
|
+
rulesetConfig[0].metadata = metadata;
|
|
2400
|
+
rulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
2401
|
+
rulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
2402
|
+
|
|
2403
|
+
// Launch with NO accounting contexts initially.
|
|
2404
|
+
JBTerminalConfig[] memory terminalConfigurations = new JBTerminalConfig[](1);
|
|
2405
|
+
terminalConfigurations[0] =
|
|
2406
|
+
JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: new JBAccountingContext[](0)});
|
|
2407
|
+
|
|
2408
|
+
// Fee project.
|
|
2409
|
+
_controller.launchProjectFor({
|
|
2410
|
+
owner: address(420),
|
|
2411
|
+
projectUri: "feeCollector",
|
|
2412
|
+
rulesetConfigurations: rulesetConfig,
|
|
2413
|
+
terminalConfigurations: terminalConfigurations,
|
|
2414
|
+
memo: ""
|
|
2415
|
+
});
|
|
2416
|
+
|
|
2417
|
+
uint256 projectId = _controller.launchProjectFor({
|
|
2418
|
+
owner: _projectOwner,
|
|
2419
|
+
projectUri: "testDecimals",
|
|
2420
|
+
rulesetConfigurations: rulesetConfig,
|
|
2421
|
+
terminalConfigurations: terminalConfigurations,
|
|
2422
|
+
memo: ""
|
|
2423
|
+
});
|
|
2424
|
+
|
|
2425
|
+
// Try to add NATIVE_TOKEN with decimals = 6 (wrong — native is 18).
|
|
2426
|
+
JBAccountingContext[] memory wrongContexts = new JBAccountingContext[](1);
|
|
2427
|
+
wrongContexts[0] = JBAccountingContext({
|
|
2428
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 6, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2429
|
+
});
|
|
2430
|
+
|
|
2431
|
+
vm.prank(_projectOwner);
|
|
2432
|
+
vm.expectRevert(JBMultiTerminal.JBMultiTerminal_AccountingContextDecimalsMismatch.selector);
|
|
2433
|
+
_terminal.addAccountingContextsFor(projectId, wrongContexts);
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
/// @notice Surplus calculation and fee processing with ERC-20.
|
|
2437
|
+
/// Fee bounces back via try/catch since fee project has no ERC-20 terminal.
|
|
2438
|
+
function test_erc20OnlyProject_surplusAndFees() public {
|
|
2439
|
+
uint256 projectId = _launchProjectERC20({cashOutTaxRate: 5000, reservedPercent: 0});
|
|
2440
|
+
MockERC20 token = usdcToken();
|
|
2441
|
+
|
|
2442
|
+
vm.prank(_projectOwner);
|
|
2443
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
2444
|
+
|
|
2445
|
+
// Pay 1000 USDC.
|
|
2446
|
+
uint256 payAmount = 1000e6;
|
|
2447
|
+
token.mint(_beneficiary, payAmount);
|
|
2448
|
+
vm.prank(_beneficiary);
|
|
2449
|
+
token.approve(address(_terminal), payAmount);
|
|
2450
|
+
|
|
2451
|
+
vm.prank(_beneficiary);
|
|
2452
|
+
_terminal.pay{value: 0}({
|
|
2453
|
+
projectId: projectId,
|
|
2454
|
+
amount: payAmount,
|
|
2455
|
+
token: address(token),
|
|
2456
|
+
beneficiary: _beneficiary,
|
|
2457
|
+
minReturnedTokens: 0,
|
|
2458
|
+
memo: "surplus test",
|
|
2459
|
+
metadata: new bytes(0)
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
// Check surplus — should equal the full balance since no payout limits.
|
|
2463
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
2464
|
+
contexts[0] =
|
|
2465
|
+
JBAccountingContext({token: address(token), decimals: 6, currency: uint32(uint160(address(token)))});
|
|
2466
|
+
|
|
2467
|
+
uint256 surplus = jbTerminalStore()
|
|
2468
|
+
.currentSurplusOf({
|
|
2469
|
+
terminal: address(_terminal),
|
|
2470
|
+
projectId: projectId,
|
|
2471
|
+
accountingContexts: contexts,
|
|
2472
|
+
decimals: 6,
|
|
2473
|
+
currency: uint32(uint160(address(token)))
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
assertEq(surplus, payAmount, "surplus should equal full payment (no payout limits)");
|
|
2477
|
+
|
|
2478
|
+
// Cash out with 50% tax — fee processing will attempt to pay the fee project.
|
|
2479
|
+
// Fee project (#1) does NOT accept this ERC-20, so the fee payment reverts in try/catch.
|
|
2480
|
+
// The fee amount stays as a held fee on this project.
|
|
2481
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
2482
|
+
uint256 usdcBefore = token.balanceOf(_beneficiary);
|
|
2483
|
+
|
|
2484
|
+
vm.prank(_beneficiary);
|
|
2485
|
+
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
2486
|
+
holder: _beneficiary,
|
|
2487
|
+
projectId: projectId,
|
|
2488
|
+
cashOutCount: tokenBalance,
|
|
2489
|
+
tokenToReclaim: address(token),
|
|
2490
|
+
minTokensReclaimed: 0,
|
|
2491
|
+
beneficiary: payable(_beneficiary),
|
|
2492
|
+
metadata: new bytes(0)
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
uint256 usdcReceived = token.balanceOf(_beneficiary) - usdcBefore;
|
|
2496
|
+
|
|
2497
|
+
// Cashing out 100% of supply → full surplus regardless of tax rate.
|
|
2498
|
+
// But a fee is taken from the gross reclaim, so net < gross.
|
|
2499
|
+
assertGt(reclaimAmount, 0, "should receive ERC-20 from cash out");
|
|
2500
|
+
assertEq(usdcReceived, reclaimAmount, "USDC received should match reclaim");
|
|
2501
|
+
|
|
2502
|
+
// Verify terminal balance is reduced. The fee bounced back (fee project has no ERC-20
|
|
2503
|
+
// terminal), so the project retains the fee amount as held fees returned to balance.
|
|
2504
|
+
uint256 balanceAfter = jbTerminalStore().balanceOf(address(_terminal), projectId, address(token));
|
|
2505
|
+
uint256 feeAmount = JBFees.feeAmountFrom({amountBeforeFee: payAmount, feePercent: _terminal.FEE()});
|
|
2506
|
+
assertEq(balanceAfter, feeAmount, "project balance should equal bounced-back fee amount");
|
|
2507
|
+
}
|
|
2508
|
+
|
|
2509
|
+
//*********************************************************************//
|
|
2510
|
+
// --- [H-4] Pending Reserves Inflate cashOutWeight (property test)-- //
|
|
2511
|
+
//*********************************************************************//
|
|
2512
|
+
|
|
2513
|
+
/// @notice Property: cashOut reclaim with pending reserves <= cashOut reclaim after reserves distributed.
|
|
2514
|
+
/// @dev When there are pending reserved tokens that haven't been distributed, they should
|
|
2515
|
+
/// not inflate the denominator used for cash-out calculations.
|
|
2516
|
+
function test_H4_pendingReserves_cashOutProperty() public {
|
|
2517
|
+
// Launch project with 50% reserved rate
|
|
2518
|
+
uint256 projectId = _launchProject({cashOutTaxRate: 0, reservedPercent: 5000});
|
|
2519
|
+
|
|
2520
|
+
vm.prank(_projectOwner);
|
|
2521
|
+
_controller.deployERC20For(projectId, "TestToken", "TT", bytes32(0));
|
|
2522
|
+
|
|
2523
|
+
// Pay the project - this triggers reserved token accumulation
|
|
2524
|
+
vm.deal(_beneficiary, 10 ether);
|
|
2525
|
+
vm.prank(_beneficiary);
|
|
2526
|
+
_terminal.pay{value: 10 ether}({
|
|
2527
|
+
projectId: projectId,
|
|
2528
|
+
amount: 10 ether,
|
|
2529
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
2530
|
+
beneficiary: _beneficiary,
|
|
2531
|
+
minReturnedTokens: 0,
|
|
2532
|
+
memo: "H-4 test",
|
|
2533
|
+
metadata: new bytes(0)
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
uint256 tokenBalance = _tokens.totalBalanceOf(_beneficiary, projectId);
|
|
2537
|
+
assertGt(tokenBalance, 0, "beneficiary should have tokens");
|
|
2538
|
+
|
|
2539
|
+
// Check pending reserved tokens
|
|
2540
|
+
uint256 pendingReserved = _controller.pendingReservedTokenBalanceOf(projectId);
|
|
2541
|
+
|
|
2542
|
+
// Record surplus before distributing reserves
|
|
2543
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
2544
|
+
contexts[0] = JBAccountingContext({
|
|
2545
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2546
|
+
});
|
|
2547
|
+
|
|
2548
|
+
uint256 supplyBefore = _tokens.totalSupplyOf(projectId);
|
|
2549
|
+
uint256 surplusBefore = jbTerminalStore()
|
|
2550
|
+
.currentSurplusOf({
|
|
2551
|
+
terminal: address(_terminal),
|
|
2552
|
+
projectId: projectId,
|
|
2553
|
+
accountingContexts: contexts,
|
|
2554
|
+
decimals: 18,
|
|
2555
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2556
|
+
});
|
|
2557
|
+
|
|
2558
|
+
// Calculate reclaimable BEFORE distributing reserves
|
|
2559
|
+
uint256 reclaimBefore = jbTerminalStore()
|
|
2560
|
+
.currentReclaimableSurplusOf({
|
|
2561
|
+
projectId: projectId, cashOutCount: tokenBalance, totalSupply: supplyBefore, surplus: surplusBefore
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
// Now distribute reserved tokens
|
|
2565
|
+
if (pendingReserved > 0) {
|
|
2566
|
+
_controller.sendReservedTokensToSplitsOf(projectId);
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
uint256 supplyAfter = _tokens.totalSupplyOf(projectId);
|
|
2570
|
+
uint256 surplusAfter = jbTerminalStore()
|
|
2571
|
+
.currentSurplusOf({
|
|
2572
|
+
terminal: address(_terminal),
|
|
2573
|
+
projectId: projectId,
|
|
2574
|
+
accountingContexts: contexts,
|
|
2575
|
+
decimals: 18,
|
|
2576
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2577
|
+
});
|
|
2578
|
+
|
|
2579
|
+
// Calculate reclaimable AFTER distributing reserves
|
|
2580
|
+
uint256 reclaimAfter = jbTerminalStore()
|
|
2581
|
+
.currentReclaimableSurplusOf({
|
|
2582
|
+
projectId: projectId, cashOutCount: tokenBalance, totalSupply: supplyAfter, surplus: surplusAfter
|
|
2583
|
+
});
|
|
2584
|
+
|
|
2585
|
+
// Property: The surplus should be the same (distributing reserves doesn't change ETH balance)
|
|
2586
|
+
assertEq(surplusAfter, surplusBefore, "surplus should not change after distributing reserves");
|
|
2587
|
+
|
|
2588
|
+
// Property: After distributing reserves, totalSupply increases but the beneficiary's
|
|
2589
|
+
// share decreases, so reclaimable should decrease or stay the same.
|
|
2590
|
+
// With 0% cashOutTaxRate and linear redemption: reclaimable = surplus * tokens / totalSupply
|
|
2591
|
+
// After reserves: totalSupply is larger, so reclaimable is smaller.
|
|
2592
|
+
assertLe(reclaimAfter, reclaimBefore, "H-4 property: reclaim after reserves distributed <= reclaim before");
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/// @notice Mock contract demonstrating the REVLoans reentrancy pattern.
|
|
2597
|
+
/// Mimics the CEI violation: sends ETH before updating state.
|
|
2598
|
+
contract ReentrancyVictim {
|
|
2599
|
+
uint256 public totalBorrowed;
|
|
2600
|
+
uint256 public maxBorrowable = 10 ether;
|
|
2601
|
+
|
|
2602
|
+
function borrow(address payable beneficiary, uint256 amount) external {
|
|
2603
|
+
// Check (stale if reentered)
|
|
2604
|
+
require(totalBorrowed + amount <= maxBorrowable, "exceeds limit");
|
|
2605
|
+
|
|
2606
|
+
// External call BEFORE state update (mirrors REVLoans._addTo → _transferFrom)
|
|
2607
|
+
(bool sent,) = beneficiary.call{value: amount}("");
|
|
2608
|
+
require(sent, "transfer failed");
|
|
2609
|
+
|
|
2610
|
+
// State update AFTER external call (mirrors REVLoans._adjust lines 921-923)
|
|
2611
|
+
totalBorrowed += amount;
|
|
2612
|
+
}
|
|
2613
|
+
|
|
2614
|
+
receive() external payable {}
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
/// @notice Attacker contract that re-enters the victim during ETH receive.
|
|
2618
|
+
contract ReentrancyAttacker {
|
|
2619
|
+
ReentrancyVictim public victim;
|
|
2620
|
+
uint256 public attackCount;
|
|
2621
|
+
uint256 public constant BORROW_AMOUNT = 5 ether;
|
|
2622
|
+
|
|
2623
|
+
constructor(ReentrancyVictim _victim) {
|
|
2624
|
+
victim = _victim;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function attack() external {
|
|
2628
|
+
attackCount = 0;
|
|
2629
|
+
victim.borrow(payable(address(this)), BORROW_AMOUNT);
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2632
|
+
receive() external payable {
|
|
2633
|
+
attackCount++;
|
|
2634
|
+
// Re-enter on first callback. The victim's totalBorrowed hasn't been updated yet.
|
|
2635
|
+
if (attackCount < 3 && address(victim).balance >= BORROW_AMOUNT) {
|
|
2636
|
+
victim.borrow(payable(address(this)), BORROW_AMOUNT);
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
}
|
|
2640
|
+
|
|
2641
|
+
/// @notice Malicious fee terminal that reenters processHeldFeesOf during pay(), demonstrating M-1.
|
|
2642
|
+
contract ReentrantFeeTerminal is ERC165 {
|
|
2643
|
+
IJBMultiTerminal public immutable victim;
|
|
2644
|
+
uint256 public immutable targetProjectId;
|
|
2645
|
+
address public immutable targetToken;
|
|
2646
|
+
uint256 public payCallCount;
|
|
2647
|
+
|
|
2648
|
+
constructor(IJBMultiTerminal _victim, uint256 _targetProjectId, address _targetToken) {
|
|
2649
|
+
victim = _victim;
|
|
2650
|
+
targetProjectId = _targetProjectId;
|
|
2651
|
+
targetToken = _targetToken;
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
/// @notice Called by the victim terminal to process a fee. Reenters processHeldFeesOf.
|
|
2655
|
+
function pay(
|
|
2656
|
+
uint256,
|
|
2657
|
+
address,
|
|
2658
|
+
uint256,
|
|
2659
|
+
address,
|
|
2660
|
+
uint256,
|
|
2661
|
+
string calldata,
|
|
2662
|
+
bytes calldata
|
|
2663
|
+
)
|
|
2664
|
+
external
|
|
2665
|
+
payable
|
|
2666
|
+
returns (uint256)
|
|
2667
|
+
{
|
|
2668
|
+
payCallCount++;
|
|
2669
|
+
// Reenter: try to process more held fees from the same project.
|
|
2670
|
+
if (payCallCount == 1) {
|
|
2671
|
+
victim.processHeldFeesOf(targetProjectId, targetToken, 100);
|
|
2672
|
+
}
|
|
2673
|
+
return 0;
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
function accountingContextForTokenOf(uint256, address) external pure returns (JBAccountingContext memory) {
|
|
2677
|
+
return JBAccountingContext({
|
|
2678
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2679
|
+
});
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
function accountingContextsOf(uint256) external pure returns (JBAccountingContext[] memory) {
|
|
2683
|
+
JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
|
|
2684
|
+
contexts[0] = JBAccountingContext({
|
|
2685
|
+
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
2686
|
+
});
|
|
2687
|
+
return contexts;
|
|
2688
|
+
}
|
|
2689
|
+
|
|
2690
|
+
function supportsInterface(bytes4 interfaceId) public view override returns (bool) {
|
|
2691
|
+
return interfaceId == type(IJBTerminal).interfaceId || super.supportsInterface(interfaceId);
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
receive() external payable {}
|
|
2695
|
+
}
|
|
2696
|
+
|
|
2697
|
+
/// @notice Malicious approval hook that always reverts, demonstrating H-3 DoS.
|
|
2698
|
+
contract MaliciousApprovalHook is ERC165, IJBRulesetApprovalHook {
|
|
2699
|
+
function DURATION() external pure override returns (uint256) {
|
|
2700
|
+
return 1 days;
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
function approvalStatusOf(uint256, JBRuleset memory) external pure override returns (JBApprovalStatus) {
|
|
2704
|
+
revert("MALICIOUS_HOOK_REVERT");
|
|
2705
|
+
}
|
|
2706
|
+
|
|
2707
|
+
function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) {
|
|
2708
|
+
return interfaceId == type(IJBRulesetApprovalHook).interfaceId || super.supportsInterface(interfaceId);
|
|
2709
|
+
}
|
|
2710
|
+
}
|