@bananapus/core-v6 0.0.31 → 0.0.33

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.
@@ -110,6 +110,9 @@ contract JBTerminalStore is IJBTerminalStore {
110
110
  /// @dev Increases as projects use their allowance.
111
111
  /// @dev The used surplus allowance is represented as a fixed point number with the same amount of decimals as the
112
112
  /// terminal it applies to.
113
+ /// @dev Surplus allowance usage is keyed by `ruleset.id`, not cycle number. Implicit cycle progression
114
+ /// (duration-based auto-cycling) does not reset allowance — this is by design. Projects must queue a new ruleset
115
+ /// to get a fresh allowance.
113
116
  /// @custom:param terminal The terminal the surplus allowance applies to.
114
117
  /// @custom:param projectId The ID of the project to get the used surplus allowance of.
115
118
  /// @custom:param token The token the surplus allowance applies to in the terminal.
@@ -172,7 +175,7 @@ contract JBTerminalStore is IJBTerminalStore {
172
175
  }
173
176
 
174
177
  // Record each accounting context.
175
- for (uint256 i; i < contexts.length; i++) {
178
+ for (uint256 i; i < contexts.length;) {
176
179
  JBAccountingContext calldata context = contexts[i];
177
180
 
178
181
  // Make sure the token accounting context isn't already set.
@@ -213,6 +216,9 @@ contract JBTerminalStore is IJBTerminalStore {
213
216
 
214
217
  // Add the context to the list.
215
218
  _accountingContextsOf[msg.sender][projectId].push(context);
219
+ unchecked {
220
+ ++i;
221
+ }
216
222
  }
217
223
  }
218
224
 
@@ -277,26 +283,29 @@ contract JBTerminalStore is IJBTerminalStore {
277
283
 
278
284
  if (hookSpecifications.length != 0) {
279
285
  uint256 numberOfSpecifications = hookSpecifications.length;
280
- for (uint256 i; i < numberOfSpecifications; i++) {
286
+ for (uint256 i; i < numberOfSpecifications;) {
281
287
  uint256 specificationAmount = hookSpecifications[i].amount;
282
288
  if (specificationAmount != 0) {
283
289
  balanceDiff += specificationAmount;
284
290
  }
291
+ unchecked {
292
+ ++i;
293
+ }
285
294
  }
286
295
  }
287
296
 
297
+ // Cache the balance slot to avoid redundant storage reads.
298
+ uint256 currentBalance = balanceOf[msg.sender][projectId][tokenToReclaim];
299
+
288
300
  // The amount being reclaimed must be within the project's balance.
289
- if (balanceDiff > balanceOf[msg.sender][projectId][tokenToReclaim]) {
290
- revert JBTerminalStore_InadequateTerminalStoreBalance(
291
- balanceDiff, balanceOf[msg.sender][projectId][tokenToReclaim]
292
- );
301
+ if (balanceDiff > currentBalance) {
302
+ revert JBTerminalStore_InadequateTerminalStoreBalance(balanceDiff, currentBalance);
293
303
  }
294
304
 
295
305
  // Remove the reclaimed funds from the project's balance.
296
306
  if (balanceDiff != 0) {
297
307
  unchecked {
298
- balanceOf[msg.sender][projectId][tokenToReclaim] =
299
- balanceOf[msg.sender][projectId][tokenToReclaim] - balanceDiff;
308
+ balanceOf[msg.sender][projectId][tokenToReclaim] = currentBalance - balanceDiff;
300
309
  }
301
310
  }
302
311
  }
@@ -338,8 +347,9 @@ contract JBTerminalStore is IJBTerminalStore {
338
347
 
339
348
  // Add the correct balance difference to the token balance of the project.
340
349
  if (balanceDiff != 0) {
341
- balanceOf[msg.sender][projectId][amount.token] =
342
- balanceOf[msg.sender][projectId][amount.token] + balanceDiff;
350
+ // Cache the balance slot to avoid redundant storage reads.
351
+ uint256 currentBalance = balanceOf[msg.sender][projectId][amount.token];
352
+ balanceOf[msg.sender][projectId][amount.token] = currentBalance + balanceDiff;
343
353
  }
344
354
  }
345
355
 
@@ -385,26 +395,20 @@ contract JBTerminalStore is IJBTerminalStore {
385
395
  })
386
396
  });
387
397
 
388
- // The amount being paid out must be available.
389
- if (amountPaidOut > balanceOf[msg.sender][projectId][token]) {
390
- revert JBTerminalStore_InadequateTerminalStoreBalance(
391
- amountPaidOut, balanceOf[msg.sender][projectId][token]
392
- );
393
- }
398
+ // Cache the balance slot to avoid redundant storage reads.
399
+ uint256 currentBalance = balanceOf[msg.sender][projectId][token];
394
400
 
395
- // Removed the paid out funds from the project's token balance.
396
- unchecked {
397
- balanceOf[msg.sender][projectId][token] = balanceOf[msg.sender][projectId][token] - amountPaidOut;
401
+ // The amount being paid out must be available.
402
+ if (amountPaidOut > currentBalance) {
403
+ revert JBTerminalStore_InadequateTerminalStoreBalance(amountPaidOut, currentBalance);
398
404
  }
399
405
 
400
406
  // The new total amount which has been paid out during this ruleset.
401
407
  uint256 newUsedPayoutLimitOf =
402
408
  usedPayoutLimitOf[msg.sender][projectId][token][ruleset.cycleNumber][currency] + amount;
403
409
 
404
- // Store the new amount.
405
- usedPayoutLimitOf[msg.sender][projectId][token][ruleset.cycleNumber][currency] = newUsedPayoutLimitOf;
406
-
407
410
  // Amount must be within what is still available to pay out.
411
+ // Validate BEFORE writing to storage to avoid wasting gas on SSTORE when the tx will revert.
408
412
  uint256 payoutLimit = IJBController(address(DIRECTORY.controllerOf(projectId))).FUND_ACCESS_LIMITS()
409
413
  .payoutLimitOf({
410
414
  projectId: projectId, rulesetId: ruleset.id, terminal: msg.sender, token: token, currency: currency
@@ -414,6 +418,14 @@ contract JBTerminalStore is IJBTerminalStore {
414
418
  if (newUsedPayoutLimitOf > payoutLimit || payoutLimit == 0) {
415
419
  revert JBTerminalStore_InadequateControllerPayoutLimit(newUsedPayoutLimitOf, payoutLimit);
416
420
  }
421
+
422
+ // Removed the paid out funds from the project's token balance.
423
+ unchecked {
424
+ balanceOf[msg.sender][projectId][token] = currentBalance - amountPaidOut;
425
+ }
426
+
427
+ // Store the new used payout limit.
428
+ usedPayoutLimitOf[msg.sender][projectId][token][ruleset.cycleNumber][currency] = newUsedPayoutLimitOf;
417
429
  }
418
430
 
419
431
  /// @notice Records the migration of funds from this store.
@@ -493,17 +505,12 @@ contract JBTerminalStore is IJBTerminalStore {
493
505
  // The amount being used must be available in the surplus.
494
506
  if (usedAmount > surplus) revert JBTerminalStore_InadequateTerminalStoreBalance(usedAmount, surplus);
495
507
 
496
- // Update the project's balance.
497
- balanceOf[msg.sender][projectId][token] = balanceOf[msg.sender][projectId][token] - usedAmount;
498
-
499
508
  // Get a reference to the new used surplus allowance for this ruleset ID.
500
509
  uint256 newUsedSurplusAllowanceOf =
501
510
  usedSurplusAllowanceOf[msg.sender][projectId][token][ruleset.id][currency] + amount;
502
511
 
503
- // Store the incremented value.
504
- usedSurplusAllowanceOf[msg.sender][projectId][token][ruleset.id][currency] = newUsedSurplusAllowanceOf;
505
-
506
512
  // There must be sufficient surplus allowance available.
513
+ // Validate BEFORE writing to storage to avoid wasting gas on SSTORE when the tx will revert.
507
514
  uint256 surplusAllowance = IJBController(address(DIRECTORY.controllerOf(projectId))).FUND_ACCESS_LIMITS()
508
515
  .surplusAllowanceOf({
509
516
  projectId: projectId, rulesetId: ruleset.id, terminal: msg.sender, token: token, currency: currency
@@ -513,6 +520,15 @@ contract JBTerminalStore is IJBTerminalStore {
513
520
  if (newUsedSurplusAllowanceOf > surplusAllowance || surplusAllowance == 0) {
514
521
  revert JBTerminalStore_InadequateControllerAllowance(newUsedSurplusAllowanceOf, surplusAllowance);
515
522
  }
523
+
524
+ // Cache the balance slot to avoid redundant storage reads.
525
+ uint256 currentBalance = balanceOf[msg.sender][projectId][token];
526
+
527
+ // Update the project's balance.
528
+ balanceOf[msg.sender][projectId][token] = currentBalance - usedAmount;
529
+
530
+ // Store the incremented value.
531
+ usedSurplusAllowanceOf[msg.sender][projectId][token][ruleset.id][currency] = newUsedSurplusAllowanceOf;
516
532
  }
517
533
 
518
534
  //*********************************************************************//
@@ -774,9 +790,17 @@ contract JBTerminalStore is IJBTerminalStore {
774
790
  override
775
791
  returns (uint256)
776
792
  {
793
+ // Fetch the current ruleset once — used for both surplus calculation and cash out tax rate.
794
+ JBRuleset memory ruleset = RULESETS.currentOf(projectId);
795
+
777
796
  // Aggregate surplus across the terminals, optionally filtered by the specified tokens.
778
797
  uint256 currentSurplus = _currentSurplusOf({
779
- projectId: projectId, terminals: terminals, tokens: tokens, decimals: decimals, currency: currency
798
+ projectId: projectId,
799
+ terminals: terminals,
800
+ tokens: tokens,
801
+ decimals: decimals,
802
+ currency: currency,
803
+ ruleset: ruleset
780
804
  });
781
805
 
782
806
  // If there's no surplus, nothing can be reclaimed.
@@ -789,15 +813,12 @@ contract JBTerminalStore is IJBTerminalStore {
789
813
  // Can't cash out more tokens than are in the total supply.
790
814
  if (cashOutCount > totalSupply) return 0;
791
815
 
792
- // Get the cash out tax rate from the current ruleset.
793
- uint256 cashOutTaxRate = RULESETS.currentOf(projectId).cashOutTaxRate();
794
-
795
816
  // Return the amount of surplus terminal tokens that would be reclaimed.
796
817
  return JBCashOuts.cashOutFrom({
797
818
  surplus: currentSurplus,
798
819
  cashOutCount: cashOutCount,
799
820
  totalSupply: totalSupply,
800
- cashOutTaxRate: cashOutTaxRate
821
+ cashOutTaxRate: ruleset.cashOutTaxRate()
801
822
  });
802
823
  }
803
824
 
@@ -806,6 +827,9 @@ contract JBTerminalStore is IJBTerminalStore {
806
827
  //*********************************************************************//
807
828
 
808
829
  /// @notice Computes the surplus relevant for a cash out (total or local, depending on ruleset flag).
830
+ /// @dev When `useTotalSurplusForCashOuts` is enabled, surplus is aggregated from ALL registered terminals without
831
+ /// validation. Projects MUST only register trusted terminals — an untrusted terminal can over-report surplus and
832
+ /// cause the executing terminal to overpay cash-outs.
809
833
  /// @param terminal The terminal the cash out is being recorded from.
810
834
  /// @param projectId The ID of the project being cashed out from.
811
835
  /// @param tokenToReclaim The token being reclaimed.
@@ -937,10 +961,13 @@ contract JBTerminalStore is IJBTerminalStore {
937
961
  IJBRulesetDataHook(ruleset.dataHook()).beforeCashOutRecordedWith(context);
938
962
 
939
963
  // Noop specifications are informational only, so they can't also request forwarded funds.
940
- for (uint256 i; i < hookSpecifications.length; i++) {
964
+ for (uint256 i; i < hookSpecifications.length;) {
941
965
  if (hookSpecifications[i].noop && hookSpecifications[i].amount != 0) {
942
966
  revert JBTerminalStore_NoopHookSpecHasAmount(hookSpecifications[i].amount);
943
967
  }
968
+ unchecked {
969
+ ++i;
970
+ }
944
971
  }
945
972
  } else {
946
973
  cashOutTaxRate = ruleset.cashOutTaxRate();
@@ -1028,7 +1055,7 @@ contract JBTerminalStore is IJBTerminalStore {
1028
1055
  balanceDiff = amount.value;
1029
1056
 
1030
1057
  // Ensure that the specifications have valid amounts.
1031
- for (uint256 i; i < hookSpecifications.length; i++) {
1058
+ for (uint256 i; i < hookSpecifications.length;) {
1032
1059
  if (hookSpecifications[i].noop && hookSpecifications[i].amount != 0) {
1033
1060
  revert JBTerminalStore_NoopHookSpecHasAmount(hookSpecifications[i].amount);
1034
1061
  }
@@ -1044,6 +1071,9 @@ contract JBTerminalStore is IJBTerminalStore {
1044
1071
  // Decrement the total amount being added to the local balance.
1045
1072
  balanceDiff -= specifiedAmount;
1046
1073
  }
1074
+ unchecked {
1075
+ ++i;
1076
+ }
1047
1077
  }
1048
1078
 
1049
1079
  // If there's no amount being recorded, there's nothing left to do.
@@ -1086,15 +1116,46 @@ contract JBTerminalStore is IJBTerminalStore {
1086
1116
  internal
1087
1117
  view
1088
1118
  returns (uint256 surplus)
1119
+ {
1120
+ // Fetch the ruleset once and delegate to the overload that accepts it.
1121
+ return _currentSurplusOf({
1122
+ projectId: projectId,
1123
+ terminals: terminals,
1124
+ tokens: tokens,
1125
+ decimals: decimals,
1126
+ currency: currency,
1127
+ ruleset: RULESETS.currentOf(projectId)
1128
+ });
1129
+ }
1130
+
1131
+ /// @notice Gets the current surplus amount for a project across specified terminals and tokens, using a
1132
+ /// pre-fetched ruleset.
1133
+ /// @dev Use this overload when the caller already has the current ruleset to avoid a redundant
1134
+ /// `RULESETS.currentOf()` call.
1135
+ /// @param projectId The ID of the project to get surplus for.
1136
+ /// @param terminals The terminals to include. If empty, all project terminals are used.
1137
+ /// @param tokens The tokens to include. If empty, all tokens per terminal are used.
1138
+ /// @param decimals The number of decimals to expect in the resulting fixed point number.
1139
+ /// @param currency The currency the resulting amount should be in terms of.
1140
+ /// @param ruleset The project's current ruleset.
1141
+ /// @return surplus The current surplus amount.
1142
+ function _currentSurplusOf(
1143
+ uint256 projectId,
1144
+ IJBTerminal[] memory terminals,
1145
+ address[] memory tokens,
1146
+ uint256 decimals,
1147
+ uint256 currency,
1148
+ JBRuleset memory ruleset
1149
+ )
1150
+ internal
1151
+ view
1152
+ returns (uint256 surplus)
1089
1153
  {
1090
1154
  // If specific terminals were provided, use them. Otherwise, get all terminals from the directory.
1091
1155
  IJBTerminal[] memory resolvedTerminals = terminals.length != 0 ? terminals : DIRECTORY.terminalsOf(projectId);
1092
1156
 
1093
- // The ruleset determines payout limits, which affect surplus. Fetch it once for all terminals.
1094
- JBRuleset memory ruleset = RULESETS.currentOf(projectId);
1095
-
1096
1157
  // Sum surplus across each terminal.
1097
- for (uint256 i; i < resolvedTerminals.length; i++) {
1158
+ for (uint256 i; i < resolvedTerminals.length;) {
1098
1159
  address terminal = address(resolvedTerminals[i]);
1099
1160
 
1100
1161
  // Build the list of accounting contexts to include in this terminal's surplus calculation.
@@ -1102,8 +1163,11 @@ contract JBTerminalStore is IJBTerminalStore {
1102
1163
  if (tokens.length != 0) {
1103
1164
  // Specific tokens requested: look up each token's accounting context at this terminal.
1104
1165
  accountingContexts = new JBAccountingContext[](tokens.length);
1105
- for (uint256 j; j < tokens.length; j++) {
1166
+ for (uint256 j; j < tokens.length;) {
1106
1167
  accountingContexts[j] = _accountingContextForTokenOf[terminal][projectId][tokens[j]];
1168
+ unchecked {
1169
+ ++j;
1170
+ }
1107
1171
  }
1108
1172
  } else {
1109
1173
  // No token filter: use all accounting contexts registered at this terminal.
@@ -1119,6 +1183,9 @@ contract JBTerminalStore is IJBTerminalStore {
1119
1183
  targetDecimals: decimals,
1120
1184
  targetCurrency: currency
1121
1185
  });
1186
+ unchecked {
1187
+ ++i;
1188
+ }
1122
1189
  }
1123
1190
  }
1124
1191
 
@@ -1151,7 +1218,7 @@ contract JBTerminalStore is IJBTerminalStore {
1151
1218
  uint256 numberOfTokenAccountingContexts = accountingContexts.length;
1152
1219
 
1153
1220
  // Add payout limits from each token.
1154
- for (uint256 i; i < numberOfTokenAccountingContexts; i++) {
1221
+ for (uint256 i; i < numberOfTokenAccountingContexts;) {
1155
1222
  uint256 tokenSurplus = _tokenSurplusFrom({
1156
1223
  terminal: terminal,
1157
1224
  projectId: projectId,
@@ -1162,6 +1229,9 @@ contract JBTerminalStore is IJBTerminalStore {
1162
1229
  });
1163
1230
  // Increment the surplus with any remaining balance.
1164
1231
  if (tokenSurplus > 0) surplus += tokenSurplus;
1232
+ unchecked {
1233
+ ++i;
1234
+ }
1165
1235
  }
1166
1236
  }
1167
1237
 
@@ -1228,7 +1298,7 @@ contract JBTerminalStore is IJBTerminalStore {
1228
1298
  uint256 numberOfPayoutLimits = payoutLimits.length;
1229
1299
 
1230
1300
  // Loop through each payout limit to determine the cumulative normalized payout limit remaining.
1231
- for (uint256 i; i < numberOfPayoutLimits; i++) {
1301
+ for (uint256 i; i < numberOfPayoutLimits;) {
1232
1302
  JBCurrencyAmount memory payoutLimit = payoutLimits[i];
1233
1303
 
1234
1304
  // Set the payout limit value to the amount still available to pay out during the ruleset.
@@ -1280,6 +1350,9 @@ contract JBTerminalStore is IJBTerminalStore {
1280
1350
  } else {
1281
1351
  return 0;
1282
1352
  }
1353
+ unchecked {
1354
+ ++i;
1355
+ }
1283
1356
  }
1284
1357
  }
1285
1358
  }
package/src/JBTokens.sol CHANGED
@@ -253,9 +253,12 @@ contract JBTokens is JBControlled, IJBTokens {
253
253
  // Save a reference to whether there a token exists.
254
254
  bool tokensWereClaimed = token != IJBToken(address(0));
255
255
 
256
+ // Cache the total supply to avoid a redundant external call on revert.
257
+ uint256 supply = totalSupplyOf(projectId);
258
+
256
259
  // The total supply after minting can't exceed the maximum value storable in a uint208.
257
- if (totalSupplyOf(projectId) + count > type(uint208).max) {
258
- revert JBTokens_OverflowAlert(totalSupplyOf(projectId) + count, type(uint208).max);
260
+ if (supply + count > type(uint208).max) {
261
+ revert JBTokens_OverflowAlert(supply + count, type(uint208).max);
259
262
  }
260
263
 
261
264
  if (tokensWereClaimed) {
@@ -45,10 +45,11 @@ abstract contract JBControlled is IJBControlled {
45
45
 
46
46
  /// @notice Only allows the controller of the specified project to proceed.
47
47
  function _onlyControllerOf(uint256 projectId) internal view {
48
+ // Cache the controller address to avoid a redundant external call on revert.
48
49
  // slither-disable-next-line calls-loop
49
- if (address(DIRECTORY.controllerOf(projectId)) != msg.sender) {
50
- // slither-disable-next-line calls-loop
51
- revert JBControlled_ControllerUnauthorized(address(DIRECTORY.controllerOf(projectId)));
50
+ address controller = address(DIRECTORY.controllerOf(projectId));
51
+ if (controller != msg.sender) {
52
+ revert JBControlled_ControllerUnauthorized(controller);
52
53
  }
53
54
  }
54
55
  }
@@ -214,7 +214,7 @@ library JBMetadataResolver {
214
214
  uint256 numberOfDatas = datas.length;
215
215
 
216
216
  // Add each metadata to the array, each padded to 32 bytes
217
- for (uint256 i; i < numberOfDatas; i++) {
217
+ for (uint256 i; i < numberOfDatas;) {
218
218
  metadata = abi.encodePacked(metadata, datas[i]);
219
219
  paddedLength = metadata.length % JBMetadataResolver.WORD_SIZE == 0
220
220
  ? metadata.length
@@ -223,6 +223,9 @@ library JBMetadataResolver {
223
223
  assembly ("memory-safe") {
224
224
  mstore(metadata, paddedLength)
225
225
  }
226
+ unchecked {
227
+ ++i;
228
+ }
226
229
  }
227
230
  }
228
231
 
@@ -69,7 +69,7 @@ library JBPayoutSplitGroupLib {
69
69
  leftoverAmount = amount;
70
70
 
71
71
  // Transfer between all splits.
72
- for (uint256 i; i < payoutSplits.length; i++) {
72
+ for (uint256 i; i < payoutSplits.length;) {
73
73
  // Get a reference to the split being iterated on.
74
74
  JBSplit memory split = payoutSplits[i];
75
75
 
@@ -108,6 +108,9 @@ library JBPayoutSplitGroupLib {
108
108
  netAmount: netPayoutAmount,
109
109
  caller: caller
110
110
  });
111
+ unchecked {
112
+ ++i;
113
+ }
111
114
  }
112
115
  }
113
116
 
@@ -30,10 +30,13 @@ library JBSurplus {
30
30
  uint256 numberOfTerminals = terminals.length;
31
31
 
32
32
  // Add the current surplus for each terminal.
33
- for (uint256 i; i < numberOfTerminals; i++) {
33
+ for (uint256 i; i < numberOfTerminals;) {
34
34
  surplus += terminals[i].currentSurplusOf({
35
35
  projectId: projectId, tokens: tokens, decimals: decimals, currency: currency
36
36
  });
37
+ unchecked {
38
+ ++i;
39
+ }
37
40
  }
38
41
  }
39
42
  }
@@ -386,6 +386,7 @@ contract TestForwardedTokenConsumption_Local is TestBaseWorkflow {
386
386
  returns (JBFundAccessLimitGroup[] memory)
387
387
  {
388
388
  JBCurrencyAmount[] memory payoutLimits = new JBCurrencyAmount[](1);
389
+ // forge-lint: disable-next-line(unsafe-typecast)
389
390
  payoutLimits[0] =
390
391
  JBCurrencyAmount({amount: uint224(payoutAmount), currency: uint32(uint160(address(usdcToken())))});
391
392
 
@@ -0,0 +1,140 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.6;
3
+
4
+ import {TestBaseWorkflow} from "../helpers/TestBaseWorkflow.sol";
5
+ import {IJBController} from "../../src/interfaces/IJBController.sol";
6
+ import {IJBDirectory} from "../../src/interfaces/IJBDirectory.sol";
7
+ import {IJBTerminal} from "../../src/interfaces/IJBTerminal.sol";
8
+ import {JBMultiTerminal} from "../../src/JBMultiTerminal.sol";
9
+ import {JBConstants} from "../../src/libraries/JBConstants.sol";
10
+ import {JBAccountingContext} from "../../src/structs/JBAccountingContext.sol";
11
+ import {JBFundAccessLimitGroup} from "../../src/structs/JBFundAccessLimitGroup.sol";
12
+ import {JBRulesetConfig} from "../../src/structs/JBRulesetConfig.sol";
13
+ import {JBRulesetMetadata} from "../../src/structs/JBRulesetMetadata.sol";
14
+ import {JBSplitGroup} from "../../src/structs/JBSplitGroup.sol";
15
+ import {JBTerminalConfig} from "../../src/structs/JBTerminalConfig.sol";
16
+ import {IJBRulesetApprovalHook} from "../../src/interfaces/IJBRulesetApprovalHook.sol";
17
+
18
+ /// @notice Verifies that `useTotalSurplusForCashOuts` trusts every terminal in the directory,
19
+ /// even though settlement still comes from the specific terminal the holder cashes out through.
20
+ contract CrossTerminalSurplusSpoof_Local is TestBaseWorkflow {
21
+ IJBController private _controller;
22
+ IJBDirectory private _directory;
23
+ JBMultiTerminal private _terminal;
24
+ address private _projectOwner;
25
+ address private _holder;
26
+ uint32 private _nativeCurrency;
27
+ uint256 private _projectId;
28
+
29
+ function setUp() public override {
30
+ super.setUp();
31
+
32
+ _controller = jbController();
33
+ _directory = jbDirectory();
34
+ _terminal = jbMultiTerminal();
35
+ _projectOwner = multisig();
36
+ _holder = beneficiary();
37
+ _nativeCurrency = uint32(uint160(JBConstants.NATIVE_TOKEN));
38
+
39
+ JBRulesetMetadata memory metadata = JBRulesetMetadata({
40
+ reservedPercent: 0,
41
+ cashOutTaxRate: 0,
42
+ baseCurrency: _nativeCurrency,
43
+ pausePay: false,
44
+ pauseCreditTransfers: false,
45
+ allowOwnerMinting: true,
46
+ allowSetCustomToken: true,
47
+ allowTerminalMigration: false,
48
+ allowSetTerminals: true,
49
+ allowSetController: false,
50
+ allowAddAccountingContext: true,
51
+ allowAddPriceFeed: false,
52
+ ownerMustSendPayouts: false,
53
+ holdFees: false,
54
+ useTotalSurplusForCashOuts: true,
55
+ useDataHookForPay: false,
56
+ useDataHookForCashOut: false,
57
+ dataHook: address(0),
58
+ metadata: 0
59
+ });
60
+
61
+ JBRulesetConfig[] memory rulesetConfigs = new JBRulesetConfig[](1);
62
+ rulesetConfigs[0] = JBRulesetConfig({
63
+ mustStartAtOrAfter: 0,
64
+ duration: 0,
65
+ weight: 1000e18,
66
+ weightCutPercent: 0,
67
+ approvalHook: IJBRulesetApprovalHook(address(0)),
68
+ metadata: metadata,
69
+ splitGroups: new JBSplitGroup[](0),
70
+ fundAccessLimitGroups: new JBFundAccessLimitGroup[](0)
71
+ });
72
+
73
+ JBAccountingContext[] memory contexts = new JBAccountingContext[](1);
74
+ contexts[0] = JBAccountingContext({token: JBConstants.NATIVE_TOKEN, decimals: 18, currency: _nativeCurrency});
75
+
76
+ JBTerminalConfig[] memory terminalConfigs = new JBTerminalConfig[](1);
77
+ terminalConfigs[0] = JBTerminalConfig({terminal: _terminal, accountingContextsToAccept: contexts});
78
+
79
+ _projectId = _controller.launchProjectFor({
80
+ owner: _projectOwner,
81
+ projectUri: "spoofed-surplus",
82
+ rulesetConfigurations: rulesetConfigs,
83
+ terminalConfigurations: terminalConfigs,
84
+ memo: ""
85
+ });
86
+ }
87
+
88
+ function test_partialCashOutCanDrainLocalTerminalUsingSpoofedSiblingSurplus() external {
89
+ vm.deal(_holder, 1 ether);
90
+
91
+ vm.prank(_holder);
92
+ uint256 minted = _terminal.pay{value: 1 ether}({
93
+ projectId: _projectId,
94
+ token: JBConstants.NATIVE_TOKEN,
95
+ amount: 1 ether,
96
+ beneficiary: _holder,
97
+ minReturnedTokens: 0,
98
+ memo: "",
99
+ metadata: ""
100
+ });
101
+
102
+ assertEq(address(_terminal).balance, 1 ether, "terminal should hold the paid ETH");
103
+
104
+ address spoofedTerminal = makeAddr("spoofedTerminal");
105
+ IJBTerminal[] memory terminals = new IJBTerminal[](2);
106
+ terminals[0] = IJBTerminal(address(_terminal));
107
+ terminals[1] = IJBTerminal(spoofedTerminal);
108
+
109
+ vm.prank(_projectOwner);
110
+ _directory.setTerminalsOf(_projectId, terminals);
111
+
112
+ vm.mockCall(
113
+ spoofedTerminal,
114
+ abi.encodeCall(IJBTerminal.currentSurplusOf, (_projectId, new address[](0), 18, _nativeCurrency)),
115
+ abi.encode(1 ether)
116
+ );
117
+
118
+ uint256 holderBalanceBefore = _holder.balance;
119
+
120
+ vm.prank(_holder);
121
+ uint256 reclaimed = _terminal.cashOutTokensOf({
122
+ holder: _holder,
123
+ projectId: _projectId,
124
+ cashOutCount: minted / 2,
125
+ tokenToReclaim: JBConstants.NATIVE_TOKEN,
126
+ minTokensReclaimed: 0,
127
+ beneficiary: payable(_holder),
128
+ metadata: new bytes(0)
129
+ });
130
+
131
+ assertEq(reclaimed, 1 ether, "spoofed global surplus should let a half burn reclaim the full local balance");
132
+ assertEq(_holder.balance - holderBalanceBefore, 1 ether, "holder should receive the full terminal balance");
133
+ assertEq(address(_terminal).balance, 0, "the honest terminal should be fully drained");
134
+ assertEq(
135
+ jbTokens().totalBalanceOf(_holder, _projectId),
136
+ minted / 2,
137
+ "the holder should keep half their project tokens after draining the terminal"
138
+ );
139
+ }
140
+ }
@@ -1,7 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  pragma solidity 0.8.28;
3
3
 
4
- import {JBController} from "../../../../src/JBController.sol";
5
4
  import {IJBRulesetApprovalHook} from "../../../../src/interfaces/IJBRulesetApprovalHook.sol";
6
5
  import {IJBRulesets} from "../../../../src/interfaces/IJBRulesets.sol";
7
6
  import {JBConstants} from "../../../../src/libraries/JBConstants.sol";
@@ -5,7 +5,6 @@ import {MockERC20} from "../../../mock/MockERC20.sol";
5
5
  import {JBMultiTerminal} from "../../../../src/JBMultiTerminal.sol";
6
6
  import {JBPermissioned} from "../../../../src/abstract/JBPermissioned.sol";
7
7
  import {IJBCashOutHook} from "../../../../src/interfaces/IJBCashOutHook.sol";
8
- import {IJBCashOutTerminal} from "../../../../src/interfaces/IJBCashOutTerminal.sol";
9
8
  import {IJBController} from "../../../../src/interfaces/IJBController.sol";
10
9
  import {IJBDirectory} from "../../../../src/interfaces/IJBDirectory.sol";
11
10
  import {IJBFeelessAddresses} from "../../../../src/interfaces/IJBFeelessAddresses.sol";