@bananapus/core-v6 0.0.30 → 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.
Files changed (49) hide show
  1. package/ADMINISTRATION.md +43 -13
  2. package/ARCHITECTURE.md +62 -137
  3. package/AUDIT_INSTRUCTIONS.md +149 -428
  4. package/CHANGELOG.md +73 -0
  5. package/README.md +90 -201
  6. package/RISKS.md +27 -12
  7. package/SKILLS.md +31 -441
  8. package/STYLE_GUIDE.md +52 -19
  9. package/USER_JOURNEYS.md +76 -627
  10. package/package.json +1 -2
  11. package/references/entrypoints.md +160 -0
  12. package/references/types-errors-events.md +297 -0
  13. package/script/Deploy.s.sol +7 -2
  14. package/script/DeployPeriphery.s.sol +51 -4
  15. package/src/JBController.sol +45 -17
  16. package/src/JBDirectory.sol +26 -13
  17. package/src/JBFundAccessLimits.sol +28 -7
  18. package/src/JBMultiTerminal.sol +180 -86
  19. package/src/JBPermissions.sol +17 -17
  20. package/src/JBRulesets.sol +82 -23
  21. package/src/JBSplits.sol +31 -12
  22. package/src/JBTerminalStore.sol +137 -53
  23. package/src/JBTokens.sol +5 -2
  24. package/src/abstract/JBControlled.sol +10 -3
  25. package/src/abstract/JBPermissioned.sol +1 -1
  26. package/src/interfaces/IJBRulesetDataHook.sol +5 -4
  27. package/src/libraries/JBCashOuts.sol +1 -1
  28. package/src/libraries/JBConstants.sol +1 -1
  29. package/src/libraries/JBCurrencyIds.sol +1 -1
  30. package/src/libraries/JBFees.sol +1 -1
  31. package/src/libraries/JBFixedPointNumber.sol +1 -1
  32. package/src/libraries/JBMetadataResolver.sol +5 -2
  33. package/src/libraries/JBPayoutSplitGroupLib.sol +7 -2
  34. package/src/libraries/JBRulesetMetadataResolver.sol +1 -1
  35. package/src/libraries/JBSplitGroupIds.sol +1 -1
  36. package/src/libraries/JBSurplus.sol +5 -2
  37. package/src/structs/JBSplit.sol +4 -1
  38. package/test/TestForwardedTokenConsumption.sol +419 -0
  39. package/test/audit/CrossTerminalSurplusSpoof.t.sol +140 -0
  40. package/test/audit/CycledSurplusAllowanceReset.t.sol +184 -0
  41. package/test/units/static/JBController/TestPreviewMintOf.sol +5 -4
  42. package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +15 -12
  43. package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +6 -0
  44. package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +3 -0
  45. package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +3 -0
  46. package/test/units/static/JBMultiTerminal/TestPay.sol +7 -15
  47. package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +1 -1
  48. package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +1 -1
  49. package/CHANGE_LOG.md +0 -479
@@ -66,10 +66,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
66
66
  error JBMultiTerminal_RecipientProjectTerminalNotFound(uint256 projectId, address token);
67
67
  error JBMultiTerminal_SplitHookInvalid(IJBSplitHook hook);
68
68
  error JBMultiTerminal_TerminalTokensIncompatible(uint256 projectId, address token, IJBTerminal terminal);
69
+ error JBMultiTerminal_TemporaryAllowanceNotConsumed(address token, address spender, uint256 allowance);
69
70
  error JBMultiTerminal_TokenNotAccepted(address token);
70
- error JBMultiTerminal_UnderMinReturnedTokens(uint256 count, uint256 min);
71
- error JBMultiTerminal_UnderMinTokensPaidOut(uint256 amount, uint256 min);
72
- error JBMultiTerminal_UnderMinTokensReclaimed(uint256 amount, uint256 min);
71
+ error JBMultiTerminal_UnderMin(uint256 value, uint256 min);
73
72
 
74
73
  //*********************************************************************//
75
74
  // ------------------------- public constants ------------------------ //
@@ -208,8 +207,12 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
208
207
  STORE.recordAccountingContextOf({projectId: projectId, contexts: accountingContexts});
209
208
 
210
209
  // Emit an event for each accounting context.
