@bananapus/core-v6 0.0.31 → 0.0.32

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.
@@ -672,17 +672,19 @@ contract JBRulesets is JBControlled, IJBRulesets {
672
672
  revert JBRulesets_WeightCacheRequired(projectId);
673
673
  }
674
674
 
675
- for (uint256 i; i < weightCutMultiple; i++) {
676
- // The number of times to apply the weight cut percent.
675
+ // Cache the cut factor and max percent to avoid recomputing each iteration.
676
+ uint256 cutFactor = JBConstants.MAX_WEIGHT_CUT_PERCENT - baseRulesetWeightCutPercent;
677
+ uint256 maxPercent = JBConstants.MAX_WEIGHT_CUT_PERCENT;
678
+
679
+ for (uint256 i; i < weightCutMultiple;) {
677
680
  // Base the new weight on the specified ruleset's weight.
678
- weight = mulDiv(
679
- weight,
680
- JBConstants.MAX_WEIGHT_CUT_PERCENT - baseRulesetWeightCutPercent,
681
- JBConstants.MAX_WEIGHT_CUT_PERCENT
682
- );
681
+ weight = mulDiv(weight, cutFactor, maxPercent);
683
682
 
684
683
  // The calculation doesn't need to continue if the weight is 0.
685
684
  if (weight == 0) break;
685
+ unchecked {
686
+ ++i;
687
+ }
686
688
  }
687
689
  }
688
690
 
@@ -721,6 +723,7 @@ contract JBRulesets is JBControlled, IJBRulesets {
721
723
  }
722
724
 
723
725
  // Get a reference to the latest ruleset's struct.
726
+ // Note: full metadata is loaded because `_approvalStatusOf` forwards the struct to external approval hooks.
724
727
  JBRuleset memory baseRuleset = _getStructFor({projectId: projectId, rulesetId: latestId});
725
728
 
726
729
  // Get a reference to the approval status.
@@ -743,7 +746,9 @@ contract JBRulesets is JBControlled, IJBRulesets {
743
746
  && approvalStatus != JBApprovalStatus.ApprovalExpected
744
747
  && approvalStatus != JBApprovalStatus.Empty)
745
748
  ) {
746
- baseRuleset = _getStructFor({projectId: projectId, rulesetId: baseRuleset.basedOnId});
749
+ // Metadata not needed — the fallback ruleset is only used for intrinsic fields (start, basedOnId, etc.)
750
+ // and not forwarded to any external approval hook.
751
+ baseRuleset = _getStructWithoutMetadataFor({projectId: projectId, rulesetId: baseRuleset.basedOnId});
747
752
  }
748
753
 
749
754
  // Make sure the ruleset starts after the base ruleset.
