@bananapus/core-v6 0.0.16 → 0.0.17

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 (43) hide show
  1. package/ADMINISTRATION.md +1 -1
  2. package/ARCHITECTURE.md +2 -1
  3. package/AUDIT_INSTRUCTIONS.md +342 -0
  4. package/CHANGE_LOG.md +375 -0
  5. package/README.md +4 -4
  6. package/RISKS.md +171 -50
  7. package/SKILLS.md +9 -6
  8. package/USER_JOURNEYS.md +622 -0
  9. package/package.json +2 -2
  10. package/script/DeployPeriphery.s.sol +7 -1
  11. package/src/JBController.sol +5 -0
  12. package/src/JBDeadline.sol +3 -0
  13. package/src/JBDirectory.sol +2 -1
  14. package/src/JBMultiTerminal.sol +50 -9
  15. package/src/JBPermissions.sol +2 -0
  16. package/src/JBPrices.sol +8 -2
  17. package/src/JBRulesets.sol +3 -0
  18. package/src/JBSplits.sol +9 -5
  19. package/src/JBTerminalStore.sol +54 -47
  20. package/src/JBTokens.sol +3 -0
  21. package/src/interfaces/IJBTerminalStore.sol +3 -0
  22. package/src/libraries/JBFees.sol +2 -0
  23. package/src/libraries/JBMetadataResolver.sol +17 -4
  24. package/src/structs/JBBeforeCashOutRecordedContext.sol +4 -0
  25. package/test/TestAuditResponseDesignProofs.sol +434 -0
  26. package/test/TestDataHookFuzzing.sol +520 -0
  27. package/test/TestFeeFreeCashOutBypass.sol +617 -0
  28. package/test/TestL2SequencerPriceFeed.sol +292 -0
  29. package/test/TestMetadataOffsetOverflow.sol +179 -0
  30. package/test/TestMultiTerminalSurplus.sol +348 -0
  31. package/test/TestPermit2DataHook.t.sol +360 -0
  32. package/test/TestRulesetQueueing.sol +1 -2
  33. package/test/TestRulesetWeightCaching.sol +122 -124
  34. package/test/WeirdTokenTests.t.sol +37 -0
  35. package/test/regression/HoldFeesCashOutReserved.t.sol +415 -0
  36. package/test/regression/WeightCacheBoundary.t.sol +291 -0
  37. package/test/units/static/JBMultiTerminal/TestAddToBalanceOf.sol +2 -2
  38. package/test/units/static/JBMultiTerminal/TestCashOutTokensOf.sol +18 -17
  39. package/test/units/static/JBMultiTerminal/TestPay.sol +6 -4
  40. package/test/units/static/JBMultiTerminal/TestProcessHeldFeesOf.sol +206 -18
  41. package/test/units/static/JBMultiTerminal/TestUseAllowanceOf.sol +280 -0
  42. package/test/units/static/JBSplits/TestSelfManagedSplitGroups.sol +55 -12
  43. package/test/units/static/JBTerminalStore/TestRecordCashOutsFor.sol +72 -0
@@ -11,6 +11,9 @@ import {JBRuleset} from "./structs/JBRuleset.sol";
11
11
  /// seconds before the current ruleset ends. In other words, rulesets must be queued before the deadline to take effect.
12
12
  /// @dev Project rulesets are stored in a queue. Rulesets take effect after the previous ruleset in the queue ends, and
13
13
  /// only if they are approved by the previous ruleset's approval hook.