211
- for (uint256 i; i < accountingContexts.length; i++) {
210
+ for (uint256 i; i < accountingContexts.length;) {
211
+ // slither-disable-next-line reentrancy-events
212
212
  emit SetAccountingContext({projectId: projectId, context: accountingContexts[i], caller: _msgSender()});
213
+ unchecked {
214
+ ++i;
215
+ }
213
216
  }
214
217
  }
215
218
 
@@ -251,7 +254,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
251
254
  /// those tokens.
252
255
  /// @param holder The account whose tokens are being cashed out.
253
256
  /// @param projectId The ID of the project the project tokens belong to.
254
- /// @param cashOutCount The number of project tokens to cash out, as a fixed point number with 18 decimals.
257
+ /// @param cashOutCount The number of project tokens to cash out and burn, as a fixed point number with 18
258
+ /// decimals.
255
259
  /// @param tokenToReclaim The token being reclaimed.
256
260
  /// @param minTokensReclaimed The minimum number of terminal tokens expected in return, as a fixed point number with
257
261
  /// the same number of decimals as this terminal. If the amount of tokens minted for the beneficiary would be less
@@ -289,9 +293,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
289
293
  });
290
294
 
291
295
  // The amount being reclaimed must be at least as much as was expected.
292
- if (reclaimAmount < minTokensReclaimed) {
293
- revert JBMultiTerminal_UnderMinTokensReclaimed(reclaimAmount, minTokensReclaimed);
294
- }
296
+ _checkMin({value: reclaimAmount, min: minTokensReclaimed});
295
297
  }
296
298
 
297
299
  /// @notice Executes a payout to a split.
@@ -349,6 +351,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
349
351
  // If this terminal's token is the native token, send it in `msg.value`.
350
352
  split.hook.processSplitWith{value: payValue}(context);
351
353
 
354
+ // Revoke the temporary pull allowance now that the hook call has finished.
355
+ _afterTransferTo({to: address(split.hook), token: token});
356
+
352
357
  // Otherwise, if a project is specified, make a payment to it.
353
358
  } else if (split.projectId != 0) {
354
359
  // Get a reference to the terminal being used.
@@ -542,20 +547,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
542
547
  // Transfer the balance minus the fee to the new terminal.
543
548
  uint256 migrationAmount = balance - feeAmount;
544
549
 
545
- // Trigger any inherited pre-transfer logic.
546
- // If this terminal's token is the native token, send it in `msg.value`.
547
- // slither-disable-next-line reentrancy-events
548
- uint256 payValue = _beforeTransferTo({to: address(to), token: token, amount: migrationAmount});
549
-
550
- // Withdraw the balance to transfer to the new terminal.
551
- // slither-disable-next-line reentrancy-events
552
- to.addToBalanceOf{value: payValue}({
553
- projectId: projectId,
554
- token: token,
555
- amount: migrationAmount,
556
- shouldReturnHeldFees: false,
557
- memo: "",
558
- metadata: bytes("")
550
+ _externalAddToBalance({
551
+ terminal: to, projectId: projectId, token: token, amount: migrationAmount, metadata: bytes("")
559
552
  });
560
553
  }
561
554
  }
@@ -592,11 +585,15 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
592
585
  // Get a reference to the beneficiary's balance before the payment.
593
586
  uint256 beneficiaryBalanceBefore = TOKENS.totalBalanceOf({holder: beneficiary, projectId: projectId});
594
587
 
588
+ // Accept the funds.
589
+ uint256 acceptedAmount =
590
+ _acceptFundsFor({projectId: projectId, token: token, amount: amount, metadata: metadata});
591
+
595
592
  // Pay the project.
596
593
  _pay({
597
594
  projectId: projectId,
598
595
  token: token,
599
- amount: _acceptFundsFor(projectId, token, amount, metadata),
596
+ amount: acceptedAmount,
600
597
  payer: _msgSender(),
601
598
  beneficiary: beneficiary,
602
599
  memo: memo,
@@ -612,9 +609,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
612
609
  }
613
610
 
614
611
  // The token count for the beneficiary must be greater than or equal to the specified minimum.
615
- if (beneficiaryTokenCount < minReturnedTokens) {
616
- revert JBMultiTerminal_UnderMinReturnedTokens(beneficiaryTokenCount, minReturnedTokens);
617
- }
612
+ _checkMin({value: beneficiaryTokenCount, min: minReturnedTokens});
618
613
  }
619
614
 
620
615
  /// @notice Process any fees that are being held for the project.
@@ -639,7 +634,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
639
634
 
640
635
  // Process each fee. Re-read the index and array length from storage each iteration to account for reentrant
641
636
  // calls that may have already advanced the index or cleaned up the array.
642
- for (uint256 i; i < count; i++) {
637
+ for (uint256 i; i < count;) {
643
638
  // Read the current index from storage (not a cached value) to prevent reentrancy from
644
639
  // causing double-processing.
645
640
  uint256 currentIndex = _nextHeldFeeIndexOf[projectId][token];
@@ -654,10 +649,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
654
649
  // if the current fee isn't yet unlocked.
655
650
  if (heldFee.unlockTimestamp > block.timestamp) break;
656
651
 
657
- // Delete the entry to reclaim gas before the external call.
652
+ // Delete the entry and advance the index *before* the external call. This is intentional:
653
+ // 1. It prevents reentrancy from reprocessing the same fee.
654
+ // 2. If `_processFee` fails (try-catch), the fee amount is returned to the project's balance via
655
+ // `_recordAddedBalanceFor` — the fee is forgiven rather than retried. This is a deliberate design
656
+ // choice: projects should not have funds permanently stuck because the fee route is misconfigured or
657
+ // reverting.
658
+ // A `FeeReverted` event is emitted so the forgiveness is observable off-chain.
658
659
  delete _heldFeesOf[projectId][token][currentIndex];
659
-
660
- // Update the index before the external call to prevent reentrancy from reprocessing the same fee.
661
660
  _nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
662
661
 
663
662
  // Process the fee.
@@ -670,6 +669,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
670
669
  feeTerminal: feeTerminal,
671
670
  wasHeld: true
672
671
  });
672
+ unchecked {
673
+ ++i;
674
+ }
673
675
  }
