@bananapus/core-v6 0.0.29 → 0.0.31

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 (44) 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 +2 -3
  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 +11 -3
  16. package/src/JBDirectory.sol +1 -0
  17. package/src/JBMultiTerminal.sol +126 -72
  18. package/src/JBRulesets.sol +2 -1
  19. package/src/JBTerminalStore.sol +22 -11
  20. package/src/abstract/JBControlled.sol +7 -1
  21. package/src/abstract/JBPermissioned.sol +1 -1
  22. package/src/interfaces/IJBRulesetDataHook.sol +5 -4
  23. package/src/libraries/JBCashOuts.sol +1 -1
  24. package/src/libraries/JBConstants.sol +1 -1
  25. package/src/libraries/JBCurrencyIds.sol +1 -1
  26. package/src/libraries/JBFees.sol +1 -1
  27. package/src/libraries/JBFixedPointNumber.sol +1 -1
  28. package/src/libraries/JBMetadataResolver.sol +1 -1
  29. package/src/libraries/JBPayoutSplitGroupLib.sol +3 -1
  30. package/src/libraries/JBRulesetMetadataResolver.sol +1 -1
  31. package/src/libraries/JBSplitGroupIds.sol +1 -1
  32. package/src/libraries/JBSurplus.sol +1 -1
  33. package/src/structs/JBSplit.sol +4 -1
  34. package/test/TestForwardedTokenConsumption.sol +418 -0
  35. package/test/audit/CycledSurplusAllowanceReset.t.sol +184 -0
  36. package/test/units/static/JBController/TestPreviewMintOf.sol +5 -3
  37. package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +15 -11
  38. package/test/units/static/JBMultiTerminal/TestExecutePayout.sol +6 -0
  39. package/test/units/static/JBMultiTerminal/TestExecuteProcessFee.sol +3 -0
  40. package/test/units/static/JBMultiTerminal/TestMigrateBalanceOf.sol +3 -0
  41. package/test/units/static/JBMultiTerminal/TestPay.sol +7 -15
  42. package/test/units/static/JBMultiTerminal/TestSendPayoutsOf.sol +1 -1
  43. package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +1 -1
  44. package/CHANGE_LOG.md +0 -479
@@ -158,6 +158,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
158
158
  RULESETS = rulesets;
159
159
  SPLITS = splits;
160
160
  TOKENS = tokens;
161
+ // slither-disable-next-line missing-zero-check
161
162
  OMNICHAIN_RULESET_OPERATOR = omnichainRulesetOperator;
162
163
  }
163
164
 
@@ -820,8 +821,8 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
820
821
  override
821
822
  returns (uint256 beneficiaryTokenCount, uint256 reservedTokenCount)
822
823
  {
823
- // Revert if there are no tokens to split.
824
- if (tokenCount == 0) revert JBController_ZeroTokensToMint();
824
+ // A zero preview amount means there are no tokens to split.
825
+ if (tokenCount == 0) return (0, 0);
825
826
 
826
827
  // Keep a reference to the current ruleset.
827
828
  JBRuleset memory ruleset = _currentRulesetOf(projectId);
@@ -900,6 +901,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
900
901
  JBTerminalConfig memory terminalConfig = terminalConfigurations[i];
901
902
 
902
903
  // Add the accounting contexts for the specified tokens.
904
+ // slither-disable-next-line calls-loop
903
905
  terminalConfig.terminal
904
906
  .addAccountingContextsFor({
905
907
  projectId: projectId, accountingContexts: terminalConfig.accountingContextsToAccept
@@ -945,6 +947,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
945
947
  }
946
948
 
947
949
  // Queue its ruleset.
950
+ // slither-disable-next-line calls-loop
948
951
  JBRuleset memory ruleset = RULESETS.queueFor({
949
952
  projectId: projectId,
950
953
  duration: rulesetConfig.duration,
@@ -956,11 +959,13 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
956
959
  });
957
960
 
958
961
  // Set its split groups.
962
+ // slither-disable-next-line calls-loop
959
963
  SPLITS.setSplitGroupsOf({
960
964
  projectId: projectId, rulesetId: ruleset.id, splitGroups: rulesetConfig.splitGroups
961
965
  });
962
966
 
963
967
  // Set its fund access limits.
968
+ // slither-disable-next-line calls-loop
964
969
  FUND_ACCESS_LIMITS.setFundAccessLimitsFor({
965
970
  projectId: projectId, rulesetId: ruleset.id, fundAccessLimitGroups: rulesetConfig.fundAccessLimitGroups
966
971
  });
@@ -1023,7 +1028,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1023
1028
  projectId: projectId, tokenCount: splitTokenCount, recipient: address(split.hook), token: token
1024
1029
  });