14
+ /// @dev If `DURATION` is set longer than the ruleset's cycle duration, no queued ruleset can ever satisfy the deadline
15
+ /// and the current ruleset will effectively be locked in perpetuity. Choose a `DURATION` shorter than the shortest
16
+ /// cycle it will govern.
14
17
  contract JBDeadline is IJBRulesetApprovalHook {
15
18
  //*********************************************************************//
16
19
  // ---------------- public immutable stored properties --------------- //
@@ -185,7 +185,8 @@ contract JBDirectory is JBPermissioned, Ownable, IJBDirectory {
185
185
  revert JBDirectory_TokenNotAccepted(projectId, token, terminal);
186
186
  }
187
187
 
188
- // If the terminal hasn't already been added to the project, add it.
188
+ // Implicit terminal addition is by design. A primary terminal must be in the terminals list;
189
+ // implicit addition avoids requiring a separate addTerminalsOf call.
189
190
  _addTerminalIfNeeded({projectId: projectId, terminal: terminal});
190
191
 
191
192
  // Store the terminal as the project's primary terminal for the token.
@@ -136,6 +136,18 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
136
136
  /// @custom:param projectId The ID of the project to get a list of accepted tokens for.
137
137
  mapping(uint256 projectId => JBAccountingContext[]) internal _accountingContextsOf;
138
138
 
139
+ /// @notice The cumulative amount of fee-free intra-terminal payouts a project has received for a given token.
140
+ /// @dev Incremented each time a fee-free payout lands (same terminal, no fee charged). During cashout with
141
+ /// `cashOutTaxRate == 0`, fees are applied only up to this amount, then decremented. This prevents a round-trip
142
+ /// fee bypass (intra-terminal payout → zero-tax cashout) while scoping the fee precisely to the fee-free inflow
143
+ /// — legitimate cashouts beyond this amount remain fee-free.
144
+ /// @dev WARNING: This accumulator persists across rulesets and cannot be cleared. Once a fee-free payout
145
+ /// increments it, the balance remains until consumed by a zero-tax cashout. There is no admin function to reset
146
+ /// it. Projects switching from zero-tax to non-zero-tax rulesets will carry forward any unconsumed balance.
147
+ /// @custom:param projectId The ID of the project that received the payout.
148
+ /// @custom:param token The token that was received.
149
+ mapping(uint256 projectId => mapping(address token => uint256)) internal _feeFreeSurplusOf;
150
+
139
151
  /// @notice Fees that are being held for each project.
140
152
  /// @dev Projects can temporarily hold fees and unlock them later by adding funds to the project's balance.
141
153
  /// @dev Held fees can be processed at any time by this terminal's owner.
@@ -416,12 +428,21 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
416
428
  revert JBMultiTerminal_RecipientProjectTerminalNotFound(split.projectId, token);
417
429
  }
418
430
 
431
+ // Fees apply to fund egress, not intra-terminal accounting. When both projects share this terminal,
432
+ // funds stay within the contract (addToBalance or pay) so no fee is charged. This is intentional:
433
+ // the fee model taxes value leaving the protocol ecosystem, not internal rebalancing.
419
434
  // This payout is eligible for a fee if the funds are leaving this contract and the receiving terminal isn't
420
- // a feelss address.
435
+ // a feeless address.
421
436
  if (terminal != this && !_isFeeless(address(terminal))) {
422
437
  netPayoutAmount -= JBFees.feeAmountFrom({amountBeforeFee: amount, feePercent: FEE});
423
438
  }
424
439
 
440
+ // Track the fee-free payout amount. During cashout at zero tax rate, fees apply
441
+ // only up to this accumulated amount, preventing round-trip fee bypass.
442
+ if (terminal == this) {
443
+ _feeFreeSurplusOf[split.projectId][token] += netPayoutAmount;
444
+ }
445
+
425
446
  // Send the `projectId` in the metadata as a referral.
426
447
  bytes memory metadata = bytes(abi.encodePacked(projectId));
427
448
 
@@ -541,6 +562,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
541
562
  revert JBMultiTerminal_TerminalTokensIncompatible({projectId: projectId, token: token, terminal: to});
542
563
  }
543
564
 
565
+ // Terminal migration intentionally does not transfer held fees. Held fees belong to the
566
+ // fee beneficiary (project #1), not the migrating project. They unlock after 28 days regardless of terminal.
544
567
  // Record the migration in the store.
545
568
  // slither-disable-next-line reentrancy-events
546
569
  balance = STORE.recordTerminalMigration({projectId: projectId, token: token});
@@ -625,6 +648,8 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
625
648
  }
626
649
 
627
650
  /// @notice Process any fees that are being held for the project.
651
+ /// @dev Reentrancy safety: the loop re-reads `_nextHeldFeeIndexOf` from storage each iteration and advances the
652
+ /// index before the external `_processFee` call, so a reentrant call cannot double-process the same fee entry.
628
653
  /// @param projectId The ID of the project to process held fees for.