674
676
 
675
677
  // If all held fees have been processed, reset the array and index entirely to bound storage growth.
@@ -715,9 +717,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
715
717
  amountPaidOut = _sendPayoutsOf({projectId: projectId, token: token, amount: amount, currency: currency});
716
718
 
717
719
  // The amount being paid out must be at least as much as was expected.
718
- if (amountPaidOut < minTokensPaidOut) {
719
- revert JBMultiTerminal_UnderMinTokensPaidOut(amountPaidOut, minTokensPaidOut);
720
- }
720
+ _checkMin({value: amountPaidOut, min: minTokensPaidOut});
721
721
  }
722
722
 
723
723
  /// @notice Allows a project to pay out funds from its surplus up to the current surplus allowance.
@@ -770,9 +770,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
770
770
  });
771
771
 
772
772
  // The amount being withdrawn must be at least as much as was expected.
773
- if (netAmountPaidOut < minTokensPaidOut) {
774
- revert JBMultiTerminal_UnderMinTokensPaidOut(netAmountPaidOut, minTokensPaidOut);
775
- }
773
+ _checkMin({value: netAmountPaidOut, min: minTokensPaidOut});
776
774
  }
777
775
 
778
776
  //*********************************************************************//
@@ -862,8 +860,11 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
862
860
  heldFees = new JBFee[](count);
863
861
 
864
862
  // Copy the fees into the array.
865
- for (uint256 i; i < count; i++) {
863
+ for (uint256 i; i < count;) {
866
864
  heldFees[i] = _heldFeesOf[projectId][token][startIndex + i];
865
+ unchecked {
866
+ ++i;
867
+ }
867
868
  }
868
869
  }
869
870
 
@@ -1022,6 +1023,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1022
1023
  // Set the allowance to `spend` tokens for the user.
1023
1024
  try PERMIT2.permit({owner: _msgSender(), permitSingle: permitSingle, signature: allowance.signature}) {}
1024
1025
  catch (bytes memory reason) {
1026
+ // slither-disable-next-line reentrancy-events
1025
1027
  emit Permit2AllowanceFailed(token, _msgSender(), reason);
1026
1028
  }
1027
1029
  }
@@ -1087,6 +1089,29 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1087
1089
  return 0;
1088
1090
  }
1089
1091
 