1025
1030
 
1026
- // slither-disable-next-line reentrancy-events
1031
+ // slither-disable-next-line calls-loop,reentrancy-events
1027
1032
  try split.hook
1028
1033
  .processSplitWith(
1029
1034
  JBSplitHookContext({
@@ -1047,6 +1052,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1047
1052
 
1048
1053
  if (split.projectId != 0) {
1049
1054
  // Get a reference to the receiving project's primary payment terminal for the token.
1055
+ // slither-disable-next-line calls-loop
1050
1056
  IJBTerminal terminal = token == IJBToken(address(0))
1051
1057
  ? IJBTerminal(address(0))
1052
1058
  : DIRECTORY.primaryTerminalOf({projectId: split.projectId, token: address(token)});
@@ -1065,6 +1071,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1065
1071
  bytes memory metadata = bytes(abi.encodePacked(projectId));
1066
1072
 
1067
1073
  // Try to fulfill the payment.
1074
+ // slither-disable-next-line calls-loop
1068
1075
  try this.executePayReservedTokenToTerminal({
1069
1076
  projectId: split.projectId,
1070
1077
  terminal: terminal,
@@ -1088,6 +1095,7 @@ contract JBController is JBPermissioned, ERC2771Context, IJBController, IJBMigra
1088
1095
  }
1089
1096
  } else if (beneficiary == address(0xdead)) {
1090
1097
  // If the split has no project ID, and the beneficiary is 0xdead, burn.
1098
+ // slither-disable-next-line calls-loop
1091
1099
  TOKENS.burnFrom({holder: address(this), projectId: projectId, count: splitTokenCount});
1092
1100
  } else {
1093
1101
  // If the split has no project Id, send to beneficiary.
@@ -139,6 +139,7 @@ contract JBDirectory is JBPermissioned, Ownable, IJBDirectory {
139
139
  // slither-disable-next-line reentrancy-no-eth
140
140
  controllerOf[projectId] = controller;
141
141
 
142
+ // slither-disable-next-line reentrancy-events
142
143
  emit SetController({projectId: projectId, controller: controller, caller: msg.sender});
143
144
 
144
145
  // Notify the new controller that migration is complete and it is now the active controller.
@@ -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 ------------------------ //
@@ -209,6 +208,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
209
208
 
210
209
  // Emit an event for each accounting context.
211
210
  for (uint256 i; i < accountingContexts.length; i++) {
211
+ // slither-disable-next-line reentrancy-events
212
212
  emit SetAccountingContext({projectId: projectId, context: accountingContexts[i], caller: _msgSender()});
213
213
  }
214
214
  }
@@ -251,7 +251,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
251
251
  /// those tokens.
252
252
  /// @param holder The account whose tokens are being cashed out.
253
253
  /// @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.
254
+ /// @param cashOutCount The number of project tokens to cash out and burn, as a fixed point number with 18
255
+ /// decimals.
255
256
  /// @param tokenToReclaim The token being reclaimed.
256
257
  /// @param minTokensReclaimed The minimum number of terminal tokens expected in return, as a fixed point number with
257
258
  /// the same number of decimals as this terminal. If the amount of tokens minted for the beneficiary would be less
@@ -289,9 +290,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
289
290
  });
290
291
 
291
292
  // The amount being reclaimed must be at least as much as was expected.
292
- if (reclaimAmount < minTokensReclaimed) {
293
- revert JBMultiTerminal_UnderMinTokensReclaimed(reclaimAmount, minTokensReclaimed);
294
- }
293
+ _checkMin({value: reclaimAmount, min: minTokensReclaimed});
295
294
  }
296
295
 
297
296
  /// @notice Executes a payout to a split.
@@ -349,6 +348,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
349
348
  // If this terminal's token is the native token, send it in `msg.value`.
350
349
  split.hook.processSplitWith{value: payValue}(context);
351
350
 
351
+ // Revoke the temporary pull allowance now that the hook call has finished.
352
+ _afterTransferTo({to: address(split.hook), token: token});
353
+
352
354
  // Otherwise, if a project is specified, make a payment to it.
353
355
  } else if (split.projectId != 0) {
354
356
  // Get a reference to the terminal being used.
@@ -542,20 +544,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
542
544
  // Transfer the balance minus the fee to the new terminal.
543
545
  uint256 migrationAmount = balance - feeAmount;
544
546
 
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("")
547
+ _externalAddToBalance({
548
+ terminal: to, projectId: projectId, token: token, amount: migrationAmount, metadata: bytes("")
559
549
  });
560
550
  }
561
551
  }
