@bananapus/core-v6 0.0.24 → 0.0.25

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 (177) hide show
  1. package/ADMINISTRATION.md +16 -3
  2. package/ARCHITECTURE.md +28 -9
  3. package/AUDIT_INSTRUCTIONS.md +102 -17
  4. package/CHANGE_LOG.md +18 -8
  5. package/README.md +58 -2
  6. package/RISKS.md +13 -20
  7. package/SKILLS.md +158 -11
  8. package/STYLE_GUIDE.md +11 -6
  9. package/USER_JOURNEYS.md +53 -16
  10. package/foundry.toml +1 -1
  11. package/package.json +2 -2
  12. package/script/Deploy.s.sol +2 -2
  13. package/script/DeployPeriphery.s.sol +2 -5
  14. package/script/helpers/CoreDeploymentLib.sol +2 -2
  15. package/src/JBChainlinkV3PriceFeed.sol +1 -1
  16. package/src/JBChainlinkV3SequencerPriceFeed.sol +1 -1
  17. package/src/JBController.sol +14 -7
  18. package/src/JBDeadline.sol +1 -1
  19. package/src/JBDirectory.sol +1 -1
  20. package/src/JBERC20.sol +6 -2
  21. package/src/JBFeelessAddresses.sol +1 -1
  22. package/src/JBFundAccessLimits.sol +1 -1
  23. package/src/JBMultiTerminal.sol +53 -4
  24. package/src/JBPermissions.sol +6 -2
  25. package/src/JBPrices.sol +1 -1
  26. package/src/JBProjects.sol +1 -1
  27. package/src/JBRulesets.sol +1 -1
  28. package/src/JBSplits.sol +1 -1
  29. package/src/JBTerminalStore.sol +57 -53
  30. package/src/JBTokens.sol +5 -1
  31. package/src/interfaces/IJBController.sol +7 -1
  32. package/src/libraries/JBPayoutSplitGroupLib.sol +1 -1
  33. package/src/periphery/JBDeadline1Day.sol +1 -1
  34. package/src/periphery/JBDeadline3Days.sol +1 -1
  35. package/src/periphery/JBDeadline3Hours.sol +1 -1
  36. package/src/periphery/JBDeadline7Days.sol +1 -1
  37. package/src/periphery/JBMatchingPriceFeed.sol +1 -1
  38. package/test/TestAccessToFunds.sol +4 -4
  39. package/test/TestFeeFreeCashOutBypass.sol +332 -0
  40. package/test/TestJBERC20Inheritance.sol +1 -1
  41. package/test/TestMetadataOffsetOverflow.sol +1 -1
  42. package/test/TestMetadataParserLib.sol +1 -1
  43. package/test/TestMultiTerminalSurplus.sol +1 -1
  44. package/test/TestMultiTokenSurplus.sol +1 -1
  45. package/test/TestMultipleAccessLimits.sol +4 -4
  46. package/test/TestPermit2DataHook.t.sol +1 -1
  47. package/test/TestPermit2Terminal.sol +1 -1
  48. package/test/TestTerminalPreviewParity.sol +1 -1
  49. package/test/audit/CashOutReenterPay.t.sol +496 -0
  50. package/test/audit/FeeFreeSurplusLifecycle.t.sol +392 -0
  51. package/test/audit/FeeFreeSurplusStale.t.sol +242 -0
  52. package/test/audit/USDTVoidReturnCompat.t.sol +519 -0
  53. package/test/fork/TestChainlinkPriceFeedFork.sol +1 -1
  54. package/test/fork/TestSequencerPriceFeedFork.sol +1 -1
  55. package/test/fork/TestTerminalPreviewParityFork.sol +1 -1
  56. package/test/helpers/JBTest.sol +1 -1
  57. package/test/helpers/MetadataResolverHelper.sol +1 -1
  58. package/test/mock/MockERC20.sol +1 -1
  59. package/test/mock/MockMaliciousBeneficiary.sol +1 -1
  60. package/test/mock/MockMaliciousSplitHook.sol +1 -1
  61. package/test/mock/MockPriceFeed.sol +1 -1
  62. package/test/mock/MockUSDT.sol +80 -0
  63. package/test/regression/HoldFeesCashOutReserved.t.sol +2 -2
  64. package/test/units/static/JBChainlinkV3PriceFeed/TestPriceFeed.sol +1 -1
  65. package/test/units/static/JBController/JBControllerSetup.sol +1 -1
  66. package/test/units/static/JBController/TestBurnTokensOf.sol +1 -1
  67. package/test/units/static/JBController/TestClaimTokensFor.sol +1 -1
  68. package/test/units/static/JBController/TestDeployErc20For.sol +1 -1
  69. package/test/units/static/JBController/TestLaunchProjectFor.sol +1 -1
  70. package/test/units/static/JBController/TestLaunchRulesetsFor.sol +1 -1
  71. package/test/units/static/JBController/TestMigrateController.sol +1 -1
  72. package/test/units/static/JBController/TestMintTokensOfUnits.sol +1 -1
  73. package/test/units/static/JBController/TestOmnichainRulesetOperator.sol +324 -0
  74. package/test/units/static/JBController/TestPayReservedTokenToTerminal.sol +1 -1
  75. package/test/units/static/JBController/TestPreviewMintOf.sol +1 -1
  76. package/test/units/static/JBController/TestReceiveMigrationFrom.sol +1 -1
  77. package/test/units/static/JBController/TestRulesetViews.sol +1 -1
  78. package/test/units/static/JBController/TestSendReservedTokensToSplitsOf.sol +1 -1
  79. package/test/units/static/JBController/TestSetSplitGroupsOf.sol +1 -1
  80. package/test/units/static/JBController/TestSetTokenFor.sol +1 -1
  81. package/test/units/static/JBController/TestSetUriOf.sol +1 -1
  82. package/test/units/static/JBController/TestTransferCreditsFrom.sol +1 -1
  83. package/test/units/static/JBDeadline/TestDeadlineFuzz.sol +1 -1
  84. package/test/units/static/JBDirectory/JBDirectorySetup.sol +1 -1
  85. package/test/units/static/JBDirectory/TestPrimaryTerminalOf.sol +1 -1
  86. package/test/units/static/JBDirectory/TestSetControllerOf.sol +1 -1
  87. package/test/units/static/JBDirectory/TestSetControllerOfMigrationOrder.sol +1 -1
  88. package/test/units/static/JBDirectory/TestSetPrimaryTerminalOf.sol +1 -1
  89. package/test/units/static/JBDirectory/TestSetTerminalsOf.sol +1 -1
  90. package/test/units/static/JBERC20/JBERC20Setup.sol +11 -4
  91. package/test/units/static/JBERC20/SigUtils.sol +1 -1
  92. package/test/units/static/JBERC20/TestInitialize.sol +8 -1
  93. package/test/units/static/JBERC20/TestName.sol +1 -1
  94. package/test/units/static/JBERC20/TestNonces.sol +1 -1
  95. package/test/units/static/JBERC20/TestSymbol.sol +1 -1
  96. package/test/units/static/JBFeelessAdresses/JBFeelessSetup.sol +1 -1
  97. package/test/units/static/JBFeelessAdresses/TestInterfaces.sol +1 -1
  98. package/test/units/static/JBFeelessAdresses/TestSetFeelessAddress.sol +1 -1
  99. package/test/units/static/JBFees/TestFeesFuzz.sol +1 -1
  100. package/test/units/static/JBFixedPointNumber/TestAdjustDecimals.sol +1 -1
  101. package/test/units/static/JBFixedPointNumber/TestAdjustDecimalsFuzz.sol +1 -1
  102. package/test/units/static/JBFundAccessLimits/JBFundAccessSetup.sol +1 -1
  103. package/test/units/static/JBFundAccessLimits/TestFundAccessLimitsEdge.sol +1 -1
  104. package/test/units/static/JBFundAccessLimits/TestPayoutLimitOf.sol +1 -1
  105. package/test/units/static/JBFundAccessLimits/TestPayoutLimitsOf.sol +1 -1
  106. package/test/units/static/JBFundAccessLimits/TestSetFundAccessLimitsFor.sol +1 -1
  107. package/test/units/static/JBFundAccessLimits/TestSurplusAllowanceOf.sol +1 -1
  108. package/test/units/static/JBFundAccessLimits/TestSurplusAllowancesOf.sol +1 -1
  109. package/test/units/static/JBMetadataResolver/TestGetDataFor.sol +1 -1
  110. package/test/units/static/JBMetadataResolver/TestMetadataResolverEdgeCases.sol +1 -1
  111. package/test/units/static/JBMetadataResolver/TestMetadataResolverFuzz.sol +1 -1
  112. package/test/units/static/JBMultiTerminal/JBMultiTerminalSetup.sol +1 -1
  113. package/test/units/static/JBMultiTerminal/TestAccountingContextsOf.sol +1 -1
  114. package/test/units/static/JBMultiTerminal/TestAddAccountingContextsFor.sol +1 -1
  115. package/test/units/static/JBMultiTerminal/TestAddToBalanceOf.sol +1 -1
  116. package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +1 -1
  117. package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +1 -1
  118. package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +1 -1
  119. package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +1 -1
  120. package/test/units/static/JBMultiTerminal/TestPay.sol +1 -1
  121. package/test/units/static/JBMultiTerminal/TestPreviewCashOutFrom.sol +1 -1
  122. package/test/units/static/JBMultiTerminal/TestPreviewPayFor.sol +1 -1
  123. package/test/units/static/JBMultiTerminal/TestProcessHeldFeesOf.sol +1 -1
  124. package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +1 -1
  125. package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +1 -1
  126. package/test/units/static/JBPermissions/JBPermissionsSetup.sol +1 -1
  127. package/test/units/static/JBPermissions/TestHasPermission.sol +1 -1
  128. package/test/units/static/JBPermissions/TestHasPermissions.sol +1 -1
  129. package/test/units/static/JBPermissions/TestSetPermissionsFor.sol +1 -1
  130. package/test/units/static/JBPrices/JBPricesSetup.sol +1 -1
  131. package/test/units/static/JBPrices/TestAddPriceFeedFor.sol +1 -1
  132. package/test/units/static/JBPrices/TestPricePerUnitOf.sol +1 -1
  133. package/test/units/static/JBPrices/TestPrices.sol +1 -1
  134. package/test/units/static/JBProjects/JBProjectsSetup.sol +1 -1
  135. package/test/units/static/JBProjects/TestCreateFor.sol +1 -1
  136. package/test/units/static/JBProjects/TestInitialProject.sol +1 -1
  137. package/test/units/static/JBProjects/TestInterfaces.sol +1 -1
  138. package/test/units/static/JBProjects/TestSetResolver.sol +1 -1
  139. package/test/units/static/JBProjects/TestTokenUri.sol +1 -1
  140. package/test/units/static/JBRulesetMetadataResolver/TestSetCashOutTaxRateTo.sol +1 -1
  141. package/test/units/static/JBRulesets/JBRulesetsSetup.sol +1 -1
  142. package/test/units/static/JBRulesets/TestCurrentApprovalStatusForLatestRulesetOf.sol +1 -1
  143. package/test/units/static/JBRulesets/TestCurrentOf.sol +1 -1
  144. package/test/units/static/JBRulesets/TestGetRulesetOf.sol +1 -1
  145. package/test/units/static/JBRulesets/TestLatestQueuedRulesetOf.sol +1 -1
  146. package/test/units/static/JBRulesets/TestRulesets.sol +1 -1
  147. package/test/units/static/JBRulesets/TestRulesetsOf.sol +1 -1
  148. package/test/units/static/JBRulesets/TestUpcomingRulesetOf.sol +1 -1
  149. package/test/units/static/JBRulesets/TestUpdateRulesetWeightCache.sol +1 -1
  150. package/test/units/static/JBSplits/JBSplitsSetup.sol +1 -1
  151. package/test/units/static/JBSplits/TestSelfManagedSplitGroups.sol +1 -1
  152. package/test/units/static/JBSplits/TestSetSplitGroupsOf.sol +1 -1
  153. package/test/units/static/JBSplits/TestSplitsLockedEdge.sol +1 -1
  154. package/test/units/static/JBSplits/TestSplitsOf.sol +1 -1
  155. package/test/units/static/JBSplits/TestSplitsPacking.sol +1 -1
  156. package/test/units/static/JBSurplus/TestSurplusFuzz.sol +1 -1
  157. package/test/units/static/JBTerminalStore/JBTerminalStoreSetup.sol +1 -1
  158. package/test/units/static/JBTerminalStore/TestCurrentReclaimableSurplusOf.sol +1 -1
  159. package/test/units/static/JBTerminalStore/TestCurrentSurplusOf.sol +1 -1
  160. package/test/units/static/JBTerminalStore/TestCurrentTotalSurplusOf.sol +1 -1
  161. package/test/units/static/JBTerminalStore/TestPreviewCashOutFrom.sol +1 -1
  162. package/test/units/static/JBTerminalStore/TestPreviewPayFrom.sol +1 -1
  163. package/test/units/static/JBTerminalStore/TestRecordCashOutsFor.sol +1 -1
  164. package/test/units/static/JBTerminalStore/TestRecordPaymentFrom.sol +1 -1
  165. package/test/units/static/JBTerminalStore/TestRecordPayoutFor.sol +1 -1
  166. package/test/units/static/JBTerminalStore/TestRecordTerminalMigration.sol +1 -1
  167. package/test/units/static/JBTerminalStore/TestRecordUsedAllowanceOf.sol +1 -1
  168. package/test/units/static/JBTerminalStore/TestUint224Overflow.sol +1 -1
  169. package/test/units/static/JBTokens/JBTokensSetup.sol +1 -1
  170. package/test/units/static/JBTokens/TestBurnFrom.sol +1 -1
  171. package/test/units/static/JBTokens/TestClaimTokensFor.sol +1 -1
  172. package/test/units/static/JBTokens/TestDeployERC20ForUnits.sol +1 -1
  173. package/test/units/static/JBTokens/TestMintFor.sol +1 -1
  174. package/test/units/static/JBTokens/TestSetTokenFor.sol +1 -1
  175. package/test/units/static/JBTokens/TestTotalBalanceOf.sol +1 -1
  176. package/test/units/static/JBTokens/TestTotalSupplyOf.sol +1 -1
  177. package/test/units/static/JBTokens/TestTransferCreditsFrom.sol +1 -1