1092
+ /// @notice Cap fee-free surplus at the project's remaining balance after an outflow.
1093
+ /// @dev Non-fee-free funds are considered to leave first. Fee-free surplus only decreases when the remaining
1094
+ /// balance can no longer support it. This prevents attackers from using outflows to drain the fee-free counter
1095
+ /// and then cashing out without incurring fees.
1096
+ /// @param projectId The ID of the project.
1097
+ /// @param token The token whose fee-free surplus to cap.
1098
+ function _capFeeFreeSurplus(uint256 projectId, address token) internal {
1099
+ // Get the current fee-free surplus for this project/token pair.
1100
+ uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][token];
1101
+
1102
+ // Nothing to cap if there's no fee-free surplus tracked.
1103
+ if (feeFreeSurplus == 0) return;
1104
+
1105
+ // Get the project's remaining balance (already decremented by the store's record call).
1106
+ uint256 remainingBalance = STORE.balanceOf({terminal: address(this), projectId: projectId, token: token});
1107
+
1108
+ // Cap fee-free surplus at the remaining balance.
1109
+ if (feeFreeSurplus > remainingBalance) {
1110
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1111
+ _feeFreeSurplusOf[projectId][token] = remainingBalance;
1112
+ }
1113
+ }
1114
+
1090
1115
  /// @notice Holders can cash out their tokens to reclaim some of a project's surplus, or to trigger rules determined
1091
1116
  /// by
1092
1117
  /// the project's current ruleset's data hook.
@@ -1122,13 +1147,16 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1122
1147
  // Keep a reference to the cash out tax rate being used.
1123
1148
  uint256 cashOutTaxRate;
1124
1149
 
1150
+ // Cache whether the beneficiary is feeless.
1151
+ bool beneficiaryIsFeeless = _isFeeless(beneficiary);
1152
+
1125
1153
  // Record the cash out.
1126
1154
  (ruleset, reclaimAmount, cashOutTaxRate, hookSpecifications) = STORE.recordCashOutFor({
1127
1155
  holder: holder,
1128
1156
  projectId: projectId,
1129
1157
  cashOutCount: cashOutCount,
1130
1158
  tokenToReclaim: tokenToReclaim,
1131
- beneficiaryIsFeeless: _isFeeless(beneficiary),
1159
+ beneficiaryIsFeeless: beneficiaryIsFeeless,
1132
1160
  metadata: metadata
1133
1161
  });
1134
1162
 
@@ -1144,7 +1172,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1144
1172
  // Send the reclaimed funds to the beneficiary.
1145
1173
  if (reclaimAmount != 0) {
1146
1174
  // Determine if a fee should be taken. Fees are not taken if the beneficiary is feeless.
1147
- if (!_isFeeless(beneficiary)) {
1175
+ if (!beneficiaryIsFeeless) {
1148
1176
  if (cashOutTaxRate != 0) {
1149
1177
  // Non-zero tax: fees apply to the full reclaim amount.
1150
1178
  amountEligibleForFees += reclaimAmount;
@@ -1154,6 +1182,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1154
1182
  uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][tokenToReclaim];
1155
1183
  if (feeFreeSurplus != 0) {
1156
1184
  uint256 feeableAmount = reclaimAmount < feeFreeSurplus ? reclaimAmount : feeFreeSurplus;
1185
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1157
1186
  _feeFreeSurplusOf[projectId][tokenToReclaim] = feeFreeSurplus - feeableAmount;
1158
1187
  amountEligibleForFees += feeableAmount;
1159
1188
  reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: feeableAmount, feePercent: FEE});
@@ -1220,6 +1249,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1220
1249
  });
1221
1250
  }
1222
1251
 
1252
+ /// @notice Revert if a value is less than the specified minimum.
1253
+ /// @param value The value to compare against the minimum.
1254
+ /// @param min The minimum acceptable value.
1255
+ function _checkMin(uint256 value, uint256 min) internal pure {
1256
+ if (value < min) revert JBMultiTerminal_UnderMin(value, min);
1257
+ }
1258
+
1223
1259
  /// @notice Fund a project either by calling this terminal's internal `addToBalance` function or by calling the
1224
1260
  /// recipient terminal's `addToBalance` function.
1225
1261
  /// @param terminal The terminal on which the project is expecting to receive funds.
@@ -1237,7 +1273,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1237
1273
  )
1238
1274
  internal