@@ -612,9 +602,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
612
602
  }
613
603
 
614
604
  // 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
- }
605
+ _checkMin({value: beneficiaryTokenCount, min: minReturnedTokens});
618
606
  }
619
607
 
620
608
  /// @notice Process any fees that are being held for the project.
@@ -654,10 +642,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
654
642
  // if the current fee isn't yet unlocked.
655
643
  if (heldFee.unlockTimestamp > block.timestamp) break;
656
644
 
657
- // Delete the entry to reclaim gas before the external call.
645
+ // Delete the entry and advance the index *before* the external call. This is intentional:
646
+ // 1. It prevents reentrancy from reprocessing the same fee.
647
+ // 2. If `_processFee` fails (try-catch), the fee amount is returned to the project's balance via
648
+ // `_recordAddedBalanceFor` — the fee is forgiven rather than retried. This is a deliberate design
649
+ // choice: projects should not have funds permanently stuck because the fee route is misconfigured or
650
+ // reverting.
651
+ // A `FeeReverted` event is emitted so the forgiveness is observable off-chain.
658
652
  delete _heldFeesOf[projectId][token][currentIndex];
659
-
660
- // Update the index before the external call to prevent reentrancy from reprocessing the same fee.
661
653
  _nextHeldFeeIndexOf[projectId][token] = currentIndex + 1;
662
654
 
663
655
  // Process the fee.
@@ -715,9 +707,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
715
707
  amountPaidOut = _sendPayoutsOf({projectId: projectId, token: token, amount: amount, currency: currency});
716
708
 
717
709
  // 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
- }
710
+ _checkMin({value: amountPaidOut, min: minTokensPaidOut});
721
711
  }
722
712
 
723
713
  /// @notice Allows a project to pay out funds from its surplus up to the current surplus allowance.
@@ -770,9 +760,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
770
760
  });
771
761
 
772
762
  // The amount being withdrawn must be at least as much as was expected.
773
- if (netAmountPaidOut < minTokensPaidOut) {
774
- revert JBMultiTerminal_UnderMinTokensPaidOut(netAmountPaidOut, minTokensPaidOut);
775
- }
763
+ _checkMin({value: netAmountPaidOut, min: minTokensPaidOut});
776
764
  }
777
765
 
778
766
  //*********************************************************************//
@@ -1022,6 +1010,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1022
1010
  // Set the allowance to `spend` tokens for the user.
1023
1011
  try PERMIT2.permit({owner: _msgSender(), permitSingle: permitSingle, signature: allowance.signature}) {}
1024
1012
  catch (bytes memory reason) {
1013
+ // slither-disable-next-line reentrancy-events
1025
1014
  emit Permit2AllowanceFailed(token, _msgSender(), reason);
1026
1015
  }
1027
1016
  }
@@ -1087,6 +1076,29 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1087
1076
  return 0;
1088
1077
  }
1089
1078
 