629
654
  /// @param token The token to process held fees for.
630
655
  /// @param count The number of fees to process.
@@ -1049,6 +1074,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1049
1074
  accountingContext: accountingContext,
1050
1075
  balanceAccountingContexts: balanceAccountingContexts,
1051
1076
  cashOutCount: cashOutCount,
1077
+ beneficiaryIsFeeless: _isFeeless(beneficiary),
1052
1078
  metadata: metadata
1053
1079
  });
1054
1080
  }
@@ -1064,12 +1090,22 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1064
1090
 
1065
1091
  // Send the reclaimed funds to the beneficiary.
1066
1092
  if (reclaimAmount != 0) {
1067
- // Determine if a fee should be taken. Fees are not taked if the cash out tax rate is zero,
1068
- // if the beneficiary is feeless, or if the fee beneficiary doesn't accept the given token.
1069
- if (!_isFeeless(beneficiary) && cashOutTaxRate != 0) {
1070
- amountEligibleForFees += reclaimAmount;
1071
- // Subtract the fee for the reclaimed amount.
1072
- reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: reclaimAmount, feePercent: FEE});
1093
+ // Determine if a fee should be taken. Fees are not taken if the beneficiary is feeless.
1094
+ if (!_isFeeless(beneficiary)) {
1095
+ if (cashOutTaxRate != 0) {
1096
+ // Non-zero tax: fees apply to the full reclaim amount.
1097
+ amountEligibleForFees += reclaimAmount;
1098
+ reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: reclaimAmount, feePercent: FEE});
1099
+ } else {
1100
+ // Zero tax: fees apply only up to the fee-free surplus (round-trip prevention).
1101
+ uint256 feeFreeSurplus = _feeFreeSurplusOf[projectId][tokenToReclaim];
1102
+ if (feeFreeSurplus != 0) {
1103
+ uint256 feeableAmount = reclaimAmount < feeFreeSurplus ? reclaimAmount : feeFreeSurplus;
1104
+ _feeFreeSurplusOf[projectId][tokenToReclaim] = feeFreeSurplus - feeableAmount;
1105
+ amountEligibleForFees += feeableAmount;
1106
+ reclaimAmount -= JBFees.feeAmountFrom({amountBeforeFee: feeableAmount, feePercent: FEE});
1107
+ }
1108
+ }
1073
1109
  }
1074
1110
 
1075
1111
  // Subtract the fee from the reclaim amount.
@@ -1623,7 +1659,10 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1623
1659
  internal
1624
1660
  returns (uint256)