1239
1275
  {
1240
- // Call the internal method if this terminal is being used.
1276
+ // Use the local internal path when staying on this terminal. Otherwise use the efficient external equivalent,
1277
+ // which forwards value directly after granting a temporary pull allowance.
1241
1278
  if (terminal == IJBTerminal(address(this))) {
1242
1279
  _addToBalanceOf({
1243
1280
  projectId: projectId,
@@ -1248,20 +1285,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1248
1285
  metadata: metadata
1249
1286
  });
1250
1287
  } else {
1251
- // Trigger any inherited pre-transfer logic.
1252
- // Keep a reference to the amount that'll be paid as a `msg.value`.
1253
- // slither-disable-next-line reentrancy-events
1254
- uint256 payValue = _beforeTransferTo({to: address(terminal), token: token, amount: amount});
1255
-
1256
- // Add to balance.
1257
- // If this terminal's token is the native token, send it in `msg.value`.
1258
- terminal.addToBalanceOf{value: payValue}({
1259
- projectId: projectId,
1260
- token: token,
1261
- amount: amount,
1262
- shouldReturnHeldFees: false,
1263
- memo: "",
1264
- metadata: metadata
1288
+ _externalAddToBalance({
1289
+ terminal: terminal, projectId: projectId, token: token, amount: amount, metadata: metadata
1265
1290
  });
1266
1291
  }
1267
1292
  }
@@ -1313,9 +1338,47 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1313
1338
  memo: "",
1314
1339
  metadata: metadata
1315
1340
  });
1341
+
1342
+ // Revoke the temporary pull allowance now that the recipient terminal call has finished.
1343
+ _afterTransferTo({to: address(terminal), token: token});
1316
1344
  }
1317
1345
  }
1318
1346
 
1347
+ /// @notice Fund a project on another terminal by granting a temporary pull allowance for this call only.
1348
+ /// @param terminal The recipient terminal.
1349
+ /// @param projectId The ID of the project being funded.
1350
+ /// @param token The token being used.
1351
+ /// @param amount The amount being funded.
1352
+ /// @param metadata Additional metadata to include with the payment.
1353
+ function _externalAddToBalance(
1354
+ IJBTerminal terminal,
1355
+ uint256 projectId,
1356
+ address token,
1357
+ uint256 amount,
1358
+ bytes memory metadata
1359
+ )
1360
+ internal
1361
+ {
1362
+ // Trigger any inherited pre-transfer logic.
1363
+ // Keep a reference to the amount that'll be paid as a `msg.value`.
1364
+ // slither-disable-next-line reentrancy-events
1365
+ uint256 payValue = _beforeTransferTo({to: address(terminal), token: token, amount: amount});
1366
+
1367
+ // Add to balance on the recipient terminal.
1368
+ // If this terminal's token is the native token, send it in `msg.value`.
1369
+ terminal.addToBalanceOf{value: payValue}({
1370
+ projectId: projectId,
1371
+ token: token,
1372
+ amount: amount,
1373
+ shouldReturnHeldFees: false,
1374
+ memo: "",
1375
+ metadata: metadata
1376
+ });
1377
+
1378
+ // Revoke the temporary pull allowance now that the recipient terminal call has finished.
1379
+ _afterTransferTo({to: address(terminal), token: token});
1380
+ }
1381
+
1319
1382
  /// @notice Fulfills a list of cash out hook specifications.
1320
1383
  /// @param projectId The ID of the project being cashed out from.
1321
1384
  /// @param beneficiaryReclaimAmount The number of tokens that are being cashed out from the project.
@@ -1356,12 +1419,18 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1356
1419
  cashOutMetadata: metadata
1357
1420
  });
1358
1421
 
1359
- for (uint256 i; i < specifications.length; i++) {
1422
+ // slither-disable-next-line calls-loop
1423
+ for (uint256 i; i < specifications.length;) {
1360
1424
  // Set the specification being iterated on.
1361
1425
  JBCashOutHookSpecification memory specification = specifications[i];
1362
1426
 
1363
1427
  // A noop specification is informational only and doesn't trigger the hook.
1364
- if (specification.noop) continue;
1428
+ if (specification.noop) {
1429
+ unchecked {
1430
+ ++i;
1431
+ }
1432
+ continue;
1433
+ }
1365
1434
 
1366
1435
  // Get the fee for the specified amount.
1367
1436
  uint256 specificationAmountFee = _isFeeless(address(specification.hook))
@@ -1393,9 +1462,12 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1393
1462
  });
1394
1463
 
1395
1464
  // Fulfill the specification.
1396
- // slither-disable-next-line reentrancy-events
1465
+ // slither-disable-next-line reentrancy-events,calls-loop
1397
1466
  specification.hook.afterCashOutRecordedWith{value: payValue}(context);
1398
1467
 