@@ -896,11 +901,14 @@ contract JBRulesets is JBControlled, IJBRulesets {
896
901
  // slither-disable-next-line incorrect-equality
897
902
  if (ruleset.basedOnId == 0) return JBApprovalStatus.Empty;
898
903
 
899
- // Get the struct of the ruleset with the approval hook.
900
- JBRuleset memory approvalHookRuleset = _getStructFor({projectId: projectId, rulesetId: ruleset.basedOnId});
904
+ // Read only the packed user properties to extract the approval hook address,
905
+ // avoiding the cost of loading the full parent ruleset struct.
906
+ uint256 packedUserProperties = _packedUserPropertiesOf[projectId][ruleset.basedOnId];
907
+ // forge-lint: disable-next-line(unsafe-typecast)
908
+ IJBRulesetApprovalHook approvalHook = IJBRulesetApprovalHook(address(uint160(packedUserProperties)));
901
909
 
902
910
  // If there is no approval hook, it's considered empty.
903
- if (approvalHookRuleset.approvalHook == IJBRulesetApprovalHook(address(0))) {
911
+ if (approvalHook == IJBRulesetApprovalHook(address(0))) {
904
912
  return JBApprovalStatus.Empty;
905
913
  }
906
914
 
@@ -909,9 +917,7 @@ contract JBRulesets is JBControlled, IJBRulesets {
909
917
  // Note: A malicious hook that consumes all gas (e.g. infinite loop) could still DoS via gas exhaustion.
910
918
  // This is accepted risk since the project owner chose their own approval hook.
911
919
  // slither-disable-next-line calls-loop
912
- try approvalHookRuleset.approvalHook.approvalStatusOf({projectId: projectId, ruleset: ruleset}) returns (
913
- JBApprovalStatus status
914
- ) {
920
+ try approvalHook.approvalStatusOf({projectId: projectId, ruleset: ruleset}) returns (JBApprovalStatus status) {
915
921
  return status;
916
922
  } catch {
917
923
  return JBApprovalStatus.Failed;
@@ -928,8 +934,8 @@ contract JBRulesets is JBControlled, IJBRulesets {
928
934
  // Get a reference to the project's latest ruleset.
929
935
  uint256 rulesetId = latestRulesetIdOf[projectId];
930
936
 
931
- // Get the struct for the latest ruleset.
932
- JBRuleset memory ruleset = _getStructFor({projectId: projectId, rulesetId: rulesetId});
937
+ // Get the struct for the latest ruleset (metadata not needed — only traversal fields are checked).
938
+ JBRuleset memory ruleset = _getStructWithoutMetadataFor({projectId: projectId, rulesetId: rulesetId});
933
939
 
934
940
  // Loop through all most recently queued rulesets until an approvable one is found, or we've proven one can't
935
941
  // exist.
@@ -945,12 +951,64 @@ contract JBRulesets is JBControlled, IJBRulesets {
945
951
  return ruleset.id;
946
952
  }
947
953
 
948
- ruleset = _getStructFor({projectId: projectId, rulesetId: ruleset.basedOnId});
954
+ ruleset = _getStructWithoutMetadataFor({projectId: projectId, rulesetId: ruleset.basedOnId});
949
955
  } while (ruleset.cycleNumber != 0);
950
956
 
951
957
  return 0;
952
958
  }
953
959
 
960
+ /// @notice Unpack a ruleset's intrinsic and user properties without loading metadata.
961
+ /// @dev Saves one cold SLOAD (~2,100 gas) compared to `_getStructFor`. Use this for linked-list traversal where
962
+ /// only `id`, `start`, `duration`, `basedOnId`, `cycleNumber`, `weight`, `weightCutPercent`, and `approvalHook`
963
+ /// are needed.
964
+ /// @param projectId The ID of the project the ruleset belongs to.
965
+ /// @param rulesetId The ID of the ruleset to get the struct for.
966
+ /// @return ruleset A ruleset struct with `metadata` set to 0.
967
+ function _getStructWithoutMetadataFor(
968
+ uint256 projectId,
969
+ uint256 rulesetId
970
+ )
971
+ internal
972
+ view
973
+ returns (JBRuleset memory ruleset)
974
+ {
975
+ // Return an empty ruleset if the specified `rulesetId` is 0.
976
+ // slither-disable-next-line incorrect-equality
977
+ if (rulesetId == 0) return ruleset;
978
+
979
+ // forge-lint: disable-next-line(unsafe-typecast)
980
+ ruleset.id = uint48(rulesetId);
981
+
982
+ uint256 packedIntrinsicProperties = _packedIntrinsicPropertiesOf[projectId][rulesetId];
983
+
984
+ // `weight` in bits 0-111 bits.
985
+ // forge-lint: disable-next-line(unsafe-typecast)
986
+ ruleset.weight = uint112(packedIntrinsicProperties);
987
+ // `basedOnId` in bits 112-159 bits.
988
+ // forge-lint: disable-next-line(unsafe-typecast)
989
+ ruleset.basedOnId = uint48(packedIntrinsicProperties >> 112);
990
+ // `start` in bits 160-207 bits.
991
+ // forge-lint: disable-next-line(unsafe-typecast)
992
+ ruleset.start = uint48(packedIntrinsicProperties >> 160);
993
+ // `cycleNumber` in bits 208-255 bits.
994
+ // forge-lint: disable-next-line(unsafe-typecast)
995
+ ruleset.cycleNumber = uint48(packedIntrinsicProperties >> 208);
996
+
997
+ uint256 packedUserProperties = _packedUserPropertiesOf[projectId][rulesetId];
998
+
999
+ // approval hook in bits 0-159 bits.
1000
+ // forge-lint: disable-next-line(unsafe-typecast)
1001
+ ruleset.approvalHook = IJBRulesetApprovalHook(address(uint160(packedUserProperties)));
1002
+ // `duration` in bits 160-191 bits.
1003
+ // forge-lint: disable-next-line(unsafe-typecast)
1004
+ ruleset.duration = uint32(packedUserProperties >> 160);
1005
+ // weight cut percent in bits 192-223 bits.
1006
+ // forge-lint: disable-next-line(unsafe-typecast)
1007
+ ruleset.weightCutPercent = uint32(packedUserProperties >> 192);
1008
+
1009
+ // metadata intentionally not loaded — saves one cold SLOAD (~2,100 gas).
1010
+ }
1011
+
954
1012
  /// @notice Unpack a ruleset's packed stored values into an easy-to-work-with ruleset struct.
955
1013
  /// @param projectId The ID of the project the ruleset belongs to.
956
1014
  /// @param rulesetId The ID of the ruleset to get the full struct for.
@@ -1067,8 +1125,8 @@ contract JBRulesets is JBControlled, IJBRulesets {
1067
1125
  // Get a reference to the ID of the project's latest ruleset.
1068
1126
  rulesetId = latestRulesetIdOf[projectId];
1069
1127
 
1070
- // Get the struct for the latest ruleset.
1071
- JBRuleset memory ruleset = _getStructFor({projectId: projectId, rulesetId: rulesetId});
1128
+ // Get the struct for the latest ruleset (metadata not needed — only traversal fields are checked).
1129
+ JBRuleset memory ruleset = _getStructWithoutMetadataFor({projectId: projectId, rulesetId: rulesetId});
1072
1130
 
1073
1131
  // There is no upcoming ruleset if the latest ruleset has already started.
1074
1132
  // slither-disable-next-line incorrect-equality
@@ -1086,7 +1144,7 @@ contract JBRulesets is JBControlled, IJBRulesets {
1086
1144
 
1087
1145
  // Find the base ruleset that is not still queued.
1088
1146
  while (true) {
1089
- baseRuleset = _getStructFor({projectId: projectId, rulesetId: basedOnId});
1147
+ baseRuleset = _getStructWithoutMetadataFor({projectId: projectId, rulesetId: basedOnId});
1090
1148
 
1091
1149
  // If the base ruleset starts in the future,
1092
1150
  if (block.timestamp < baseRuleset.start) {
@@ -1100,8 +1158,8 @@ contract JBRulesets is JBControlled, IJBRulesets {
1100
1158
  }
1101
1159
  }
1102
1160
 
1103
- // Get the ruleset struct for the ID found.
1104
- ruleset = _getStructFor({projectId: projectId, rulesetId: rulesetId});
1161
+ // Get the ruleset struct for the ID found (metadata not needed — only `start` and `duration` are checked).
1162
+ ruleset = _getStructWithoutMetadataFor({projectId: projectId, rulesetId: rulesetId});
1105
1163
 
1106
1164
  // If the latest ruleset doesn't start until after another base ruleset return 0.
1107
1165
  if (baseRuleset.duration != 0 && block.timestamp < ruleset.start - baseRuleset.duration) {
package/src/JBSplits.sol CHANGED
@@ -97,15 +97,13 @@ contract JBSplits is JBControlled, IJBSplits {
97
97
  // Cache whether the controller check has already passed to avoid repeated external calls.
98
98
  bool controllerChecked;
99
99
 
100
- // Set each grouped splits.
101
- for (uint256 i; i < splitGroups.length; i++) {
102
- // Get a reference to the grouped split being iterated on.
103
- JBSplitGroup memory splitGroup = splitGroups[i];
104
-
100
+ // Set each grouped splits. Access calldata directly to avoid copying each split group to memory.
101
+ for (uint256 i; i < splitGroups.length;) {
105
102
  // Self-auth: lower 160 bits must match msg.sender AND upper 96 bits must be non-zero.
106
103
  // GroupIds with zero upper bits are reserved for protocol use (e.g. terminal payout groups)
107
104
  // and always require controller authorization to prevent token contracts from hijacking payouts.
108
- bool isSelfManaged = splitGroup.groupId >> 160 != 0 && address(uint160(splitGroup.groupId)) == msg.sender;
105
+ bool isSelfManaged =
106
+ splitGroups[i].groupId >> 160 != 0 && address(uint160(splitGroups[i].groupId)) == msg.sender;
109
107
 
110
108
  if (!isSelfManaged && !controllerChecked) {
111
109
  _onlyControllerOf(projectId);
@@ -114,8 +112,14 @@ contract JBSplits is JBControlled, IJBSplits {
114
112
 
115
113
  // Set the splits for the group.
116
114
  _setSplitsOf({
117
- projectId: projectId, rulesetId: rulesetId, groupId: splitGroup.groupId, splits: splitGroup.splits
115
+ projectId: projectId,
116
+ rulesetId: rulesetId,
117
+ groupId: splitGroups[i].groupId,
118
+ splits: splitGroups[i].splits
118
119
  });
120
+ unchecked {
121
+ ++i;
122
+ }
119
123
  }
120
124
  }
121
125
 
@@ -168,7 +172,7 @@ contract JBSplits is JBControlled, IJBSplits {
168
172
  uint256 numberOfCurrentSplits = currentSplits.length;
169
173
 
170
174
  // Check to see if all locked splits are included in the array of splits which is being set.
171
- for (uint256 i; i < numberOfCurrentSplits; i++) {
175
+ for (uint256 i; i < numberOfCurrentSplits;) {
172
176
  // If not locked, continue.
173
177
  if (
174
178
  block.timestamp < currentSplits[i].lockedUntil
@@ -176,6 +180,9 @@ contract JBSplits is JBControlled, IJBSplits {
176
180
  ) {
177
181
  revert JBSplits_PreviousLockedSplitsNotIncluded(projectId, rulesetId);
178
182
  }
183
+ unchecked {
184
+ ++i;
185
+ }
179
186
  }
180
187
 
181
188
  // Add up all the `percent`s to make sure their total is under 100%.
@@ -184,7 +191,7 @@ contract JBSplits is JBControlled, IJBSplits {
184
191
  // Keep a reference to the number of splits to set.
185
192
  uint256 numberOfSplits = splits.length;
186
193
 
187
- for (uint256 i; i < numberOfSplits; i++) {
194
+ for (uint256 i; i < numberOfSplits;) {
188
195
  // Set the split being iterated on.
189
196
  JBSplit memory split = splits[i];
190
197
 
@@ -228,6 +235,9 @@ contract JBSplits is JBControlled, IJBSplits {
228
235
  emit SetSplit({
229
236
  projectId: projectId, rulesetId: rulesetId, groupId: groupId, split: split, caller: msg.sender
230
237
  });
238
+ unchecked {
239
+ ++i;
240
+ }
231
241
  }
232
242
 
233
243
  // Store the number of splits for the project, ruleset, and group.
@@ -235,9 +245,12 @@ contract JBSplits is JBControlled, IJBSplits {
235
245
 
236
246
  // Clean up stale storage slots if the new split count is less than the previous count.
237
247
  // This zeroes out leftover packed data to reclaim gas via storage refunds.
238
- for (uint256 i = numberOfSplits; i < numberOfCurrentSplits; i++) {
248
+ for (uint256 i = numberOfSplits; i < numberOfCurrentSplits;) {
239
249
  delete _packedSplitParts1Of[projectId][rulesetId][groupId][i];
240
250
  delete _packedSplitParts2Of[projectId][rulesetId][groupId][i];
251
+ unchecked {
252
+ ++i;
253
+ }
241
254
  }
242
255
  }
243
256
 
@@ -267,7 +280,7 @@ contract JBSplits is JBControlled, IJBSplits {
267
280
  JBSplit[] memory splits = new JBSplit[](splitCount);
268
281
 
269
282
  // Loop through each split and unpack the values into structs.
270
- for (uint256 i; i < splitCount; i++) {
283
+ for (uint256 i; i < splitCount;) {
271
284
  // Get a reference to the first part of the split's packed data.
272
285
  uint256 packedSplitPart1 = _packedSplitParts1Of[projectId][rulesetId][groupId][i];
273
286
 
@@ -301,6 +314,9 @@ contract JBSplits is JBControlled, IJBSplits {
301
314
 
302
315
  // Add the split to the value being returned.
303
316
  splits[i] = split;
317
+ unchecked {
318
+ ++i;
319
+ }
304
320
  }
305
321
 
306
322
  return splits;
@@ -314,7 +330,7 @@ contract JBSplits is JBControlled, IJBSplits {
314
330
  // Keep a reference to the number of splits.
315
331
  uint256 numberOfSplits = splits.length;
316
332
 
317
- for (uint256 i; i < numberOfSplits; i++) {
333
+ for (uint256 i; i < numberOfSplits;) {
318
334
  // Set the split being iterated on.
319
335
  JBSplit memory split = splits[i];
320
336
 
@@ -326,6 +342,9 @@ contract JBSplits is JBControlled, IJBSplits {
326
342
  && split.preferAddToBalance == lockedSplit.preferAddToBalance
327
343
  && split.lockedUntil >= lockedSplit.lockedUntil
328
344
  ) return true;
345
+ unchecked {
346
+ ++i;
347
+ }
329
348
  }
330
349
 
331
350
  return false;
@@ -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) {