@bananapus/core-v6 0.0.37 → 0.0.38
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/foundry.lock +1 -7
- package/foundry.toml +1 -1
- package/package.json +19 -7
- package/src/JBController.sol +19 -1
- package/src/JBMultiTerminal.sol +68 -34
- package/src/JBTerminalStore.sol +6 -6
- package/src/interfaces/IJBController.sol +4 -1
- package/src/libraries/JBFees.sol +47 -9
- package/src/libraries/JBPayoutSplitGroupLib.sol +2 -2
- package/src/periphery/JBMatchingPriceFeed.sol +1 -1
- package/test/mock/MockMaliciousBeneficiary.sol +15 -15
- package/ADMINISTRATION.md +0 -103
- package/ARCHITECTURE.md +0 -133
- package/AUDIT_INSTRUCTIONS.md +0 -139
- package/RISKS.md +0 -215
- package/SKILLS.md +0 -55
- package/STYLE_GUIDE.md +0 -610
- package/USER_JOURNEYS.md +0 -215
- package/script/Deploy.s.sol +0 -124
- package/script/DeployPeriphery.s.sol +0 -354
- package/slither-ci.config.json +0 -10
- package/test/AuditFixes.t.sol +0 -808
- package/test/ComprehensiveInvariant.t.sol +0 -306
- package/test/CoreExploitTests.t.sol +0 -2741
- package/test/EconomicSimulation.t.sol +0 -348
- package/test/EntryPointPermutations.t.sol +0 -684
- package/test/FlashLoanAttacks.t.sol +0 -797
- package/test/PermissionEscalation.t.sol +0 -711
- package/test/PermissionsInvariant.t.sol +0 -403
- package/test/RulesetTransitions.t.sol +0 -713
- package/test/SplitLoopTests.t.sol +0 -752
- package/test/TestAccessToFunds.sol +0 -2683
- package/test/TestAuditResponseDesignProofs.sol +0 -434
- package/test/TestCashOut.sol +0 -198
- package/test/TestCashOutCountFor.sol +0 -271
- package/test/TestCashOutHooks.sol +0 -351
- package/test/TestCashOutTimingEdge.sol +0 -241
- package/test/TestDataHookFuzzing.sol +0 -524
- package/test/TestDurationUnderflow.sol +0 -233
- package/test/TestFeeFreeCashOutBypass.sol +0 -949
- package/test/TestFeeProcessingFailure.sol +0 -218
- package/test/TestFees.sol +0 -619
- package/test/TestForwardedTokenConsumption.sol +0 -425
- package/test/TestInterfaceSupport.sol +0 -81
- package/test/TestJBERC20Inheritance.sol +0 -103
- package/test/TestL2SequencerPriceFeed.sol +0 -292
- package/test/TestLaunchProject.sol +0 -188
- package/test/TestMetaTx.sol +0 -217
- package/test/TestMetadataOffsetOverflow.sol +0 -179
- package/test/TestMetadataParserLib.sol +0 -471
- package/test/TestMigrationHeldFees.sol +0 -255
- package/test/TestMintTokensOf.sol +0 -185
- package/test/TestMultiTerminalSurplus.sol +0 -348
- package/test/TestMultiTokenSurplus.sol +0 -202
- package/test/TestMultipleAccessLimits.sol +0 -664
- package/test/TestPayBurnRedeemFlow.sol +0 -195
- package/test/TestPayHooks.sol +0 -209
- package/test/TestPermissions.sol +0 -324
- package/test/TestPermissionsEdge.sol +0 -290
- package/test/TestPermit2DataHook.t.sol +0 -360
- package/test/TestPermit2Terminal.sol +0 -372
- package/test/TestRulesetQueueing.sol +0 -1025
- package/test/TestRulesetQueuingStress.sol +0 -806
- package/test/TestRulesetWeightCaching.sol +0 -178
- package/test/TestSplits.sol +0 -391
- package/test/TestTerminalMigration.sol +0 -274
- package/test/TestTerminalPreviewParity.sol +0 -208
- package/test/TestTokenFlow.sol +0 -191
- package/test/TestWeightCacheStaleAfterRejection.sol +0 -303
- package/test/WeirdTokenTests.t.sol +0 -817
- package/test/audit/CashOutReenterPay.t.sol +0 -501
- package/test/audit/CodexHeldFeeRounding.t.sol +0 -159
- package/test/audit/CodexMigrationFeeFailure.t.sol +0 -163
- package/test/audit/CrossTerminalSurplusSpoof.t.sol +0 -140
- package/test/audit/CycledSurplusAllowanceReset.t.sol +0 -184
- package/test/audit/FeeFreeSurplusLifecycle.t.sol +0 -399
- package/test/audit/FeeFreeSurplusStale.t.sol +0 -248
- package/test/audit/USDTVoidReturnCompat.t.sol +0 -525
- package/test/fork/TestChainlinkPriceFeedFork.sol +0 -254
- package/test/fork/TestSequencerPriceFeedFork.sol +0 -168
- package/test/fork/TestTerminalPreviewParityFork.sol +0 -108
- package/test/formal/BondingCurveProperties.t.sol +0 -420
- package/test/formal/FeeProperties.t.sol +0 -252
- package/test/invariants/Phase3DeepInvariant.t.sol +0 -412
- package/test/invariants/RulesetsInvariant.t.sol +0 -125
- package/test/invariants/TerminalStoreInvariant.t.sol +0 -227
- package/test/invariants/TokensInvariant.t.sol +0 -195
- package/test/invariants/handlers/ComprehensiveHandler.sol +0 -303
- package/test/invariants/handlers/EconomicHandler.sol +0 -377
- package/test/invariants/handlers/Phase3Handler.sol +0 -443
- package/test/invariants/handlers/RulesetsHandler.sol +0 -115
- package/test/invariants/handlers/TerminalStoreHandler.sol +0 -151
- package/test/invariants/handlers/TokensHandler.sol +0 -126
- package/test/regression/HoldFeesCashOutReserved.t.sol +0 -415
- package/test/regression/WeightCacheBoundary.t.sol +0 -291
- package/test/trees/JBController/burnTokensOf.tree +0 -9
- package/test/trees/JBController/claimTokensFor.tree +0 -5
- package/test/trees/JBController/deployERC20For.tree +0 -5
- package/test/trees/JBController/getRulesetOf.tree +0 -5
- package/test/trees/JBController/launchProjectFor.tree +0 -12
- package/test/trees/JBController/launchRulesetsFor.tree +0 -8
- package/test/trees/JBController/migrateController.tree +0 -12
- package/test/trees/JBController/mintTokensOf.tree +0 -12
- package/test/trees/JBController/payReservedTokenToTerminal.tree +0 -8
- package/test/trees/JBController/receiveMigrationFrom.tree +0 -4
- package/test/trees/JBController/sendReservedTokensToSplitsOf.tree +0 -12
- package/test/trees/JBController/setMetadataOf.tree +0 -5
- package/test/trees/JBController/setSplitGroupsOf.tree +0 -5
- package/test/trees/JBController/setTokenFor.tree +0 -5
- package/test/trees/JBController/transferCreditsFrom.tree +0 -8
- package/test/trees/JBDirectory/primaryTerminalOf.tree +0 -8
- package/test/trees/JBDirectory/setControllerOf.tree +0 -11
- package/test/trees/JBDirectory/setPrimaryTerminalOf.tree +0 -15
- package/test/trees/JBDirectory/setTerminalsOf.tree +0 -11
- package/test/trees/JBERC20/initialize.tree +0 -7
- package/test/trees/JBERC20/name.tree +0 -5
- package/test/trees/JBERC20/nonces.tree +0 -5
- package/test/trees/JBERC20/symbol.tree +0 -5
- package/test/trees/JBFeelessAddresses/setFeelessAddress.tree +0 -5
- package/test/trees/JBFeelessAddresses/supportsInterface.tree +0 -5
- package/test/trees/JBFundAccessLimits/payoutLimitOf.tree +0 -5
- package/test/trees/JBFundAccessLimits/payoutLimitsOf.tree +0 -8
- package/test/trees/JBFundAccessLimits/setFundAccessLimitsFor.tree +0 -18
- package/test/trees/JBFundAccessLimits/surplusAllowanceOf.tree +0 -5
- package/test/trees/JBFundAccessLimits/surplusAllowancesOf.tree +0 -8
- package/test/trees/JBMetadataResolver/getDataFor.tree +0 -8
- package/test/trees/JBMultiTerminal/accountingContextsOf.tree +0 -5
- package/test/trees/JBMultiTerminal/addAccountingContextsFor.tree +0 -10
- package/test/trees/JBMultiTerminal/addToBalanceOf.tree +0 -23
- package/test/trees/JBMultiTerminal/cashOutTokensOf.tree +0 -23
- package/test/trees/JBMultiTerminal/executePayout.tree +0 -32
- package/test/trees/JBMultiTerminal/executeProcessFee.tree +0 -14
- package/test/trees/JBMultiTerminal/migrateBalanceOf.tree +0 -12
- package/test/trees/JBMultiTerminal/pay.tree +0 -23
- package/test/trees/JBMultiTerminal/processHeldFeesOf.tree +0 -8
- package/test/trees/JBMultiTerminal/sendPayoutsOf.tree +0 -34
- package/test/trees/JBMultiTerminal/useAllowanceOf.tree +0 -16
- package/test/trees/JBPermissions/hasPermission.tree +0 -8
- package/test/trees/JBPermissions/hasPermissions.tree +0 -8
- package/test/trees/JBPermissions/setPermissionsFor.tree +0 -5
- package/test/trees/JBPrices/addPriceFeedFor.tree +0 -14
- package/test/trees/JBPrices/pricePerUnitOf.tree +0 -11
- package/test/trees/JBProjects/createFor.tree +0 -11
- package/test/trees/JBProjects/setTokenUriResolver.tree +0 -5
- package/test/trees/JBProjects/supportsInterface.tree +0 -9
- package/test/trees/JBProjects/tokenURI.tree +0 -5
- package/test/trees/JBRulesets/currentApprovalStatusForLatestRulesetOf.tree +0 -8
- package/test/trees/JBRulesets/currentOf.tree +0 -12
- package/test/trees/JBRulesets/getRulesetOf.tree +0 -5
- package/test/trees/JBRulesets/latestQueuedRulesetOf.tree +0 -10
- package/test/trees/JBRulesets/rulesetsOf.tree +0 -11
- package/test/trees/JBRulesets/upcomingRulesetOf.tree +0 -20
- package/test/trees/JBRulesets/updateRulesetWeightCache.tree +0 -5
- package/test/trees/JBSplits/setSplitGroupsOf.tree +0 -17
- package/test/trees/JBSplits/splitsOf.tree +0 -5
- package/test/trees/JBTerminalStore/currentReclaimableSurplusOf.tree +0 -16
- package/test/trees/JBTerminalStore/currentSurplusOf.tree +0 -25
- package/test/trees/JBTerminalStore/currentTotalSurplusOf.tree +0 -5
- package/test/trees/JBTerminalStore/recordCashOutsFor.tree +0 -16
- package/test/trees/JBTerminalStore/recordPaymentFrom.tree +0 -14
- package/test/trees/JBTerminalStore/recordPayoutFor.tree +0 -10
- package/test/trees/JBTerminalStore/recordTerminalMigration.tree +0 -5
- package/test/trees/JBTerminalStore/recordUsedAllowanceOf.tree +0 -10
- package/test/trees/JBTokens/burnFrom.tree +0 -10
- package/test/trees/JBTokens/claimTokensFor.tree +0 -10
- package/test/trees/JBTokens/deployERC20For.tree +0 -12
- package/test/trees/JBTokens/mintFor.tree +0 -10
- package/test/trees/JBTokens/setTokenFor.tree +0 -11
- package/test/trees/JBTokens/totalBalanceOf.tree +0 -5
- package/test/trees/JBTokens/totalSupplyOf.tree +0 -5
- package/test/trees/JBTokens/transferCreditsFrom.tree +0 -8
- package/test/trees/mintTokensOf.tree +0 -12
- package/test/units/static/JBChainlinkV3PriceFeed/TestPriceFeed.sol +0 -223
- package/test/units/static/JBController/JBControllerSetup.sol +0 -50
- package/test/units/static/JBController/TestBurnTokensOf.sol +0 -114
- package/test/units/static/JBController/TestClaimTokensFor.sol +0 -63
- package/test/units/static/JBController/TestDeployErc20For.sol +0 -86
- package/test/units/static/JBController/TestLaunchProjectFor.sol +0 -302
- package/test/units/static/JBController/TestLaunchRulesetsFor.sol +0 -342
- package/test/units/static/JBController/TestMigrateController.sol +0 -157
- package/test/units/static/JBController/TestMintTokensOfUnits.sol +0 -111
- package/test/units/static/JBController/TestOmnichainRulesetOperator.sol +0 -324
- package/test/units/static/JBController/TestPayReservedTokenToTerminal.sol +0 -74
- package/test/units/static/JBController/TestPreviewMintOf.sol +0 -117
- package/test/units/static/JBController/TestReceiveMigrationFrom.sol +0 -99
- package/test/units/static/JBController/TestRulesetViews.sol +0 -225
- package/test/units/static/JBController/TestSendReservedTokensToSplitsOf.sol +0 -615
- package/test/units/static/JBController/TestSetSplitGroupsOf.sol +0 -68
- package/test/units/static/JBController/TestSetTokenFor.sol +0 -239
- package/test/units/static/JBController/TestSetUriOf.sol +0 -57
- package/test/units/static/JBController/TestTransferCreditsFrom.sol +0 -169
- package/test/units/static/JBDeadline/TestDeadlineFuzz.sol +0 -211
- package/test/units/static/JBDirectory/JBDirectorySetup.sol +0 -26
- package/test/units/static/JBDirectory/TestPrimaryTerminalOf.sol +0 -126
- package/test/units/static/JBDirectory/TestSetControllerOf.sol +0 -183
- package/test/units/static/JBDirectory/TestSetControllerOfMigrationOrder.sol +0 -104
- package/test/units/static/JBDirectory/TestSetPrimaryTerminalOf.sol +0 -179
- package/test/units/static/JBDirectory/TestSetTerminalsOf.sol +0 -137
- package/test/units/static/JBERC20/JBERC20Setup.sol +0 -34
- package/test/units/static/JBERC20/SigUtils.sol +0 -36
- package/test/units/static/JBERC20/TestInitialize.sol +0 -60
- package/test/units/static/JBERC20/TestName.sol +0 -30
- package/test/units/static/JBERC20/TestNonces.sol +0 -62
- package/test/units/static/JBERC20/TestSymbol.sol +0 -31
- package/test/units/static/JBFeelessAdresses/JBFeelessSetup.sol +0 -22
- package/test/units/static/JBFeelessAdresses/TestInterfaces.sol +0 -30
- package/test/units/static/JBFeelessAdresses/TestSetFeelessAddress.sol +0 -35
- package/test/units/static/JBFees/TestFeesFuzz.sol +0 -79
- package/test/units/static/JBFixedPointNumber/TestAdjustDecimals.sol +0 -16
- package/test/units/static/JBFixedPointNumber/TestAdjustDecimalsFuzz.sol +0 -71
- package/test/units/static/JBFundAccessLimits/JBFundAccessSetup.sol +0 -24
- package/test/units/static/JBFundAccessLimits/TestFundAccessLimitsEdge.sol +0 -163
- package/test/units/static/JBFundAccessLimits/TestPayoutLimitOf.sol +0 -59
- package/test/units/static/JBFundAccessLimits/TestPayoutLimitsOf.sol +0 -101
- package/test/units/static/JBFundAccessLimits/TestSetFundAccessLimitsFor.sol +0 -189
- package/test/units/static/JBFundAccessLimits/TestSurplusAllowanceOf.sol +0 -64
- package/test/units/static/JBFundAccessLimits/TestSurplusAllowancesOf.sol +0 -102
- package/test/units/static/JBMetadataResolver/TestGetDataFor.sol +0 -90
- package/test/units/static/JBMetadataResolver/TestMetadataResolverEdgeCases.sol +0 -247
- package/test/units/static/JBMetadataResolver/TestMetadataResolverFuzz.sol +0 -229
- package/test/units/static/JBMultiTerminal/JBMultiTerminalSetup.sol +0 -50
- package/test/units/static/JBMultiTerminal/TestAccountingContextsOf.sol +0 -72
- package/test/units/static/JBMultiTerminal/TestAddAccountingContextsFor.sol +0 -289
- package/test/units/static/JBMultiTerminal/TestAddToBalanceOf.sol +0 -474
- package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +0 -624
- package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +0 -578
- package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +0 -202
- package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +0 -222
- package/test/units/static/JBMultiTerminal/TestPay.sol +0 -604
- package/test/units/static/JBMultiTerminal/TestPreviewCashOutFrom.sol +0 -117
- package/test/units/static/JBMultiTerminal/TestPreviewPayFor.sol +0 -114
- package/test/units/static/JBMultiTerminal/TestProcessHeldFeesOf.sol +0 -228
- package/test/units/static/JBMultiTerminal/TestSelfPayRevert.sol +0 -55
- package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +0 -257
- package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +0 -611
- package/test/units/static/JBPermissions/JBPermissionsSetup.sol +0 -20
- package/test/units/static/JBPermissions/TestHasPermission.sol +0 -50
- package/test/units/static/JBPermissions/TestHasPermissions.sol +0 -93
- package/test/units/static/JBPermissions/TestSetPermissionsFor.sol +0 -64
- package/test/units/static/JBPrices/JBPricesSetup.sol +0 -32
- package/test/units/static/JBPrices/TestAddPriceFeedFor.sol +0 -107
- package/test/units/static/JBPrices/TestPricePerUnitOf.sol +0 -132
- package/test/units/static/JBPrices/TestPrices.sol +0 -265
- package/test/units/static/JBProjects/JBProjectsSetup.sol +0 -22
- package/test/units/static/JBProjects/TestCreateFor.sol +0 -71
- package/test/units/static/JBProjects/TestInitialProject.sol +0 -21
- package/test/units/static/JBProjects/TestInterfaces.sol +0 -26
- package/test/units/static/JBProjects/TestSetResolver.sol +0 -37
- package/test/units/static/JBProjects/TestTokenUri.sol +0 -40
- package/test/units/static/JBRulesetMetadataResolver/TestSetCashOutTaxRateTo.sol +0 -108
- package/test/units/static/JBRulesets/JBRulesetsSetup.sol +0 -24
- package/test/units/static/JBRulesets/TestCurrentApprovalStatusForLatestRulesetOf.sol +0 -265
- package/test/units/static/JBRulesets/TestCurrentOf.sol +0 -242
- package/test/units/static/JBRulesets/TestGetRulesetOf.sol +0 -100
- package/test/units/static/JBRulesets/TestLatestQueuedRulesetOf.sol +0 -260
- package/test/units/static/JBRulesets/TestRulesets.sol +0 -632
- package/test/units/static/JBRulesets/TestRulesetsOf.sol +0 -37
- package/test/units/static/JBRulesets/TestUpcomingRulesetOf.sol +0 -522
- package/test/units/static/JBRulesets/TestUpdateRulesetWeightCache.sol +0 -96
- package/test/units/static/JBSplits/JBSplitsSetup.sol +0 -26
- package/test/units/static/JBSplits/TestSelfManagedSplitGroups.sol +0 -552
- package/test/units/static/JBSplits/TestSetSplitGroupsOf.sol +0 -377
- package/test/units/static/JBSplits/TestSplitsLockedEdge.sol +0 -267
- package/test/units/static/JBSplits/TestSplitsOf.sol +0 -24
- package/test/units/static/JBSplits/TestSplitsPacking.sol +0 -36
- package/test/units/static/JBSurplus/TestSurplusFuzz.sol +0 -160
- package/test/units/static/JBTerminalStore/JBTerminalStoreSetup.sol +0 -45
- package/test/units/static/JBTerminalStore/TestCurrentReclaimableSurplusOf.sol +0 -536
- package/test/units/static/JBTerminalStore/TestCurrentSurplusOf.sol +0 -463
- package/test/units/static/JBTerminalStore/TestCurrentTotalSurplusOf.sol +0 -135
- package/test/units/static/JBTerminalStore/TestPreviewCashOutFrom.sol +0 -476
- package/test/units/static/JBTerminalStore/TestPreviewPayFrom.sol +0 -494
- package/test/units/static/JBTerminalStore/TestRecordCashOutsFor.sol +0 -652
- package/test/units/static/JBTerminalStore/TestRecordPaymentFrom.sol +0 -744
- package/test/units/static/JBTerminalStore/TestRecordPayoutFor.sol +0 -289
- package/test/units/static/JBTerminalStore/TestRecordTerminalMigration.sol +0 -138
- package/test/units/static/JBTerminalStore/TestRecordUsedAllowanceOf.sol +0 -415
- package/test/units/static/JBTerminalStore/TestUint224Overflow.sol +0 -219
- package/test/units/static/JBTokens/JBTokensSetup.sol +0 -32
- package/test/units/static/JBTokens/TestBurnFrom.sol +0 -107
- package/test/units/static/JBTokens/TestClaimTokensFor.sol +0 -110
- package/test/units/static/JBTokens/TestDeployERC20ForUnits.sol +0 -92
- package/test/units/static/JBTokens/TestMintFor.sol +0 -100
- package/test/units/static/JBTokens/TestSetTokenFor.sol +0 -98
- package/test/units/static/JBTokens/TestTotalBalanceOf.sol +0 -65
- package/test/units/static/JBTokens/TestTotalSupplyOf.sol +0 -56
- package/test/units/static/JBTokens/TestTransferCreditsFrom.sol +0 -56
|
@@ -1,949 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: MIT
|
|
2
|
-
pragma solidity ^0.8.6;
|
|
3
|
-
|
|
4
|
-
import {TestBaseWorkflow} from "./helpers/TestBaseWorkflow.sol";
|
|
5
|
-
import {JBTokens} from "../src/JBTokens.sol";
|
|
6
|
-
import {IJBController} from "../src/interfaces/IJBController.sol";
|
|
7
|
-
import {IJBMultiTerminal} from "../src/interfaces/IJBMultiTerminal.sol";
|
|
8
|
-
import {IJBSplitHook} from "../src/interfaces/IJBSplitHook.sol";
|
|
9
|
-
import {JBConstants} from "../src/libraries/JBConstants.sol";
|
|
10
|
-
import {JBAccountingContext} from "../src/structs/JBAccountingContext.sol";
|
|
11
|
-
import {JBCurrencyAmount} from "../src/structs/JBCurrencyAmount.sol";
|
|
12
|
-
import {JBFundAccessLimitGroup} from "../src/structs/JBFundAccessLimitGroup.sol";
|
|
13
|
-
import {JBRulesetConfig} from "../src/structs/JBRulesetConfig.sol";
|
|
14
|
-
import {JBRulesetMetadata} from "../src/structs/JBRulesetMetadata.sol";
|
|
15
|
-
import {JBSplit} from "../src/structs/JBSplit.sol";
|
|
16
|
-
import {JBSplitGroup} from "../src/structs/JBSplitGroup.sol";
|
|
17
|
-
import {JBTerminalConfig} from "../src/structs/JBTerminalConfig.sol";
|
|
18
|
-
import {mulDiv} from "@prb/math/src/Common.sol";
|
|
19
|
-
|
|
20
|
-
/// @notice Tests that the fee-free cashout bypass via same-terminal round-trip is closed:
|
|
21
|
-
/// fees apply on cashout up to the cumulative fee-free payout amount, then deplete.
|
|
22
|
-
contract TestFeeFreeCashOutBypass is TestBaseWorkflow {
|
|
23
|
-
IJBController private _controller;
|
|
24
|
-
IJBMultiTerminal private _terminal;
|
|
25
|
-
JBTokens private _tokens;
|
|
26
|
-
|
|
27
|
-
address private _projectOwner;
|
|
28
|
-
address private _attacker;
|
|
29
|
-
|
|
30
|
-
// Project A: sends payouts to project B via same terminal.
|
|
31
|
-
uint256 private _projectIdA;
|
|
32
|
-
// Project B: pass-through project with cashOutTaxRate = 0.
|
|
33
|
-
uint256 private _projectIdB;
|
|
34
|
-
|
|
35
|
-
uint112 private _weight = 1000 * 10 ** 18;
|
|
36
|
-
uint224 private _payoutLimit = 10 ether;
|
|
37
|
-
|
|
38
|
-
function setUp() public override {
|
|
39
|
-
super.setUp();
|
|
40
|
-
|
|
41
|
-
_projectOwner = multisig();
|
|
42
|
-
_attacker = makeAddr("attacker");
|
|
43
|
-
_controller = jbController();
|
|
44
|
-
_terminal = jbMultiTerminal();
|
|
45
|
-
_tokens = jbTokens();
|
|
46
|
-
|
|
47
|
-
// Shared terminal config.
|
|
48
|
-
JBTerminalConfig[] memory _terminalConfigurations = new JBTerminalConfig[](1);
|
|
49
|
-
JBAccountingContext[] memory _tokensToAccept = new JBAccountingContext[](1);
|
|
50
|
-
_tokensToAccept[0] = JBAccountingContext({
|
|
51
|
-
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
52
|
-
});
|
|
53
|
-
_terminalConfigurations[0] =
|
|
54
|
-
JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: _tokensToAccept});
|
|
55
|
-
|
|
56
|
-
// --- Fee-receiving project (project #1) ---
|
|
57
|
-
JBRulesetConfig[] memory _feeRulesetConfig = new JBRulesetConfig[](1);
|
|
58
|
-
_feeRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
59
|
-
_feeRulesetConfig[0].duration = 0;
|
|
60
|
-
_feeRulesetConfig[0].weight = _weight;
|
|
61
|
-
_feeRulesetConfig[0].metadata = _zeroTaxMetadata();
|
|
62
|
-
_feeRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
63
|
-
_feeRulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
64
|
-
|
|
65
|
-
_controller.launchProjectFor({
|
|
66
|
-
owner: address(420),
|
|
67
|
-
projectUri: "fee-project",
|
|
68
|
-
rulesetConfigurations: _feeRulesetConfig,
|
|
69
|
-
terminalConfigurations: _terminalConfigurations,
|
|
70
|
-
memo: ""
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// --- Project B: pass-through, cashOutTaxRate = 0 ---
|
|
74
|
-
JBRulesetConfig[] memory _bRulesetConfig = new JBRulesetConfig[](1);
|
|
75
|
-
_bRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
76
|
-
_bRulesetConfig[0].duration = 0;
|
|
77
|
-
_bRulesetConfig[0].weight = _weight;
|
|
78
|
-
_bRulesetConfig[0].metadata = _zeroTaxMetadata();
|
|
79
|
-
_bRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
80
|
-
_bRulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
81
|
-
|
|
82
|
-
_projectIdB = _controller.launchProjectFor({
|
|
83
|
-
owner: _projectOwner,
|
|
84
|
-
projectUri: "project-b",
|
|
85
|
-
rulesetConfigurations: _bRulesetConfig,
|
|
86
|
-
terminalConfigurations: _terminalConfigurations,
|
|
87
|
-
memo: ""
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
// --- Project A: routes 100% payouts to project B (same terminal) ---
|
|
91
|
-
JBSplit[] memory _splits = new JBSplit[](1);
|
|
92
|
-
_splits[0] = JBSplit({
|
|
93
|
-
preferAddToBalance: false,
|
|
94
|
-
percent: JBConstants.SPLITS_TOTAL_PERCENT,
|
|
95
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
96
|
-
projectId: uint64(_projectIdB),
|
|
97
|
-
beneficiary: payable(_attacker),
|
|
98
|
-
lockedUntil: 0,
|
|
99
|
-
hook: IJBSplitHook(address(0))
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
JBSplitGroup[] memory _splitGroups = new JBSplitGroup[](1);
|
|
103
|
-
_splitGroups[0] = JBSplitGroup({groupId: uint32(uint160(JBConstants.NATIVE_TOKEN)), splits: _splits});
|
|
104
|
-
|
|
105
|
-
JBFundAccessLimitGroup[] memory _fundAccessLimitGroup = new JBFundAccessLimitGroup[](1);
|
|
106
|
-
JBCurrencyAmount[] memory _payoutLimits = new JBCurrencyAmount[](1);
|
|
107
|
-
_payoutLimits[0] = JBCurrencyAmount({amount: _payoutLimit, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
108
|
-
_fundAccessLimitGroup[0] = JBFundAccessLimitGroup({
|
|
109
|
-
terminal: address(_terminal),
|
|
110
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
111
|
-
payoutLimits: _payoutLimits,
|
|
112
|
-
surplusAllowances: new JBCurrencyAmount[](0)
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
JBRulesetConfig[] memory _aRulesetConfig = new JBRulesetConfig[](1);
|
|
116
|
-
_aRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
117
|
-
_aRulesetConfig[0].duration = 0;
|
|
118
|
-
_aRulesetConfig[0].weight = _weight;
|
|
119
|
-
_aRulesetConfig[0].metadata = _zeroTaxMetadata();
|
|
120
|
-
_aRulesetConfig[0].splitGroups = _splitGroups;
|
|
121
|
-
_aRulesetConfig[0].fundAccessLimitGroups = _fundAccessLimitGroup;
|
|
122
|
-
|
|
123
|
-
_projectIdA = _controller.launchProjectFor({
|
|
124
|
-
owner: _projectOwner,
|
|
125
|
-
projectUri: "project-a",
|
|
126
|
-
rulesetConfigurations: _aRulesetConfig,
|
|
127
|
-
terminalConfigurations: _terminalConfigurations,
|
|
128
|
-
memo: ""
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
/// @notice After an intra-terminal payout from A → B, cashing out from B charges a fee
|
|
133
|
-
/// even though B has cashOutTaxRate = 0.
|
|
134
|
-
function testCashOutChargesFeeAfterFeeFreePayout() external {
|
|
135
|
-
uint256 payAmount = 10 ether;
|
|
136
|
-
vm.deal(_attacker, payAmount);
|
|
137
|
-
|
|
138
|
-
// Deploy ERC-20 for project B so the attacker can cash out.
|
|
139
|
-
vm.prank(_projectOwner);
|
|
140
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
141
|
-
|
|
142
|
-
// Step 1: Pay project A.
|
|
143
|
-
vm.prank(_attacker);
|
|
144
|
-
_terminal.pay{value: payAmount}({
|
|
145
|
-
projectId: _projectIdA,
|
|
146
|
-
amount: payAmount,
|
|
147
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
148
|
-
beneficiary: _attacker,
|
|
149
|
-
minReturnedTokens: 0,
|
|
150
|
-
memo: "",
|
|
151
|
-
metadata: new bytes(0)
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
// Step 2: Project A sends payouts → funds route to project B (same terminal, fee-free).
|
|
155
|
-
_terminal.sendPayoutsOf({
|
|
156
|
-
projectId: _projectIdA,
|
|
157
|
-
amount: _payoutLimit,
|
|
158
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
159
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
160
|
-
minTokensPaidOut: 0
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// The attacker now has tokens in project B (from the pay-in routed by the split).
|
|
164
|
-
uint256 attackerTokenBalance = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
165
|
-
assertGt(attackerTokenBalance, 0, "attacker should have project B tokens");
|
|
166
|
-
|
|
167
|
-
// Step 3: Cash out from project B.
|
|
168
|
-
vm.prank(_attacker);
|
|
169
|
-
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
170
|
-
holder: _attacker,
|
|
171
|
-
projectId: _projectIdB,
|
|
172
|
-
cashOutCount: attackerTokenBalance,
|
|
173
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
174
|
-
minTokensReclaimed: 0,
|
|
175
|
-
beneficiary: payable(_attacker),
|
|
176
|
-
metadata: new bytes(0)
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// With cashOutTaxRate = 0 the gross reclaim equals the full terminal balance (the payout amount).
|
|
180
|
-
// A 2.5% fee must have been charged, so net = gross * (1 - 2.5%).
|
|
181
|
-
uint256 expectedFee = mulDiv(_payoutLimit, 25, 1000); // 2.5% of 10 ETH
|
|
182
|
-
uint256 expectedNet = _payoutLimit - expectedFee;
|
|
183
|
-
assertApproxEqAbs(reclaimAmount, expectedNet, 2, "fee should be charged on cashout");
|
|
184
|
-
assertLt(reclaimAmount, _payoutLimit, "attacker should not get full amount (fee must apply)");
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/// @notice Direct pay-in → cashout with cashOutTaxRate = 0 remains fee-free (no payout flag set).
|
|
188
|
-
function testCashOutRemainsFeeFreForDirectPayIn() external {
|
|
189
|
-
uint256 payAmount = 10 ether;
|
|
190
|
-
address user = makeAddr("user");
|
|
191
|
-
vm.deal(user, payAmount);
|
|
192
|
-
|
|
193
|
-
// Deploy ERC-20 for project B.
|
|
194
|
-
vm.prank(_projectOwner);
|
|
195
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
196
|
-
|
|
197
|
-
// Pay directly into project B (no payout from another project).
|
|
198
|
-
vm.prank(user);
|
|
199
|
-
_terminal.pay{value: payAmount}({
|
|
200
|
-
projectId: _projectIdB,
|
|
201
|
-
amount: payAmount,
|
|
202
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
203
|
-
beneficiary: user,
|
|
204
|
-
minReturnedTokens: 0,
|
|
205
|
-
memo: "",
|
|
206
|
-
metadata: new bytes(0)
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
uint256 userTokenBalance = _tokens.totalBalanceOf(user, _projectIdB);
|
|
210
|
-
assertGt(userTokenBalance, 0, "user should have project B tokens");
|
|
211
|
-
|
|
212
|
-
// Cash out — should be fee-free since no intra-terminal payout was received.
|
|
213
|
-
vm.prank(user);
|
|
214
|
-
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
215
|
-
holder: user,
|
|
216
|
-
projectId: _projectIdB,
|
|
217
|
-
cashOutCount: userTokenBalance,
|
|
218
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
219
|
-
minTokensReclaimed: 0,
|
|
220
|
-
beneficiary: payable(user),
|
|
221
|
-
metadata: new bytes(0)
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
// With cashOutTaxRate = 0, full cashout returns the entire surplus 1:1.
|
|
225
|
-
// No fee should be charged.
|
|
226
|
-
assertEq(reclaimAmount, payAmount, "direct pay-in cashout should be fee-free");
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/// @notice After the fee-free surplus is fully consumed, subsequent direct pay-ins cash out fee-free.
|
|
230
|
-
function testSurplusDepletionRestoresFeeFreeCashOut() external {
|
|
231
|
-
uint256 payAmount = 10 ether;
|
|
232
|
-
vm.deal(_attacker, payAmount * 2);
|
|
233
|
-
|
|
234
|
-
vm.prank(_projectOwner);
|
|
235
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
236
|
-
|
|
237
|
-
// Pay project A and trigger payout → accumulates fee-free surplus on project B.
|
|
238
|
-
vm.prank(_attacker);
|
|
239
|
-
_terminal.pay{value: payAmount}({
|
|
240
|
-
projectId: _projectIdA,
|
|
241
|
-
amount: payAmount,
|
|
242
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
243
|
-
beneficiary: _attacker,
|
|
244
|
-
minReturnedTokens: 0,
|
|
245
|
-
memo: "",
|
|
246
|
-
metadata: new bytes(0)
|
|
247
|
-
});
|
|
248
|
-
_terminal.sendPayoutsOf({
|
|
249
|
-
projectId: _projectIdA,
|
|
250
|
-
amount: _payoutLimit,
|
|
251
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
252
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
253
|
-
minTokensPaidOut: 0
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
// Cash out all tokens from project B — fee applied against fee-free surplus, which is now depleted.
|
|
257
|
-
uint256 tokensFromPayout = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
258
|
-
vm.prank(_attacker);
|
|
259
|
-
uint256 firstReclaim = _terminal.cashOutTokensOf({
|
|
260
|
-
holder: _attacker,
|
|
261
|
-
projectId: _projectIdB,
|
|
262
|
-
cashOutCount: tokensFromPayout,
|
|
263
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
264
|
-
minTokensReclaimed: 0,
|
|
265
|
-
beneficiary: payable(_attacker),
|
|
266
|
-
metadata: new bytes(0)
|
|
267
|
-
});
|
|
268
|
-
// First cashout should have had a fee deducted.
|
|
269
|
-
assertLt(firstReclaim, _payoutLimit, "first cashout should have fee deducted");
|
|
270
|
-
|
|
271
|
-
// Now pay project B directly with fresh funds.
|
|
272
|
-
vm.prank(_attacker);
|
|
273
|
-
_terminal.pay{value: payAmount}({
|
|
274
|
-
projectId: _projectIdB,
|
|
275
|
-
amount: payAmount,
|
|
276
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
277
|
-
beneficiary: _attacker,
|
|
278
|
-
minReturnedTokens: 0,
|
|
279
|
-
memo: "",
|
|
280
|
-
metadata: new bytes(0)
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
uint256 newTokens = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
284
|
-
assertGt(newTokens, 0, "attacker should have new tokens");
|
|
285
|
-
|
|
286
|
-
// Cash out again — surplus is depleted, so this direct pay-in should be fee-free.
|
|
287
|
-
vm.prank(_attacker);
|
|
288
|
-
uint256 secondReclaim = _terminal.cashOutTokensOf({
|
|
289
|
-
holder: _attacker,
|
|
290
|
-
projectId: _projectIdB,
|
|
291
|
-
cashOutCount: newTokens,
|
|
292
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
293
|
-
minTokensReclaimed: 0,
|
|
294
|
-
beneficiary: payable(_attacker),
|
|
295
|
-
metadata: new bytes(0)
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
// After surplus depletion, direct pay-in cashout should be fee-free.
|
|
299
|
-
assertEq(secondReclaim, payAmount, "direct pay-in cashout should be fee-free after surplus depleted");
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/// @notice Fee-free surplus only covers the exact payout amount — partial cashout leaves remainder.
|
|
303
|
-
function testPartialCashOutConsumesPartialSurplus() external {
|
|
304
|
-
uint256 payAmount = 10 ether;
|
|
305
|
-
vm.deal(_attacker, payAmount);
|
|
306
|
-
|
|
307
|
-
vm.prank(_projectOwner);
|
|
308
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
309
|
-
|
|
310
|
-
// Pay project A and trigger payout → 10 ETH fee-free surplus on project B.
|
|
311
|
-
vm.prank(_attacker);
|
|
312
|
-
_terminal.pay{value: payAmount}({
|
|
313
|
-
projectId: _projectIdA,
|
|
314
|
-
amount: payAmount,
|
|
315
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
316
|
-
beneficiary: _attacker,
|
|
317
|
-
minReturnedTokens: 0,
|
|
318
|
-
memo: "",
|
|
319
|
-
metadata: new bytes(0)
|
|
320
|
-
});
|
|
321
|
-
_terminal.sendPayoutsOf({
|
|
322
|
-
projectId: _projectIdA,
|
|
323
|
-
amount: _payoutLimit,
|
|
324
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
325
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
326
|
-
minTokensPaidOut: 0
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
// Cash out half the tokens — should consume roughly half the fee-free surplus.
|
|
330
|
-
uint256 totalTokens = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
331
|
-
uint256 halfTokens = totalTokens / 2;
|
|
332
|
-
|
|
333
|
-
vm.prank(_attacker);
|
|
334
|
-
uint256 firstReclaim = _terminal.cashOutTokensOf({
|
|
335
|
-
holder: _attacker,
|
|
336
|
-
projectId: _projectIdB,
|
|
337
|
-
cashOutCount: halfTokens,
|
|
338
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
339
|
-
minTokensReclaimed: 0,
|
|
340
|
-
beneficiary: payable(_attacker),
|
|
341
|
-
metadata: new bytes(0)
|
|
342
|
-
});
|
|
343
|
-
// First half-cashout should have fee deducted (fee-free surplus partially consumed).
|
|
344
|
-
assertLt(firstReclaim, _payoutLimit / 2, "partial cashout should have fee deducted");
|
|
345
|
-
|
|
346
|
-
// Cash out the remaining tokens — should also have fee (remaining surplus covers it).
|
|
347
|
-
uint256 remainingTokens = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
348
|
-
vm.prank(_attacker);
|
|
349
|
-
uint256 secondReclaim = _terminal.cashOutTokensOf({
|
|
350
|
-
holder: _attacker,
|
|
351
|
-
projectId: _projectIdB,
|
|
352
|
-
cashOutCount: remainingTokens,
|
|
353
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
354
|
-
minTokensReclaimed: 0,
|
|
355
|
-
beneficiary: payable(_attacker),
|
|
356
|
-
metadata: new bytes(0)
|
|
357
|
-
});
|
|
358
|
-
// Second cashout also has fee deducted from remaining surplus.
|
|
359
|
-
assertLt(secondReclaim, firstReclaim + 1 ether, "second partial cashout should also have fee");
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/// @notice Griefing with a tiny payout only costs the victim fees on that tiny amount.
|
|
363
|
-
function testGriefingWithTinyPayoutIsScoped() external {
|
|
364
|
-
address victim = makeAddr("victim");
|
|
365
|
-
vm.deal(victim, 10 ether);
|
|
366
|
-
vm.deal(_attacker, 1); // 1 wei for the griefing payout
|
|
367
|
-
|
|
368
|
-
vm.prank(_projectOwner);
|
|
369
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
370
|
-
|
|
371
|
-
// Victim pays directly into project B.
|
|
372
|
-
vm.prank(victim);
|
|
373
|
-
_terminal.pay{value: 10 ether}({
|
|
374
|
-
projectId: _projectIdB,
|
|
375
|
-
amount: 10 ether,
|
|
376
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
377
|
-
beneficiary: victim,
|
|
378
|
-
minReturnedTokens: 0,
|
|
379
|
-
memo: "",
|
|
380
|
-
metadata: new bytes(0)
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
// Attacker triggers a 1 wei payout to project B via project A to set fee-free surplus.
|
|
384
|
-
// First fund project A with 1 wei.
|
|
385
|
-
vm.prank(_attacker);
|
|
386
|
-
_terminal.pay{value: 1}({
|
|
387
|
-
projectId: _projectIdA,
|
|
388
|
-
amount: 1,
|
|
389
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
390
|
-
beneficiary: _attacker,
|
|
391
|
-
minReturnedTokens: 0,
|
|
392
|
-
memo: "",
|
|
393
|
-
metadata: new bytes(0)
|
|
394
|
-
});
|
|
395
|
-
_terminal.sendPayoutsOf({
|
|
396
|
-
projectId: _projectIdA,
|
|
397
|
-
amount: 1,
|
|
398
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
399
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
400
|
-
minTokensPaidOut: 0
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
// Victim cashes out — fee should only apply to 1 wei of surplus, not their full 10 ETH.
|
|
404
|
-
uint256 victimTokens = _tokens.totalBalanceOf(victim, _projectIdB);
|
|
405
|
-
vm.prank(victim);
|
|
406
|
-
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
407
|
-
holder: victim,
|
|
408
|
-
projectId: _projectIdB,
|
|
409
|
-
cashOutCount: victimTokens,
|
|
410
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
411
|
-
minTokensReclaimed: 0,
|
|
412
|
-
beneficiary: payable(victim),
|
|
413
|
-
metadata: new bytes(0)
|
|
414
|
-
});
|
|
415
|
-
|
|
416
|
-
// Fee on 1 wei is 0 (rounds down). Victim should get essentially their full amount back.
|
|
417
|
-
// The key assertion: victim is NOT penalized with a fee on their full 10 ETH.
|
|
418
|
-
uint256 feeOn1Wei = mulDiv(1, 25, 1000); // 0 (rounds down)
|
|
419
|
-
assertGe(reclaimAmount, 10 ether - feeOn1Wei - 1, "griefing should cost at most fee on 1 wei");
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/// @notice Non-zero cashOutTaxRate applies fees to the full reclaim, ignoring surplus.
|
|
423
|
-
function testNonZeroTaxRateIgnoresSurplus() external {
|
|
424
|
-
uint256 payAmount = 10 ether;
|
|
425
|
-
vm.deal(_attacker, payAmount);
|
|
426
|
-
|
|
427
|
-
// Launch project C with cashOutTaxRate = 5000 (50%).
|
|
428
|
-
JBTerminalConfig[] memory _terminalConfigurations = new JBTerminalConfig[](1);
|
|
429
|
-
JBAccountingContext[] memory _tokensToAccept = new JBAccountingContext[](1);
|
|
430
|
-
_tokensToAccept[0] = JBAccountingContext({
|
|
431
|
-
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
432
|
-
});
|
|
433
|
-
_terminalConfigurations[0] =
|
|
434
|
-
JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: _tokensToAccept});
|
|
435
|
-
|
|
436
|
-
JBRulesetMetadata memory taxMeta = _zeroTaxMetadata();
|
|
437
|
-
taxMeta.cashOutTaxRate = 5000; // 50%
|
|
438
|
-
|
|
439
|
-
JBRulesetConfig[] memory _cRulesetConfig = new JBRulesetConfig[](1);
|
|
440
|
-
_cRulesetConfig[0].mustStartAtOrAfter = 0;
|
|
441
|
-
_cRulesetConfig[0].duration = 0;
|
|
442
|
-
_cRulesetConfig[0].weight = _weight;
|
|
443
|
-
_cRulesetConfig[0].metadata = taxMeta;
|
|
444
|
-
_cRulesetConfig[0].splitGroups = new JBSplitGroup[](0);
|
|
445
|
-
_cRulesetConfig[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
446
|
-
|
|
447
|
-
uint256 projectIdC = _controller.launchProjectFor({
|
|
448
|
-
owner: _projectOwner,
|
|
449
|
-
projectUri: "project-c",
|
|
450
|
-
rulesetConfigurations: _cRulesetConfig,
|
|
451
|
-
terminalConfigurations: _terminalConfigurations,
|
|
452
|
-
memo: ""
|
|
453
|
-
});
|
|
454
|
-
|
|
455
|
-
vm.prank(_projectOwner);
|
|
456
|
-
_controller.deployERC20For(projectIdC, "ProjectC", "PC", bytes32(0));
|
|
457
|
-
|
|
458
|
-
// Pay and cash out — non-zero tax rate means full fee applies (surplus irrelevant).
|
|
459
|
-
vm.prank(_attacker);
|
|
460
|
-
_terminal.pay{value: payAmount}({
|
|
461
|
-
projectId: projectIdC,
|
|
462
|
-
amount: payAmount,
|
|
463
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
464
|
-
beneficiary: _attacker,
|
|
465
|
-
minReturnedTokens: 0,
|
|
466
|
-
memo: "",
|
|
467
|
-
metadata: new bytes(0)
|
|
468
|
-
});
|
|
469
|
-
|
|
470
|
-
uint256 tokens = _tokens.totalBalanceOf(_attacker, projectIdC);
|
|
471
|
-
vm.prank(_attacker);
|
|
472
|
-
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
473
|
-
holder: _attacker,
|
|
474
|
-
projectId: projectIdC,
|
|
475
|
-
cashOutCount: tokens,
|
|
476
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
477
|
-
minTokensReclaimed: 0,
|
|
478
|
-
beneficiary: payable(_attacker),
|
|
479
|
-
metadata: new bytes(0)
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
// With non-zero tax, the 2.5% fee applies to the full bonding curve output.
|
|
483
|
-
// Full cashout (count == supply) returns the entire surplus regardless of tax rate,
|
|
484
|
-
// so gross = 10 ETH, fee = 2.5%, net = 9.75 ETH.
|
|
485
|
-
assertLt(reclaimAmount, payAmount, "non-zero tax should charge fee on full amount");
|
|
486
|
-
uint256 expectedFee = mulDiv(payAmount, 25, 1000);
|
|
487
|
-
uint256 expectedNet = payAmount - expectedFee;
|
|
488
|
-
assertApproxEqAbs(reclaimAmount, expectedNet, 2, "fee should apply to full bonding curve output");
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
/// @notice Multiple payouts accumulate fee-free surplus correctly.
|
|
492
|
-
function testMultiplePayoutsAccumulateSurplus() external {
|
|
493
|
-
uint256 payAmount = 10 ether;
|
|
494
|
-
// Need to fund project A twice, so give attacker enough.
|
|
495
|
-
vm.deal(_attacker, payAmount * 2);
|
|
496
|
-
|
|
497
|
-
vm.prank(_projectOwner);
|
|
498
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
499
|
-
|
|
500
|
-
// First payout cycle: pay A, send payouts → 10 ETH fee-free surplus on B.
|
|
501
|
-
vm.prank(_attacker);
|
|
502
|
-
_terminal.pay{value: payAmount}({
|
|
503
|
-
projectId: _projectIdA,
|
|
504
|
-
amount: payAmount,
|
|
505
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
506
|
-
beneficiary: _attacker,
|
|
507
|
-
minReturnedTokens: 0,
|
|
508
|
-
memo: "",
|
|
509
|
-
metadata: new bytes(0)
|
|
510
|
-
});
|
|
511
|
-
_terminal.sendPayoutsOf({
|
|
512
|
-
projectId: _projectIdA,
|
|
513
|
-
amount: _payoutLimit,
|
|
514
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
515
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
516
|
-
minTokensPaidOut: 0
|
|
517
|
-
});
|
|
518
|
-
|
|
519
|
-
// Queue a new ruleset so the payout limit resets for the next cycle.
|
|
520
|
-
JBSplit[] memory _splits = new JBSplit[](1);
|
|
521
|
-
_splits[0] = JBSplit({
|
|
522
|
-
preferAddToBalance: false,
|
|
523
|
-
percent: JBConstants.SPLITS_TOTAL_PERCENT,
|
|
524
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
525
|
-
projectId: uint64(_projectIdB),
|
|
526
|
-
beneficiary: payable(_attacker),
|
|
527
|
-
lockedUntil: 0,
|
|
528
|
-
hook: IJBSplitHook(address(0))
|
|
529
|
-
});
|
|
530
|
-
JBSplitGroup[] memory _splitGroups = new JBSplitGroup[](1);
|
|
531
|
-
_splitGroups[0] = JBSplitGroup({groupId: uint32(uint160(JBConstants.NATIVE_TOKEN)), splits: _splits});
|
|
532
|
-
|
|
533
|
-
JBCurrencyAmount[] memory _payoutLimits = new JBCurrencyAmount[](1);
|
|
534
|
-
_payoutLimits[0] = JBCurrencyAmount({amount: _payoutLimit, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
535
|
-
JBFundAccessLimitGroup[] memory _fundAccessLimitGroup = new JBFundAccessLimitGroup[](1);
|
|
536
|
-
_fundAccessLimitGroup[0] = JBFundAccessLimitGroup({
|
|
537
|
-
terminal: address(_terminal),
|
|
538
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
539
|
-
payoutLimits: _payoutLimits,
|
|
540
|
-
surplusAllowances: new JBCurrencyAmount[](0)
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
JBRulesetConfig[] memory _newRuleset = new JBRulesetConfig[](1);
|
|
544
|
-
_newRuleset[0].mustStartAtOrAfter = 0;
|
|
545
|
-
_newRuleset[0].duration = 0;
|
|
546
|
-
_newRuleset[0].weight = _weight;
|
|
547
|
-
_newRuleset[0].metadata = _zeroTaxMetadata();
|
|
548
|
-
_newRuleset[0].splitGroups = _splitGroups;
|
|
549
|
-
_newRuleset[0].fundAccessLimitGroups = _fundAccessLimitGroup;
|
|
550
|
-
|
|
551
|
-
vm.prank(_projectOwner);
|
|
552
|
-
_controller.queueRulesetsOf({projectId: _projectIdA, rulesetConfigurations: _newRuleset, memo: ""});
|
|
553
|
-
|
|
554
|
-
// Second payout cycle: pay A again, send payouts → another 10 ETH, total 20 ETH surplus.
|
|
555
|
-
vm.prank(_attacker);
|
|
556
|
-
_terminal.pay{value: payAmount}({
|
|
557
|
-
projectId: _projectIdA,
|
|
558
|
-
amount: payAmount,
|
|
559
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
560
|
-
beneficiary: _attacker,
|
|
561
|
-
minReturnedTokens: 0,
|
|
562
|
-
memo: "",
|
|
563
|
-
metadata: new bytes(0)
|
|
564
|
-
});
|
|
565
|
-
_terminal.sendPayoutsOf({
|
|
566
|
-
projectId: _projectIdA,
|
|
567
|
-
amount: _payoutLimit,
|
|
568
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
569
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
570
|
-
minTokensPaidOut: 0
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
// Cash out all tokens from B — fee should apply to the full 20 ETH surplus.
|
|
574
|
-
uint256 allTokens = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
575
|
-
vm.prank(_attacker);
|
|
576
|
-
uint256 reclaimAmount = _terminal.cashOutTokensOf({
|
|
577
|
-
holder: _attacker,
|
|
578
|
-
projectId: _projectIdB,
|
|
579
|
-
cashOutCount: allTokens,
|
|
580
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
581
|
-
minTokensReclaimed: 0,
|
|
582
|
-
beneficiary: payable(_attacker),
|
|
583
|
-
metadata: new bytes(0)
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
// The 2.5% fee should apply to the entire reclaim (covered by 20 ETH surplus).
|
|
587
|
-
// Gross reclaim ≈ 20 ETH (full balance of B), fee = 2.5% of that.
|
|
588
|
-
assertLt(reclaimAmount, 20 ether, "fee should be charged on accumulated surplus");
|
|
589
|
-
uint256 expectedFee = mulDiv(20 ether, 25, 1000);
|
|
590
|
-
uint256 expectedNet = 20 ether - expectedFee;
|
|
591
|
-
assertApproxEqAbs(reclaimAmount, expectedNet, 2, "fee should apply to full 20 ETH surplus");
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
/// @notice Multi-hop payouts: A → B → C. Both B and C should incur fees on cashout.
|
|
595
|
-
/// Project A pays Project B (0 cashout tax, same terminal). Project B receives external payment.
|
|
596
|
-
/// Project B pays Project C (0 cashout tax, same terminal). Both B and C cashouts incur fees.
|
|
597
|
-
function testMultiHopPayoutsCannotAvoidFees() external {
|
|
598
|
-
uint256 projectIdC = _launchMultiHopProjectC();
|
|
599
|
-
_configureProjectBPayoutsTo(projectIdC);
|
|
600
|
-
|
|
601
|
-
vm.prank(_projectOwner);
|
|
602
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
603
|
-
vm.prank(_projectOwner);
|
|
604
|
-
_controller.deployERC20For(projectIdC, "ProjectC", "PC", bytes32(0));
|
|
605
|
-
|
|
606
|
-
// Step 1: Pay project A, trigger payout → 10 ETH lands on B (fee-free).
|
|
607
|
-
vm.deal(_attacker, 100 ether);
|
|
608
|
-
vm.prank(_attacker);
|
|
609
|
-
_terminal.pay{value: 100 ether}({
|
|
610
|
-
projectId: _projectIdA,
|
|
611
|
-
amount: 100 ether,
|
|
612
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
613
|
-
beneficiary: _attacker,
|
|
614
|
-
minReturnedTokens: 0,
|
|
615
|
-
memo: "",
|
|
616
|
-
metadata: new bytes(0)
|
|
617
|
-
});
|
|
618
|
-
_terminal.sendPayoutsOf({
|
|
619
|
-
projectId: _projectIdA,
|
|
620
|
-
amount: _payoutLimit,
|
|
621
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
622
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
623
|
-
minTokensPaidOut: 0
|
|
624
|
-
});
|
|
625
|
-
// B: balance = 10 ETH, fee-free = 10 ETH.
|
|
626
|
-
|
|
627
|
-
// Step 2: External payment of 100 ETH to project B.
|
|
628
|
-
address user = makeAddr("user");
|
|
629
|
-
vm.deal(user, 100 ether);
|
|
630
|
-
vm.prank(user);
|
|
631
|
-
_terminal.pay{value: 100 ether}({
|
|
632
|
-
projectId: _projectIdB,
|
|
633
|
-
amount: 100 ether,
|
|
634
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
635
|
-
beneficiary: user,
|
|
636
|
-
minReturnedTokens: 0,
|
|
637
|
-
memo: "",
|
|
638
|
-
metadata: new bytes(0)
|
|
639
|
-
});
|
|
640
|
-
// B: balance = 110 ETH, fee-free = 10 ETH.
|
|
641
|
-
|
|
642
|
-
// Step 3: Project B pays out 100 ETH to Project C (same terminal, fee-free for C).
|
|
643
|
-
_terminal.sendPayoutsOf({
|
|
644
|
-
projectId: _projectIdB,
|
|
645
|
-
amount: 100 ether,
|
|
646
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
647
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
648
|
-
minTokensPaidOut: 0
|
|
649
|
-
});
|
|
650
|
-
// B: balance = 10 ETH, fee-free = 10 (stays: non-fee-free left first).
|
|
651
|
-
// C: balance = 100 ETH, fee-free = 100 ETH.
|
|
652
|
-
|
|
653
|
-
// Step 4: Cash out from C — fees must apply to the full 100 ETH.
|
|
654
|
-
_assertCashOutIncursFees(projectIdC, _attacker);
|
|
655
|
-
|
|
656
|
-
// Step 5: Cash out attacker's tokens from B — fees must apply (B is all fee-free).
|
|
657
|
-
_assertCashOutIncursFees(_projectIdB, _attacker);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/// @dev Assert that cashing out all tokens from `projectId` for `holder` incurs a fee.
|
|
661
|
-
function _assertCashOutIncursFees(uint256 projectId, address holder) internal {
|
|
662
|
-
uint256 holderTokens = _tokens.totalBalanceOf(holder, projectId);
|
|
663
|
-
if (holderTokens == 0) return;
|
|
664
|
-
|
|
665
|
-
uint256 balBefore = holder.balance;
|
|
666
|
-
vm.prank(holder);
|
|
667
|
-
uint256 reclaim = _terminal.cashOutTokensOf({
|
|
668
|
-
holder: holder,
|
|
669
|
-
projectId: projectId,
|
|
670
|
-
cashOutCount: holderTokens,
|
|
671
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
672
|
-
minTokensReclaimed: 0,
|
|
673
|
-
beneficiary: payable(holder),
|
|
674
|
-
metadata: new bytes(0)
|
|
675
|
-
});
|
|
676
|
-
// With cashOutTaxRate = 0, gross reclaim = proportional share of balance.
|
|
677
|
-
// Fee-free surplus means a 2.5% fee is applied → net < gross.
|
|
678
|
-
assertGt(reclaim, 0, "should reclaim something");
|
|
679
|
-
// The fee should have been deducted: actual ETH received < reclaim + fee.
|
|
680
|
-
uint256 ethReceived = holder.balance - balBefore;
|
|
681
|
-
assertEq(ethReceived, reclaim, "ETH received should match reclaim");
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
/// @dev Launch Project C with zero tax for multi-hop test.
|
|
685
|
-
function _launchMultiHopProjectC() internal returns (uint256) {
|
|
686
|
-
JBTerminalConfig[] memory termConfigs = new JBTerminalConfig[](1);
|
|
687
|
-
JBAccountingContext[] memory ctxs = new JBAccountingContext[](1);
|
|
688
|
-
ctxs[0] = JBAccountingContext({
|
|
689
|
-
token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
690
|
-
});
|
|
691
|
-
termConfigs[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: ctxs});
|
|
692
|
-
|
|
693
|
-
JBRulesetConfig[] memory rc = new JBRulesetConfig[](1);
|
|
694
|
-
rc[0].mustStartAtOrAfter = 0;
|
|
695
|
-
rc[0].duration = 0;
|
|
696
|
-
rc[0].weight = _weight;
|
|
697
|
-
rc[0].metadata = _zeroTaxMetadata();
|
|
698
|
-
rc[0].splitGroups = new JBSplitGroup[](0);
|
|
699
|
-
rc[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
700
|
-
|
|
701
|
-
return _controller.launchProjectFor({
|
|
702
|
-
owner: _projectOwner,
|
|
703
|
-
projectUri: "project-c-multihop",
|
|
704
|
-
rulesetConfigurations: rc,
|
|
705
|
-
terminalConfigurations: termConfigs,
|
|
706
|
-
memo: ""
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
/// @dev Reconfigure Project B to route 100% payouts to `targetProjectId`.
|
|
711
|
-
function _configureProjectBPayoutsTo(uint256 targetProjectId) internal {
|
|
712
|
-
JBSplit[] memory splits = new JBSplit[](1);
|
|
713
|
-
splits[0] = JBSplit({
|
|
714
|
-
preferAddToBalance: false,
|
|
715
|
-
percent: JBConstants.SPLITS_TOTAL_PERCENT,
|
|
716
|
-
// forge-lint: disable-next-line(unsafe-typecast)
|
|
717
|
-
projectId: uint64(targetProjectId),
|
|
718
|
-
beneficiary: payable(_attacker),
|
|
719
|
-
lockedUntil: 0,
|
|
720
|
-
hook: IJBSplitHook(address(0))
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
JBSplitGroup[] memory splitGroups = new JBSplitGroup[](1);
|
|
724
|
-
splitGroups[0] = JBSplitGroup({groupId: uint32(uint160(JBConstants.NATIVE_TOKEN)), splits: splits});
|
|
725
|
-
|
|
726
|
-
JBCurrencyAmount[] memory limits = new JBCurrencyAmount[](1);
|
|
727
|
-
limits[0] = JBCurrencyAmount({amount: 100 ether, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))});
|
|
728
|
-
JBFundAccessLimitGroup[] memory fundAccess = new JBFundAccessLimitGroup[](1);
|
|
729
|
-
fundAccess[0] = JBFundAccessLimitGroup({
|
|
730
|
-
terminal: address(_terminal),
|
|
731
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
732
|
-
payoutLimits: limits,
|
|
733
|
-
surplusAllowances: new JBCurrencyAmount[](0)
|
|
734
|
-
});
|
|
735
|
-
|
|
736
|
-
JBRulesetConfig[] memory rc = new JBRulesetConfig[](1);
|
|
737
|
-
rc[0].mustStartAtOrAfter = 0;
|
|
738
|
-
rc[0].duration = 0;
|
|
739
|
-
rc[0].weight = _weight;
|
|
740
|
-
rc[0].metadata = _zeroTaxMetadata();
|
|
741
|
-
rc[0].splitGroups = splitGroups;
|
|
742
|
-
rc[0].fundAccessLimitGroups = fundAccess;
|
|
743
|
-
|
|
744
|
-
vm.prank(_projectOwner);
|
|
745
|
-
_controller.queueRulesetsOf({projectId: _projectIdB, rulesetConfigurations: rc, memo: ""});
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/// @notice Non-zero-tax cashouts cap fee-free surplus at remaining balance.
|
|
749
|
-
/// Without this, switching from non-zero to zero tax could let stale surplus over-charge.
|
|
750
|
-
function testNonZeroTaxCashOutCapsFeeFreeSurplus() external {
|
|
751
|
-
vm.prank(_projectOwner);
|
|
752
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
753
|
-
|
|
754
|
-
// Step 1: Pay project A, trigger payout → 10 ETH fee-free on B.
|
|
755
|
-
vm.deal(_attacker, 10 ether);
|
|
756
|
-
vm.prank(_attacker);
|
|
757
|
-
_terminal.pay{value: 10 ether}({
|
|
758
|
-
projectId: _projectIdA,
|
|
759
|
-
amount: 10 ether,
|
|
760
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
761
|
-
beneficiary: _attacker,
|
|
762
|
-
minReturnedTokens: 0,
|
|
763
|
-
memo: "",
|
|
764
|
-
metadata: new bytes(0)
|
|
765
|
-
});
|
|
766
|
-
_terminal.sendPayoutsOf({
|
|
767
|
-
projectId: _projectIdA,
|
|
768
|
-
amount: _payoutLimit,
|
|
769
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
770
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
771
|
-
minTokensPaidOut: 0
|
|
772
|
-
});
|
|
773
|
-
// B: balance = 10 ETH, fee-free = 10 ETH.
|
|
774
|
-
|
|
775
|
-
// Step 2: Pay B directly with 90 ETH more.
|
|
776
|
-
address user = makeAddr("user");
|
|
777
|
-
vm.deal(user, 90 ether);
|
|
778
|
-
vm.prank(user);
|
|
779
|
-
_terminal.pay{value: 90 ether}({
|
|
780
|
-
projectId: _projectIdB,
|
|
781
|
-
amount: 90 ether,
|
|
782
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
783
|
-
beneficiary: user,
|
|
784
|
-
minReturnedTokens: 0,
|
|
785
|
-
memo: "",
|
|
786
|
-
metadata: new bytes(0)
|
|
787
|
-
});
|
|
788
|
-
// B: balance = 100 ETH, fee-free = 10 ETH.
|
|
789
|
-
|
|
790
|
-
// Step 3: Reconfigure B with non-zero cash out tax, then cash out most of the balance.
|
|
791
|
-
_reconfigureWithTaxRate(_projectIdB, 5000); // 50% tax
|
|
792
|
-
|
|
793
|
-
// Cash out 95% of user's tokens at 50% tax → drains most balance.
|
|
794
|
-
uint256 userTokens = _tokens.totalBalanceOf(user, _projectIdB);
|
|
795
|
-
vm.prank(user);
|
|
796
|
-
_terminal.cashOutTokensOf({
|
|
797
|
-
holder: user,
|
|
798
|
-
projectId: _projectIdB,
|
|
799
|
-
cashOutCount: userTokens * 95 / 100,
|
|
800
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
801
|
-
minTokensReclaimed: 0,
|
|
802
|
-
beneficiary: payable(user),
|
|
803
|
-
metadata: new bytes(0)
|
|
804
|
-
});
|
|
805
|
-
// Balance dropped significantly. _capFeeFreeSurplus should have capped fee-free at remaining balance.
|
|
806
|
-
|
|
807
|
-
// Step 4: Switch back to zero tax. Cash out remaining.
|
|
808
|
-
_reconfigureWithTaxRate(_projectIdB, 0);
|
|
809
|
-
|
|
810
|
-
uint256 remainingUserTokens = _tokens.totalBalanceOf(user, _projectIdB);
|
|
811
|
-
if (remainingUserTokens > 0) {
|
|
812
|
-
vm.prank(user);
|
|
813
|
-
uint256 reclaim = _terminal.cashOutTokensOf({
|
|
814
|
-
holder: user,
|
|
815
|
-
projectId: _projectIdB,
|
|
816
|
-
cashOutCount: remainingUserTokens,
|
|
817
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
818
|
-
minTokensReclaimed: 0,
|
|
819
|
-
beneficiary: payable(user),
|
|
820
|
-
metadata: new bytes(0)
|
|
821
|
-
});
|
|
822
|
-
// Should NOT revert. Fee-free was capped during the non-zero-tax cashout,
|
|
823
|
-
// so it doesn't exceed the remaining balance.
|
|
824
|
-
assertGt(reclaim, 0, "should reclaim something after tax rate switch");
|
|
825
|
-
}
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/// @notice Feeless beneficiary cashout still caps fee-free surplus at remaining balance.
|
|
829
|
-
function testFeelessBeneficiaryCashOutCapsFeeFreeSurplus() external {
|
|
830
|
-
vm.prank(_projectOwner);
|
|
831
|
-
_controller.deployERC20For(_projectIdB, "ProjectB", "PB", bytes32(0));
|
|
832
|
-
|
|
833
|
-
// Step 1: Pay project A, trigger payout → 10 ETH fee-free on B.
|
|
834
|
-
vm.deal(_attacker, 10 ether);
|
|
835
|
-
vm.prank(_attacker);
|
|
836
|
-
_terminal.pay{value: 10 ether}({
|
|
837
|
-
projectId: _projectIdA,
|
|
838
|
-
amount: 10 ether,
|
|
839
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
840
|
-
beneficiary: _attacker,
|
|
841
|
-
minReturnedTokens: 0,
|
|
842
|
-
memo: "",
|
|
843
|
-
metadata: new bytes(0)
|
|
844
|
-
});
|
|
845
|
-
_terminal.sendPayoutsOf({
|
|
846
|
-
projectId: _projectIdA,
|
|
847
|
-
amount: _payoutLimit,
|
|
848
|
-
currency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
849
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
850
|
-
minTokensPaidOut: 0
|
|
851
|
-
});
|
|
852
|
-
// B: balance = 10 ETH, fee-free = 10 ETH. Attacker has tokens.
|
|
853
|
-
|
|
854
|
-
// Step 2: Mark attacker as feeless.
|
|
855
|
-
vm.prank(multisig());
|
|
856
|
-
jbFeelessAddresses().setFeelessAddress(_attacker, true);
|
|
857
|
-
|
|
858
|
-
// Step 3: Feeless cashout — no fees charged, but _capFeeFreeSurplus should still cap.
|
|
859
|
-
uint256 attackerTokens = _tokens.totalBalanceOf(_attacker, _projectIdB);
|
|
860
|
-
vm.prank(_attacker);
|
|
861
|
-
uint256 reclaim = _terminal.cashOutTokensOf({
|
|
862
|
-
holder: _attacker,
|
|
863
|
-
projectId: _projectIdB,
|
|
864
|
-
cashOutCount: attackerTokens,
|
|
865
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
866
|
-
minTokensReclaimed: 0,
|
|
867
|
-
beneficiary: payable(_attacker),
|
|
868
|
-
metadata: new bytes(0)
|
|
869
|
-
});
|
|
870
|
-
// Feeless beneficiary gets full amount — no fee deducted.
|
|
871
|
-
assertEq(reclaim, 10 ether, "feeless beneficiary should get full reclaim");
|
|
872
|
-
|
|
873
|
-
// Step 4: Pay B again directly. If fee-free wasn't capped, it would still be 10 ETH
|
|
874
|
-
// even though balance went to 0 after the feeless cashout.
|
|
875
|
-
address user = makeAddr("user");
|
|
876
|
-
vm.deal(user, 10 ether);
|
|
877
|
-
vm.prank(user);
|
|
878
|
-
_terminal.pay{value: 10 ether}({
|
|
879
|
-
projectId: _projectIdB,
|
|
880
|
-
amount: 10 ether,
|
|
881
|
-
token: JBConstants.NATIVE_TOKEN,
|
|
882
|
-
beneficiary: user,
|
|
883
|
-
minReturnedTokens: 0,
|
|
884
|
-
memo: "",
|
|
885
|
-
metadata: new bytes(0)
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
// Step 5: Cash out as user (not feeless). Fee-free should be 0 (capped at 0 after feeless cashout).
|
|
889
|
-
// So this direct-pay cashout should be fee-free.
|
|
890
|
-
uint256 userTokens = _tokens.totalBalanceOf(user, _projectIdB);
|
|
891
|
-
vm.prank(user);
|
|
892
|
-
uint256 userReclaim = _terminal.cashOutTokensOf({
|
|
893
|
-
holder: user,
|
|
894
|
-
projectId: _projectIdB,
|
|
895
|
-
cashOutCount: userTokens,
|
|
896
|
-
tokenToReclaim: JBConstants.NATIVE_TOKEN,
|
|
897
|
-
minTokensReclaimed: 0,
|
|
898
|
-
beneficiary: payable(user),
|
|
899
|
-
metadata: new bytes(0)
|
|
900
|
-
});
|
|
901
|
-
// Direct payment → zero fee-free surplus → no fee. User gets full amount.
|
|
902
|
-
assertEq(userReclaim, 10 ether, "direct pay-in should be fee-free after feeless cashout cleared surplus");
|
|
903
|
-
|
|
904
|
-
// Clean up: unmark feeless.
|
|
905
|
-
vm.prank(multisig());
|
|
906
|
-
jbFeelessAddresses().setFeelessAddress(_attacker, false);
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
/// @dev Reconfigure project with a new cashOutTaxRate (keeps zero-tax metadata otherwise).
|
|
910
|
-
function _reconfigureWithTaxRate(uint256 projectId, uint16 taxRate) internal {
|
|
911
|
-
JBRulesetMetadata memory meta = _zeroTaxMetadata();
|
|
912
|
-
meta.cashOutTaxRate = taxRate;
|
|
913
|
-
|
|
914
|
-
JBRulesetConfig[] memory rc = new JBRulesetConfig[](1);
|
|
915
|
-
rc[0].mustStartAtOrAfter = 0;
|
|
916
|
-
rc[0].duration = 0;
|
|
917
|
-
rc[0].weight = _weight;
|
|
918
|
-
rc[0].metadata = meta;
|
|
919
|
-
rc[0].splitGroups = new JBSplitGroup[](0);
|
|
920
|
-
rc[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
|
|
921
|
-
|
|
922
|
-
vm.prank(_projectOwner);
|
|
923
|
-
_controller.queueRulesetsOf({projectId: projectId, rulesetConfigurations: rc, memo: ""});
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function _zeroTaxMetadata() internal pure returns (JBRulesetMetadata memory) {
|
|
927
|
-
return JBRulesetMetadata({
|
|
928
|
-
reservedPercent: 0,
|
|
929
|
-
cashOutTaxRate: 0,
|
|
930
|
-
baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
931
|
-
pausePay: false,
|
|
932
|
-
pauseCreditTransfers: false,
|
|
933
|
-
allowOwnerMinting: false,
|
|
934
|
-
allowSetCustomToken: false,
|
|
935
|
-
allowTerminalMigration: false,
|
|
936
|
-
allowSetTerminals: false,
|
|
937
|
-
ownerMustSendPayouts: false,
|
|
938
|
-
allowSetController: false,
|
|
939
|
-
allowAddAccountingContext: true,
|
|
940
|
-
allowAddPriceFeed: false,
|
|
941
|
-
holdFees: false,
|
|
942
|
-
useTotalSurplusForCashOuts: false,
|
|
943
|
-
useDataHookForPay: false,
|
|
944
|
-
useDataHookForCashOut: false,
|
|
945
|
-
dataHook: address(0),
|
|
946
|
-
metadata: 0
|
|
947
|
-
});
|
|
948
|
-
}
|
|
949
|
-
}
|