@bananapus/core-v6 0.0.27 → 0.0.29

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.
@@ -802,7 +802,7 @@ contract TestFeeFreeCashOutBypass is TestBaseWorkflow {
802
802
  beneficiary: payable(user),
803
803
  metadata: new bytes(0)
804
804
  });
805
- // Balance dropped significantly. _reduceFeeFreeSurplus should have capped fee-free at remaining balance.
805
+ // Balance dropped significantly. _capFeeFreeSurplus should have capped fee-free at remaining balance.
806
806
 
807
807
  // Step 4: Switch back to zero tax. Cash out remaining.
808
808
  _reconfigureWithTaxRate(_projectIdB, 0);
@@ -855,7 +855,7 @@ contract TestFeeFreeCashOutBypass is TestBaseWorkflow {
855
855
  vm.prank(multisig());
856
856
  jbFeelessAddresses().setFeelessAddress(_attacker, true);
857
857
 
858
- // Step 3: Feeless cashout — no fees charged, but _reduceFeeFreeSurplus should still cap.
858
+ // Step 3: Feeless cashout — no fees charged, but _capFeeFreeSurplus should still cap.
859
859
  uint256 attackerTokens = _tokens.totalBalanceOf(_attacker, _projectIdB);
860
860
  vm.prank(_attacker);
861
861
  uint256 reclaim = _terminal.cashOutTokensOf({
package/test/TestFees.sol CHANGED
@@ -216,8 +216,9 @@ contract TestFees_Local is TestBaseWorkflow {
216
216
  // Send: Migration to terminal2
217
217
  _terminal.migrateBalanceOf(_projectId, JBConstants.NATIVE_TOKEN, _terminal2);
218
218
 
219
- // Check: Held Fee is processed and feeAmount remains in terminal
220
- assertEq(address(_terminal).balance, _feeAmount);
219
+ // Check: Held fee remains in terminal, plus migration fee paid to fee project (on same terminal)
220
+ uint256 _migrationFee = _nativeDistLimit * _terminal.FEE() / JBConstants.MAX_FEE;
221
+ assertEq(address(_terminal).balance, _feeAmount + _migrationFee);
221
222
 
222
223
  vm.stopPrank();
223
224
  }
@@ -265,8 +266,9 @@ contract TestFees_Local is TestBaseWorkflow {
265
266
  // Send: Migration to terminal2
266
267
  _terminal.migrateBalanceOf(_projectId, JBConstants.NATIVE_TOKEN, _terminal2);
267
268
 
268
- // Check: Held fee has been repaid, no balance remains.
269
- assertEq(address(_terminal).balance, 0);
269
+ // Check: Held fee has been repaid. Migration fee paid to fee project remains on this terminal.
270
+ uint256 _migrationFee = _nativePayAmount * _terminal.FEE() / JBConstants.MAX_FEE;
271
+ assertEq(address(_terminal).balance, _migrationFee);
270
272
 
271
273
  vm.stopPrank();
272
274
  }
@@ -6,6 +6,7 @@ import {JBMultiTerminal} from "../src/JBMultiTerminal.sol";
6
6
  import {IJBController} from "../src/interfaces/IJBController.sol";
7
7
  import {IJBRulesetApprovalHook} from "../src/interfaces/IJBRulesetApprovalHook.sol";
8
8
  import {JBConstants} from "../src/libraries/JBConstants.sol";
9
+ import {JBFees} from "../src/libraries/JBFees.sol";
9
10
  import {JBAccountingContext} from "../src/structs/JBAccountingContext.sol";
10
11
  import {JBFundAccessLimitGroup} from "../src/structs/JBFundAccessLimitGroup.sol";
11
12
  import {JBRulesetConfig} from "../src/structs/JBRulesetConfig.sol";
@@ -105,7 +106,7 @@ contract TestTerminalMigration_Local is TestBaseWorkflow {
105
106
  uint256 migratedBalance = _terminalA.migrateBalanceOf(_projectId, JBConstants.NATIVE_TOKEN, _terminalB);
106
107
  assertEq(migratedBalance, payAmount, "full balance should be migrated");
107
108
 
108
- // Step 4: Verify balances after migration
109
+ // Step 4: Verify balances after migration (fee project is exempt from migration fee)
109
110
  uint256 balanceAAfter = jbTerminalStore().balanceOf(address(_terminalA), _projectId, JBConstants.NATIVE_TOKEN);
110
111
  assertEq(balanceAAfter, 0, "terminal A should have zero after migration");
111
112
 
@@ -152,7 +153,7 @@ contract TestTerminalMigration_Local is TestBaseWorkflow {
152
153
  vm.prank(_projectOwner);
153
154
  _terminalA.migrateBalanceOf(_projectId, JBConstants.NATIVE_TOKEN, _terminalB);
154
155
 
155
- // Check surplus from terminal B
156
+ // Check surplus from terminal B (fee project exempt from migration fee)
156
157
  uint256 surplusAfter =
157
158
  _terminalB.currentSurplusOf(_projectId, new address[](0), 18, uint32(uint160(JBConstants.NATIVE_TOKEN)));
158
159
  assertEq(surplusAfter, surplusBefore, "surplus should be preserved after migration");
@@ -169,4 +170,105 @@ contract TestTerminalMigration_Local is TestBaseWorkflow {
169
170
  vm.expectRevert();
170
171
  _terminalA.migrateBalanceOf(_projectId, JBConstants.NATIVE_TOKEN, _terminalB);
171
172
  }
173
+
174
+ /// @notice Non-fee project migration charges the 2.5% fee; fee project balance increases.
175
+ function test_migration_nonFeeProject_chargesFee() public {
176
+ // Launch a second project (project 2) — this is NOT the fee project.
177
+ JBRulesetMetadata memory _metadata2 = JBRulesetMetadata({
178
+ reservedPercent: 0,
179
+ cashOutTaxRate: 0,
180
+ baseCurrency: uint32(uint160(JBConstants.NATIVE_TOKEN)),
181
+ pausePay: false,
182
+ pauseCreditTransfers: false,
183
+ allowOwnerMinting: false,
184
+ allowSetCustomToken: false,
185
+ allowTerminalMigration: true,
186
+ allowSetTerminals: true,
187
+ allowSetController: false,
188
+ allowAddAccountingContext: false,
189
+ allowAddPriceFeed: false,
190
+ ownerMustSendPayouts: false,
191
+ holdFees: false,
192
+ useTotalSurplusForCashOuts: false,
193
+ useDataHookForPay: false,
194
+ useDataHookForCashOut: false,
195
+ dataHook: address(0),
196
+ metadata: 0
197
+ });
198
+
199
+ JBRulesetConfig[] memory _rulesetConfig2 = new JBRulesetConfig[](1);
200
+ _rulesetConfig2[0].mustStartAtOrAfter = 0;
201
+ _rulesetConfig2[0].duration = 0;
202
+ _rulesetConfig2[0].weight = 1000 * 10 ** 18;
203
+ _rulesetConfig2[0].weightCutPercent = 0;
204
+ _rulesetConfig2[0].approvalHook = IJBRulesetApprovalHook(address(0));
205
+ _rulesetConfig2[0].metadata = _metadata2;
206
+ _rulesetConfig2[0].splitGroups = new JBSplitGroup[](0);
207
+ _rulesetConfig2[0].fundAccessLimitGroups = new JBFundAccessLimitGroup[](0);
208
+
209
+ JBAccountingContext[] memory _tokensToAccept = new JBAccountingContext[](1);
210
+ _tokensToAccept[0] = JBAccountingContext({
211
+ token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
212
+ });
213
+
214
+ JBTerminalConfig[] memory _terminalConfigs2 = new JBTerminalConfig[](2);
215
+ _terminalConfigs2[0] = JBTerminalConfig({terminal: _terminalA, accountingContextsToAccept: _tokensToAccept});
216
+ _terminalConfigs2[1] = JBTerminalConfig({terminal: _terminalB, accountingContextsToAccept: _tokensToAccept});
217
+
218
+ uint256 project2 = _controller.launchProjectFor({
219
+ owner: _projectOwner,
220
+ projectUri: "non-fee-project",
221
+ rulesetConfigurations: _rulesetConfig2,
222
+ terminalConfigurations: _terminalConfigs2,
223
+ memo: ""
224
+ });
225
+
226
+ // Confirm project 2 is NOT the fee project.
227
+ assertGt(project2, 1, "project2 should not be the fee project");
228
+
229
+ uint256 payAmount = 10 ether;
230
+
231
+ // Pay into terminal A for project 2.
232
+ vm.deal(_beneficiary, payAmount);
233
+ vm.prank(_beneficiary);
234
+ _terminalA.pay{value: payAmount}(project2, JBConstants.NATIVE_TOKEN, payAmount, _beneficiary, 0, "", "");
235
+
236
+ // Snapshot fee project balance before migration.
237
+ uint256 feeBalanceBefore = jbTerminalStore().balanceOf(address(_terminalA), 1, JBConstants.NATIVE_TOKEN);
238
+
239
+ // Migrate project 2 from terminal A to terminal B.
240
+ vm.prank(_projectOwner);
241
+ _terminalA.migrateBalanceOf(project2, JBConstants.NATIVE_TOKEN, _terminalB);
242
+
243
+ // Fee project balance should have increased (fee was charged).
244
+ uint256 feeBalanceAfter = jbTerminalStore().balanceOf(address(_terminalA), 1, JBConstants.NATIVE_TOKEN);
245
+ assertGt(feeBalanceAfter, feeBalanceBefore, "fee project should receive migration fee");
246
+
247
+ // Terminal B should have received payAmount minus the 2.5% fee.
248
+ uint256 expectedFee = JBFees.feeAmountFrom({amountBeforeFee: payAmount, feePercent: 25});
249
+ uint256 balanceBAfter = jbTerminalStore().balanceOf(address(_terminalB), project2, JBConstants.NATIVE_TOKEN);
250
+ assertEq(balanceBAfter, payAmount - expectedFee, "terminal B balance should reflect fee deduction");
251
+ }
252
+
253
+ /// @notice Fee project (project 1) migration is exempt from fees.
254
+ function test_migration_feeProject_noFeeCharged() public {
255
+ // _projectId is project 1, which IS the fee project.
256
+ assertEq(_projectId, 1, "project under test should be the fee project");
257
+
258
+ uint256 payAmount = 10 ether;
259
+
260
+ // Pay into terminal A for the fee project.
261
+ vm.deal(_beneficiary, payAmount);
262
+ vm.prank(_beneficiary);
263
+ _terminalA.pay{value: payAmount}(_projectId, JBConstants.NATIVE_TOKEN, payAmount, _beneficiary, 0, "", "");
264
+
265
+ // Migrate fee project from terminal A to terminal B.
266
+ vm.prank(_projectOwner);
267
+ uint256 migratedBalance = _terminalA.migrateBalanceOf(_projectId, JBConstants.NATIVE_TOKEN, _terminalB);
268
+ assertEq(migratedBalance, payAmount, "full balance should be migrated without fee");
269
+
270
+ // Terminal B should have the full amount (no fee deducted).
271
+ uint256 balanceBAfter = jbTerminalStore().balanceOf(address(_terminalB), _projectId, JBConstants.NATIVE_TOKEN);
272
+ assertEq(balanceBAfter, payAmount, "fee project should not be charged migration fee");
273
+ }
172
274
  }
@@ -318,10 +318,13 @@ contract FeeFreeSurplusLifecycleTest is TestBaseWorkflow {
318
318
  jbTerminalStore().balanceOf(address(_terminal), _recipientProjectId, JBConstants.NATIVE_TOKEN);
319
319
  assertEq(balanceAfterOnOldTerminal, 0, "Old terminal balance should be zero after migration");
320
320
 
321
- // Verify the new terminal received the balance.
321
+ // Verify the new terminal received the balance minus the 2.5% migration fee.
322
322
  uint256 balanceOnNewTerminal =
323
323
  jbTerminalStore().balanceOf(address(_terminal2), _recipientProjectId, JBConstants.NATIVE_TOKEN);
324
- assertEq(balanceOnNewTerminal, balanceBefore, "New terminal should have the migrated balance");
324
+ uint256 migrationFee = balanceBefore * 25 / 1000;
325
+ assertEq(
326
+ balanceOnNewTerminal, balanceBefore - migrationFee, "New terminal should have balance minus migration fee"
327
+ );
325
328
  }
326
329
 
327
330
  // --- Helpers ---
@@ -340,7 +340,7 @@ contract TestCashOutTokensOf_Local is JBMultiTerminalSetup {
340
340
  whenADataHookIsConfigured
341
341
  whenCallerHasPermission
342
342
  {
343
- // it will safeIncreaseAllowance pass the full amount to the hook and emit HookAfterRecordCashOut
343
+ // it will forceApprove pass the full amount to the hook and emit HookAfterRecordCashOut
344
344
 
345
345
  // mint mocked erc20 tokens to hodler
346
346
  _mockToken2.mint(address(_terminal), _defaultAmount * 10);
@@ -350,9 +350,6 @@ contract TestCashOutTokensOf_Local is JBMultiTerminalSetup {
350
350
  vm.prank(_holder);
351
351
  _mockToken2.approve(address(_terminal), _defaultAmount);
352
352
 
353
- vm.prank(address(_terminal));
354
- _mockToken2.approve(address(_mockHook), _defaultAmount);
355
-
356
353
  uint256 reclaimAmount = 1e9;
357
354
  JBCashOutHookSpecification[] memory hookSpecifications = new JBCashOutHookSpecification[](1);
358
355
  hookSpecifications[0] =
@@ -431,8 +428,8 @@ contract TestCashOutTokensOf_Local is JBMultiTerminalSetup {
431
428
 
432
429
  mockExpect(address(_mockHook), abi.encodeCall(IJBCashOutHook.afterCashOutRecordedWith, (context)), "");
433
430
 
434
- // ensure approval is increased
435
- vm.expectCall(address(_mockToken2), abi.encodeCall(IERC20.approve, (address(_mockHook), _defaultAmount * 2)));
431
+ // ensure approval is set via forceApprove
432
+ vm.expectCall(address(_mockToken2), abi.encodeCall(IERC20.approve, (address(_mockHook), _defaultAmount)));
436
433
 
437
434
  _acceptToken(address(_mockToken2), 18, uint32(uint160(address(_mockToken2))));
438
435
 
@@ -456,7 +453,7 @@ contract TestCashOutTokensOf_Local is JBMultiTerminalSetup {
456
453
  whenADataHookIsConfigured
457
454
  whenCallerHasPermission
458
455
  {
459
- // it will safeIncreaseAllowance pass the amount to the hook and emit HookAfterRecordCashOut
456
+ // it will forceApprove pass the amount to the hook and emit HookAfterRecordCashOut
460
457
 
461
458
  // mint mocked erc20 tokens to hodler
462
459
  _mockToken2.mint(address(_terminal), _defaultAmount * 10);
@@ -466,9 +463,6 @@ contract TestCashOutTokensOf_Local is JBMultiTerminalSetup {
466
463
  vm.prank(_holder);
467
464
  _mockToken2.approve(address(_terminal), _defaultAmount);
468
465
 
469
- vm.prank(address(_terminal));
470
- _mockToken2.approve(address(_mockHook), _defaultAmount);
471
-
472
466
  uint256 reclaimAmount = 1e9;
473
467
  JBCashOutHookSpecification[] memory hookSpecifications = new JBCashOutHookSpecification[](1);
474
468
  JBCashOutHookSpecification[] memory paySpecs = new JBCashOutHookSpecification[](0);
@@ -4,18 +4,14 @@ pragma solidity 0.8.28;
4
4
  import {JBMultiTerminal} from "../../../../src/JBMultiTerminal.sol";
5
5
  import {IJBDirectory} from "../../../../src/interfaces/IJBDirectory.sol";
6
6
  import {IJBFeelessAddresses} from "../../../../src/interfaces/IJBFeelessAddresses.sol";
7
- import {IJBRulesetApprovalHook} from "../../../../src/interfaces/IJBRulesetApprovalHook.sol";
8
7
  import {IJBSplitHook} from "../../../../src/interfaces/IJBSplitHook.sol";
9
8
  import {IJBTerminal} from "../../../../src/interfaces/IJBTerminal.sol";
10
9
  import {IJBTerminalStore} from "../../../../src/interfaces/IJBTerminalStore.sol";
11
10
  import {JBConstants} from "../../../../src/libraries/JBConstants.sol";
12
11
  import {JBFees} from "../../../../src/libraries/JBFees.sol";
13
12
  import {JBAccountingContext} from "../../../../src/structs/JBAccountingContext.sol";
14
- import {JBPayHookSpecification} from "../../../../src/structs/JBPayHookSpecification.sol";
15
- import {JBRuleset} from "../../../../src/structs/JBRuleset.sol";
16
13
  import {JBSplit} from "../../../../src/structs/JBSplit.sol";
17
14
  import {JBSplitHookContext} from "../../../../src/structs/JBSplitHookContext.sol";
18
- import {JBTokenAmount} from "../../../../src/structs/JBTokenAmount.sol";
19
15
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
20
16
  import {IERC165} from "@openzeppelin/contracts/utils/introspection/ERC165.sol";
21
17
  import {JBMultiTerminalSetup} from "./JBMultiTerminalSetup.sol";
@@ -336,12 +332,7 @@ contract TestExecutePayout_Local is JBMultiTerminalSetup {
336
332
  uint256 taxedAmount = JBFees.feeAmountFrom(_defaultAmount, _fee);
337
333
  uint256 amountAfterTax = _defaultAmount - taxedAmount;
338
334
 
339
- // mock call for SafeERC20s allowance check
340
- mockExpect(
341
- _usdc, abi.encodeCall(IERC20.allowance, (address(_terminal), address(_mockSecondTerminal))), abi.encode(0)
342
- );
343
-
344
- // mock call for SafeERC20s safeIncreaseAllowance approval
335
+ // mock call for SafeERC20s forceApprove approval
345
336
  mockExpect(_usdc, abi.encodeCall(IERC20.approve, (_mockSecondTerminal, amountAfterTax)), "");
346
337
 
347
338
  // mock call to second terminals addToBalanceOf
@@ -366,9 +357,7 @@ contract TestExecutePayout_Local is JBMultiTerminalSetup {
366
357
  }
367
358
 
368
359
  function test_GivenPreferAddToBalanceDNEQTrueAndTerminalEQThisAddress() external {
369
- // it will call internal _pay
370
-
371
- _setAccountingContext(_projectId, _usdc, 0, _usdcCurrency);
360
+ // it will revert with MintNotAllowed because same-project same-terminal pay splits are blocked
372
361
 
373
362
  // mock call to directory primaryTerminalOf
374
363
  mockExpect(
@@ -386,41 +375,8 @@ contract TestExecutePayout_Local is JBMultiTerminalSetup {
386
375
  hook: IJBSplitHook(address(0))
387
376
  });
388
377
 
389
- // needed for next mock call returns
390
- JBTokenAmount memory tokenAmount =
391
- JBTokenAmount({token: _usdc, decimals: 0, currency: _usdcCurrency, value: _defaultAmount});
392
- JBPayHookSpecification[] memory hookSpecifications = new JBPayHookSpecification[](0);
393
- JBRuleset memory returnedRuleset = JBRuleset({
394
- cycleNumber: 1,
395
- id: 1,
396
- basedOnId: 0,
397
- start: 0,
398
- duration: 0,
399
- weight: 0,
400
- weightCutPercent: 0,
401
- approvalHook: IJBRulesetApprovalHook(address(0)),
402
- metadata: 0
403
- });
404
-
405
- // mock call to JBTerminalStore recordPaymentFrom
406
- mockExpect(
407
- address(store),
408
- abi.encodeCall(
409
- IJBTerminalStore.recordPaymentFrom,
410
- (
411
- address(_terminal),
412
- tokenAmount,
413
- _projectId,
414
- address(this),
415
- bytes(abi.encodePacked(uint256(_projectId)))
416
- )
417
- ),
418
- abi.encode(returnedRuleset, 0, hookSpecifications)
419
- );
420
-
421
- // for safe ERC20 check of code length at token address
422
378
  vm.prank(address(_terminal));
423
-
379
+ vm.expectRevert(JBMultiTerminal.JBMultiTerminal_MintNotAllowed.selector);
424
380
  JBMultiTerminal(address(_terminal))
425
381
  .executePayout({
426
382
  split: _splitMemory,
@@ -460,12 +416,7 @@ contract TestExecutePayout_Local is JBMultiTerminalSetup {
460
416
  uint256 taxedAmount = JBFees.feeAmountFrom(_defaultAmount, _fee);
461
417
  uint256 amountAfterTax = _defaultAmount - taxedAmount;
462
418
 
463
- // mock call for SafeERC20s allowance check
464
- mockExpect(
465
- _usdc, abi.encodeCall(IERC20.allowance, (address(_terminal), address(_mockSecondTerminal))), abi.encode(0)
466
- );
467
-
468
- // mock call for SafeERC20s safeIncreaseAllowance approval
419
+ // mock call for SafeERC20s forceApprove approval
469
420
  mockExpect(_usdc, abi.encodeCall(IERC20.approve, (_mockSecondTerminal, amountAfterTax)), "");
470
421
 
471
422
  // mock call to second terminals pay function
@@ -68,12 +68,9 @@ contract TestExecuteProcessFee_Local is JBMultiTerminalSetup {
68
68
  }
69
69
 
70
70
  function test_WhenTokenIsErc20AndFeeTerminalIsExternal() external {
71
- // it will safeIncreaseAllowance
71
+ // it will forceApprove
72
72
 
73
- // mock token allowance call
74
- mockExpect(_usdc, abi.encodeCall(IERC20.allowance, (address(_terminal), address(_feeTerminal))), abi.encode(0));
75
-
76
- // mock approval call
73
+ // mock approval call for forceApprove
77
74
  mockExpect(_usdc, abi.encodeCall(IERC20.approve, (address(_feeTerminal), _defaultAmount)), "");
78
75
 
79
76
  // mock pay call to fee terminal
@@ -11,6 +11,7 @@ import {JBAccountingContext} from "../../../../src/structs/JBAccountingContext.s
11
11
  import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
12
12
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
13
13
  import {IERC721} from "@openzeppelin/contracts/token/ERC721/IERC721.sol";
14
+ import {IJBFeelessAddresses} from "../../../../src/interfaces/IJBFeelessAddresses.sol";
14
15
  import {JBMultiTerminalSetup} from "./JBMultiTerminalSetup.sol";
15
16
 
16
17
  contract TestMigrateBalanceOf_Local is JBMultiTerminalSetup {
@@ -102,7 +103,14 @@ contract TestMigrateBalanceOf_Local is JBMultiTerminalSetup {
102
103
  } */
103
104
 
104
105
  function test_GivenTokenIsERC20() external whenPermissioned {
105
- // it will safeIncreaseAllowance and addToBalanceOf
106
+ // it will forceApprove and addToBalanceOf
107
+
108
+ // mock _isFeeless to return true (skip migration fee for this unit test)
109
+ mockExpect(
110
+ address(feelessAddresses),
111
+ abi.encodeCall(IJBFeelessAddresses.isFeeless, (address(_newTerminal))),
112
+ abi.encode(true)
113
+ );
106
114
 
107
115
  // for next mock
108
116
  // forge-lint: disable-next-line(unsafe-typecast)
@@ -124,10 +132,7 @@ contract TestMigrateBalanceOf_Local is JBMultiTerminalSetup {
124
132
  abi.encode(_defaultAmount)
125
133
  );
126
134
 
127
- // mock call for SafeERC20s allowance check
128
- mockExpect(_usdc, abi.encodeCall(IERC20.allowance, (address(_terminal), address(_newTerminal))), abi.encode(0));
129
-
130
- // mock call for SafeERC20s safeIncreaseAllowance approval
135
+ // mock call for SafeERC20s forceApprove approval
131
136
  mockExpect(_usdc, abi.encodeCall(IERC20.approve, (address(_newTerminal), _defaultAmount)), "");
132
137
 
133
138
  // mock call to new terminal addToBalance
@@ -143,6 +148,13 @@ contract TestMigrateBalanceOf_Local is JBMultiTerminalSetup {
143
148
  function test_GivenTokenIsNative() external whenPermissioned {
144
149
  // it will addToBalanceOf with value in msgvalue
145
150
 
151
+ // mock _isFeeless to return true (skip migration fee for this unit test)
152
+ mockExpect(
153
+ address(feelessAddresses),
154
+ abi.encodeCall(IJBFeelessAddresses.isFeeless, (address(_newTerminal))),
155
+ abi.encode(true)
156
+ );
157
+
146
158
  // for next mock
147
159
  // forge-lint: disable-next-line(unsafe-typecast)
148
160
  JBAccountingContext memory _context =
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {JBMultiTerminal} from "../../../../src/JBMultiTerminal.sol";
5
+ import {IJBDirectory} from "../../../../src/interfaces/IJBDirectory.sol";
6
+ import {JBConstants} from "../../../../src/libraries/JBConstants.sol";
7
+ import {JBSplit} from "../../../../src/structs/JBSplit.sol";
8
+ import {IJBSplitHook} from "../../../../src/interfaces/IJBSplitHook.sol";
9
+ import {JBMultiTerminalSetup} from "./JBMultiTerminalSetup.sol";
10
+
11
+ /// @notice Tests that a pay-type split back into the same terminal reverts with MintNotAllowed.
12
+ contract TestSelfPayRevert_Local is JBMultiTerminalSetup {
13
+ uint64 _projectId = 1;
14
+ uint256 _defaultAmount = 1e18;
15
+ address _sender = makeAddr("sender");
16
+ address _native = JBConstants.NATIVE_TOKEN;
17
+
18
+ function setUp() public {
19
+ super.multiTerminalSetup();
20
+ }
21
+
22
+ /// @notice When a split routes a pay back to the same project on the same terminal,
23
+ /// executePayout should revert with MintNotAllowed.
24
+ function test_RevertWhen_SplitPaysBackToSameTerminal() external {
25
+ // Build a split targeting the SAME project with preferAddToBalance = false (pay path).
26
+ JBSplit memory split = JBSplit({
27
+ preferAddToBalance: false,
28
+ percent: 1_000_000_000,
29
+ projectId: _projectId,
30
+ beneficiary: payable(_sender),
31
+ lockedUntil: 0,
32
+ hook: IJBSplitHook(address(0))
33
+ });
34
+
35
+ // Mock primaryTerminalOf to return this terminal (self-referencing).
36
+ mockExpect(
37
+ address(directory),
38
+ abi.encodeCall(IJBDirectory.primaryTerminalOf, (_projectId, _native)),
39
+ abi.encode(address(_terminal))
40
+ );
41
+
42
+ // executePayout requires msg.sender == address(this), so we call it via the terminal.
43
+ // The terminal's try-catch in the split group lib would normally catch this.
44
+ vm.prank(address(_terminal));
45
+ vm.expectRevert(JBMultiTerminal.JBMultiTerminal_MintNotAllowed.selector);
46
+ JBMultiTerminal(payable(address(_terminal)))
47
+ .executePayout({
48
+ split: split,
49
+ projectId: uint256(_projectId),
50
+ token: _native,
51
+ amount: _defaultAmount,
52
+ originalMessageSender: _sender
53
+ });
54
+ }
55
+ }