@bananapus/core-v6 0.0.51 → 0.0.53

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.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,17 @@ This file describes the verified change from `nana-core-v5` to the current `nana
13
13
  - `JBTokens`
14
14
  - the shared core interfaces, structs, and libraries under `src/`
15
15
 
16
+ ## 0.0.53 — Drop the `via_ir` requirement
17
+
18
+ `JBCashOutHookSpecsLib.fulfill` originally took 10 named arguments. When `JBMultiTerminal._cashOutTokensOf` and `_payAfterCashOutTokensOf` called it, the call site ran past solc 0.8.28's 16-slot Yul stack ceiling, which forced every consumer of `@bananapus/core-v6` to enable `via_ir = true` in their own `foundry.toml` profile. That cascaded into stack-too-deep failures in downstream packages whose own functions couldn't tolerate `via_ir` (notably `nana-721-hook-v6`'s `JB721TiersHookStore.tiersOf`).
19
+
20
+ This release reshapes `fulfill` to accept the existing `JBAfterCashOutRecordedContext` directly — the same struct the hook receives — so no parallel arg bundle is needed. The new signature is `fulfill(IJBFeelessAddresses, JBAfterCashOutRecordedContext, JBCashOutHookSpecification[])`. The per-iteration loop body is extracted into a private `_fulfillOne` helper so its locals don't share `fulfill`'s stack frame. Both call sites in `JBMultiTerminal` build the context field-by-field (one stack slot per assignment) instead of via a struct literal (which would push all ten fields onto the stack at once and trip the same ceiling at the caller).
21
+
22
+ Integrator impact:
23
+ - `JBCashOutHookSpecsLib.fulfill(IJBFeelessAddresses, uint256, JBTokenAmount, address, uint256, bytes, JBRuleset, uint256, address payable, JBCashOutHookSpecification[])` → `fulfill(IJBFeelessAddresses, JBAfterCashOutRecordedContext, JBCashOutHookSpecification[])`. The only on-chain caller is `JBMultiTerminal` (this PR updates both call sites), so the public ABI surface of `JBMultiTerminal` is unchanged.
24
+ - Consumers of `@bananapus/core-v6@^0.0.53` can drop `via_ir = true` from their `foundry.toml` profiles if they only enabled it because of `JBCashOutHookSpecsLib`. `nana-core-v6`'s own profile flips `via_ir` to `false` to lock in the property.
25
+ - All 997 unit/non-fork tests pass on the refactored library + call sites.
26
+
16
27
  ## Summary
17
28
 
18
29
  - v6 adds explicit preview APIs for pay and cash-out flows. Integrations can simulate more of the terminal path directly from the core contracts.
@@ -57,6 +68,9 @@ This file describes the verified change from `nana-core-v5` to the current `nana
57
68
  - `IJBCashOutTerminal.previewCashOutFrom(...)` is new.
58
69
  - `IJBCashOutTerminal.payAfterCashOutTokensOf(...)` is new.
59
70
  - `IJBCashOutTerminal.addToBalanceAfterCashOutTokensOf(...)` is new.
71
+ - `IJBFeeTerminal.FEE()` is REMOVED. The terminal no longer re-exports the protocol fee constant; read
72
+ `JBConstants.FEE` directly. Off-chain integrators that previously called `terminal.FEE()` must switch to
73
+ reading the constant from `JBConstants`.
60
74
  - `IJBTerminalStore.previewPayFrom(...)` and `previewCashOutFrom(...)` are new.
61
75
  - `IJBTerminal.currentSurplusOf(...)` changed parameter shape.
62
76
  - `JBRulesetMetadata` adds `pauseCrossProjectFeeFreeInflows` and narrows `metadata` from 14 to 13 bits.
