@bananapus/721-hook-v6 0.0.21 → 0.0.23

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.
@@ -26,6 +26,7 @@ import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
26
26
  /// @notice External library for JB721TiersHook operations extracted to stay within the EIP-170 contract size limit.
27
27
  /// @dev Handles tier adjustments, split calculations, price normalization, and split fund distribution.
28
28
  library JB721TiersHookLib {
29
+ error JB721TiersHookLib_NoTerminalForLeftover(uint256 projectId, address token, uint256 leftoverAmount);
29
30
  // Events mirrored from IJB721TiersHook (emitted via DELEGATECALL from the hook's context).
30
31
  event AddToBalanceReverted(uint256 indexed projectId, address token, uint256 amount, bytes reason);
31
32
  event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
@@ -221,73 +222,26 @@ library JB721TiersHookLib {
221
222
  }
222
223
  }
223
224
 
224
- /// @notice Converts split amounts from tier pricing denomination to payment token denomination.
225
- /// @dev Called after `calculateSplitAmounts` when the payment currency differs from the tier pricing currency.
226
- /// @param totalSplitAmount The total split amount in tier pricing denomination.
227
- /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts) from calculateSplitAmounts.
228
- /// @param packedPricingContext The packed pricing context (currency, decimals).
229
- /// @param prices The prices contract used for currency conversion.
230
- /// @param projectId The project ID.
231
- /// @param amountCurrency The payment amount currency.
232
- /// @param amountDecimals The payment amount decimals.
233
- /// @return convertedTotal The total split amount converted to payment token denomination.
234
- /// @return convertedMetadata The re-encoded per-tier breakdown with converted amounts.
235
- function convertSplitAmounts(
236
- uint256 totalSplitAmount,
237
- bytes memory splitMetadata,
238
- uint256 packedPricingContext,
239
- IJBPrices prices,
240
- uint256 projectId,
241
- uint256 amountCurrency,
242
- uint256 amountDecimals
243
- )
244
- external
245
- view
246
- returns (uint256 convertedTotal, bytes memory convertedMetadata)
247
- {
248
- // forge-lint: disable-next-line(unsafe-typecast)
249
- uint256 pricingCurrency = uint256(uint32(packedPricingContext));
250
- if (amountCurrency == pricingCurrency) return (totalSplitAmount, splitMetadata);
251
-
252
- // No price oracle available to convert between currencies. Return 0 to skip the split rather than
253
- // forwarding an unconverted amount denominated in the wrong currency, which would over- or under-pay.
254
- if (address(prices) == address(0)) return (0, splitMetadata);
255
-
256
- // forge-lint: disable-next-line(unsafe-typecast)
257
- uint256 pricingDecimals = uint256(uint8(packedPricingContext >> 32));
258
- uint256 ratio = prices.pricePerUnitOf({
259
- projectId: projectId,
260
- pricingCurrency: amountCurrency,
261
- unitCurrency: pricingCurrency,
262
- decimals: amountDecimals
263
- });
264
-
265
- (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(splitMetadata, (uint16[], uint256[]));
266
- for (uint256 i; i < amounts.length; i++) {
267
- amounts[i] = mulDiv({x: amounts[i], y: ratio, denominator: 10 ** pricingDecimals});
268
- convertedTotal += amounts[i];
269
- }
270
- convertedMetadata = abi.encode(tierIds, amounts);
271
- }
272
-
273
225
  /// @notice Calculates the weight for token minting after accounting for tier split amounts.
274
226
  /// @dev Extracted from the hook to keep mulDiv's bytecode out of the hook (EIP-170 compliance).
275
227
  /// @param contextWeight The original weight from the payment context.
276
228
  /// @param amountValue The payment amount value.
277
229
  /// @param totalSplitAmount The total amount routed to tier splits.
278
- /// @param issueTokensForSplits Whether to issue tokens for the full payment regardless of splits.
230
+ /// @param store The 721 tiers hook store (to read flags).
231
+ /// @param hook The hook address.
279
232
  /// @return weight The adjusted weight for token minting.
280
233
  function calculateWeight(
281
234
  uint256 contextWeight,
282
235
  uint256 amountValue,
283
236
  uint256 totalSplitAmount,
284
- bool issueTokensForSplits
237
+ IJB721TiersHookStore store,
238
+ address hook
285
239
  )
286
240
  external
287
- pure
241
+ view
288
242
  returns (uint256 weight)
289
243
  {
290
- if (totalSplitAmount == 0 || issueTokensForSplits) {
244
+ if (totalSplitAmount == 0 || store.flagsOf(hook).issueTokensForSplits) {
291
245
  // No splits, or hook configured to give full token credit regardless — full weight.
292
246
  weight = contextWeight;
293
247
  } else if (amountValue > totalSplitAmount) {
@@ -299,6 +253,96 @@ library JB721TiersHookLib {
299
253
  }
300
254
  }
301
255
 
256
+ /// @notice Converts split amounts from tier pricing to payment denomination (if currencies differ), then caps
257
+ /// the total at the actual payment value — proportionally reducing per-tier amounts when the cap applies.
258
+ /// @dev Combines currency conversion and cap into one external call to keep hook bytecode under EIP-170.
259
+ /// @param totalSplitAmount The total split amount in tier pricing denomination.
260
+ /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts).
261
+ /// @param packedPricingContext The packed pricing context (currency in bits 0-31, decimals in bits 32-39).
262
+ /// @param prices The prices contract used for currency conversion.
263
+ /// @param projectId The project ID.
264
+ /// @param amountCurrency The payment amount currency.
265
+ /// @param amountDecimals The payment amount decimals.
266
+ /// @param amountValue The actual payment value (used as the cap).
267
+ /// @return convertedTotal The total split amount after conversion and capping.
268
+ /// @return convertedMetadata The re-encoded per-tier breakdown with adjusted amounts.
269
+ function convertAndCapSplitAmounts(
270
+ uint256 totalSplitAmount,
271
+ bytes memory splitMetadata,
272
+ uint256 packedPricingContext,
273
+ IJBPrices prices,
274
+ uint256 projectId,
275
+ uint256 amountCurrency,
276
+ uint256 amountDecimals,
277
+ uint256 amountValue
278
+ )
279
+ external
280
+ view
281
+ returns (uint256 convertedTotal, bytes memory convertedMetadata)
282
+ {
283
+ // Start from the input values; conversion and capping modify them in-place below.
284
+ convertedTotal = totalSplitAmount;
285
+ convertedMetadata = splitMetadata;
286
+
287
+ // Convert each per-tier amount from the tier pricing currency to the payment currency.
288
+ // forge-lint: disable-next-line(unsafe-typecast)
289
+ if (amountCurrency != uint256(uint32(packedPricingContext))) {
290
+ // No price oracle available — return 0 to skip the split rather than forwarding an unconverted
291
+ // amount denominated in the wrong currency, which would over- or under-pay.
292
+ if (address(prices) == address(0)) return (0, convertedMetadata);
293
+
294
+ {
295
+ // Get the price ratio: how many payment-currency units per one tier-pricing-currency unit.
296
+ // forge-lint: disable-next-line(unsafe-typecast)
297
+ uint256 ratio = prices.pricePerUnitOf({
298
+ projectId: projectId,
299
+ pricingCurrency: amountCurrency,
300
+ // forge-lint: disable-next-line(unsafe-typecast)
301
+ unitCurrency: uint256(uint32(packedPricingContext)),
302
+ decimals: amountDecimals
303
+ });
304
+
305
+ // The denominator scales each amount from tier-pricing decimals to payment-token decimals.
306
+ // forge-lint: disable-next-line(unsafe-typecast)
307
+ uint256 denom = 10 ** uint256(uint8(packedPricingContext >> 32));
308
+
309
+ // Decode per-tier breakdown so each amount can be converted individually.
310
+ (uint16[] memory tierIds, uint256[] memory amounts) =
311
+ abi.decode(convertedMetadata, (uint16[], uint256[]));
312
+
313
+ // Re-accumulate the total from converted amounts to avoid rounding drift.
314
+ convertedTotal = 0;
315
+ for (uint256 i; i < amounts.length; i++) {
316
+ // Convert this tier's amount: amount * ratio / 10^pricingDecimals.
317
+ amounts[i] = mulDiv({x: amounts[i], y: ratio, denominator: denom});
318
+ convertedTotal += amounts[i];
319
+ }
320
+
321
+ // Re-encode with the converted amounts.
322
+ convertedMetadata = abi.encode(tierIds, amounts);
323
+ }
324
+ }
325
+
326
+ // Cap the total at the actual payment value. Pay credits fund NFT minting (virtual), but splits
327
+ // require real tokens to distribute. Without this cap, a user with sufficient pay credits but
328
+ // insufficient ETH would revert because the terminal can't forward more than what was actually paid.
329
+ if (convertedTotal > amountValue) {
330
+ // Proportionally reduce each per-tier amount to stay in sync with the capped total.
331
+ if (convertedMetadata.length != 0) {
332
+ (uint16[] memory tierIds, uint256[] memory amounts) =
333
+ abi.decode(convertedMetadata, (uint16[], uint256[]));
334
+ for (uint256 i; i < amounts.length; i++) {
335
+ // Scale down: amount * amountValue / originalTotal.
336
+ amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: convertedTotal});
337
+ }
338
+ convertedMetadata = abi.encode(tierIds, amounts);
339
+ }
340
+
341
+ // Clamp the total to the payment value.
342
+ convertedTotal = amountValue;
343
+ }
344
+ }
345
+
302
346
  /// @notice Sets split groups in JBSplits for tiers that have splits configured.