1468
+ // Revoke the temporary pull allowance now that the hook call has finished.
1469
+ _afterTransferTo({to: address(specification.hook), token: beneficiaryReclaimAmount.token});
1470
+
1399
1471
  emit HookAfterRecordCashOut({
1400
1472
  hook: specification.hook,
1401
1473
  context: context,
@@ -1403,6 +1475,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1403
1475
  fee: specificationAmountFee,
1404
1476
  caller: _msgSender()
1405
1477
  });
1478
+ unchecked {
1479
+ ++i;
1480
+ }
1406
1481
  }
1407
1482
  }
1408
1483
 
@@ -1442,12 +1517,18 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1442
1517
  });
1443
1518
 
1444
1519
  // Fulfill each specification through their pay hooks.
1445
- for (uint256 i; i < specifications.length; i++) {
1520
+ // slither-disable-next-line calls-loop
1521
+ for (uint256 i; i < specifications.length;) {
1446
1522
  // Set the specification being iterated on.
1447
1523
  JBPayHookSpecification memory specification = specifications[i];
1448
1524
 
1449
1525
  // A noop specification is informational only and doesn't trigger the hook.
1450
- if (specification.noop) continue;
1526
+ if (specification.noop) {
1527
+ unchecked {
1528
+ ++i;
1529
+ }
1530
+ continue;
1531
+ }
1451
1532
 
1452
1533
  // Pass the correct token `forwardedAmount` to the hook.
1453
1534
  context.forwardedAmount = JBTokenAmount({
@@ -1468,15 +1549,21 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1468
1549
  });
1469
1550
 
1470
1551
  // Fulfill the specification.
1471
- // slither-disable-next-line reentrancy-events
1552
+ // slither-disable-next-line reentrancy-events,calls-loop
1472
1553
  specification.hook.afterPayRecordedWith{value: payValue}(context);
1473
1554
 
1555
+ // Revoke the temporary pull allowance now that the hook call has finished.
1556
+ _afterTransferTo({to: address(specification.hook), token: tokenAmount.token});
1557
+
1474
1558
  emit HookAfterRecordPay({
1475
1559
  hook: specification.hook,
1476
1560
  context: context,
1477
1561
  specificationAmount: specification.amount,
1478
1562
  caller: _msgSender()
1479
1563
  });
1564
+ unchecked {
1565
+ ++i;
1566
+ }
1480
1567
  }
1481
1568
  }
1482
1569
 
@@ -1590,6 +1677,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1590
1677
  caller: _msgSender()
1591
1678
  });
1592
1679
  } catch (bytes memory reason) {
1680
+ // Fee processing failed — intentionally forgive the fee and return the amount to the project.
1681
+ // The held-fee entry (if any) was already deleted by `processHeldFeesOf` before this call, so there is no
1682
+ // retry path. This is by design: a broken or misconfigured fee route should not permanently lock project
1683
+ // funds. The `FeeReverted` event makes this observable off-chain.
1593
1684
  emit FeeReverted({
1594
1685
  projectId: projectId,
1595
1686
  token: token,
@@ -1615,6 +1706,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1615
1706
  }
1616
1707
 
1617
1708
  /// @notice Returns held fees to the project who paid them based on the specified amount.
1709
+ /// @dev Fee rounding during partial replenishment can zero out dust-level fee entries (< 40 wei at 2.5% fee).
1710
+ /// This is accepted behavior — dust fees are economically insignificant.
1618
1711
  /// @param projectId The project held fees are being returned to.
1619
1712
  /// @param token The token that the held fees are in.
1620
1713
  /// @param amount The amount to base the calculation on, as a fixed point number with the same number of decimals
@@ -1641,7 +1734,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1641
1734
  uint256 newStartIndex = startIndex;
1642
1735
 
1643
1736
  // Process each fee.
1644
- for (uint256 i; i < count; i++) {
1737
+ for (uint256 i; i < count;) {
1645
1738
  // Save the fee being iterated on.
1646
1739
  JBFee memory heldFee = _heldFeesOf[projectId][token][startIndex + i];
1647
1740
 
@@ -1675,6 +1768,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1675
1768
  leftoverAmount = 0;
1676
1769
  }
1677
1770
  }
1771
+ unchecked {
1772
+ ++i;
1773
+ }
1678
1774
  }
1679
1775
 
1680
1776
  // Update the next held fee index.
@@ -1707,6 +1803,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1707
1803
  internal
1708
1804
  returns (uint256 amountPaidOut)
1709
1805
  {
1806
+ // Cache the message sender.
1807
+ address sender = _msgSender();
1808
+
1710
1809
  // Keep a reference to the ruleset.
1711
1810
  JBRuleset memory ruleset;
1712
1811
 
@@ -1715,6 +1814,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1715
1814
  STORE.recordPayoutFor({projectId: projectId, token: token, amount: amount, currency: currency});
1716
1815
 
1717
1816
  // Cap fee-free surplus at remaining balance. Non-fee-free funds leave first.
1817
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1718
1818
  _capFeeFreeSurplus({projectId: projectId, token: token});
1719
1819
 
1720
1820
  // Get a reference to the project's owner.
@@ -1739,7 +1839,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1739
1839
  token: token,
1740
1840
  rulesetId: ruleset.id,
1741
1841
  amount: amountPaidOut,
1742
- caller: _msgSender()
1842
+ caller: sender
1743
1843
  });
