@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.
- package/ADMINISTRATION.md +16 -3
- package/ARCHITECTURE.md +28 -9
- package/AUDIT_INSTRUCTIONS.md +102 -17
- package/CHANGE_LOG.md +18 -8
- package/README.md +58 -2
- package/RISKS.md +13 -20
- package/SKILLS.md +158 -11
- package/STYLE_GUIDE.md +11 -6
- package/USER_JOURNEYS.md +53 -16
- package/foundry.toml +1 -1
- package/package.json +2 -2
- package/script/Deploy.s.sol +2 -2
- package/script/DeployPeriphery.s.sol +2 -5
- package/script/helpers/CoreDeploymentLib.sol +2 -2
- package/src/JBChainlinkV3PriceFeed.sol +1 -1
- package/src/JBChainlinkV3SequencerPriceFeed.sol +1 -1
- package/src/JBController.sol +14 -7
- package/src/JBDeadline.sol +1 -1
- package/src/JBDirectory.sol +1 -1
- package/src/JBERC20.sol +6 -2
- package/src/JBFeelessAddresses.sol +1 -1
- package/src/JBFundAccessLimits.sol +1 -1
- package/src/JBMultiTerminal.sol +53 -4
- package/src/JBPermissions.sol +6 -2
- package/src/JBPrices.sol +1 -1
- package/src/JBProjects.sol +1 -1
- package/src/JBRulesets.sol +1 -1
- package/src/JBSplits.sol +1 -1
- package/src/JBTerminalStore.sol +57 -53
- package/src/JBTokens.sol +5 -1
- package/src/interfaces/IJBController.sol +7 -1
- package/src/libraries/JBPayoutSplitGroupLib.sol +1 -1
- package/src/periphery/JBDeadline1Day.sol +1 -1
- package/src/periphery/JBDeadline3Days.sol +1 -1
- package/src/periphery/JBDeadline3Hours.sol +1 -1
- package/src/periphery/JBDeadline7Days.sol +1 -1
- package/src/periphery/JBMatchingPriceFeed.sol +1 -1
- package/test/TestAccessToFunds.sol +4 -4
- package/test/TestFeeFreeCashOutBypass.sol +332 -0
- package/test/TestJBERC20Inheritance.sol +1 -1
- package/test/TestMetadataOffsetOverflow.sol +1 -1
- package/test/TestMetadataParserLib.sol +1 -1
- package/test/TestMultiTerminalSurplus.sol +1 -1
- package/test/TestMultiTokenSurplus.sol +1 -1
- package/test/TestMultipleAccessLimits.sol +4 -4
- package/test/TestPermit2DataHook.t.sol +1 -1
- package/test/TestPermit2Terminal.sol +1 -1
- package/test/TestTerminalPreviewParity.sol +1 -1
- package/test/audit/CashOutReenterPay.t.sol +496 -0
- package/test/audit/FeeFreeSurplusLifecycle.t.sol +392 -0
- package/test/audit/FeeFreeSurplusStale.t.sol +242 -0
- package/test/audit/USDTVoidReturnCompat.t.sol +519 -0
- package/test/fork/TestChainlinkPriceFeedFork.sol +1 -1
- package/test/fork/TestSequencerPriceFeedFork.sol +1 -1
- package/test/fork/TestTerminalPreviewParityFork.sol +1 -1
- package/test/helpers/JBTest.sol +1 -1
- package/test/helpers/MetadataResolverHelper.sol +1 -1
- package/test/mock/MockERC20.sol +1 -1
- package/test/mock/MockMaliciousBeneficiary.sol +1 -1
- package/test/mock/MockMaliciousSplitHook.sol +1 -1
- package/test/mock/MockPriceFeed.sol +1 -1
- package/test/mock/MockUSDT.sol +80 -0
- package/test/regression/HoldFeesCashOutReserved.t.sol +2 -2
- package/test/units/static/JBChainlinkV3PriceFeed/TestPriceFeed.sol +1 -1
- package/test/units/static/JBController/JBControllerSetup.sol +1 -1
- package/test/units/static/JBController/TestBurnTokensOf.sol +1 -1
- package/test/units/static/JBController/TestClaimTokensFor.sol +1 -1
- package/test/units/static/JBController/TestDeployErc20For.sol +1 -1
- package/test/units/static/JBController/TestLaunchProjectFor.sol +1 -1
- package/test/units/static/JBController/TestLaunchRulesetsFor.sol +1 -1
- package/test/units/static/JBController/TestMigrateController.sol +1 -1
- package/test/units/static/JBController/TestMintTokensOfUnits.sol +1 -1
- package/test/units/static/JBController/TestOmnichainRulesetOperator.sol +324 -0
- package/test/units/static/JBController/TestPayReservedTokenToTerminal.sol +1 -1
- package/test/units/static/JBController/TestPreviewMintOf.sol +1 -1
- package/test/units/static/JBController/TestReceiveMigrationFrom.sol +1 -1
- package/test/units/static/JBController/TestRulesetViews.sol +1 -1
- package/test/units/static/JBController/TestSendReservedTokensToSplitsOf.sol +1 -1
- package/test/units/static/JBController/TestSetSplitGroupsOf.sol +1 -1
- package/test/units/static/JBController/TestSetTokenFor.sol +1 -1
- package/test/units/static/JBController/TestSetUriOf.sol +1 -1
- package/test/units/static/JBController/TestTransferCreditsFrom.sol +1 -1
- package/test/units/static/JBDeadline/TestDeadlineFuzz.sol +1 -1
- package/test/units/static/JBDirectory/JBDirectorySetup.sol +1 -1
- package/test/units/static/JBDirectory/TestPrimaryTerminalOf.sol +1 -1
- package/test/units/static/JBDirectory/TestSetControllerOf.sol +1 -1
- package/test/units/static/JBDirectory/TestSetControllerOfMigrationOrder.sol +1 -1
- package/test/units/static/JBDirectory/TestSetPrimaryTerminalOf.sol +1 -1
- package/test/units/static/JBDirectory/TestSetTerminalsOf.sol +1 -1
- package/test/units/static/JBERC20/JBERC20Setup.sol +11 -4
- package/test/units/static/JBERC20/SigUtils.sol +1 -1
- package/test/units/static/JBERC20/TestInitialize.sol +8 -1
- package/test/units/static/JBERC20/TestName.sol +1 -1
- package/test/units/static/JBERC20/TestNonces.sol +1 -1
- package/test/units/static/JBERC20/TestSymbol.sol +1 -1
- package/test/units/static/JBFeelessAdresses/JBFeelessSetup.sol +1 -1
- package/test/units/static/JBFeelessAdresses/TestInterfaces.sol +1 -1
- package/test/units/static/JBFeelessAdresses/TestSetFeelessAddress.sol +1 -1
- package/test/units/static/JBFees/TestFeesFuzz.sol +1 -1
- package/test/units/static/JBFixedPointNumber/TestAdjustDecimals.sol +1 -1
- package/test/units/static/JBFixedPointNumber/TestAdjustDecimalsFuzz.sol +1 -1
- package/test/units/static/JBFundAccessLimits/JBFundAccessSetup.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestFundAccessLimitsEdge.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestPayoutLimitOf.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestPayoutLimitsOf.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestSetFundAccessLimitsFor.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestSurplusAllowanceOf.sol +1 -1
- package/test/units/static/JBFundAccessLimits/TestSurplusAllowancesOf.sol +1 -1
- package/test/units/static/JBMetadataResolver/TestGetDataFor.sol +1 -1
- package/test/units/static/JBMetadataResolver/TestMetadataResolverEdgeCases.sol +1 -1
- package/test/units/static/JBMetadataResolver/TestMetadataResolverFuzz.sol +1 -1
- package/test/units/static/JBMultiTerminal/JBMultiTerminalSetup.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestAccountingContextsOf.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestAddAccountingContextsFor.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestAddToBalanceOf.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestPay.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestPreviewCashOutFrom.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestPreviewPayFor.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestProcessHeldFeesOf.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +1 -1
- package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +1 -1
- package/test/units/static/JBPermissions/JBPermissionsSetup.sol +1 -1
- package/test/units/static/JBPermissions/TestHasPermission.sol +1 -1
- package/test/units/static/JBPermissions/TestHasPermissions.sol +1 -1
- package/test/units/static/JBPermissions/TestSetPermissionsFor.sol +1 -1
- package/test/units/static/JBPrices/JBPricesSetup.sol +1 -1
- package/test/units/static/JBPrices/TestAddPriceFeedFor.sol +1 -1
- package/test/units/static/JBPrices/TestPricePerUnitOf.sol +1 -1
- package/test/units/static/JBPrices/TestPrices.sol +1 -1
- package/test/units/static/JBProjects/JBProjectsSetup.sol +1 -1
- package/test/units/static/JBProjects/TestCreateFor.sol +1 -1
- package/test/units/static/JBProjects/TestInitialProject.sol +1 -1
- package/test/units/static/JBProjects/TestInterfaces.sol +1 -1
- package/test/units/static/JBProjects/TestSetResolver.sol +1 -1
- package/test/units/static/JBProjects/TestTokenUri.sol +1 -1
- package/test/units/static/JBRulesetMetadataResolver/TestSetCashOutTaxRateTo.sol +1 -1
- package/test/units/static/JBRulesets/JBRulesetsSetup.sol +1 -1
- package/test/units/static/JBRulesets/TestCurrentApprovalStatusForLatestRulesetOf.sol +1 -1
- package/test/units/static/JBRulesets/TestCurrentOf.sol +1 -1
- package/test/units/static/JBRulesets/TestGetRulesetOf.sol +1 -1
- package/test/units/static/JBRulesets/TestLatestQueuedRulesetOf.sol +1 -1
- package/test/units/static/JBRulesets/TestRulesets.sol +1 -1
- package/test/units/static/JBRulesets/TestRulesetsOf.sol +1 -1
- package/test/units/static/JBRulesets/TestUpcomingRulesetOf.sol +1 -1
- package/test/units/static/JBRulesets/TestUpdateRulesetWeightCache.sol +1 -1
- package/test/units/static/JBSplits/JBSplitsSetup.sol +1 -1
- package/test/units/static/JBSplits/TestSelfManagedSplitGroups.sol +1 -1
- package/test/units/static/JBSplits/TestSetSplitGroupsOf.sol +1 -1
- package/test/units/static/JBSplits/TestSplitsLockedEdge.sol +1 -1
- package/test/units/static/JBSplits/TestSplitsOf.sol +1 -1
- package/test/units/static/JBSplits/TestSplitsPacking.sol +1 -1
- package/test/units/static/JBSurplus/TestSurplusFuzz.sol +1 -1
- package/test/units/static/JBTerminalStore/JBTerminalStoreSetup.sol +1 -1
- package/test/units/static/JBTerminalStore/TestCurrentReclaimableSurplusOf.sol +1 -1
- package/test/units/static/JBTerminalStore/TestCurrentSurplusOf.sol +1 -1
- package/test/units/static/JBTerminalStore/TestCurrentTotalSurplusOf.sol +1 -1
- package/test/units/static/JBTerminalStore/TestPreviewCashOutFrom.sol +1 -1
- package/test/units/static/JBTerminalStore/TestPreviewPayFrom.sol +1 -1
- package/test/units/static/JBTerminalStore/TestRecordCashOutsFor.sol +1 -1
- package/test/units/static/JBTerminalStore/TestRecordPaymentFrom.sol +1 -1
- package/test/units/static/JBTerminalStore/TestRecordPayoutFor.sol +1 -1
- package/test/units/static/JBTerminalStore/TestRecordTerminalMigration.sol +1 -1
- package/test/units/static/JBTerminalStore/TestRecordUsedAllowanceOf.sol +1 -1
- package/test/units/static/JBTerminalStore/TestUint224Overflow.sol +1 -1
- package/test/units/static/JBTokens/JBTokensSetup.sol +1 -1
- package/test/units/static/JBTokens/TestBurnFrom.sol +1 -1
- package/test/units/static/JBTokens/TestClaimTokensFor.sol +1 -1
- package/test/units/static/JBTokens/TestDeployERC20ForUnits.sol +1 -1
- package/test/units/static/JBTokens/TestMintFor.sol +1 -1
- package/test/units/static/JBTokens/TestSetTokenFor.sol +1 -1
- package/test/units/static/JBTokens/TestTotalBalanceOf.sol +1 -1
- package/test/units/static/JBTokens/TestTotalSupplyOf.sol +1 -1
- 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,
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
128
|
+
_controller.addPriceFeedFor({
|
|
129
129
|
projectId: _projectId,
|
|
130
130
|
pricingCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
|
|
131
131
|
unitCurrency: uint32(uint160(address(usdcToken()))),
|