1079
+ /// @notice Cap fee-free surplus at the project's remaining balance after an outflow.
1080
+ /// @dev Non-fee-free funds are considered to leave first. Fee-free surplus only decreases when the remaining
1081
+ /// balance can no longer support it. This prevents attackers from using outflows to drain the fee-free counter
1082
+ /// and then cashing out without incurring fees.
1083
+ /// @param projectId The ID of the project.
1084
+ /// @param token The token whose fee-free surplus to cap.
1085
+ function _capFeeFreeSurplus(uint256 projectId, address token) internal {
1086
+ // Get the current fee-free surplus for this project/token pair.
1087
+ uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][token];
1088
+
1089
+ // Nothing to cap if there's no fee-free surplus tracked.
1090
+ if (feeFreeSurplus == 0) return;
1091
+
1092
+ // Get the project's remaining balance (already decremented by the store's record call).
1093
+ uint256 remainingBalance = STORE.balanceOf({terminal: address(this), projectId: projectId, token: token});
1094
+
1095
+ // Cap fee-free surplus at the remaining balance.
1096
+ if (feeFreeSurplus > remainingBalance) {
1097
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1098
+ _feeFreeSurplusOf[projectId][token] = remainingBalance;
1099
+ }
1100
+ }
1101
+
1090
1102
  /// @notice Holders can cash out their tokens to reclaim some of a project's surplus, or to trigger rules determined
1091
1103
  /// by
1092
1104
  /// the project's current ruleset's data hook.
@@ -1154,6 +1166,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1154
1166
  uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][tokenToReclaim];
1155
1167
  if (feeFreeSurplus != 0) {
1156
1168
  uint256 feeableAmount = reclaimAmount < feeFreeSurplus ? reclaimAmount : feeFreeSurplus;
1169
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1157
1170
  _feeFreeSurplusOf[projectId][tokenToReclaim] = feeFreeSurplus - feeableAmount;
1158
1171
  amountEligibleForFees += feeableAmount;
1159
1172
  reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: feeableAmount, feePercent: FEE});
@@ -1220,6 +1233,13 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1220
1233
  });
1221
1234
  }
1222
1235
 
1236
+ /// @notice Revert if a value is less than the specified minimum.
1237
+ /// @param value The value to compare against the minimum.
1238
+ /// @param min The minimum acceptable value.
1239
+ function _checkMin(uint256 value, uint256 min) internal pure {
1240
+ if (value < min) revert JBMultiTerminal_UnderMin(value, min);
1241
+ }
1242
+
1223
1243
  /// @notice Fund a project either by calling this terminal's internal `addToBalance` function or by calling the
1224
1244
  /// recipient terminal's `addToBalance` function.
1225
1245
  /// @param terminal The terminal on which the project is expecting to receive funds.
@@ -1237,7 +1257,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1237
1257
  )
1238
1258
  internal
1239
1259
  {
1240
- // Call the internal method if this terminal is being used.
1260
+ // Use the local internal path when staying on this terminal. Otherwise use the efficient external equivalent,
1261
+ // which forwards value directly after granting a temporary pull allowance.
1241
1262
  if (terminal == IJBTerminal(address(this))) {
1242
1263
  _addToBalanceOf({
1243
1264
  projectId: projectId,
@@ -1248,20 +1269,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1248
1269
  metadata: metadata
1249
1270
  });
1250
1271
  } 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
1272
+ _externalAddToBalance({
1273
+ terminal: terminal, projectId: projectId, token: token, amount: amount, metadata: metadata
1265
1274
  });
1266
1275
  }
1267
1276
  }
@@ -1313,9 +1322,47 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1313
1322
  memo: "",
1314
1323
  metadata: metadata
1315
1324
  });
1325
+
1326
+ // Revoke the temporary pull allowance now that the recipient terminal call has finished.
1327
+ _afterTransferTo({to: address(terminal), token: token});
1316
1328
  }
1317
1329
  }
1318
1330
 