1744
1844
 
1745
1845
  // Send any leftover funds to the project owner and update the fee tracking accordingly.
@@ -1758,6 +1858,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1758
1858
  leftoverPayoutAmount -= fee;
1759
1859
  }
1760
1860
  } catch (bytes memory reason) {
1861
+ // slither-disable-next-line reentrancy-events
1761
1862
  emit PayoutTransferReverted({
1762
1863
  projectId: projectId,
1763
1864
  addr: projectOwner,
@@ -1765,7 +1866,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1765
1866
  amount: leftoverPayoutAmount - fee,
1766
1867
  fee: fee,
1767
1868
  reason: reason,
1768
- caller: _msgSender()
1869
+ caller: sender
1769
1870
  });
1770
1871
 
1771
1872
  // Add balance back to the project.
@@ -1791,7 +1892,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1791
1892
  amountPaidOut: amountPaidOut,
1792
1893
  fee: feeTaken,
1793
1894
  netLeftoverPayoutAmount: leftoverPayoutAmount,
1794
- caller: _msgSender()
1895
+ caller: sender
1795
1896
  });
1796
1897
  }
1797
1898
 
@@ -1916,6 +2017,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1916
2017
  STORE.recordUsedAllowanceOf({projectId: projectId, token: token, amount: amount, currency: currency});
1917
2018
 
1918
2019
  // Cap fee-free surplus at remaining balance. Non-fee-free funds leave first.
2020
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1919
2021
  _capFeeFreeSurplus({projectId: projectId, token: token});
1920
2022
 
1921
2023
  // Take a fee from the `amountPaidOut`, if needed.
@@ -1952,32 +2054,24 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1952
2054
  }
1953
2055
  }
1954
2056
 
1955
- /// @notice Cap fee-free surplus at the project's remaining balance after an outflow.
1956
- /// @dev Non-fee-free funds are considered to leave first. Fee-free surplus only decreases when the remaining
1957
- /// balance can no longer support it. This prevents attackers from using outflows to drain the fee-free counter
1958
- /// and then cashing out without incurring fees.
1959
- /// @param projectId The ID of the project.
1960
- /// @param token The token whose fee-free surplus to cap.
1961
- function _capFeeFreeSurplus(uint256 projectId, address token) internal {
1962
- // Get the current fee-free surplus for this project/token pair.
1963
- uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][token];
1964
-
1965
- // Nothing to cap if there's no fee-free surplus tracked.
1966
- if (feeFreeSurplus == 0) return;
1967
-
1968
- // Get the project's remaining balance (already decremented by the store's record call).
1969
- uint256 remainingBalance = STORE.balanceOf({terminal: address(this), projectId: projectId, token: token});
1970
-
1971
- // Cap fee-free surplus at the remaining balance.
1972
- if (feeFreeSurplus > remainingBalance) {
1973
- _feeFreeSurplusOf[projectId][token] = remainingBalance;
1974
- }
1975
- }
1976
-
1977
2057
  //*********************************************************************//
1978
2058
  // -------------------------- internal views ------------------------- //
1979
2059
  //*********************************************************************//
1980
2060
 