@@ -591,6 +591,338 @@ contract TestFeeFreeCashOutBypass is TestBaseWorkflow {
591
591
  assertApproxEqAbs(reclaimAmount, expectedNet, 2, "fee should apply to full 20 ETH surplus");
592
592
  }
593
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. _reduceFeeFreeSurplus 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 _reduceFeeFreeSurplus 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
+
594
926
  function _zeroTaxMetadata() internal pure returns (JBRulesetMetadata memory) {
595
927
  return JBRulesetMetadata({
596
928
  reservedPercent: 0,
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {TestBaseWorkflow} from "./helpers/TestBaseWorkflow.sol";
5
5
  import {JBERC20} from "../src/JBERC20.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
  import {MetadataResolverHelper} from "./helpers/MetadataResolverHelper.sol";
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity 0.8.26;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {Test} from "forge-std/Test.sol";
5
5
  import {MetadataResolverHelper} from "./helpers/MetadataResolverHelper.sol";
@@ -106,7 +106,7 @@ contract TestMultiTerminalSurplus_Local is TestBaseWorkflow {
106
106
 
107
107
  // Add price feed: USDC priced in native token terms.
108
108
  vm.prank(_projectOwner);
109
- _controller.addPriceFeed({
109
+ _controller.addPriceFeedFor({
110
110
  projectId: _projectId, pricingCurrency: _usdcCurrency, unitCurrency: _nativeCurrency, feed: _ethToUsdcFeed
111
111
  });
112
112
 
@@ -97,7 +97,7 @@ contract TestMultiTokenSurplus_Local is TestBaseWorkflow {
97
97
  // Add price feed: USDC priced in terms of native token
98
98
  // This allows the terminal to convert USDC balances to ETH-denominated surplus
99
99
  vm.prank(_projectOwner);
100
- _controller.addPriceFeed({
100
+ _controller.addPriceFeedFor({
101
101
  projectId: _projectId, pricingCurrency: _usdcCurrency, unitCurrency: _nativeCurrency, feed: _ethToUsdcFeed
102
102
  });
103
103
  }
@@ -135,7 +135,7 @@ contract TestMultipleAccessLimits_Local is TestBaseWorkflow {
135
135
  MockPriceFeed _priceFeedNativeUsd = new MockPriceFeed(_nativePricePerUsd, 18);
136
136
  vm.label(address(_priceFeedNativeUsd), "Mock Price Feed Native-USD");
137
137
 
138
- _controller.addPriceFeed({
138
+ _controller.addPriceFeedFor({
139
139
  projectId: _projectId,
140
140
  pricingCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
141
141
  unitCurrency: uint32(uint160(address(usdcToken()))),
@@ -473,13 +473,13 @@ contract TestMultipleAccessLimits_Local is TestBaseWorkflow {
473
473
  });
474
474
 
475
475
  vm.startPrank(address(_projectOwner));
476
- _controller.addPriceFeed({
476
+ _controller.addPriceFeedFor({
477
477
  projectId: _projectId,
478
478
  pricingCurrency: JBCurrencyIds.USD,
479
479
  unitCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
480
480
  feed: IJBPriceFeed(address(new MockPriceFeed(_nativePricePerUsd, 18)))
481
481
  });
482
- _controller.addPriceFeed({
482
+ _controller.addPriceFeedFor({
483
483
  projectId: _dummyy,
484
484
  pricingCurrency: JBCurrencyIds.USD,
485
485
  unitCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
@@ -594,7 +594,7 @@ contract TestMultipleAccessLimits_Local is TestBaseWorkflow {
594
594
  MockPriceFeed _priceFeedNativeUsd = new MockPriceFeed(_price, 18);
595
595
  vm.label(address(_priceFeedNativeUsd), "Mock Price Feed MyToken-Native");
596
596
 
597
- _controller.addPriceFeed({
597
+ _controller.addPriceFeedFor({
598
598
  projectId: _projectId,
599
599
  pricingCurrency: _nativeCurrency,
600
600
  unitCurrency: uint32(uint160(address(usdcToken()))),
@@ -164,7 +164,7 @@ contract TestPermit2DataHook_Local is TestBaseWorkflow {
164
164
  MockPriceFeed _priceFeedNativeUsd = new MockPriceFeed(_nativePricePerUsd, 18);
165
165
  vm.label(address(_priceFeedNativeUsd), "Mock Price Feed Native-USD");
166
166
 
167
- _controller.addPriceFeed({
167
+ _controller.addPriceFeedFor({
168
168
  projectId: _projectId,
169
169
  pricingCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
170
170
  unitCurrency: uint32(uint160(address(usdcToken()))),
@@ -125,7 +125,7 @@ contract TestPermit2Terminal_Local is TestBaseWorkflow {
125
125
  MockPriceFeed _priceFeedNativeUsd = new MockPriceFeed(_nativePricePerUsd, 18);
126
126
  vm.label(address(_priceFeedNativeUsd), "Mock Price Feed Native-USD");
127
127
 
128
- _controller.addPriceFeed({
128
+ _controller.addPriceFeedFor({
129
129
  projectId: _projectId,
130
130
  pricingCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
131
131
  unitCurrency: uint32(uint160(address(usdcToken()))),
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.26;
2
+ pragma solidity ^0.8.28;
3
3
 
4
4
  import {TestBaseWorkflow} from "./helpers/TestBaseWorkflow.sol";
5
5
  import {IJBController} from "../src/interfaces/IJBController.sol";