1331
+ /// @notice Fund a project on another terminal by granting a temporary pull allowance for this call only.
1332
+ /// @param terminal The recipient terminal.
1333
+ /// @param projectId The ID of the project being funded.
1334
+ /// @param token The token being used.
1335
+ /// @param amount The amount being funded.
1336
+ /// @param metadata Additional metadata to include with the payment.
1337
+ function _externalAddToBalance(
1338
+ IJBTerminal terminal,
1339
+ uint256 projectId,
1340
+ address token,
1341
+ uint256 amount,
1342
+ bytes memory metadata
1343
+ )
1344
+ internal
1345
+ {
1346
+ // Trigger any inherited pre-transfer logic.
1347
+ // Keep a reference to the amount that'll be paid as a `msg.value`.
1348
+ // slither-disable-next-line reentrancy-events
1349
+ uint256 payValue = _beforeTransferTo({to: address(terminal), token: token, amount: amount});
1350
+
1351
+ // Add to balance on the recipient terminal.
1352
+ // If this terminal's token is the native token, send it in `msg.value`.
1353
+ terminal.addToBalanceOf{value: payValue}({
1354
+ projectId: projectId,
1355
+ token: token,
1356
+ amount: amount,
1357
+ shouldReturnHeldFees: false,
1358
+ memo: "",
1359
+ metadata: metadata
1360
+ });
1361
+
1362
+ // Revoke the temporary pull allowance now that the recipient terminal call has finished.
1363
+ _afterTransferTo({to: address(terminal), token: token});
1364
+ }
1365
+
1319
1366
  /// @notice Fulfills a list of cash out hook specifications.
1320
1367
  /// @param projectId The ID of the project being cashed out from.
1321
1368
  /// @param beneficiaryReclaimAmount The number of tokens that are being cashed out from the project.
@@ -1356,6 +1403,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1356
1403
  cashOutMetadata: metadata
1357
1404
  });
1358
1405
 
1406
+ // slither-disable-next-line calls-loop
1359
1407
  for (uint256 i; i < specifications.length; i++) {
1360
1408
  // Set the specification being iterated on.
1361
1409
  JBCashOutHookSpecification memory specification = specifications[i];
@@ -1393,9 +1441,12 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1393
1441
  });
1394
1442
 
1395
1443
  // Fulfill the specification.
1396
- // slither-disable-next-line reentrancy-events
1444
+ // slither-disable-next-line reentrancy-events,calls-loop
1397
1445
  specification.hook.afterCashOutRecordedWith{value: payValue}(context);
1398
1446
 
1447
+ // Revoke the temporary pull allowance now that the hook call has finished.
1448
+ _afterTransferTo({to: address(specification.hook), token: beneficiaryReclaimAmount.token});
1449
+
1399
1450
  emit HookAfterRecordCashOut({
1400
1451
  hook: specification.hook,
1401
1452
  context: context,
@@ -1442,6 +1493,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1442
1493
  });
1443
1494
 
1444
1495
  // Fulfill each specification through their pay hooks.
1496
+ // slither-disable-next-line calls-loop
1445
1497
  for (uint256 i; i < specifications.length; i++) {
1446
1498
  // Set the specification being iterated on.
1447
1499
  JBPayHookSpecification memory specification = specifications[i];
@@ -1468,9 +1520,12 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1468
1520
  });
1469
1521
 
1470
1522
  // Fulfill the specification.
1471
- // slither-disable-next-line reentrancy-events
1523
+ // slither-disable-next-line reentrancy-events,calls-loop
1472
1524
  specification.hook.afterPayRecordedWith{value: payValue}(context);
1473
1525
 
1526
+ // Revoke the temporary pull allowance now that the hook call has finished.
1527
+ _afterTransferTo({to: address(specification.hook), token: tokenAmount.token});
1528
+
1474
1529
  emit HookAfterRecordPay({
1475
1530
  hook: specification.hook,
1476
1531
  context: context,
@@ -1590,6 +1645,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1590
1645
  caller: _msgSender()
1591
1646
  });