2061
+ /// @notice Logic to be triggered after transferring tokens from this terminal.
2062
+ /// @dev Clears any allowance granted by `_beforeTransferTo` so receivers cannot retain pull access after the call.
2063
+ /// @param to The address whose temporary pull allowance should be cleared.
2064
+ /// @param token The token whose temporary allowance should be cleared.
2065
+ function _afterTransferTo(address to, address token) internal view {
2066
+ // Native-token transfers use `msg.value`, so there is no ERC-20 approval to clear.
2067
+ if (token == JBConstants.NATIVE_TOKEN) return;
2068
+
2069
+ // Revert if the callee returned without consuming the full forwarded ERC-20 amount.
2070
+ // slither-disable-next-line calls-loop
2071
+ uint256 allowance = IERC20(token).allowance({owner: address(this), spender: to});
2072
+ if (allowance != 0) revert JBMultiTerminal_TemporaryAllowanceNotConsumed(token, to, allowance);
2073
+ }
2074
+
1981
2075
  /// @notice Returns a project's accounting context for a token, reverting if it is not accepted.
1982
2076
  /// @param projectId The ID of the project to get the accounting context for.
1983
2077
  /// @param token The token to get the accounting context for.
@@ -160,7 +160,7 @@ contract JBPermissions is ERC2771Context, IJBPermissions {
160
160
 
161
161
  // Returns true for empty permission arrays by design (vacuous truth). An empty set of
162
162
  // required permissions is trivially satisfied. Callers should validate non-empty permission arrays if needed.
163
- for (uint256 i; i < permissionIds.length; i++) {
163
+ for (uint256 i; i < permissionIds.length;) {
164
164
  // Set the permission being iterated on.
165
165
  uint256 permissionId = permissionIds[i];
166
166
 
@@ -176,6 +176,9 @@ contract JBPermissions is ERC2771Context, IJBPermissions {
176
176
  ) {
177
177
  return false;
178
178
  }
179
+ unchecked {
180
+ ++i;
181
+ }
179
182
  }
180
183
  return true;
181
184
  }
@@ -210,29 +213,23 @@ contract JBPermissions is ERC2771Context, IJBPermissions {
210
213
  // Indexes above 255 don't exist
211
214
  if (permissionId > 255) revert JBPermissions_PermissionIdOutOfBounds(permissionId);
212
215
 
216
+ // Cache both permission slots upfront to avoid redundant storage reads.
217
+ uint256 projectPermissions = permissionsOf[operator][account][projectId];
218
+ uint256 wildcardPermissions =
219
+ includeWildcardProjectId ? permissionsOf[operator][account][WILDCARD_PROJECT_ID] : 0;
220
+
213
221
  // If the ROOT permission is set and should be included, return true.
214
222
  if (
215
223
  includeRoot
216
- && (_includesPermission({
217
- permissions: permissionsOf[operator][account][projectId], permissionId: JBPermissionIds.ROOT
218
- })
219
- || (includeWildcardProjectId
220
- && _includesPermission({
221
- permissions: permissionsOf[operator][account][WILDCARD_PROJECT_ID],
222
- permissionId: JBPermissionIds.ROOT
223
- })))
224
+ && (_includesPermission({permissions: projectPermissions, permissionId: JBPermissionIds.ROOT})
225
+ || _includesPermission({permissions: wildcardPermissions, permissionId: JBPermissionIds.ROOT}))
224
226
  ) {
225
227
  return true;
226
228
  }
227
229
 
228
230
  // Otherwise return the t/f flag of the specified id.
229
- return _includesPermission({
230
- permissions: permissionsOf[operator][account][projectId], permissionId: permissionId
231
- })
232
- || (includeWildcardProjectId
233
- && _includesPermission({
234
- permissions: permissionsOf[operator][account][WILDCARD_PROJECT_ID], permissionId: permissionId
235
- }));
231
+ return _includesPermission({permissions: projectPermissions, permissionId: permissionId})
232
+ || _includesPermission({permissions: wildcardPermissions, permissionId: permissionId});
236
233
  }
237
234
 
238
235
  //*********************************************************************//
@@ -251,13 +248,16 @@ contract JBPermissions is ERC2771Context, IJBPermissions {
251
248
  /// @param permissionIds The IDs of the permissions to pack.
252
249
  /// @return packed The packed value.
253
250
  function _packedPermissions(uint8[] calldata permissionIds) internal pure returns (uint256 packed) {
254
- for (uint256 i; i < permissionIds.length; i++) {
251
+ for (uint256 i; i < permissionIds.length;) {
255
252
  // Set the permission being iterated on.
256
253
  uint256 permissionId = permissionIds[i];
257
254
 
258
255
  // Turn on the bit at the ID.
259
256
  // forge-lint: disable-next-line(incorrect-shift)
260
257
  packed |= 1 << permissionId;
258
+ unchecked {
259
+ ++i;
260
+ }
261
261
  }
262
262
  }
263
263
  }