@@ -96,6 +110,8 @@ This file describes the verified change from `nana-core-v5` to the current `nana
96
110
  `HookAfterRecordCashOut` from the terminal address)
97
111
  - Renamed functions
98
112
  - `IJBController.addPriceFeed(...)` -> `addPriceFeedFor(...)`
113
+ - Removed functions
114
+ - `IJBFeeTerminal.FEE()` (read `JBConstants.FEE` directly)
99
115
  - Changed function shapes
100
116
  - `IJBTerminal.currentSurplusOf(...)`
101
117
  - Added events
package/foundry.toml CHANGED
@@ -3,7 +3,7 @@ solc = '0.8.28'
3
3
  bytecode_hash = "none"
4
4
  evm_version = 'cancun'
5
5
  optimizer_runs = 200
6
- via_ir = true
6
+ via_ir = false
7
7
  libs = ["node_modules", "lib"]
8
8
  fs_permissions = [{ access = "read-write", path = "./"}]
9
9
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/core-v6",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -81,16 +81,6 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
81
81
  error JBMultiTerminal_TokenNotAccepted(address token);
82
82
  error JBMultiTerminal_UnderMin(uint256 value, uint256 min);
83
83
 
84
- //*********************************************************************//
85
- // ------------------------- public constants ------------------------ //
86
- //*********************************************************************//
87
-
88
- /// @notice This terminal's fee (as a fraction out of `JBConstants.MAX_FEE`).
89
- /// @dev Fees are charged on payouts to addresses and surplus allowance usage, as well as cash outs while the
90
- /// cash out tax rate is less than 100%. Re-exports `JBConstants.FEE` so external callers can read it through
91
- /// the `IJBFeeTerminal` interface.
92
- uint256 public constant override FEE = JBConstants.FEE;
93
-
94
84
  //*********************************************************************//
95
85
  // ------------------------ internal constants ----------------------- //
96
86
  //*********************************************************************//
@@ -260,7 +250,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
260
250
  override
261
251
  returns (uint256 reclaimAmount)
262
252
  {
253
+ // Caller must hold (or be operator with) `CASH_OUT_TOKENS` permission on the source `holder`/`projectId`.
254
+ // Burning A's tokens is the load-bearing side effect — gating it stays at the same authority bar as the
255
+ // direct `cashOutTokensOf` entrypoint.
263
256
  _requireCashOutPermissionFrom({holder: holder, projectId: projectId});
257
+
258
+ // Destination opt-out check: B's current ruleset can set `pauseCrossProjectFeeFreeInflows = true` to
259
+ // refuse cross-project fee-free credits. Without this gate, anyone holding A's tokens could push a
260
+ // deferred-fee credit onto `_feeFreeSurplusOf[B]` without B consenting.
264
261
  _requireBeneficiaryAcceptsFeeFreeInflows(beneficiaryProjectId);
265
262
 
266
263
  // Burn source-project tokens, run cashout-side hooks, take any hook fees, and cap source fee-free.
@@ -278,13 +275,19 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
278
275
  // Nothing to route if the data hook returned zero reclaim.
279
276
  if (reclaimAmount == 0) return 0;
280
277
 
281
- // Route the reclaim to B's primary terminal as an `addToBalanceOf`, then credit B's fee-free surplus
282
- // by the delivery delta. `_efficientAddToBalance` handles same-terminal vs cross-terminal and
283
- // hardcodes `shouldReturnHeldFees: false` — this entrypoint cannot unlock B's held fees.
278
+ // Resolve B's primary terminal for the reclaim token. Could be this terminal (same-terminal short-
279
+ // circuit) or a router that swaps before depositing. Reverts if no primary terminal is registered.
284
280
  IJBTerminal destinationTerminal = _resolveBeneficiaryTerminal(beneficiaryProjectId, tokenToReclaim);
281
+
282
+ // Snapshot B's per-context balances on this terminal BEFORE routing. The post-routing comparison
283
+ // identifies the bucket the reclaim actually landed in (matters for cross-token routes where a router
284
+ // swaps to a different token in B's accounting-context list).
285
285
  (JBAccountingContext[] memory contexts, uint256[] memory balancesBefore) =
286
286
  _snapshotBeneficiaryContextBalances(beneficiaryProjectId);
287
287
 
288
+ // Route via `_efficientAddToBalance` — handles same-terminal vs cross-terminal (with the standard
289
+ // `_beforeTransferTo`/`_afterTransferTo` allowance dance) and hardcodes `shouldReturnHeldFees: false`,
290
+ // so this entrypoint cannot be used to unlock B's held fees on top of the source-side fee skip.
288
291
  _efficientAddToBalance({
289
292
  terminal: destinationTerminal,
290
293
  projectId: beneficiaryProjectId,
@@ -293,6 +296,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
293
296
  metadata: addToBalanceMetadata
294
297
  });
295
298
 
299
+ // Credit `_feeFreeSurplusOf[B]` on the first of B's contexts whose balance grew. This binds the
300
+ // skipped source-side fee on the destination side: B's next non-feeless cashout pays it. Reverts if
301
+ // no context grew (no delivery landed) — without delivery, the fee skip would leak.
296
302
  _creditFirstGrowingBeneficiaryContext(beneficiaryProjectId, contexts, balancesBefore);
297
303
  }
298
304
 
@@ -737,7 +743,14 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
737
743
  override
738
744
  returns (uint256 reclaimAmount, uint256 beneficiaryTokenCount)
739
745
  {
746
+ // Caller must hold (or be operator with) `CASH_OUT_TOKENS` permission on the source `holder`/`projectId`.
747
+ // Burning A's tokens is the load-bearing side effect — gating it stays at the same authority bar as the
748
+ // direct `cashOutTokensOf` entrypoint.
740
749
  _requireCashOutPermissionFrom({holder: holder, projectId: projectId});
750
+
751
+ // Destination opt-out check: B's current ruleset can set `pauseCrossProjectFeeFreeInflows = true` to
752
+ // refuse cross-project fee-free credits. Without this gate, anyone holding A's tokens could push a
753
+ // deferred-fee credit onto `_feeFreeSurplusOf[B]` without B consenting.
741
754
  _requireBeneficiaryAcceptsFeeFreeInflows(beneficiaryProjectId);
742
755
 
743
756
  // Burn source-project tokens, run cashout-side hooks, take any hook fees, and cap source fee-free.
@@ -1300,23 +1313,24 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1300
1313
  }
1301
1314
  }
1302
1315
 
1303
- // If the data hook returned cash out hook specifications, fulfill them.
1316
+ // If the data hook returned cash out hook specifications, fulfill them. The context is built
1317
+ // field-by-field rather than as a struct literal so each assignment uses only one stack slot at a
1318
+ // time, keeping the call site under solc 0.8.28's non-via-ir Yul stack ceiling (a 10-field struct
1319
+ // literal would otherwise trip it from inside `_cashOutTokensOf`).
1304
1320
  if (hookSpecifications.length != 0) {
1305
- // Fulfill the cash out hook specifications.
1306
- amountEligibleForFees += JBCashOutHookSpecsLib.fulfill({
1307
- feelessAddresses: FEELESS_ADDRESSES,
1308
- projectId: projectId,
1309
- holder: holder,
1310
- cashOutCount: cashOutCount,
1311
- ruleset: ruleset,
1312
- cashOutTaxRate: cashOutTaxRate,
1313
- beneficiary: beneficiary,
1314
- beneficiaryReclaimAmount: _tokenAmountOf({
1315
- projectId: projectId, token: tokenToReclaim, value: reclaimAmount
1316
- }),
1317
- specifications: hookSpecifications,
1318
- metadata: metadata
1319
- });
1321
+ JBTokenAmount memory reclaimTokenAmount =
1322
+ _tokenAmountOf({projectId: projectId, token: tokenToReclaim, value: reclaimAmount});
1323
+ JBAfterCashOutRecordedContext memory ctx;
1324
+ ctx.holder = holder;
1325
+ ctx.projectId = projectId;
1326
+ ctx.rulesetId = ruleset.id;
1327
+ ctx.cashOutCount = cashOutCount;
1328
+ ctx.reclaimedAmount = reclaimTokenAmount;
1329
+ ctx.forwardedAmount = reclaimTokenAmount;
1330
+ ctx.cashOutTaxRate = cashOutTaxRate;
1331
+ ctx.beneficiary = beneficiary;
1332
+ ctx.cashOutMetadata = metadata;
1333
+ amountEligibleForFees += JBCashOutHookSpecsLib.fulfill(FEELESS_ADDRESSES, ctx, hookSpecifications);
1320
1334
  }
1321
1335
 
1322
1336
  // Cap fee-free surplus at remaining balance.
@@ -1377,14 +1391,30 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1377
1391
  )
1378
1392
  internal
1379
1393
  {
1394
+ // Walk B's accounting contexts in declared order. The first context whose balance grew is treated as
1395
+ // the bucket the router chose to deposit into (typically the post-swap token in a cross-token route).
1396
+ // We bind the deferred source-side fee to that bucket and stop — subsequent grown contexts are ignored
1397
+ // by design, capping the credit at one delivery delta even if a misbehaving router somehow split the
1398
+ // deposit across multiple buckets.
1380
1399
  for (uint256 i; i < contexts.length;) {
1400
+ // Read B's post-routing balance for this context's token. Compared against the pre-routing snapshot
1401
+ // captured by `_snapshotBeneficiaryContextBalances` to detect the delivery delta.
1381
1402
  uint256 balanceAfter =
1382
1403
  STORE.balanceOf({terminal: address(this), projectId: beneficiaryProjectId, token: contexts[i].token});
1383
1404
 
1384
1405
  if (balanceAfter > balancesBefore[i]) {
1406
+ // Credit the delivery delta into B's fee-free counter for this token. `unchecked` is safe:
1407
+ // `balanceAfter > balancesBefore[i]` is the loop condition, so the subtraction can't underflow,
1408
+ // and the addition can't overflow before the underlying balance does (terminal balance is the
1409
+ // upper bound on any cumulative credit — see the cap call below).
1385
1410
  unchecked {
1386
1411
  _feeFreeSurplusOf[beneficiaryProjectId][contexts[i].token] += balanceAfter - balancesBefore[i];
1387
1412
  }
1413
+
1414
+ // Cap the credit at B's current balance for this token. If pay/addToBalance hooks pulled funds
1415
+ // back out during routing, the post-balance read inside `_capFeeFreeSurplus` clamps the
1416
+ // counter so it never exceeds what's actually sitting in B's bucket. Without this, B's later
1417
+ // zero-tax cashouts would over-fee phantom amounts.
1388
1418
  _capFeeFreeSurplus({projectId: beneficiaryProjectId, token: contexts[i].token});
1389
1419
  return;
1390
1420
  }
@@ -1394,6 +1424,9 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1394
1424
  }
1395
1425
  }
1396
1426
 
1427
+ // No context grew. Either the destination terminal silently dropped the funds or routed them
1428
+ // elsewhere (e.g. to a different terminal not registered as B's primary). Revert the entire
1429
+ // cross-project flow so the source-side fee skip never becomes a leak — A's burn is undone too.
1397
1430
  revert JBMultiTerminal_BeneficiaryProjectNotPaid(beneficiaryProjectId);
1398
1431
  }
1399
1432
 
@@ -1545,20 +1578,19 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
1545
1578
  // Hook fees still apply (those funds leave the protocol to external hooks). Hook context sees
1546
1579
  // `address(this)` as the beneficiary since the terminal is custodying the reclaim mid-flow.
1547
1580
  if (hookSpecifications.length != 0) {
1548
- amountEligibleForFees = JBCashOutHookSpecsLib.fulfill({
1549
- feelessAddresses: FEELESS_ADDRESSES,
1550
- projectId: projectId,
1551
- beneficiaryReclaimAmount: _tokenAmountOf({
1552
- projectId: projectId, token: tokenToReclaim, value: reclaimAmount
1553
- }),
1554
- holder: holder,
1555
- cashOutCount: cashOutCount,
1556
- metadata: cashOutMetadata,
1557
- ruleset: ruleset,
1558
- cashOutTaxRate: cashOutTaxRate,
1559
- beneficiary: payable(address(this)),
1560
- specifications: hookSpecifications
1561
- });
1581
+ JBTokenAmount memory reclaimTokenAmount =
1582
+ _tokenAmountOf({projectId: projectId, token: tokenToReclaim, value: reclaimAmount});
1583
+ JBAfterCashOutRecordedContext memory ctx;
1584
+ ctx.holder = holder;
1585
+ ctx.projectId = projectId;
1586
+ ctx.rulesetId = ruleset.id;
1587
+ ctx.cashOutCount = cashOutCount;
1588
+ ctx.reclaimedAmount = reclaimTokenAmount;
1589
+ ctx.forwardedAmount = reclaimTokenAmount;
1590
+ ctx.cashOutTaxRate = cashOutTaxRate;
1591
+ ctx.beneficiary = payable(address(this));
1592
+ ctx.cashOutMetadata = cashOutMetadata;
1593
+ amountEligibleForFees = JBCashOutHookSpecsLib.fulfill(FEELESS_ADDRESSES, ctx, hookSpecifications);
1562
1594
  }
1563
1595
 
1564
1596
  // Cap the source project's fee-free surplus at remaining balance after the outflow. Same invariant as
@@ -2112,7 +2144,7 @@ contract JBMultiTerminal is JBPermissioned, ERC2771Context, IJBMultiTerminal {
2112
2144
  projectId: projectId,
2113
2145
  token: token,
2114
2146
  amount: amount,
2115
- fee: FEE,
2147
+ fee: JBConstants.FEE,
2116
2148
  beneficiary: beneficiary,
2117
2149
  caller: _msgSender()
2118
2150
  });
@@ -71,9 +71,6 @@ interface IJBFeeTerminal is IJBTerminal {
71
71
  address caller
72
72
  );
73
73
 
74
- /// @notice The terminal's fee as a fraction of `JBConstants.MAX_FEE`.
75
- function FEE() external view returns (uint256);
76
-
77
74
  /// @notice The contract that tracks feeless addresses.
78
75
  function FEELESS_ADDRESSES() external view returns (IJBFeelessAddresses);
79
76
 
@@ -8,7 +8,6 @@ import {IJBCashOutHook} from "../interfaces/IJBCashOutHook.sol";
8
8
  import {IJBFeelessAddresses} from "../interfaces/IJBFeelessAddresses.sol";
9
9
  import {JBAfterCashOutRecordedContext} from "../structs/JBAfterCashOutRecordedContext.sol";
10
10
  import {JBCashOutHookSpecification} from "../structs/JBCashOutHookSpecification.sol";
11
- import {JBRuleset} from "../structs/JBRuleset.sol";
12
11
  import {JBTokenAmount} from "../structs/JBTokenAmount.sol";
13
12
  import {JBConstants} from "./JBConstants.sol";
14
13
  import {JBFees} from "./JBFees.sol";
@@ -58,108 +57,104 @@ library JBCashOutHookSpecsLib {
58
57
  /// @dev For each spec: if the hook is feeless, it gets the full spec amount; otherwise the hook gets
59
58
  /// `amount - feeAmountFrom(amount)` and the gross spec amount is added to the eligible-for-fees total
60
59
  /// (the caller takes the fee separately via `_takeFeeFrom`). Cross-token semantics: the hook context's
61
- /// `forwardedAmount` carries the post-fee amount in the same token as `beneficiaryReclaimAmount`.
60
+ /// `forwardedAmount` carries the post-fee amount in the same token as `context.reclaimedAmount`.
61
+ /// @dev The caller builds `context` once (`JBAfterCashOutRecordedContext`) and passes it in; the
62
+ /// library mutates `context.forwardedAmount` and `context.hookMetadata` per spec. Bundling the
63
+ /// caller-side state into the existing hook-context struct keeps the call site under solc 0.8.28's
64
+ /// non-via-ir 16-slot Yul stack ceiling (the prior 10-arg shape tripped it from
65
+ /// `JBMultiTerminal._cashOutTokensOf`).
62
66
  /// @param feelessAddresses Registry of fee-exempt addresses (consulted per-hook).
63
- /// @param projectId The project being cashed out from.
64
- /// @param beneficiaryReclaimAmount The token amount reference (token, decimals, currency, gross value).
65
- /// @param holder The account whose project tokens were burned.
66
- /// @param cashOutCount The number of project tokens burned.
67
- /// @param metadata Bytes forwarded to each hook as `cashOutMetadata`.
68
- /// @param ruleset The ruleset active during the cash out.
69
- /// @param cashOutTaxRate The cash out tax rate applied.
70
- /// @param beneficiary The address forwarded as the hook context's `beneficiary` (typically the user-supplied
71
- /// recipient or `address(this)` for cross-project flows where the terminal custodies the reclaim mid-flow).
67
+ /// @param context Cash-out context to forward into each hook. `context.reclaimedAmount` doubles as the
68
+ /// token-amount reference for per-spec transfers; `context.forwardedAmount` / `context.hookMetadata`
69
+ /// are rewritten per iteration.
72
70
  /// @param specifications The hook specifications returned by the data hook.
73
71
  /// @return amountEligibleForFees Total spec amounts (gross) from non-feeless hooks, used by the caller to
74
72
  /// charge fees in a single pass.
75
73
  function fulfill(
76
74
  IJBFeelessAddresses feelessAddresses,
77
- uint256 projectId,
78
- JBTokenAmount memory beneficiaryReclaimAmount,
79
- address holder,
80
- uint256 cashOutCount,
81
- bytes memory metadata,
82
- JBRuleset memory ruleset,
83
- uint256 cashOutTaxRate,
84
- address payable beneficiary,
75
+ JBAfterCashOutRecordedContext memory context,
85
76
  JBCashOutHookSpecification[] memory specifications
86
77
  )
87
78
  external
88
79
  returns (uint256 amountEligibleForFees)
89
80
  {
90
- JBAfterCashOutRecordedContext memory context = JBAfterCashOutRecordedContext({
91
- holder: holder,
92
- projectId: projectId,
93
- rulesetId: ruleset.id,
94
- cashOutCount: cashOutCount,
95
- reclaimedAmount: beneficiaryReclaimAmount,
96
- forwardedAmount: beneficiaryReclaimAmount,
97
- cashOutTaxRate: cashOutTaxRate,
98
- beneficiary: beneficiary,
99
- hookMetadata: "",
100
- cashOutMetadata: metadata
101
- });
102
-
81
+ // Loop body is extracted into a helper to keep `fulfill`'s stack frame shallow enough for solc 0.8.28
82
+ // to compile *without* `via_ir`.
103
83
  for (uint256 i; i < specifications.length;) {
104
- JBCashOutHookSpecification memory specification = specifications[i];
105
-
106
- // A noop specification is informational only and doesn't trigger the hook.
107
- if (specification.noop) {
108
- unchecked {
109
- ++i;
110
- }
111
- continue;
112
- }
113
-
114
- // Get the fee for the specified amount.
115
- uint256 specificationAmountFee = feelessAddresses.isFeelessFor({
116
- addr: address(specification.hook), projectId: projectId
117
- })
118
- ? 0
119
- : JBFees.standardFeeAmountFrom({amountBeforeFee: specification.amount});
120
-
121
- // Add the specification's amount to the amount eligible for fees.
122
- if (specificationAmountFee != 0) {
123
- amountEligibleForFees += specification.amount;
124
- specification.amount -= specificationAmountFee;
125
- }
126
-
127
- // Pass the correct token `forwardedAmount` to the hook.
128
- context.forwardedAmount = JBTokenAmount({
129
- value: specification.amount,
130
- token: beneficiaryReclaimAmount.token,
131
- decimals: beneficiaryReclaimAmount.decimals,
132
- currency: beneficiaryReclaimAmount.currency
133
- });
134
-
135
- // Pass the correct metadata from the data hook's specification.
136
- context.hookMetadata = specification.metadata;
137
-
138
- // Trigger any inherited pre-transfer logic.
139
- // Keep a reference to the amount that'll be paid as a `msg.value`.
140
- uint256 payValue = _beforeTransferTo({
141
- to: address(specification.hook), token: beneficiaryReclaimAmount.token, amount: specification.amount
142
- });
143
-
144
- // Fulfill the specification.
145
- specification.hook.afterCashOutRecordedWith{value: payValue}(context);
146
-
147
- // Revoke the temporary pull allowance now that the hook call has finished.
148
- _afterTransferTo({to: address(specification.hook), token: beneficiaryReclaimAmount.token});
149
-
150
- emit HookAfterRecordCashOut({
151
- hook: specification.hook,
152
- context: context,
153
- specificationAmount: specification.amount,
154
- fee: specificationAmountFee,
155
- caller: msg.sender
156
- });
84
+ amountEligibleForFees += _fulfillOne(feelessAddresses, context, specifications[i]);
157
85
  unchecked {
158
86
  ++i;
159
87
  }
160
88
  }
161
89
  }
162
90
 
91
+ /// @notice Fulfill a single hook specification: charge the protocol fee (if non-feeless), set up the hook
92
+ /// call context, transfer the post-fee amount to the hook, and emit the after-cash-out event.
93
+ /// @dev Returns the gross spec amount when the hook is non-feeless (so the caller can accumulate it into
94
+ /// `amountEligibleForFees`), or `0` when the hook is feeless / noop. Mutates `context.forwardedAmount`
95
+ /// and `context.hookMetadata` so the caller's next iteration can overwrite them.
96
+ /// @param feelessAddresses Registry of fee-exempt addresses (consulted to skip the per-hook fee).
97
+ /// @param context Cash-out context forwarded into the hook. `context.reclaimedAmount` is used as the
98
+ /// token-amount reference for the per-spec transfer; `context.forwardedAmount` and
99
+ /// `context.hookMetadata` are overwritten by this call.
100
+ /// @param specification The hook specification to fulfill (hook address, amount, metadata, noop flag).
101
+ /// @return grossSpecAmount The gross spec amount when the hook is non-feeless (for caller accumulation
102
+ /// into `amountEligibleForFees`); `0` for feeless or noop specifications.
103
+ function _fulfillOne(
104
+ IJBFeelessAddresses feelessAddresses,
105
+ JBAfterCashOutRecordedContext memory context,
106
+ JBCashOutHookSpecification memory specification
107
+ )
108
+ private
109
+ returns (uint256 grossSpecAmount)
110
+ {
111
+ // A noop specification is informational only and doesn't trigger the hook.
112
+ if (specification.noop) return 0;
113
+
114
+ // Get the fee for the specified amount.
115
+ uint256 specificationAmountFee = feelessAddresses.isFeelessFor({
116
+ addr: address(specification.hook), projectId: context.projectId
117
+ })
118
+ ? 0
119
+ : JBFees.standardFeeAmountFrom({amountBeforeFee: specification.amount});
120
+
121
+ // Surface the gross spec amount to the caller so it can be accumulated into `amountEligibleForFees`.
122
+ if (specificationAmountFee != 0) {
123
+ grossSpecAmount = specification.amount;
124
+ specification.amount -= specificationAmountFee;
125
+ }
126
+
127
+ // Pass the correct token `forwardedAmount` to the hook.
128
+ context.forwardedAmount = JBTokenAmount({
129
+ value: specification.amount,
130
+ token: context.reclaimedAmount.token,
131
+ decimals: context.reclaimedAmount.decimals,
132
+ currency: context.reclaimedAmount.currency
133
+ });
134
+
135
+ // Pass the correct metadata from the data hook's specification.
136
+ context.hookMetadata = specification.metadata;
137
+
138
+ // Trigger any inherited pre-transfer logic. Keep a reference to the amount that'll be paid as a `msg.value`.
139
+ uint256 payValue = _beforeTransferTo({
140
+ to: address(specification.hook), token: context.reclaimedAmount.token, amount: specification.amount
141
+ });
142
+
143
+ // Fulfill the specification.
144
+ specification.hook.afterCashOutRecordedWith{value: payValue}(context);
145
+
146
+ // Revoke the temporary pull allowance now that the hook call has finished.
147
+ _afterTransferTo({to: address(specification.hook), token: context.reclaimedAmount.token});
148
+
149
+ emit HookAfterRecordCashOut({
150
+ hook: specification.hook,
151
+ context: context,
152
+ specificationAmount: specification.amount,
153
+ fee: specificationAmountFee,
154
+ caller: msg.sender
155
+ });
156
+ }
157
+
163
158
  //*********************************************************************//
164
159
  // ----------------------- private helpers --------------------------- //
165
160
  //*********************************************************************//