1592
1647
  } catch (bytes memory reason) {
1648
+ // Fee processing failed — intentionally forgive the fee and return the amount to the project.
1649
+ // The held-fee entry (if any) was already deleted by `processHeldFeesOf` before this call, so there is no
1650
+ // retry path. This is by design: a broken or misconfigured fee route should not permanently lock project
1651
+ // funds. The `FeeReverted` event makes this observable off-chain.
1593
1652
  emit FeeReverted({
1594
1653
  projectId: projectId,
1595
1654
  token: token,
@@ -1715,6 +1774,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1715
1774
  STORE.recordPayoutFor({projectId: projectId, token: token, amount: amount, currency: currency});
1716
1775
 
1717
1776
  // Cap fee-free surplus at remaining balance. Non-fee-free funds leave first.
1777
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1718
1778
  _capFeeFreeSurplus({projectId: projectId, token: token});
1719
1779
 
1720
1780
  // Get a reference to the project's owner.
@@ -1758,6 +1818,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1758
1818
  leftoverPayoutAmount -= fee;
1759
1819
  }
1760
1820
  } catch (bytes memory reason) {
1821
+ // slither-disable-next-line reentrancy-events
1761
1822
  emit PayoutTransferReverted({
1762
1823
  projectId: projectId,
1763
1824
  addr: projectOwner,
@@ -1916,6 +1977,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1916
1977
  STORE.recordUsedAllowanceOf({projectId: projectId, token: token, amount: amount, currency: currency});
1917
1978
 
1918
1979
  // Cap fee-free surplus at remaining balance. Non-fee-free funds leave first.
1980
+ // slither-disable-next-line reentrancy-no-eth,reentrancy-eth,reentrancy-benign
1919
1981
  _capFeeFreeSurplus({projectId: projectId, token: token});
1920
1982
 
1921
1983
  // Take a fee from the `amountPaidOut`, if needed.
@@ -1952,32 +2014,24 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1952
2014
  }
1953
2015
  }
1954
2016
 
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
2017
  //*********************************************************************//
1978
2018
  // -------------------------- internal views ------------------------- //
1979
2019
  //*********************************************************************//
1980
2020
 
2021
+ /// @notice Logic to be triggered after transferring tokens from this terminal.
2022
+ /// @dev Clears any allowance granted by `_beforeTransferTo` so receivers cannot retain pull access after the call.
2023
+ /// @param to The address whose temporary pull allowance should be cleared.
2024
+ /// @param token The token whose temporary allowance should be cleared.
2025
+ function _afterTransferTo(address to, address token) internal view {
2026
+ // Native-token transfers use `msg.value`, so there is no ERC-20 approval to clear.
2027
+ if (token == JBConstants.NATIVE_TOKEN) return;
2028
+
2029
+ // Revert if the callee returned without consuming the full forwarded ERC-20 amount.
2030
+ // slither-disable-next-line calls-loop
2031
+ uint256 allowance = IERC20(token).allowance({owner: address(this), spender: to});
2032
+ if (allowance != 0) revert JBMultiTerminal_TemporaryAllowanceNotConsumed(token, to, allowance);
2033
+ }
2034
+
1981
2035
  /// @notice Returns a project's accounting context for a token, reverting if it is not accepted.
1982
2036
  /// @param projectId The ID of the project to get the accounting context for.
1983
2037
  /// @param token The token to get the accounting context for.
@@ -355,7 +355,8 @@ contract JBRulesets is JBControlled, IJBRulesets {
355
355
  /// @dev If a current ruleset of the project is not found, returns an empty ruleset with all properties set to 0.
356
356
  /// @dev The first cycle returns the stored ruleset directly (cycleNumber=1, original weight). Subsequent cycles
357
357
  /// simulate cycling with weight decay via `_simulateCycledRulesetBasedOn`. Payout limits reset each cycle because
358
- /// the terminal store keys usage by rulesetId, and each cycle produces a new simulated rulesetId.
358
+ /// they are keyed by `cycleNumber`, but the simulated cycle keeps the same `rulesetId` / config id as the stored
359
+ /// ruleset it is based on.
359
360
  /// @param projectId The ID of the project to get the current ruleset of.
360
361
  /// @return ruleset The project's current ruleset.
361
362
  function currentOf(uint256 projectId) external view override returns (JBRuleset memory ruleset) {
@@ -228,12 +228,12 @@ contract JBTerminalStore is IJBTerminalStore {
228
228
 
229
229
  /// @notice Records a cash out from a project.
230
230
  /// @dev Cashs out the project's tokens according to values provided by the ruleset's data hook. If the ruleset has
231
- /// no
232
- /// data hook, cashs out tokens along a cash out bonding curve that is a function of the number of tokens being
231
+ /// no data hook, cashs out tokens along a cash out bonding curve that is a function of the number of tokens being
233
232
  /// burned.
234
233
  /// @param holder The account that is cashing out tokens.
235
234
  /// @param projectId The ID of the project being cashing out from.
236
- /// @param cashOutCount The number of project tokens to cash out, as a fixed point number with 18 decimals.
235
+ /// @param cashOutCount The number of project tokens to cash out, as supplied by the caller and later burned by the
236
+ /// terminal, as a fixed point number with 18 decimals.
237
237
  /// @param tokenToReclaim The token being reclaimed by the cash out.
238
238
  /// @param beneficiaryIsFeeless Whether the cash out's beneficiary is a feeless address. Passed through to data
239
239
  /// hooks so they can skip their own fees when value stays in the protocol (e.g. project-to-project routing).
@@ -895,18 +895,23 @@ contract JBTerminalStore is IJBTerminalStore {
895
895
  JBAccountingContext memory accountingContext = _accountingContextForTokenOf[terminal][projectId][tokenToReclaim];
896
896
 
897
897
  // Get the total number of outstanding project tokens.
898
- uint256 totalSupply =
898
+ uint256 effectiveTotalSupply =
899
899
  IJBController(address(DIRECTORY.controllerOf(projectId))).totalTokenSupplyWithReservedTokensOf(projectId);
900
900
 
901
901
  // Can't cash out more tokens than are in the supply.
902
- if (cashOutCount > totalSupply) revert JBTerminalStore_InsufficientTokens(cashOutCount, totalSupply);
902
+ if (cashOutCount > effectiveTotalSupply) {
903
+ revert JBTerminalStore_InsufficientTokens(cashOutCount, effectiveTotalSupply);
904
+ }
903
905
 
904
- // SECURITY NOTE: The data hook has absolute control over cash-out economics.
905
- // It can set totalSupply, cashOutCount, and cashOutTaxRate to arbitrary values,
906
+ // SECURITY NOTE: The data hook has absolute control over cash-out pricing.
907
+ // It can set effectiveTotalSupply, effectiveCashOutCount, and cashOutTaxRate to arbitrary values,
906
908
  // completely overriding the terminal's bonding curve math. For example, setting
907
- // totalSupply = surplus makes reclaimAmount = cashOutCount, bypassing the curve.
909
+ // effectiveTotalSupply = surplus makes reclaimAmount = effectiveCashOutCount, bypassing the curve.
910
+ // The terminal still burns the caller-supplied cashOutCount after pricing completes.
908
911
  // Project owners MUST audit their data hooks with the same rigor as the terminal.
909
912
 
913
+ uint256 effectiveCashOutCount = cashOutCount;
914
+
910
915
  // If the ruleset has a data hook which is enabled for cash outs, use it to derive a claim amount and memo.
911
916
  if (ruleset.useDataHookForCashOut() && ruleset.dataHook() != address(0)) {
912
917
  // Build the cash out context field-by-field — the struct has 11 fields, too many for a literal.
@@ -916,7 +921,7 @@ contract JBTerminalStore is IJBTerminalStore {
916
921
  context.projectId = projectId;
917
922
  context.rulesetId = ruleset.id;
918
923
  context.cashOutCount = cashOutCount;
919
- context.totalSupply = totalSupply;
924
+ context.totalSupply = effectiveTotalSupply;
920
925
  context.surplus = JBTokenAmount({
921
926
  token: accountingContext.token,
922
927
  value: surplus,
@@ -928,7 +933,7 @@ contract JBTerminalStore is IJBTerminalStore {
928
933
  context.beneficiaryIsFeeless = beneficiaryIsFeeless;
929
934
  context.metadata = metadata;
930
935
 
931
- (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications) =
936
+ (cashOutTaxRate, effectiveCashOutCount, effectiveTotalSupply, hookSpecifications) =
932
937
  IJBRulesetDataHook(ruleset.dataHook()).beforeCashOutRecordedWith(context);
933
938
 
934
939
  // Noop specifications are informational only, so they can't also request forwarded funds.
@@ -944,7 +949,10 @@ contract JBTerminalStore is IJBTerminalStore {
944
949
  // Apply the bonding curve to calculate how much of the surplus is reclaimable.
945
950
  if (surplus != 0) {
946
951
  reclaimAmount = JBCashOuts.cashOutFrom({
947
- surplus: surplus, cashOutCount: cashOutCount, totalSupply: totalSupply, cashOutTaxRate: cashOutTaxRate
952
+ surplus: surplus,
953
+ cashOutCount: effectiveCashOutCount,
954
+ totalSupply: effectiveTotalSupply,
955
+ cashOutTaxRate: cashOutTaxRate
948
956
  });
949
957
  }
950
958
  }
@@ -1193,6 +1201,7 @@ contract JBTerminalStore is IJBTerminalStore {
1193
1201
  });
1194
1202
 
1195
1203
  // Add up all the balances.
1204
+ // slither-disable-next-line calls-loop
1196
1205
  surplus = (surplus == 0 || accountingContext.currency == targetCurrency)
1197
1206
  ? surplus
1198
1207
  : mulDiv({
@@ -1208,6 +1217,7 @@ contract JBTerminalStore is IJBTerminalStore {
1208
1217
  });
1209
1218
 
1210
1219
  // Get a reference to the payout limit during the ruleset for the token.
1220
+ // slither-disable-next-line calls-loop
1211
1221
  JBCurrencyAmount[] memory payoutLimits = IJBController(address(DIRECTORY.controllerOf(projectId)))
1212
1222
  .FUND_ACCESS_LIMITS()
1213
1223
  .payoutLimitsOf({
@@ -1247,6 +1257,7 @@ contract JBTerminalStore is IJBTerminalStore {
1247
1257
 
1248
1258
  // Convert the `payoutLimit`'s amount to be in terms of the provided currency.
1249
1259
  if (payoutLimit.amount != 0 && payoutLimit.currency != targetCurrency) {
1260
+ // slither-disable-next-line calls-loop
1250
1261
  uint256 converted = mulDiv({
1251
1262
  x: payoutLimit.amount,
1252
1263
  y: 10 ** _MAX_FIXED_POINT_FIDELITY, // Use `_MAX_FIXED_POINT_FIDELITY` to keep as much of the
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.0;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {IJBControlled} from "./../interfaces/IJBControlled.sol";
5
5
  import {IJBDirectory} from "./../interfaces/IJBDirectory.sol";
@@ -39,9 +39,15 @@ abstract contract JBControlled is IJBControlled {
39
39
  DIRECTORY = directory;
40
40
  }
41
41
 
42
+ //*********************************************************************//
43
+ // -------------------------- internal views ------------------------- //
44
+ //*********************************************************************//
45
+
42
46
  /// @notice Only allows the controller of the specified project to proceed.
43
47
  function _onlyControllerOf(uint256 projectId) internal view {
48
+ // slither-disable-next-line calls-loop
44
49
  if (address(DIRECTORY.controllerOf(projectId)) != msg.sender) {
50
+ // slither-disable-next-line calls-loop
45
51
  revert JBControlled_ControllerUnauthorized(address(DIRECTORY.controllerOf(projectId)));
46
52
  }
47
53
  }
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.0;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {Context} from "@openzeppelin/contracts/utils/Context.sol";
5
5
 
@@ -17,8 +17,9 @@ interface IJBRulesetDataHook is IERC165 {
17
17
  /// @notice Calculates data before a cash out is recorded in the terminal store.
18
18
  /// @param context The context passed to this data hook by the `cashOutTokensOf(...)` function.
19
19
  /// @return cashOutTaxRate The rate determining the reclaimable amount for a given surplus and token supply.
20
- /// @return cashOutCount The number of tokens to consider cashed out.
21
- /// @return totalSupply The total number of tokens to consider existing.
20
+ /// @return effectiveCashOutCount The effective token count to use for pricing the cash out. The terminal still
21
+ /// burns the caller-supplied token count.
22
+ /// @return effectiveTotalSupply The effective total supply to use for pricing the cash out.
22
23
  /// @return hookSpecifications The amount and data to send to cash out hooks instead of returning to the
23
24
  /// beneficiary.
24
25
  function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
@@ -26,8 +27,8 @@ interface IJBRulesetDataHook is IERC165 {
26
27
  view
27
28
  returns (
28
29
  uint256 cashOutTaxRate,
29
- uint256 cashOutCount,
30
- uint256 totalSupply,
30
+ uint256 effectiveCashOutCount,
31
+ uint256 effectiveTotalSupply,
31
32
  JBCashOutHookSpecification[] memory hookSpecifications
32
33
  );
33
34
 
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
- pragma solidity ^0.8.17;
2
+ pragma solidity 0.8.28;
3
3
 
4
4
  import {mulDiv} from "@prb/math/src/Common.sol";
5
5