@bananapus/core-v6 0.0.36 → 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.
Files changed (286) hide show
  1. package/foundry.lock +1 -7
  2. package/foundry.toml +1 -1
  3. package/package.json +19 -7
  4. package/src/JBController.sol +26 -5
  5. package/src/JBMultiTerminal.sol +85 -47
  6. package/src/JBTerminalStore.sol +6 -6
  7. package/src/interfaces/IJBController.sol +4 -1
  8. package/src/libraries/JBFees.sol +47 -9
  9. package/src/libraries/JBPayoutSplitGroupLib.sol +2 -2
  10. package/src/periphery/JBMatchingPriceFeed.sol +1 -1
  11. package/test/mock/MockMaliciousBeneficiary.sol +15 -15
  12. package/ADMINISTRATION.md +0 -103
  13. package/ARCHITECTURE.md +0 -133
  14. package/AUDIT_INSTRUCTIONS.md +0 -139
  15. package/RISKS.md +0 -215
  16. package/SKILLS.md +0 -55
  17. package/STYLE_GUIDE.md +0 -610
  18. package/USER_JOURNEYS.md +0 -215
  19. package/script/Deploy.s.sol +0 -124
  20. package/script/DeployPeriphery.s.sol +0 -354
  21. package/slither-ci.config.json +0 -10
  22. package/test/AuditFixes.t.sol +0 -808
  23. package/test/ComprehensiveInvariant.t.sol +0 -306
  24. package/test/CoreExploitTests.t.sol +0 -2741
  25. package/test/EconomicSimulation.t.sol +0 -348
  26. package/test/EntryPointPermutations.t.sol +0 -684
  27. package/test/FlashLoanAttacks.t.sol +0 -797
  28. package/test/PermissionEscalation.t.sol +0 -711
  29. package/test/PermissionsInvariant.t.sol +0 -403
  30. package/test/RulesetTransitions.t.sol +0 -713
  31. package/test/SplitLoopTests.t.sol +0 -752
  32. package/test/TestAccessToFunds.sol +0 -2683
  33. package/test/TestAuditResponseDesignProofs.sol +0 -434
  34. package/test/TestCashOut.sol +0 -198
  35. package/test/TestCashOutCountFor.sol +0 -271
  36. package/test/TestCashOutHooks.sol +0 -351
  37. package/test/TestCashOutTimingEdge.sol +0 -241
  38. package/test/TestDataHookFuzzing.sol +0 -524
  39. package/test/TestDurationUnderflow.sol +0 -233
  40. package/test/TestFeeFreeCashOutBypass.sol +0 -949
  41. package/test/TestFeeProcessingFailure.sol +0 -218
  42. package/test/TestFees.sol +0 -619
  43. package/test/TestForwardedTokenConsumption.sol +0 -425
  44. package/test/TestInterfaceSupport.sol +0 -81
  45. package/test/TestJBERC20Inheritance.sol +0 -103
  46. package/test/TestL2SequencerPriceFeed.sol +0 -292
  47. package/test/TestLaunchProject.sol +0 -188
  48. package/test/TestMetaTx.sol +0 -217
  49. package/test/TestMetadataOffsetOverflow.sol +0 -179
  50. package/test/TestMetadataParserLib.sol +0 -471
  51. package/test/TestMigrationHeldFees.sol +0 -255
  52. package/test/TestMintTokensOf.sol +0 -185
  53. package/test/TestMultiTerminalSurplus.sol +0 -348
  54. package/test/TestMultiTokenSurplus.sol +0 -202
  55. package/test/TestMultipleAccessLimits.sol +0 -664
  56. package/test/TestPayBurnRedeemFlow.sol +0 -195
  57. package/test/TestPayHooks.sol +0 -209
  58. package/test/TestPermissions.sol +0 -324
  59. package/test/TestPermissionsEdge.sol +0 -290
  60. package/test/TestPermit2DataHook.t.sol +0 -360
  61. package/test/TestPermit2Terminal.sol +0 -372
  62. package/test/TestRulesetQueueing.sol +0 -1025
  63. package/test/TestRulesetQueuingStress.sol +0 -806
  64. package/test/TestRulesetWeightCaching.sol +0 -178
  65. package/test/TestSplits.sol +0 -391
  66. package/test/TestTerminalMigration.sol +0 -274
  67. package/test/TestTerminalPreviewParity.sol +0 -208
  68. package/test/TestTokenFlow.sol +0 -191
  69. package/test/TestWeightCacheStaleAfterRejection.sol +0 -303
  70. package/test/WeirdTokenTests.t.sol +0 -817
  71. package/test/audit/CashOutReenterPay.t.sol +0 -501
  72. package/test/audit/CodexHeldFeeRounding.t.sol +0 -159
  73. package/test/audit/CrossTerminalSurplusSpoof.t.sol +0 -140
  74. package/test/audit/CycledSurplusAllowanceReset.t.sol +0 -184
  75. package/test/audit/FeeFreeSurplusLifecycle.t.sol +0 -399
  76. package/test/audit/FeeFreeSurplusStale.t.sol +0 -248
  77. package/test/audit/USDTVoidReturnCompat.t.sol +0 -525
  78. package/test/fork/TestChainlinkPriceFeedFork.sol +0 -254
  79. package/test/fork/TestSequencerPriceFeedFork.sol +0 -168
  80. package/test/fork/TestTerminalPreviewParityFork.sol +0 -108
  81. package/test/formal/BondingCurveProperties.t.sol +0 -420
  82. package/test/formal/FeeProperties.t.sol +0 -252
  83. package/test/invariants/Phase3DeepInvariant.t.sol +0 -412
  84. package/test/invariants/RulesetsInvariant.t.sol +0 -125
  85. package/test/invariants/TerminalStoreInvariant.t.sol +0 -227
  86. package/test/invariants/TokensInvariant.t.sol +0 -195
  87. package/test/invariants/handlers/ComprehensiveHandler.sol +0 -303
  88. package/test/invariants/handlers/EconomicHandler.sol +0 -377
  89. package/test/invariants/handlers/Phase3Handler.sol +0 -443
  90. package/test/invariants/handlers/RulesetsHandler.sol +0 -115
  91. package/test/invariants/handlers/TerminalStoreHandler.sol +0 -151
  92. package/test/invariants/handlers/TokensHandler.sol +0 -126
  93. package/test/regression/HoldFeesCashOutReserved.t.sol +0 -415
  94. package/test/regression/WeightCacheBoundary.t.sol +0 -291
  95. package/test/trees/JBController/burnTokensOf.tree +0 -9
  96. package/test/trees/JBController/claimTokensFor.tree +0 -5
  97. package/test/trees/JBController/deployERC20For.tree +0 -5
  98. package/test/trees/JBController/getRulesetOf.tree +0 -5
  99. package/test/trees/JBController/launchProjectFor.tree +0 -12
  100. package/test/trees/JBController/launchRulesetsFor.tree +0 -8
  101. package/test/trees/JBController/migrateController.tree +0 -12
  102. package/test/trees/JBController/mintTokensOf.tree +0 -12
  103. package/test/trees/JBController/payReservedTokenToTerminal.tree +0 -8
  104. package/test/trees/JBController/receiveMigrationFrom.tree +0 -4
  105. package/test/trees/JBController/sendReservedTokensToSplitsOf.tree +0 -12
  106. package/test/trees/JBController/setMetadataOf.tree +0 -5
  107. package/test/trees/JBController/setSplitGroupsOf.tree +0 -5
  108. package/test/trees/JBController/setTokenFor.tree +0 -5
  109. package/test/trees/JBController/transferCreditsFrom.tree +0 -8
  110. package/test/trees/JBDirectory/primaryTerminalOf.tree +0 -8
  111. package/test/trees/JBDirectory/setControllerOf.tree +0 -11
  112. package/test/trees/JBDirectory/setPrimaryTerminalOf.tree +0 -15
  113. package/test/trees/JBDirectory/setTerminalsOf.tree +0 -11
  114. package/test/trees/JBERC20/initialize.tree +0 -7
  115. package/test/trees/JBERC20/name.tree +0 -5
  116. package/test/trees/JBERC20/nonces.tree +0 -5
  117. package/test/trees/JBERC20/symbol.tree +0 -5
  118. package/test/trees/JBFeelessAddresses/setFeelessAddress.tree +0 -5
  119. package/test/trees/JBFeelessAddresses/supportsInterface.tree +0 -5
  120. package/test/trees/JBFundAccessLimits/payoutLimitOf.tree +0 -5
  121. package/test/trees/JBFundAccessLimits/payoutLimitsOf.tree +0 -8
  122. package/test/trees/JBFundAccessLimits/setFundAccessLimitsFor.tree +0 -18
  123. package/test/trees/JBFundAccessLimits/surplusAllowanceOf.tree +0 -5
  124. package/test/trees/JBFundAccessLimits/surplusAllowancesOf.tree +0 -8
  125. package/test/trees/JBMetadataResolver/getDataFor.tree +0 -8
  126. package/test/trees/JBMultiTerminal/accountingContextsOf.tree +0 -5
  127. package/test/trees/JBMultiTerminal/addAccountingContextsFor.tree +0 -10
  128. package/test/trees/JBMultiTerminal/addToBalanceOf.tree +0 -23
  129. package/test/trees/JBMultiTerminal/cashOutTokensOf.tree +0 -23
  130. package/test/trees/JBMultiTerminal/executePayout.tree +0 -32
  131. package/test/trees/JBMultiTerminal/executeProcessFee.tree +0 -14
  132. package/test/trees/JBMultiTerminal/migrateBalanceOf.tree +0 -12
  133. package/test/trees/JBMultiTerminal/pay.tree +0 -23
  134. package/test/trees/JBMultiTerminal/processHeldFeesOf.tree +0 -8
  135. package/test/trees/JBMultiTerminal/sendPayoutsOf.tree +0 -34
  136. package/test/trees/JBMultiTerminal/useAllowanceOf.tree +0 -16
  137. package/test/trees/JBPermissions/hasPermission.tree +0 -8
  138. package/test/trees/JBPermissions/hasPermissions.tree +0 -8
  139. package/test/trees/JBPermissions/setPermissionsFor.tree +0 -5
  140. package/test/trees/JBPrices/addPriceFeedFor.tree +0 -14
  141. package/test/trees/JBPrices/pricePerUnitOf.tree +0 -11
  142. package/test/trees/JBProjects/createFor.tree +0 -11
  143. package/test/trees/JBProjects/setTokenUriResolver.tree +0 -5
  144. package/test/trees/JBProjects/supportsInterface.tree +0 -9
  145. package/test/trees/JBProjects/tokenURI.tree +0 -5
  146. package/test/trees/JBRulesets/currentApprovalStatusForLatestRulesetOf.tree +0 -8
  147. package/test/trees/JBRulesets/currentOf.tree +0 -12
  148. package/test/trees/JBRulesets/getRulesetOf.tree +0 -5
  149. package/test/trees/JBRulesets/latestQueuedRulesetOf.tree +0 -10
  150. package/test/trees/JBRulesets/rulesetsOf.tree +0 -11
  151. package/test/trees/JBRulesets/upcomingRulesetOf.tree +0 -20
  152. package/test/trees/JBRulesets/updateRulesetWeightCache.tree +0 -5
  153. package/test/trees/JBSplits/setSplitGroupsOf.tree +0 -17
  154. package/test/trees/JBSplits/splitsOf.tree +0 -5
  155. package/test/trees/JBTerminalStore/currentReclaimableSurplusOf.tree +0 -16
  156. package/test/trees/JBTerminalStore/currentSurplusOf.tree +0 -25
  157. package/test/trees/JBTerminalStore/currentTotalSurplusOf.tree +0 -5
  158. package/test/trees/JBTerminalStore/recordCashOutsFor.tree +0 -16
  159. package/test/trees/JBTerminalStore/recordPaymentFrom.tree +0 -14
  160. package/test/trees/JBTerminalStore/recordPayoutFor.tree +0 -10
  161. package/test/trees/JBTerminalStore/recordTerminalMigration.tree +0 -5
  162. package/test/trees/JBTerminalStore/recordUsedAllowanceOf.tree +0 -10
  163. package/test/trees/JBTokens/burnFrom.tree +0 -10
  164. package/test/trees/JBTokens/claimTokensFor.tree +0 -10
  165. package/test/trees/JBTokens/deployERC20For.tree +0 -12
  166. package/test/trees/JBTokens/mintFor.tree +0 -10
  167. package/test/trees/JBTokens/setTokenFor.tree +0 -11
  168. package/test/trees/JBTokens/totalBalanceOf.tree +0 -5
  169. package/test/trees/JBTokens/totalSupplyOf.tree +0 -5
  170. package/test/trees/JBTokens/transferCreditsFrom.tree +0 -8
  171. package/test/trees/mintTokensOf.tree +0 -12
  172. package/test/units/static/JBChainlinkV3PriceFeed/TestPriceFeed.sol +0 -223
  173. package/test/units/static/JBController/JBControllerSetup.sol +0 -50
  174. package/test/units/static/JBController/TestBurnTokensOf.sol +0 -114
  175. package/test/units/static/JBController/TestClaimTokensFor.sol +0 -63
  176. package/test/units/static/JBController/TestDeployErc20For.sol +0 -86
  177. package/test/units/static/JBController/TestLaunchProjectFor.sol +0 -302
  178. package/test/units/static/JBController/TestLaunchRulesetsFor.sol +0 -342
  179. package/test/units/static/JBController/TestMigrateController.sol +0 -157
  180. package/test/units/static/JBController/TestMintTokensOfUnits.sol +0 -111
  181. package/test/units/static/JBController/TestOmnichainRulesetOperator.sol +0 -324
  182. package/test/units/static/JBController/TestPayReservedTokenToTerminal.sol +0 -74
  183. package/test/units/static/JBController/TestPreviewMintOf.sol +0 -117
  184. package/test/units/static/JBController/TestReceiveMigrationFrom.sol +0 -99
  185. package/test/units/static/JBController/TestRulesetViews.sol +0 -225
  186. package/test/units/static/JBController/TestSendReservedTokensToSplitsOf.sol +0 -615
  187. package/test/units/static/JBController/TestSetSplitGroupsOf.sol +0 -68
  188. package/test/units/static/JBController/TestSetTokenFor.sol +0 -239
  189. package/test/units/static/JBController/TestSetUriOf.sol +0 -57
  190. package/test/units/static/JBController/TestTransferCreditsFrom.sol +0 -169
  191. package/test/units/static/JBDeadline/TestDeadlineFuzz.sol +0 -211
  192. package/test/units/static/JBDirectory/JBDirectorySetup.sol +0 -26
  193. package/test/units/static/JBDirectory/TestPrimaryTerminalOf.sol +0 -126
  194. package/test/units/static/JBDirectory/TestSetControllerOf.sol +0 -183
  195. package/test/units/static/JBDirectory/TestSetControllerOfMigrationOrder.sol +0 -104
  196. package/test/units/static/JBDirectory/TestSetPrimaryTerminalOf.sol +0 -179
  197. package/test/units/static/JBDirectory/TestSetTerminalsOf.sol +0 -137
  198. package/test/units/static/JBERC20/JBERC20Setup.sol +0 -34
  199. package/test/units/static/JBERC20/SigUtils.sol +0 -36
  200. package/test/units/static/JBERC20/TestInitialize.sol +0 -60
  201. package/test/units/static/JBERC20/TestName.sol +0 -30
  202. package/test/units/static/JBERC20/TestNonces.sol +0 -62
  203. package/test/units/static/JBERC20/TestSymbol.sol +0 -31
  204. package/test/units/static/JBFeelessAdresses/JBFeelessSetup.sol +0 -22
  205. package/test/units/static/JBFeelessAdresses/TestInterfaces.sol +0 -30
  206. package/test/units/static/JBFeelessAdresses/TestSetFeelessAddress.sol +0 -35
  207. package/test/units/static/JBFees/TestFeesFuzz.sol +0 -79
  208. package/test/units/static/JBFixedPointNumber/TestAdjustDecimals.sol +0 -16
  209. package/test/units/static/JBFixedPointNumber/TestAdjustDecimalsFuzz.sol +0 -71
  210. package/test/units/static/JBFundAccessLimits/JBFundAccessSetup.sol +0 -24
  211. package/test/units/static/JBFundAccessLimits/TestFundAccessLimitsEdge.sol +0 -163
  212. package/test/units/static/JBFundAccessLimits/TestPayoutLimitOf.sol +0 -59
  213. package/test/units/static/JBFundAccessLimits/TestPayoutLimitsOf.sol +0 -101
  214. package/test/units/static/JBFundAccessLimits/TestSetFundAccessLimitsFor.sol +0 -189
  215. package/test/units/static/JBFundAccessLimits/TestSurplusAllowanceOf.sol +0 -64
  216. package/test/units/static/JBFundAccessLimits/TestSurplusAllowancesOf.sol +0 -102
  217. package/test/units/static/JBMetadataResolver/TestGetDataFor.sol +0 -90
  218. package/test/units/static/JBMetadataResolver/TestMetadataResolverEdgeCases.sol +0 -247
  219. package/test/units/static/JBMetadataResolver/TestMetadataResolverFuzz.sol +0 -229
  220. package/test/units/static/JBMultiTerminal/JBMultiTerminalSetup.sol +0 -50
  221. package/test/units/static/JBMultiTerminal/TestAccountingContextsOf.sol +0 -72
  222. package/test/units/static/JBMultiTerminal/TestAddAccountingContextsFor.sol +0 -289
  223. package/test/units/static/JBMultiTerminal/TestAddToBalanceOf.sol +0 -474
  224. package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +0 -624
  225. package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +0 -578
  226. package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +0 -202
  227. package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +0 -222
  228. package/test/units/static/JBMultiTerminal/TestPay.sol +0 -604
  229. package/test/units/static/JBMultiTerminal/TestPreviewCashOutFrom.sol +0 -117
  230. package/test/units/static/JBMultiTerminal/TestPreviewPayFor.sol +0 -114
  231. package/test/units/static/JBMultiTerminal/TestProcessHeldFeesOf.sol +0 -228
  232. package/test/units/static/JBMultiTerminal/TestSelfPayRevert.sol +0 -55
  233. package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +0 -257
  234. package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +0 -611
  235. package/test/units/static/JBPermissions/JBPermissionsSetup.sol +0 -20
  236. package/test/units/static/JBPermissions/TestHasPermission.sol +0 -50
  237. package/test/units/static/JBPermissions/TestHasPermissions.sol +0 -93
  238. package/test/units/static/JBPermissions/TestSetPermissionsFor.sol +0 -64
  239. package/test/units/static/JBPrices/JBPricesSetup.sol +0 -32
  240. package/test/units/static/JBPrices/TestAddPriceFeedFor.sol +0 -107
  241. package/test/units/static/JBPrices/TestPricePerUnitOf.sol +0 -132
  242. package/test/units/static/JBPrices/TestPrices.sol +0 -265
  243. package/test/units/static/JBProjects/JBProjectsSetup.sol +0 -22
  244. package/test/units/static/JBProjects/TestCreateFor.sol +0 -71
  245. package/test/units/static/JBProjects/TestInitialProject.sol +0 -21
  246. package/test/units/static/JBProjects/TestInterfaces.sol +0 -26
  247. package/test/units/static/JBProjects/TestSetResolver.sol +0 -37
  248. package/test/units/static/JBProjects/TestTokenUri.sol +0 -40
  249. package/test/units/static/JBRulesetMetadataResolver/TestSetCashOutTaxRateTo.sol +0 -108
  250. package/test/units/static/JBRulesets/JBRulesetsSetup.sol +0 -24
  251. package/test/units/static/JBRulesets/TestCurrentApprovalStatusForLatestRulesetOf.sol +0 -265
  252. package/test/units/static/JBRulesets/TestCurrentOf.sol +0 -242
  253. package/test/units/static/JBRulesets/TestGetRulesetOf.sol +0 -100
  254. package/test/units/static/JBRulesets/TestLatestQueuedRulesetOf.sol +0 -260
  255. package/test/units/static/JBRulesets/TestRulesets.sol +0 -632
  256. package/test/units/static/JBRulesets/TestRulesetsOf.sol +0 -37
  257. package/test/units/static/JBRulesets/TestUpcomingRulesetOf.sol +0 -522
  258. package/test/units/static/JBRulesets/TestUpdateRulesetWeightCache.sol +0 -96
  259. package/test/units/static/JBSplits/JBSplitsSetup.sol +0 -26
  260. package/test/units/static/JBSplits/TestSelfManagedSplitGroups.sol +0 -552
  261. package/test/units/static/JBSplits/TestSetSplitGroupsOf.sol +0 -377
  262. package/test/units/static/JBSplits/TestSplitsLockedEdge.sol +0 -267
  263. package/test/units/static/JBSplits/TestSplitsOf.sol +0 -24
  264. package/test/units/static/JBSplits/TestSplitsPacking.sol +0 -36
  265. package/test/units/static/JBSurplus/TestSurplusFuzz.sol +0 -160
  266. package/test/units/static/JBTerminalStore/JBTerminalStoreSetup.sol +0 -45
  267. package/test/units/static/JBTerminalStore/TestCurrentReclaimableSurplusOf.sol +0 -536
  268. package/test/units/static/JBTerminalStore/TestCurrentSurplusOf.sol +0 -463
  269. package/test/units/static/JBTerminalStore/TestCurrentTotalSurplusOf.sol +0 -135
  270. package/test/units/static/JBTerminalStore/TestPreviewCashOutFrom.sol +0 -476
  271. package/test/units/static/JBTerminalStore/TestPreviewPayFrom.sol +0 -494
  272. package/test/units/static/JBTerminalStore/TestRecordCashOutsFor.sol +0 -652
  273. package/test/units/static/JBTerminalStore/TestRecordPaymentFrom.sol +0 -744
  274. package/test/units/static/JBTerminalStore/TestRecordPayoutFor.sol +0 -289
  275. package/test/units/static/JBTerminalStore/TestRecordTerminalMigration.sol +0 -138
  276. package/test/units/static/JBTerminalStore/TestRecordUsedAllowanceOf.sol +0 -415
  277. package/test/units/static/JBTerminalStore/TestUint224Overflow.sol +0 -219
  278. package/test/units/static/JBTokens/JBTokensSetup.sol +0 -32
  279. package/test/units/static/JBTokens/TestBurnFrom.sol +0 -107
  280. package/test/units/static/JBTokens/TestClaimTokensFor.sol +0 -110
  281. package/test/units/static/JBTokens/TestDeployERC20ForUnits.sol +0 -92
  282. package/test/units/static/JBTokens/TestMintFor.sol +0 -100
  283. package/test/units/static/JBTokens/TestSetTokenFor.sol +0 -98
  284. package/test/units/static/JBTokens/TestTotalBalanceOf.sol +0 -65
  285. package/test/units/static/JBTokens/TestTotalSupplyOf.sol +0 -56
  286. 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
- }