1625
1661
  {
1626
- // Attempt to distribute this split.
1662
+ // Failed split payouts consume the payout limit by design. The try-catch prevents a single
1663
+ // split from DoS-ing the entire payout. Failed splits' amounts are returned to the project balance via
1664
+ // `_recordAddedBalanceFor`. Payout limit consumption is correct because the project authorized the
1665
+ // distribution.
1627
1666
  // slither-disable-next-line reentrancy-events
1628
1667
  try this.executePayout({
1629
1668
  split: split, projectId: projectId, token: token, amount: amount, originalMessageSender: _msgSender()
@@ -1697,7 +1736,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1697
1736
  ? 0
1698
1737
  : JBFees.feeAmountFrom({amountBeforeFee: leftoverPayoutAmount, feePercent: FEE});
1699
1738
 
1700
- // Transfer the amount to the project owner.
1739
+ // Failed owner transfer consumes the payout limit by design. Same pattern as split payouts:
1740
+ // the try-catch prevents revert, failed amount is returned to project balance, and the owner can retry
1741
+ // via addToBalanceOf or in the next cycle.
1701
1742
  try this.executeTransferTo({addr: projectOwner, token: token, amount: leftoverPayoutAmount - fee}) {
1702
1743
  if (fee > 0) {
1703
1744
  amountEligibleForFees += leftoverPayoutAmount;
@@ -154,6 +154,8 @@ contract JBPermissions is ERC2771Context, IJBPermissions {
154
154
  uint256 operatorAccountWildcardProjectPermissions =
155
155
  includeWildcardProjectId ? permissionsOf[operator][account][WILDCARD_PROJECT_ID] : 0;
156
156
 
157
+ // Returns true for empty permission arrays by design (vacuous truth). An empty set of
158
+ // required permissions is trivially satisfied. Callers should validate non-empty permission arrays if needed.
157
159
  for (uint256 i; i < permissionIds.length; i++) {
158
160
  // Set the permission being iterated on.
159
161
  uint256 permissionId = permissionIds[i];
package/src/JBPrices.sol CHANGED
@@ -15,8 +15,10 @@ import {IJBProjects} from "./interfaces/IJBProjects.sol";
15
15
 
16
16
  /// @notice Manages and normalizes price feeds. Price feeds are contracts which return the "pricing currency" cost of 1
17
17
  /// "unit currency".
18
- /// @dev Price feeds are immutable once set and cannot be replaced or removed. If a price feed needs to be changed,
19
- /// a new JBPrices contract must be deployed and projects must migrate to use it.
18
+ /// @dev Price feeds are immutable once set and cannot be replaced or removed. This prevents oracle manipulation via
19
+ /// admin-key attacks, but means a misconfigured or failing feed will cause operations using that currency pair to
20
+ /// revert (DoS, not fund loss). Select feeds carefully — recovery requires deploying a new JBPrices contract and
21
+ /// migrating projects.
20
22
  contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBPrices {
21
23
  //*********************************************************************//
22
24
  // --------------------------- custom errors ------------------------- //
@@ -134,6 +136,10 @@ contract JBPrices is JBControlled, JBPermissioned, ERC2771Context, Ownable, IJBP
134
136
  : priceFeedFor[projectId][unitCurrency][pricingCurrency]);
135
137
  }
136
138
 
139
+ // Price feed immutability is by design to prevent admin-key attacks on price oracles.
140
+ // If a feed fails, operations using that currency pair revert (DoS but not fund loss). Projects can use
141
+ // alternative currency pairs. A default feed for a currency pair prevents per-project overrides to ensure
142
+ // price consistency; projects should use unused currency IDs for custom pricing.
137
143
  // Store the feed.
138
144
  priceFeedFor[projectId][pricingCurrency][unitCurrency] = feed;
139
145
 
@@ -353,6 +353,9 @@ contract JBRulesets is JBControlled, IJBRulesets {
353
353
 
354
354
  /// @notice The ruleset that is currently active for the specified project.
355
355
  /// @dev If a current ruleset of the project is not found, returns an empty ruleset with all properties set to 0.
356
+ /// @dev The first cycle returns the stored ruleset directly (cycleNumber=1, original weight). Subsequent cycles
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.
356
359
  /// @param projectId The ID of the project to get the current ruleset of.
357
360
  /// @return ruleset The project's current ruleset.
358
361
  function currentOf(uint256 projectId) external view override returns (JBRuleset memory ruleset) {
package/src/JBSplits.sol CHANGED
@@ -77,8 +77,9 @@ contract JBSplits is JBControlled, IJBSplits {
77
77
 
78
78
  /// @notice Sets a project's split groups.
79
79
  /// @dev Only a project's controller can set its splits, unless the first 160 bits of the group's ID match
80
- /// `msg.sender`, in which case the caller can set its own splits. The remaining upper 96 bits are free for the
81
- /// caller to use as sub-categorization.
80
+ /// `msg.sender` AND the upper 96 bits are non-zero, in which case the caller can set its own splits.
81
+ /// GroupIds with zero upper 96 bits (i.e. bare addresses) are reserved for protocol use (e.g. terminal
82
+ /// payout groups keyed by token address) and always require controller authorization.
82
83
  /// @dev The new split groups must include any currently set splits that are locked.
83
84
  /// @param projectId The ID of the project to set the split groups of.
84
85
  /// @param rulesetId The ID of the ruleset the split groups should be active in. Send
@@ -101,9 +102,12 @@ contract JBSplits is JBControlled, IJBSplits {
101
102
  // Get a reference to the grouped split being iterated on.
102
103
  JBSplitGroup memory splitGroup = splitGroups[i];
103
104
 
104
- // Allow contracts to set splits in their own namespace (first 160 bits of groupId == msg.sender).
105
- // Otherwise, require controller (checked once).
106
- if (address(uint160(splitGroup.groupId)) != msg.sender && !controllerChecked) {
105
+ // Self-auth: lower 160 bits must match msg.sender AND upper 96 bits must be non-zero.
106
+ // GroupIds with zero upper bits are reserved for protocol use (e.g. terminal payout groups)
107
+ // 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;
109
+
110
+ if (!isSelfManaged && !controllerChecked) {
107
111
  _onlyControllerOf(projectId);
108
112
  controllerChecked = true;
109
113
  }
@@ -156,6 +156,8 @@ contract JBTerminalStore is IJBTerminalStore {
156
156
  /// @param accountingContext The accounting context of the token being reclaimed by the cash out.
157
157
  /// @param balanceAccountingContexts The accounting contexts of the tokens whose balances should contribute to the
158
158
  /// surplus being reclaimed from.
159
+ /// @param beneficiaryIsFeeless Whether the cash out's beneficiary is a feeless address. Passed through to data
160
+ /// hooks so they can skip their own fees when value stays in the protocol (e.g. project-to-project routing).
159
161
  /// @param metadata Bytes to send to the data hook, if the project's current ruleset specifies one.
160
162
  /// @return ruleset The ruleset during the cash out was made during, as a `JBRuleset` struct. This ruleset will
161
163
  /// have a cash out tax rate provided by the cash out hook if applicable.
@@ -170,6 +172,7 @@ contract JBTerminalStore is IJBTerminalStore {
170
172
  uint256 cashOutCount,
171
173
  JBAccountingContext calldata accountingContext,
172
174
  JBAccountingContext[] calldata balanceAccountingContexts,
175
+ bool beneficiaryIsFeeless,
173
176
  bytes memory metadata
174
177
  )
175
178
  external
@@ -184,10 +187,9 @@ contract JBTerminalStore is IJBTerminalStore {
184
187
  // Get a reference to the project's current ruleset.
185
188
  ruleset = RULESETS.currentOf(projectId);
186
189
 
187
- // Get the current surplus amount.
188
- // Use the local surplus if the ruleset specifies that it should be used. Otherwise, use the project's total
189
- // surplus across all of its terminals.
190
- uint256 currentSurplus = ruleset.useTotalSurplusForCashOuts()
190
+ // Store the current surplus in `reclaimAmount` temporarily to avoid allocating a separate local variable
191
+ // (saves one stack slot, which is needed to fit the 7th parameter without hitting stack-too-deep).
192
+ reclaimAmount = ruleset.useTotalSurplusForCashOuts()
191
193
  ? JBSurplus.currentSurplusOf({
192
194
  projectId: projectId,
193
195
  terminals: DIRECTORY.terminalsOf(projectId),
@@ -204,54 +206,59 @@ contract JBTerminalStore is IJBTerminalStore {
204
206
  targetCurrency: accountingContext.currency
205
207
  });
206
208
 
207
- // Get the total number of outstanding project tokens.
208
- uint256 totalSupply =
209
- IJBController(address(DIRECTORY.controllerOf(projectId))).totalTokenSupplyWithReservedTokensOf(projectId);
210
-
211
- // Can't cash out more tokens than are in the supply.
212
- if (cashOutCount > totalSupply) revert JBTerminalStore_InsufficientTokens(cashOutCount, totalSupply);
213
-
214
- // SECURITY NOTE: The data hook has absolute control over cash-out economics.
215
- // It can set totalSupply, cashOutCount, and cashOutTaxRate to arbitrary values,
216
- // completely overriding the terminal's bonding curve math. For example, setting
217
- // totalSupply = surplus makes reclaimAmount = cashOutCount, bypassing the curve.
218
- // Project owners MUST audit their data hooks with the same rigor as the terminal.
219
-
220
- // If the ruleset has a data hook which is enabled for cash outs, use it to derive a claim amount and memo.
221
- if (ruleset.useDataHookForCashOut() && ruleset.dataHook() != address(0)) {
222
- // Create the cash out context that'll be sent to the data hook.
223
- JBBeforeCashOutRecordedContext memory context = JBBeforeCashOutRecordedContext({
224
- terminal: msg.sender,
225
- holder: holder,
226
- projectId: projectId,
227
- rulesetId: ruleset.id,
228
- cashOutCount: cashOutCount,
229
- totalSupply: totalSupply,
230
- surplus: JBTokenAmount({
209
+ // Scoped to keep `totalSupply` and `context` off the outer stack.
210
+ {
211
+ // Get the total number of outstanding project tokens.
212
+ uint256 totalSupply = IJBController(address(DIRECTORY.controllerOf(projectId)))
213
+ .totalTokenSupplyWithReservedTokensOf(projectId);
214
+
215
+ // Can't cash out more tokens than are in the supply.
216
+ if (cashOutCount > totalSupply) revert JBTerminalStore_InsufficientTokens(cashOutCount, totalSupply);
217
+
218
+ // SECURITY NOTE: The data hook has absolute control over cash-out economics.
219
+ // It can set totalSupply, cashOutCount, and cashOutTaxRate to arbitrary values,
220
+ // completely overriding the terminal's bonding curve math. For example, setting
221
+ // totalSupply = surplus makes reclaimAmount = cashOutCount, bypassing the curve.
222
+ // Project owners MUST audit their data hooks with the same rigor as the terminal.
223
+
224
+ // If the ruleset has a data hook which is enabled for cash outs, use it to derive a claim amount and memo.
225
+ if (ruleset.useDataHookForCashOut() && ruleset.dataHook() != address(0)) {
226
+ // Build the cash out context field-by-field to avoid stack-too-deep
227
+ // (the struct has 11 fields — a struct literal would require all values on the stack at once).
228
+ JBBeforeCashOutRecordedContext memory context;
229
+ context.terminal = msg.sender;
230
+ context.holder = holder;
231
+ context.projectId = projectId;
232
+ context.rulesetId = ruleset.id;
233
+ context.cashOutCount = cashOutCount;
234
+ context.totalSupply = totalSupply;
235
+ context.surplus = JBTokenAmount({
231
236
  token: accountingContext.token,
232
- value: currentSurplus,
237
+ value: reclaimAmount, // reclaimAmount temporarily holds the current surplus.
233
238
  decimals: accountingContext.decimals,
234
239
  currency: accountingContext.currency
235
- }),
236
- useTotalSurplus: ruleset.useTotalSurplusForCashOuts(),
237
- cashOutTaxRate: ruleset.cashOutTaxRate(),
238
- metadata: metadata
239
- });
240
+ });
241
+ context.useTotalSurplus = ruleset.useTotalSurplusForCashOuts();
242
+ context.cashOutTaxRate = ruleset.cashOutTaxRate();
243
+ context.beneficiaryIsFeeless = beneficiaryIsFeeless;
244
+ context.metadata = metadata;
240
245
 
241
- (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications) =
242
- IJBRulesetDataHook(ruleset.dataHook()).beforeCashOutRecordedWith(context);
243
- } else {
244
- cashOutTaxRate = ruleset.cashOutTaxRate();
245
- }
246
+ (cashOutTaxRate, cashOutCount, totalSupply, hookSpecifications) =
247
+ IJBRulesetDataHook(ruleset.dataHook()).beforeCashOutRecordedWith(context);
248
+ } else {
249
+ cashOutTaxRate = ruleset.cashOutTaxRate();
250
+ }
246
251
 
247
- if (currentSurplus != 0) {
248
- // Calculate reclaim amount using the current surplus amount.
249
- reclaimAmount = JBCashOuts.cashOutFrom({
250
- surplus: currentSurplus,
251
- cashOutCount: cashOutCount,
252
- totalSupply: totalSupply,
253
- cashOutTaxRate: cashOutTaxRate
254
- });
252
+ // Calculate the reclaim amount. `reclaimAmount` currently holds the surplus — overwrite it with the
253
+ // result.
254
+ if (reclaimAmount != 0) {
255
+ reclaimAmount = JBCashOuts.cashOutFrom({
256
+ surplus: reclaimAmount,
257
+ cashOutCount: cashOutCount,
258
+ totalSupply: totalSupply,
259
+ cashOutTaxRate: cashOutTaxRate
260
+ });
261
+ }
255
262
  }
256
263
 
257
264
  // Keep a reference to the amount that should be added to the project's balance.
package/src/JBTokens.sol CHANGED
@@ -275,6 +275,9 @@ contract JBTokens is JBControlled, IJBTokens {
275
275
 
276
276
  /// @notice Set a project's token if not already set.
277
277
  /// @dev Only a project's controller can set its token.
278
+ /// @dev If the provided ERC-20 has a pre-existing supply (minted outside this contract), that supply will be
279
+ /// included in `totalSupplyOf` and will dilute cash-out calculations for all token holders. Project owners are
280
+ /// responsible for ensuring the token's supply is appropriate before calling this function.
278
281
  /// @param projectId The ID of the project to set the token of.
279
282
  /// @param token The new token's address.
280
283
  function setTokenFor(uint256 projectId, IJBToken token) external override onlyControllerOf(projectId) {
@@ -145,6 +145,8 @@ interface IJBTerminalStore {
145
145
  /// @param cashOutCount The number of project tokens being cashed out.
146
146
  /// @param accountingContext The accounting context of the token being reclaimed.
147
147
  /// @param balanceAccountingContexts The accounting contexts to include in the balance calculation.
148
+ /// @param beneficiaryIsFeeless Whether the cash out's beneficiary is a feeless address. Passed through to data
149
+ /// hooks so they can skip their own fees when value stays in the protocol (e.g. project-to-project routing).
148
150
  /// @param metadata Extra data to pass along to the data hook.
149
151
  /// @return ruleset The project's current ruleset.
150
152
  /// @return reclaimAmount The amount reclaimed.
@@ -156,6 +158,7 @@ interface IJBTerminalStore {
156
158
  uint256 cashOutCount,
157
159
  JBAccountingContext calldata accountingContext,
158
160
  JBAccountingContext[] calldata balanceAccountingContexts,
161
+ bool beneficiaryIsFeeless,
159
162
  bytes calldata metadata
160
163
  )
161
164
  external
@@ -19,6 +19,8 @@ library JBFees {
19
19
 
20
20
  /// @notice Returns the fee that would be taken from `amountBeforeFee`.
21
21
  /// @dev Use this to forward-calculate the fee from a known pre-fee amount.
22
+ /// @dev Fee rounding error is bounded by N-1 wei (N = number of splits). Economically
23
+ /// insignificant. Rounds down (mulDiv floors), so the fee beneficiary may receive up to 1 wei less per split.
22
24
  /// @param amountBeforeFee The amount before the fee is applied, as a fixed point number.
23
25
  /// @param feePercent The fee percent, out of `JBConstants.MAX_FEE`.
24
26
  /// @return The fee amount, as a fixed point number with the same number of decimals as the provided `amount`.
@@ -60,6 +60,9 @@ library JBMetadataResolver {
60
60
  pure
61
61
  returns (bytes memory newMetadata)
62
62
  {
63
+ // Validate data padding upfront so that the fast path below cannot bypass the check.
64
+ if (dataToAdd.length < 32) revert JBMetadataResolver_DataNotPadded();
65
+
63
66
  // Empty original metadata and maybe something in the first 32 bytes: create new metadata
64
67
  if (originalMetadata.length <= RESERVED_SIZE) {
65
68
  // forge-lint: disable-next-line(unsafe-typecast)
@@ -69,9 +72,6 @@ library JBMetadataResolver {
69
72
  // There is something in the table offset, but not a valid entry - avoid overwriting
70
73
  if (originalMetadata.length < RESERVED_SIZE + ID_SIZE + 1) revert JBMetadataResolver_MetadataTooShort();
71
74
 
72
- // Make sure the data is padded to 32 bytes.
73
- if (dataToAdd.length < 32) revert JBMetadataResolver_DataNotPadded();
74
-
75
75
  // Get the first data offset - upcast to avoid overflow (same for other offset)...
76
76
  uint256 firstOffset = uint8(originalMetadata[RESERVED_SIZE + ID_SIZE]);
77
77
 
@@ -103,7 +103,10 @@ library JBMetadataResolver {
103
103
  if (i + TOTAL_ID_SIZE >= firstOffset * WORD_SIZE) {
104
104
  // Increment every offset by 1 (as the table now takes one more word)
105
105
  for (uint256 j = RESERVED_SIZE + ID_SIZE; j < lastOffsetIndex + 1; j += TOTAL_ID_SIZE) {
106
- newMetadata[j] = bytes1(uint8(originalMetadata[j]) + 1);
106
+ uint256 incremented = uint256(uint8(originalMetadata[j])) + 1;
107
+ if (incremented > 255) revert JBMetadataResolver_MetadataTooLong();
108
+ // forge-lint: disable-next-line(unsafe-typecast)
109
+ newMetadata[j] = bytes1(uint8(incremented));
107
110
  }
108
111
 
109
112
  // Increment the last offset so the new offset will be properly set too
@@ -123,6 +126,10 @@ library JBMetadataResolver {
123
126
  newMetadata = abi.encodePacked(newMetadata, idToAdd, bytes1(uint8(newOffset)));
124
127
 
125
128
  // Pad as needed - inlined for gas saving
129
+ // The length inflation to the next 32-byte boundary does NOT read uninitialized memory.
130
+ // abi.encodePacked always advances the free memory pointer to a 32-byte-aligned position, so the
131
+ // padding bytes (up to 31) are within the already-allocated region and guaranteed to be zero by
132
+ // Solidity's virgin-memory invariant. The zeros are the intended table padding per the metadata format.
126
133
  uint256 paddedLength =
127
134
  newMetadata.length % WORD_SIZE == 0 ? newMetadata.length : (newMetadata.length / WORD_SIZE + 1) * WORD_SIZE;
128
135
  assembly ("memory-safe") {
@@ -162,6 +169,9 @@ library JBMetadataResolver {
162
169
  function createMetadata(bytes4[] memory ids, bytes[] memory datas) internal pure returns (bytes memory metadata) {
163
170
  if (ids.length != datas.length) revert JBMetadataResolver_LengthMismatch();
164
171
 
172
+ // Return empty bytes for empty input arrays to avoid underflow in offset calculation below.
173
+ if (ids.length == 0) return bytes("");
174
+
165
175
  // Add a first empty 32B for the protocol reserved word
166
176
  metadata = abi.encodePacked(bytes32(0));
167
177
 
@@ -218,6 +228,9 @@ library JBMetadataResolver {
218
228
 
219
229
  /// @notice Parse the metadata to find the data for a specific ID
220
230
  /// @dev Returns false and an empty bytes if no data is found
231
+ /// @dev Padding to 32-byte boundaries is required for ABI compatibility. The apparent
232
+ /// inconsistency between creation and parsing is intentional: creation pads for storage alignment,
233
+ /// parsing uses actual data length boundaries from the lookup table.
221
234
  /// @param id The ID to find.
222
235
  /// @param metadata The metadata to parse.
223
236
  /// @return found Whether the {id:data} was found
@@ -16,6 +16,9 @@ import {JBTokenAmount} from "./JBTokenAmount.sol";
16
16
  /// included, and the currency of the surplus.
17
17
  /// @custom:member useTotalSurplus If surplus across all of a project's terminals is being used when making cash outs.
18
18
  /// @custom:member cashOutTaxRate The cash out tax rate of the ruleset the cash out is being made during.
19
+ /// @custom:member beneficiaryIsFeeless Whether the cash out's beneficiary is a feeless address. Useful for data hooks
20
+ /// that charge their own fees — they can skip fees when value stays in the protocol (e.g. project-to-project
21
+ /// routing).
19
22
  /// @custom:member metadata Extra data provided by the casher.
20
23
  // forge-lint: disable-next-line(pascal-case-struct)
21
24
  struct JBBeforeCashOutRecordedContext {
@@ -28,5 +31,6 @@ struct JBBeforeCashOutRecordedContext {
28
31
  JBTokenAmount surplus;
29
32
  bool useTotalSurplus;
30
33
  uint256 cashOutTaxRate;
34
+ bool beneficiaryIsFeeless;
31
35
  bytes metadata;
32
36
  }