303
347
  function _setSplitGroupsFor(
304
348
  IJBSplits splits,
@@ -395,15 +439,19 @@ library JB721TiersHookLib {
395
439
  bool isNativeToken = token == JBConstants.NATIVE_TOKEN;
396
440
  uint256 leftoverPercentage = JBConstants.SPLITS_TOTAL_PERCENT;
397
441
  uint256 leftoverAmount = amount;
442
+ amount = 0;
398
443
 
399
444
  for (uint256 j; j < tierSplits.length; j++) {
400
445
  uint256 payoutAmount =
401
446
  mulDiv({x: leftoverAmount, y: tierSplits[j].percent, denominator: leftoverPercentage});
402
447
  if (payoutAmount != 0) {
403
- // Only subtract from leftover if the split has a valid recipient.
404
- // Splits with no projectId and no beneficiary are skipped — their share
405
- // stays in leftoverAmount and is added to the project's balance below.
406
- if (_sendPayoutToSplit({
448
+ unchecked {
449
+ leftoverAmount -= payoutAmount;
450
+ }
451
+ // On failure, don't re-add to leftoverAmount — this prevents inflating later recipients.
452
+ // Failed amounts accumulate as the gap between `amount` and `leftoverAmount + total sent`.
453
+ // After the loop, we re-add leftoverPercentage-based residual naturally.
454
+ if (!_sendPayoutToSplit({
407
455
  directory: directory,
408
456
  split: tierSplits[j],
409
457
  token: token,
@@ -412,8 +460,10 @@ library JB721TiersHookLib {
412
460
  groupId: groupId,
413
461
  decimals: decimals
414
462
  })) {
463
+ // Payout failed — route to project balance by returning to leftover after the loop.
464
+ // We add back to `amount` (parameter, no longer used for its original purpose).
415
465
  unchecked {
416
- leftoverAmount -= payoutAmount;
466
+ amount += payoutAmount;
417
467
  }
418
468
  }
419
469
  }
@@ -422,39 +472,44 @@ library JB721TiersHookLib {
422
472
  }
423
473
  }
424
474
 
475
+ // Route failed payout amounts to the project's balance.
476
+ leftoverAmount += amount;
477
+
425
478
  if (leftoverAmount != 0) {
426
479
  // slither-disable-next-line calls-loop
427
480
  IJBTerminal terminal = directory.primaryTerminalOf({projectId: projectId, token: token});
428
- if (address(terminal) != address(0)) {
429
- if (isNativeToken) {
430
- // slither-disable-next-line arbitrary-send-eth,calls-loop
431
- try terminal.addToBalanceOf{value: leftoverAmount}({
432
- projectId: projectId,
433
- token: token,
434
- amount: leftoverAmount,
435
- shouldReturnHeldFees: false,
436
- memo: "",
437
- metadata: bytes("")
438
- }) {}
439
- catch (bytes memory reason) {
440
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
441
- }
442
- } else {
443
- SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: leftoverAmount});
444
- // slither-disable-next-line calls-loop
445
- try terminal.addToBalanceOf({
446
- projectId: projectId,
447
- token: token,
448
- amount: leftoverAmount,
449
- shouldReturnHeldFees: false,
450
- memo: "",
451
- metadata: bytes("")
452
- }) {}
453
- catch (bytes memory reason) {
454
- // Reset approval on failure.
455
- SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
456
- emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
457
- }
481
+ // Revert if there are leftover funds but no terminal to route them to.
482
+ if (address(terminal) == address(0)) {
483
+ revert JB721TiersHookLib_NoTerminalForLeftover(projectId, token, leftoverAmount);
484
+ }
485
+ if (isNativeToken) {
486
+ // slither-disable-next-line arbitrary-send-eth,calls-loop
487
+ try terminal.addToBalanceOf{value: leftoverAmount}({
488
+ projectId: projectId,
489
+ token: token,
490
+ amount: leftoverAmount,
491
+ shouldReturnHeldFees: false,
492
+ memo: "",
493
+ metadata: bytes("")
494
+ }) {}
495
+ catch (bytes memory reason) {
496
+ emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
497
+ }
498
+ } else {
499
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: leftoverAmount});
500
+ // slither-disable-next-line calls-loop
501
+ try terminal.addToBalanceOf({
502
+ projectId: projectId,
503
+ token: token,
504
+ amount: leftoverAmount,
505
+ shouldReturnHeldFees: false,
506
+ memo: "",
507
+ metadata: bytes("")
508
+ }) {}
509
+ catch (bytes memory reason) {
510
+ // Reset approval on failure.
511
+ SafeERC20.forceApprove({token: IERC20(token), spender: address(terminal), value: 0});
512
+ emit AddToBalanceReverted(projectId, token, leftoverAmount, reason);
458
513
  }
459
514
  }
460
515
  }
@@ -11,7 +11,6 @@ import {IJB721TokenUriResolver} from "../interfaces/IJB721TokenUriResolver.sol";
11
11
  /// @custom:member tokenUriResolver The contract responsible for resolving the URI for each NFT.
12
12
  /// @custom:member contractUri The URI where this contract's metadata can be found.
13
13
  /// @custom:member tiersConfig The NFT tiers and pricing config to launch the hook with.
14
- /// @custom:member reserveBeneficiary The default reserved beneficiary for all tiers.
15
14
  /// @custom:member flags A set of boolean options to configure the hook with.
16
15
  // forge-lint: disable-next-line(pascal-case-struct)
17
16
  struct JBDeploy721TiersHookConfig {
@@ -21,6 +20,5 @@ struct JBDeploy721TiersHookConfig {
21
20
  IJB721TokenUriResolver tokenUriResolver;
22
21
  string contractUri;
23
22
  JB721InitTiersConfig tiersConfig;
24
- address reserveBeneficiary;
25
23
  JB721TiersHookFlags flags;
26
24
  }
@@ -815,7 +815,6 @@ contract Test_TiersHook_E2E is TestBaseWorkflow {
815
815
  tiersConfig: JB721InitTiersConfig({
816
816
  tiers: tierConfigs, currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
817
817
  }),
818
- reserveBeneficiary: reserveBeneficiary,
819
818
  flags: JB721TiersHookFlags({
820
819
  preventOverspending: false,
821
820
  issueTokensForSplits: false,
@@ -908,7 +907,6 @@ contract Test_TiersHook_E2E is TestBaseWorkflow {
908
907
  tiersConfig: JB721InitTiersConfig({
909
908
  tiers: tierConfigs, currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
910
909
  }),
911
- reserveBeneficiary: reserveBeneficiary,
912
910
  flags: JB721TiersHookFlags({
913
911
  preventOverspending: false,
914
912
  issueTokensForSplits: false,
package/test/Fork.t.sol CHANGED
@@ -272,7 +272,6 @@ contract Fork_721Hook_Test is Test {
272
272
  currency: uint32(uint160(NATIVE_TOKEN)),
273
273
  decimals: 18
274
274
  }),
275
- reserveBeneficiary: reserveBeneficiary,
276
275
  flags: flags
277
276
  });
278
277
 
@@ -0,0 +1,196 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "../utils/UnitTestSetup.sol";
6
+ import {IJB721TiersHookStore} from "../../src/interfaces/IJB721TiersHookStore.sol";
7
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
8
+ import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
9
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
10
+ import {JBAfterPayRecordedContext} from "@bananapus/core-v6/src/structs/JBAfterPayRecordedContext.sol";
11
+ import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
12
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
13
+ import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
14
+
15
+ contract CodexPayCreditsBypassTierSplits is UnitTestSetup {
16
+ address internal splitBeneficiary = makeAddr("splitBeneficiary");
17
+
18
+ function setUp() public override {
19
+ super.setUp();
20
+ vm.etch(mockJBSplits, new bytes(0x69));
21
+ }
22
+
23
+ function _buildPayMetadata(
24
+ address hookAddress,
25
+ bool allowOverspending,
26
+ uint16[] memory tierIdsToMint
27
+ )
28
+ internal
29
+ view
30
+ returns (bytes memory)
31
+ {
32
+ bytes[] memory data = new bytes[](1);
33
+ data[0] = abi.encode(allowOverspending, tierIdsToMint);
34
+ bytes4[] memory ids = new bytes4[](1);
35
+ ids[0] = metadataHelper.getId("pay", hookAddress);
36
+ return metadataHelper.createMetadata(ids, data);
37
+ }
38
+
39
+ function _beforePayContext(
40
+ address hookAddress,
41
+ uint256 amountValue,
42
+ uint16[] memory tierIdsToMint
43
+ )
44
+ internal
45
+ view
46
+ returns (JBBeforePayRecordedContext memory)
47
+ {
48
+ return JBBeforePayRecordedContext({
49
+ terminal: mockTerminalAddress,
50
+ payer: beneficiary,
51
+ amount: JBTokenAmount({
52
+ token: JBConstants.NATIVE_TOKEN,
53
+ value: amountValue,
54
+ decimals: 18,
55
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
56
+ }),
57
+ projectId: projectId,
58
+ rulesetId: 0,
59
+ beneficiary: beneficiary,
60
+ weight: 10e18,
61
+ reservedPercent: 5000,
62
+ metadata: _buildPayMetadata(hookAddress, false, tierIdsToMint)
63
+ });
64
+ }
65
+
66
+ function _afterPayContext(
67
+ address hookAddress,
68
+ uint256 amountValue,
69
+ uint256 forwardedAmountValue,
70
+ bytes memory hookMetadata,
71
+ uint16[] memory tierIdsToMint
72
+ )
73
+ internal
74
+ view
75
+ returns (JBAfterPayRecordedContext memory)
76
+ {
77
+ return JBAfterPayRecordedContext({
78
+ payer: beneficiary,
79
+ projectId: projectId,
80
+ rulesetId: 0,
81
+ amount: JBTokenAmount({
82
+ token: JBConstants.NATIVE_TOKEN,
83
+ value: amountValue,
84
+ decimals: 18,
85
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
86
+ }),
87
+ forwardedAmount: JBTokenAmount({
88
+ token: JBConstants.NATIVE_TOKEN,
89
+ value: forwardedAmountValue,
90
+ decimals: 18,
91
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
92
+ }),
93
+ weight: 10e18,
94
+ newlyIssuedTokenCount: 0,
95
+ beneficiary: beneficiary,
96
+ hookMetadata: hookMetadata,
97
+ payerMetadata: _buildPayMetadata(hookAddress, true, tierIdsToMint)
98
+ });
99
+ }
100
+
101
+ function test_payCredits_can_bypass_most_of_tier_split_payment() public {
102
+ ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
103
+ IJB721TiersHookStore hookStore = testHook.STORE();
104
+
105
+ vm.mockCall(
106
+ mockJBDirectory,
107
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
108
+ abi.encode(true)
109
+ );
110
+
111
+ JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
112
+ tierConfigs[0] = JB721TierConfig({
113
+ price: 1 ether,
114
+ initialSupply: 100,
115
+ votingUnits: 0,
116
+ reserveFrequency: 0,
117
+ reserveBeneficiary: reserveBeneficiary,
118
+ encodedIPFSUri: bytes32(uint256(0x1234)),
119
+ category: 1,
120
+ discountPercent: 0,
121
+ allowOwnerMint: false,
122
+ useReserveBeneficiaryAsDefault: false,
123
+ transfersPausable: false,
124
+ cannotBeRemoved: false,
125
+ cannotIncreaseDiscountPercent: false,
126
+ useVotingUnits: false,
127
+ splitPercent: 1_000_000_000,
128
+ splits: new JBSplit[](0)
129
+ });
130
+
131
+ vm.prank(address(testHook));
132
+ uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
133
+
134
+ uint16[] memory noTiers = new uint16[](0);
135
+ JBAfterPayRecordedContext memory creditSeedContext = JBAfterPayRecordedContext({
136
+ payer: beneficiary,
137
+ projectId: projectId,
138
+ rulesetId: 0,
139
+ amount: JBTokenAmount({
140
+ token: JBConstants.NATIVE_TOKEN,
141
+ value: 1 ether,
142
+ decimals: 18,
143
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
144
+ }),
145
+ forwardedAmount: JBTokenAmount({
146
+ token: JBConstants.NATIVE_TOKEN,
147
+ value: 0,
148
+ decimals: 18,
149
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
150
+ }),
151
+ weight: 10e18,
152
+ newlyIssuedTokenCount: 0,
153
+ beneficiary: beneficiary,
154
+ hookMetadata: "",
155
+ payerMetadata: _buildPayMetadata(address(testHook), true, noTiers)
156
+ });
157
+
158
+ vm.prank(mockTerminalAddress);
159
+ testHook.afterPayRecordedWith(creditSeedContext);
160
+ assertEq(testHook.payCreditsOf(beneficiary), 1 ether, "setup: credits should be seeded");
161
+
162
+ uint16[] memory mintIds = new uint16[](1);
163
+ mintIds[0] = uint16(tierIds[0]);
164
+
165
+ (, JBPayHookSpecification[] memory specs) =
166
+ testHook.beforePayRecordedWith(_beforePayContext(address(testHook), 1, mintIds));
167
+ assertEq(specs[0].amount, 1, "forwarded amount is capped to the fresh payment");
168
+
169
+ JBSplit[] memory splits = new JBSplit[](1);
170
+ splits[0] = JBSplit({
171
+ percent: 1_000_000_000,
172
+ projectId: 0,
173
+ beneficiary: payable(splitBeneficiary),
174
+ preferAddToBalance: false,
175
+ lockedUntil: 0,
176
+ hook: IJBSplitHook(address(0))
177
+ });
178
+
179
+ uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
180
+ vm.mockCall(
181
+ mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
182
+ );
183
+
184
+ uint256 splitBalanceBefore = splitBeneficiary.balance;
185
+
186
+ JBAfterPayRecordedContext memory creditMintContext =
187
+ _afterPayContext(address(testHook), 1, 1, specs[0].metadata, mintIds);
188
+ vm.deal(mockTerminalAddress, 1);
189
+ vm.prank(mockTerminalAddress);
190
+ testHook.afterPayRecordedWith{value: 1}(creditMintContext);
191
+
192
+ assertEq(testHook.balanceOf(beneficiary), 1, "beneficiary should receive the NFT");
193
+ assertEq(testHook.payCreditsOf(beneficiary), 1, "credits should fund almost the entire mint");
194
+ assertEq(splitBeneficiary.balance - splitBalanceBefore, 1, "split recipient only gets the fresh payment");
195
+ }
196
+ }