@bananapus/721-hook-v6 0.0.33 → 0.0.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bananapus/721-hook-v6",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -24,6 +24,7 @@ import {IJB721CheckpointsDeployer} from "./interfaces/IJB721CheckpointsDeployer.
24
24
  import {IJB721TiersHook} from "./interfaces/IJB721TiersHook.sol";
25
25
  import {IJB721TiersHookStore} from "./interfaces/IJB721TiersHookStore.sol";
26
26
  import {IJB721TokenUriResolver} from "./interfaces/IJB721TokenUriResolver.sol";
27
+ import {JB721Constants} from "./libraries/JB721Constants.sol";
27
28
  import {JB721TiersHookLib} from "./libraries/JB721TiersHookLib.sol";
28
29
  import {JB721TiersRulesetMetadataResolver} from "./libraries/JB721TiersRulesetMetadataResolver.sol";
29
30
  import {JB721InitTiersConfig} from "./structs/JB721InitTiersConfig.sol";
@@ -201,40 +202,23 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
201
202
  {
202
203
  hookSpecifications = new JBPayHookSpecification[](1);
203
204
 
204
- // Calculate per-tier split amounts via the library.
205
- (uint256 totalSplitAmount, bytes memory splitMetadata) = JB721TiersHookLib.calculateSplitAmounts({
206
- store: STORE, hook: address(this), metadataIdTarget: METADATA_ID_TARGET, metadata: context.metadata
207
- });
208
-
209
- // Convert split amounts from tier pricing to payment token denomination (if currencies differ)
210
- // and cap at the actual payment value so the terminal never forwards more than was paid.
211
- if (totalSplitAmount != 0) {
212
- (totalSplitAmount, splitMetadata) = JB721TiersHookLib.convertAndCapSplitAmounts({
213
- totalSplitAmount: totalSplitAmount,
214
- splitMetadata: splitMetadata,
215
- packedPricingContext: _packedPricingContext,
216
- prices: PRICES,
217
- projectId: context.projectId,
218
- amountCurrency: context.amount.currency,
219
- amountDecimals: context.amount.decimals,
220
- amountValue: context.amount.value
221
- });
222
- }
223
-
224
- // Adjust weight so the terminal mints tokens only for the amount that actually enters the project.
225
- weight = JB721TiersHookLib.calculateWeight({
226
- contextWeight: context.weight,
227
- amountValue: context.amount.value,
228
- totalSplitAmount: totalSplitAmount,
205
+ // Compute split amounts, adjusted weight, and resolved beneficiary in a single library call.
206
+ uint256 totalSplitAmount;
207
+ bytes memory splitMetadata;
208
+ address beneficiary;
209
+ (weight, totalSplitAmount, splitMetadata, beneficiary) = JB721TiersHookLib.computeSplitsAndWeight({
229
210
  store: STORE,
230
- hook: address(this)
211
+ metadataIdTarget: METADATA_ID_TARGET,
212
+ packedPricingContext: _packedPricingContext,
213
+ prices: PRICES,
214
+ context: context
231
215
  });
232
216
 
233
217
  hookSpecifications[0] = JBPayHookSpecification({
234
218
  hook: this,
235
219
  noop: false,
236
220
  amount: totalSplitAmount,
237
- metadata: abi.encode(context.beneficiary, context.payer, splitMetadata)
221
+ metadata: abi.encode(beneficiary, context.payer, splitMetadata)
238
222
  });
239
223
  }
240
224
 
@@ -662,74 +646,27 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
662
646
  // Keep a reference to the number of NFT credits the beneficiary already has.
663
647
  uint256 payCredits = payCreditsOf[beneficiary];
664
648
 
665
- // Set the leftover amount as the initial value.
666
- uint256 leftoverAmount = value;
667
-
668
- // If the payer is the effective beneficiary, combine their NFT credits with the amount paid.
669
- uint256 unusedPayCredits;
670
- if (payer == beneficiary) {
671
- leftoverAmount += payCredits;
672
- } else {
673
- // Otherwise, the payer's NFT credits won't be used, and we keep track of the unused credits.
674
- unusedPayCredits = payCredits;
675
- }
676
-
677
- // Keep a reference to the boolean indicating whether paying more than the price of the NFTs being minted
678
- // is allowed. Defaults to the collection's flag.
679
- bool allowOverspending = !STORE.flagsOf(address(this)).preventOverspending;
680
-
681
- // Resolve the metadata.
682
- (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
683
- id: JBMetadataResolver.getId({purpose: "pay", target: METADATA_ID_TARGET}), metadata: payerMetadata
649
+ // Compute the mint: combine credits, decode metadata, record mint, and check overspending.
650
+ (uint256[] memory tokenIds, uint16[] memory tierIdsToMint, uint256 newPayCredits) = JB721TiersHookLib.prepareMint({
651
+ store: STORE,
652
+ metadataIdTarget: METADATA_ID_TARGET,
653
+ value: value,
654
+ payer: payer,
655
+ beneficiary: beneficiary,
656
+ payCredits: payCredits,
657
+ payerMetadata: payerMetadata
684
658
  });
685
659
 
686
- if (found) {
687
- // Keep a reference to the IDs of the tier be to minted.
688
- uint16[] memory tierIdsToMint;
689
-
690
- // Keep a reference to the payer's flag indicating whether overspending is allowed.
691
- bool payerAllowsOverspending;
692
-
693
- // Decode the metadata.
694
- (payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
695
-
696
- // Make sure overspending is allowed if requested.
697
- if (allowOverspending && !payerAllowsOverspending) {
698
- allowOverspending = false;
699
- }
700
-
701
- // Mint NFTs from the tiers as specified.
702
- if (tierIdsToMint.length != 0) {
703
- uint256[] memory tokenIds;
704
- uint256 restrictedCost;
705
- uint256 totalAmountPaid = leftoverAmount;
706
-
707
- // Record the mints.
708
- // slither-disable-next-line reentrancy-events,reentrancy-no-eth
709
- (tokenIds, leftoverAmount, restrictedCost) =
710
- STORE.recordMint({amount: leftoverAmount, tierIds: tierIdsToMint, isOwnerMint: false});
711
-
712
- // Enforce `cantBuyWithCredits`: only tiers explicitly configured as credit-restricted must be fully
713
- // covered by fresh payment (not stored credits). Split-bearing tiers are not automatically restricted;
714
- // deployers must set that flag in tier configuration when they need that invariant.
715
- if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
716
-
717
- // Mint each token to the effective beneficiary.
718
- _mintTokens({
719
- tokenIds: tokenIds,
720
- tierIds: tierIdsToMint,
721
- beneficiary: beneficiary,
722
- totalAmountPaid: totalAmountPaid
723
- });
724
- }
660
+ // Mint each token to the effective beneficiary.
661
+ if (tokenIds.length != 0) {
662
+ // totalAmountPaid is the full amount available before recordMint deducted tier prices.
663
+ uint256 totalAmountPaid = (payer == beneficiary) ? value + payCredits : value;
664
+ _mintTokens({
665
+ tokenIds: tokenIds, tierIds: tierIdsToMint, beneficiary: beneficiary, totalAmountPaid: totalAmountPaid
666
+ });
725
667
  }
726
668
 
727
- // If overspending isn't allowed, revert.
728
- if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
729
-
730
669
  // Update NFT credits if they changed.
731
- uint256 newPayCredits = leftoverAmount + unusedPayCredits;
732
-
733
670
  if (newPayCredits != payCredits) {
734
671
  if (newPayCredits > payCredits) {
735
672
  emit AddPayCredits({
@@ -747,6 +684,7 @@ contract JB721TiersHook is JBOwnable, ERC2771Context, JB721Hook, IJB721TiersHook
747
684
  });
748
685
  }
749
686
 
687
+ // slither-disable-next-line reentrancy-no-eth
750
688
  payCreditsOf[beneficiary] = newPayCredits;
751
689
  }
752
690
  }
@@ -4,4 +4,10 @@ pragma solidity 0.8.28;
4
4
  /// @notice Global constants used across 721 hook contracts.
5
5
  library JB721Constants {
6
6
  uint16 public constant DISCOUNT_DENOMINATOR = 200;
7
+
8
+ /// @notice The metadata ID used to identify the 721 beneficiary entry in payment metadata.
9
+ /// @dev When a sucker pays on behalf of a remote user, the real user's address is embedded under this key
10
+ /// so NFTs mint to the correct recipient.
11
+ // forge-lint: disable-next-line(mixed-case-variable)
12
+ bytes4 public constant BENEFICIARY_METADATA_ID = bytes4(keccak256("JB_721_BENEFICIARY"));
7
13
  }
@@ -6,32 +6,50 @@ import {IJBPrices} from "@bananapus/core-v6/src/interfaces/IJBPrices.sol";
6
6
  import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
7
7
  import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
8
8
  import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
9
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
10
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
11
9
  import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
12
10
  import {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
11
+ import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
12
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
13
+ import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
14
+ import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.sol";
13
15
  import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
14
16
  import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
15
17
  import {mulDiv} from "@prb/math/src/Common.sol";
16
18
 
17
- import {JBSplitGroup} from "@bananapus/core-v6/src/structs/JBSplitGroup.sol";
18
-
19
19
  import {IJB721TiersHookStore} from "../interfaces/IJB721TiersHookStore.sol";
20
20
  import {IJB721TokenUriResolver} from "../interfaces/IJB721TokenUriResolver.sol";
21
- import {JB721TierConfig} from "../structs/JB721TierConfig.sol";
21
+
22
22
  import {JB721Constants} from "./JB721Constants.sol";
23
23
  import {JBIpfsDecoder} from "./JBIpfsDecoder.sol";
24
24
 
25
+ import {JB721TierConfig} from "../structs/JB721TierConfig.sol";
26
+
25
27
  /// @notice External library for JB721TiersHook operations extracted to stay within the EIP-170 contract size limit.
26
28
  /// @dev Handles tier adjustments, split calculations, price normalization, and split fund distribution.
27
29
  library JB721TiersHookLib {
30
+ //*********************************************************************//
31
+ // --------------------------- custom errors ------------------------- //
32
+ //*********************************************************************//
33
+
34
+ error JB721TiersHook_CantBuyWithCredits();
35
+ error JB721TiersHook_Overspending(uint256 leftoverAmount);
28
36
  error JB721TiersHookLib_NoTerminalForLeftover(uint256 projectId, address token, uint256 leftoverAmount);
29
37
  error JB721TiersHookLib_SplitFallbackFailed(uint256 projectId, address token, uint256 amount, bytes reason);
30
38
  error JB721TiersHookLib_TokenTransferAmountMismatch(uint256 expectedAmount, uint256 receivedAmount);
39
+
40
+ //*********************************************************************//
41
+ // ------------------------------- events ---------------------------- //
42
+ //*********************************************************************//
43
+
31
44
  event AddTier(uint256 indexed tierId, JB721TierConfig tier, address caller);
32
45
  event RemoveTier(uint256 indexed tierId, address caller);
46
+ event SetDiscountPercent(uint256 indexed tierId, uint256 discountPercent, address caller);
33
47
  event SplitPayoutReverted(uint256 indexed projectId, JBSplit split, uint256 amount, bytes reason, address caller);
34
48
 
49
+ //*********************************************************************//
50
+ // ---------------------- external transactions ---------------------- //
51
+ //*********************************************************************//
52
+
35
53
  /// @notice Handles the full tier adjustment logic: removes tiers, adds tiers, emits events, and sets splits.
36
54
  /// @dev Called via DELEGATECALL from the hook, so events are emitted from the hook's address.
37
55
  /// @param store The 721 tiers hook store.
@@ -89,6 +107,137 @@ library JB721TiersHookLib {
89
107
  }
90
108
  }
91
109
 
110
+ /// @notice Pulls ERC-20 tokens from the terminal (if needed) and distributes forwarded funds to tier splits.
111
+ /// @dev For ERC-20 tokens, pulls from the terminal using the allowance it granted via _beforeTransferTo.
112
+ /// @param directory The directory to look up terminals.
113
+ /// @param splits The splits contract to read tier split groups from.
114
+ /// @param projectId The project ID of the hook.
115
+ /// @param hookAddress The hook address (for computing split group IDs).
116
+ /// @param token The token being distributed.
117
+ /// @param amount The total amount to distribute.
118
+ /// @param decimals The token decimals.
119
+ /// @param encodedSplitData The encoded per-tier breakdown from hookMetadata.
120
+ function distributeAll(
121
+ IJBDirectory directory,
122
+ IJBSplits splits,
123
+ uint256 projectId,
124
+ address hookAddress,
125
+ address token,
126
+ uint256 amount,
127
+ uint256 decimals,
128
+ bytes calldata encodedSplitData
129
+ )
130
+ external
131
+ {
132
+ // For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
133
+ if (token != JBConstants.NATIVE_TOKEN) {
134
+ uint256 balanceBefore = IERC20(token).balanceOf(address(this));
135
+ SafeERC20.safeTransferFrom({token: IERC20(token), from: msg.sender, to: address(this), value: amount});
136
+ uint256 receivedAmount = IERC20(token).balanceOf(address(this)) - balanceBefore;
137
+ if (receivedAmount != amount) {
138
+ revert JB721TiersHookLib_TokenTransferAmountMismatch(amount, receivedAmount);
139
+ }
140
+ }
141
+
142
+ (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
143
+
144
+ for (uint256 i; i < tierIds.length;) {
145
+ if (amounts[i] == 0) {
146
+ unchecked {
147
+ ++i;
148
+ }
149
+ continue;
150
+ }
151
+ uint256 groupId = uint256(uint160(hookAddress)) | (uint256(tierIds[i]) << 160);
152
+ _distributeSingleSplit({
153
+ directory: directory,
154
+ splitsContract: splits,
155
+ projectId: projectId,
156
+ token: token,
157
+ groupId: groupId,
158
+ amount: amounts[i],
159
+ decimals: decimals
160
+ });
161
+
162
+ unchecked {
163
+ ++i;
164
+ }
165
+ }
166
+ }
167
+
168
+ /// @notice Prepares NFT minting data for a payment: combines credits, decodes metadata, records mint, and checks
169
+ /// overspending.
170
+ /// @dev Called via DELEGATECALL from the hook; uses address(this) as the hook address.
171
+ /// Reverts with JB721TiersHook_CantBuyWithCredits or JB721TiersHook_Overspending on failure.
172
+ /// @param store The 721 tiers hook store.
173
+ /// @param metadataIdTarget The metadata ID target for resolving pay metadata.
174
+ /// @param value The normalized payment value.
175
+ /// @param payer The address that initiated the payment.
176
+ /// @param beneficiary The address to mint NFTs to and track credits for.
177
+ /// @param payCredits The beneficiary's current pay credits balance.
178
+ /// @param payerMetadata The metadata provided by the payer.
179
+ /// @return tokenIds The token IDs minted (empty if none).
180
+ /// @return tierIdsToMint The tier IDs corresponding to each minted token (empty if none).
181
+ /// @return newPayCredits The beneficiary's updated pay credits balance.
182
+ function prepareMint(
183
+ IJB721TiersHookStore store,
184
+ address metadataIdTarget,
185
+ uint256 value,
186
+ address payer,
187
+ address beneficiary,
188
+ uint256 payCredits,
189
+ bytes calldata payerMetadata
190
+ )
191
+ external
192
+ returns (uint256[] memory tokenIds, uint16[] memory tierIdsToMint, uint256 newPayCredits)
193
+ {
194
+ // Resolve metadata first (minimal stack: only 3 return vars). Scope block frees temporaries.
195
+ bool payerDisallowsOverspending;
196
+ {
197
+ (bool found, bytes memory metadata) = JBMetadataResolver.getDataFor({
198
+ id: JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget}), metadata: payerMetadata
199
+ });
200
+
201
+ if (found) {
202
+ bool payerAllowsOverspending;
203
+ (payerAllowsOverspending, tierIdsToMint) = abi.decode(metadata, (bool, uint16[]));
204
+ payerDisallowsOverspending = !payerAllowsOverspending;
205
+ }
206
+ }
207
+
208
+ // Set the leftover amount as the initial value.
209
+ uint256 leftoverAmount = value;
210
+
211
+ // If the payer is the effective beneficiary, combine their NFT credits with the amount paid.
212
+ // Reuse newPayCredits to hold unused credits (avoids an extra local variable).
213
+ if (payer == beneficiary) {
214
+ leftoverAmount += payCredits;
215
+ } else {
216
+ newPayCredits = payCredits;
217
+ }
218
+
219
+ // Determine whether overspending is allowed (collection flag AND payer preference).
220
+ bool allowOverspending = !store.flagsOf(address(this)).preventOverspending && !payerDisallowsOverspending;
221
+
222
+ // Record the mints.
223
+ if (tierIdsToMint.length != 0) {
224
+ uint256 restrictedCost;
225
+
226
+ // slither-disable-next-line reentrancy-events,reentrancy-no-eth
227
+ (tokenIds, leftoverAmount, restrictedCost) =
228
+ store.recordMint({amount: leftoverAmount, tierIds: tierIdsToMint, isOwnerMint: false});
229
+
230
+ // Credit-restricted tiers must be fully covered by fresh payment (not stored credits).
231
+ if (restrictedCost > value) revert JB721TiersHook_CantBuyWithCredits();
232
+ }
233
+
234
+ // If overspending isn't allowed, revert.
235
+ if (leftoverAmount != 0 && !allowOverspending) revert JB721TiersHook_Overspending(leftoverAmount);
236
+
237
+ // Compute the new pay credits balance: leftover + unused credits (held in newPayCredits).
238
+ newPayCredits = leftoverAmount + newPayCredits;
239
+ }
240
+
92
241
  /// @notice Records new tiers, emits events, and sets their split groups.
93
242
  /// @dev Used during initialization when tier configs are in memory.
94
243
  /// @param store The 721 tiers hook store.
@@ -128,6 +277,87 @@ library JB721TiersHookLib {
128
277
  });
129
278
  }
130
279
 
280
+ /// @notice Set the discount percent for a tier, emitting an event and recording it in the store.
281
+ /// @param store The 721 tiers hook store.
282
+ /// @param tierId The ID of the tier.
283
+ /// @param discountPercent The discount percent to set.
284
+ /// @param caller The msg.sender of the original call.
285
+ function setDiscountPercentOf(
286
+ IJB721TiersHookStore store,
287
+ uint256 tierId,
288
+ uint256 discountPercent,
289
+ address caller
290
+ )
291
+ external
292
+ {
293
+ emit SetDiscountPercent({tierId: tierId, discountPercent: discountPercent, caller: caller});
294
+ store.recordSetDiscountPercentOf({tierId: tierId, discountPercent: discountPercent});
295
+ }
296
+
297
+ //*********************************************************************//
298
+ // ----------------------- external views ---------------------------- //
299
+ //*********************************************************************//
300
+
301
+ /// @notice Computes split amounts, weight adjustment, and resolved beneficiary for a payment.
302
+ /// @dev Called via DELEGATECALL from the hook; uses address(this) as the hook address.
303
+ /// @param store The 721 tiers hook store.
304
+ /// @param metadataIdTarget The metadata ID target for resolving pay metadata.
305
+ /// @param packedPricingContext The packed pricing context (currency, decimals).
306
+ /// @param prices The prices contract used for currency conversion.
307
+ /// @param context The full payment context.
308
+ /// @return weight The adjusted weight for token minting.
309
+ /// @return totalSplitAmount The total amount to forward for splits.
310
+ /// @return splitMetadata Encoded per-tier breakdown (tierIds, amounts).
311
+ /// @return beneficiary The resolved beneficiary address.
312
+ function computeSplitsAndWeight(
313
+ IJB721TiersHookStore store,
314
+ address metadataIdTarget,
315
+ uint256 packedPricingContext,
316
+ IJBPrices prices,
317
+ JBBeforePayRecordedContext calldata context
318
+ )
319
+ external
320
+ view
321
+ returns (uint256 weight, uint256 totalSplitAmount, bytes memory splitMetadata, address beneficiary)
322
+ {
323
+ // Calculate per-tier split amounts.
324
+ (totalSplitAmount, splitMetadata) = _calculateSplitAmounts({
325
+ store: store, hook: address(this), metadataIdTarget: metadataIdTarget, metadata: context.metadata
326
+ });
327
+
328
+ // Convert split amounts from tier pricing to payment token denomination (if currencies differ)
329
+ // and cap at the actual payment value so the terminal never forwards more than was paid.
330
+ if (totalSplitAmount != 0) {
331
+ (totalSplitAmount, splitMetadata) = _convertAndCapSplitAmounts({
332
+ totalSplitAmount: totalSplitAmount,
333
+ splitMetadata: splitMetadata,
334
+ packedPricingContext: packedPricingContext,
335
+ prices: prices,
336
+ context: context
337
+ });
338
+ }
339
+
340
+ // Adjust weight so the terminal mints tokens only for the amount that actually enters the project.
341
+ weight = _calculateWeight({
342
+ contextWeight: context.weight,
343
+ amountValue: context.amount.value,
344
+ totalSplitAmount: totalSplitAmount,
345
+ store: store,
346
+ hook: address(this)
347
+ });
348
+
349
+ // Resolve the effective beneficiary from payment metadata.
350
+ beneficiary = context.beneficiary;
351
+ {
352
+ (bool found, bytes memory data) =
353
+ JBMetadataResolver.getDataFor({id: JB721Constants.BENEFICIARY_METADATA_ID, metadata: context.metadata});
354
+ if (found && data.length >= 32) {
355
+ address metadataBeneficiary = abi.decode(data, (address));
356
+ if (metadataBeneficiary != address(0)) beneficiary = metadataBeneficiary;
357
+ }
358
+ }
359
+ }
360
+
131
361
  /// @notice Normalizes a payment value based on the packed pricing context.
132
362
  /// @param packedPricingContext The packed pricing context (currency, decimals).
133
363
  /// @param prices The prices contract used for currency conversion.
@@ -176,6 +406,40 @@ library JB721TiersHookLib {
176
406
  valid = true;
177
407
  }
178
408
 
409
+ /// @notice Resolves the token URI for a given NFT token ID.
410
+ /// @dev Extracted to the library to keep JBIpfsDecoder bytecode out of the hook contract (EIP-170 compliance).
411
+ /// @param store The 721 tiers hook store.
412
+ /// @param hook The hook address.
413
+ /// @param baseUri The base URI for IPFS-based token URIs.
414
+ /// @param tokenId The token ID to resolve the URI for.
415
+ /// @return The resolved token URI string.
416
+ function resolveTokenURI(
417
+ IJB721TiersHookStore store,
418
+ address hook,
419
+ string memory baseUri,
420
+ uint256 tokenId
421
+ )
422
+ external
423
+ view
424
+ returns (string memory)
425
+ {
426
+ // Get a reference to the `tokenUriResolver`.
427
+ IJB721TokenUriResolver resolver = store.tokenUriResolverOf(hook);
428
+
429
+ // If a `tokenUriResolver` is set, use it to resolve the token URI.
430
+ if (address(resolver) != address(0)) return resolver.tokenUriOf({nft: hook, tokenId: tokenId});
431
+
432
+ // Otherwise, return the token URI corresponding with the NFT's tier.
433
+ return
434
+ JBIpfsDecoder.decode({
435
+ baseUri: baseUri, hexString: store.encodedTierIPFSUriOf({hook: hook, tokenId: tokenId})
436
+ });
437
+ }
438
+
439
+ //*********************************************************************//
440
+ // ----------------------- internal views ---------------------------- //
441
+ //*********************************************************************//
442
+
179
443
  /// @notice Calculates per-tier split amounts for a pay event.
180
444
  /// @param store The 721 tiers hook store.
181
445
  /// @param hook The hook address.
@@ -183,13 +447,13 @@ library JB721TiersHookLib {
183
447
  /// @param metadata The payer metadata.
184
448
  /// @return totalSplitAmount The total amount to forward for splits.
185
449
  /// @return hookMetadata Encoded per-tier breakdown (tierIds, amounts) for afterPay.
186
- function calculateSplitAmounts(
450
+ function _calculateSplitAmounts(
187
451
  IJB721TiersHookStore store,
188
452
  address hook,
189
453
  address metadataIdTarget,
190
454
  bytes calldata metadata
191
455
  )
192
- external
456
+ internal
193
457
  view
194
458
  returns (uint256 totalSplitAmount, bytes memory hookMetadata)
195
459
  {
@@ -256,14 +520,14 @@ library JB721TiersHookLib {
256
520
  /// @param store The 721 tiers hook store (to read flags).
257
521
  /// @param hook The hook address.
258
522
  /// @return weight The adjusted weight for token minting.
259
- function calculateWeight(
523
+ function _calculateWeight(
260
524
  uint256 contextWeight,
261
525
  uint256 amountValue,
262
526
  uint256 totalSplitAmount,
263
527
  IJB721TiersHookStore store,
264
528
  address hook
265
529
  )
266
- external
530
+ internal
267
531
  view
268
532
  returns (uint256 weight)
269
533
  {
@@ -281,28 +545,22 @@ library JB721TiersHookLib {
281
545
 
282
546
  /// @notice Converts split amounts from tier pricing to payment denomination (if currencies differ), then caps
283
547
  /// the total at the actual payment value — proportionally reducing per-tier amounts when the cap applies.
284
- /// @dev Combines currency conversion and cap into one external call to keep hook bytecode under EIP-170.
548
+ /// @dev Combines currency conversion and cap into one call to keep hook bytecode under EIP-170.
285
549
  /// @param totalSplitAmount The total split amount in tier pricing denomination.
286
550
  /// @param splitMetadata The encoded per-tier breakdown (tierIds, amounts).
287
551
  /// @param packedPricingContext The packed pricing context (currency in bits 0-31, decimals in bits 32-39).
288
552
  /// @param prices The prices contract used for currency conversion.
289
- /// @param projectId The project ID.
290
- /// @param amountCurrency The payment amount currency.
291
- /// @param amountDecimals The payment amount decimals.
292
- /// @param amountValue The actual payment value (used as the cap).
553
+ /// @param context The full payment context (provides projectId, amount currency/decimals/value).
293
554
  /// @return convertedTotal The total split amount after conversion and capping.
294
555
  /// @return convertedMetadata The re-encoded per-tier breakdown with adjusted amounts.
295
- function convertAndCapSplitAmounts(
556
+ function _convertAndCapSplitAmounts(
296
557
  uint256 totalSplitAmount,
297
558
  bytes memory splitMetadata,
298
559
  uint256 packedPricingContext,
299
560
  IJBPrices prices,
300
- uint256 projectId,
301
- uint256 amountCurrency,
302
- uint256 amountDecimals,
303
- uint256 amountValue
561
+ JBBeforePayRecordedContext calldata context
304
562
  )
305
- external
563
+ internal
306
564
  view
307
565
  returns (uint256 convertedTotal, bytes memory convertedMetadata)
308
566
  {
@@ -316,7 +574,7 @@ library JB721TiersHookLib {
316
574
 
317
575
  // Convert each per-tier amount from the tier pricing currency to the payment currency.
318
576
  // forge-lint: disable-next-line(unsafe-typecast)
319
- if (amountCurrency != uint256(uint32(packedPricingContext))) {
577
+ if (context.amount.currency != uint256(uint32(packedPricingContext))) {
320
578
  // No price oracle available — return 0 to skip the split rather than forwarding an unconverted
321
579
  // amount denominated in the wrong currency, which would over- or under-pay.
322
580
  if (address(prices) == address(0)) return (0, convertedMetadata);
@@ -325,11 +583,11 @@ library JB721TiersHookLib {
325
583
  // Get the price ratio: how many payment-currency units per one tier-pricing-currency unit.
326
584
  // forge-lint: disable-next-line(unsafe-typecast)
327
585
  uint256 ratio = prices.pricePerUnitOf({
328
- projectId: projectId,
329
- pricingCurrency: amountCurrency,
586
+ projectId: context.projectId,
587
+ pricingCurrency: context.amount.currency,
330
588
  // forge-lint: disable-next-line(unsafe-typecast)
331
589
  unitCurrency: uint256(uint32(packedPricingContext)),
332
- decimals: amountDecimals
590
+ decimals: context.amount.decimals
333
591
  });
334
592
 
335
593
  // The denominator scales each amount from tier-pricing decimals to payment-token decimals.
@@ -354,7 +612,7 @@ library JB721TiersHookLib {
354
612
  // Re-encode with the converted amounts.
355
613
  convertedMetadata = abi.encode(tierIds, amounts);
356
614
  }
357
- } else if (amountDecimals != pricingDecimals) {
615
+ } else if (context.amount.decimals != pricingDecimals) {
358
616
  // Same currency but different decimal scales (e.g. pricing at 18 decimals, payment token at 6).
359
617
  // Without this branch, split amounts stay in pricing decimals while the cap comparison uses
360
618
  // payment decimals — causing orders-of-magnitude mis-scaling. This mirrors the same-currency
@@ -367,12 +625,12 @@ library JB721TiersHookLib {
367
625
  convertedTotal = 0;
368
626
  for (uint256 i; i < amounts.length;) {
369
627
  // Scale each amount from pricing decimals to payment decimals.
370
- if (amountDecimals > pricingDecimals) {
628
+ if (context.amount.decimals > pricingDecimals) {
371
629
  // Payment has more decimals — multiply to add precision (e.g. 6→18: multiply by 10^12).
372
- amounts[i] = amounts[i] * (10 ** (amountDecimals - pricingDecimals));
630
+ amounts[i] = amounts[i] * (10 ** (context.amount.decimals - pricingDecimals));
373
631
  } else {
374
632
  // Payment has fewer decimals — divide to remove precision (e.g. 18→6: divide by 10^12).
375
- amounts[i] = amounts[i] / (10 ** (pricingDecimals - amountDecimals));
633
+ amounts[i] = amounts[i] / (10 ** (pricingDecimals - context.amount.decimals));
376
634
  }
377
635
  convertedTotal += amounts[i];
378
636
 
@@ -388,7 +646,7 @@ library JB721TiersHookLib {
388
646
  // Cap the total at the actual payment value. Pay credits fund NFT minting (virtual), but splits
389
647
  // require real tokens to distribute. Without this cap, a user with sufficient pay credits but
390
648
  // insufficient ETH would revert because the terminal can't forward more than what was actually paid.
391
- if (convertedTotal > amountValue) {
649
+ if (convertedTotal > context.amount.value) {
392
650
  // Proportionally reduce each per-tier amount to stay in sync with the capped total.
393
651
  if (convertedMetadata.length != 0) {
394
652
  (uint16[] memory tierIds, uint256[] memory amounts) =
@@ -397,7 +655,7 @@ library JB721TiersHookLib {
397
655
  convertedTotal = 0;
398
656
  for (uint256 i; i < amounts.length;) {
399
657
  // Scale down: amount * amountValue / originalTotal.
400
- amounts[i] = mulDiv({x: amounts[i], y: amountValue, denominator: uncappedTotal});
658
+ amounts[i] = mulDiv({x: amounts[i], y: context.amount.value, denominator: uncappedTotal});
401
659
  convertedTotal += amounts[i];
402
660
 
403
661
  unchecked {
@@ -407,104 +665,14 @@ library JB721TiersHookLib {
407
665
  convertedMetadata = abi.encode(tierIds, amounts);
408
666
  } else {
409
667
  // Clamp the total to the payment value.
410
- convertedTotal = amountValue;
668
+ convertedTotal = context.amount.value;
411
669
  }
412
670
  }
413
671
  }
414
672
 
415
- /// @notice Sets split groups in JBSplits for tiers that have splits configured.
416
- function _setSplitGroupsFor(
417
- IJBSplits splits,
418
- uint256 projectId,
419
- address hookAddress,
420
- JB721TierConfig[] memory tiersToAdd,
421
- uint256[] memory tierIdsAdded
422
- )
423
- private
424
- {
425
- uint256 splitGroupCount;
426
- for (uint256 i; i < tiersToAdd.length;) {
427
- if (tiersToAdd[i].splits.length != 0) splitGroupCount++;
428
-
429
- unchecked {
430
- ++i;
431
- }
432
- }
433
- if (splitGroupCount == 0) return;
434
-
435
- JBSplitGroup[] memory splitGroups = new JBSplitGroup[](splitGroupCount);
436
- uint256 groupIndex;
437
- for (uint256 i; i < tiersToAdd.length;) {
438
- if (tiersToAdd[i].splits.length != 0) {
439
- splitGroups[groupIndex] = JBSplitGroup({
440
- groupId: uint256(uint160(hookAddress)) | (tierIdsAdded[i] << 160), splits: tiersToAdd[i].splits
441
- });
442
- groupIndex++;
443
- }
444
-
445
- unchecked {
446
- ++i;
447
- }
448
- }
449
- splits.setSplitGroupsOf({projectId: projectId, rulesetId: 0, splitGroups: splitGroups});
450
- }
451
-
452
- /// @notice Pulls ERC-20 tokens from the terminal (if needed) and distributes forwarded funds to tier splits.
453
- /// @dev For ERC-20 tokens, pulls from the terminal using the allowance it granted via _beforeTransferTo.
454
- /// @param directory The directory to look up terminals.
455
- /// @param splits The splits contract to read tier split groups from.
456
- /// @param projectId The project ID of the hook.
457
- /// @param hookAddress The hook address (for computing split group IDs).
458
- /// @param token The token being distributed.
459
- /// @param amount The total amount to distribute.
460
- /// @param encodedSplitData The encoded per-tier breakdown from hookMetadata.
461
- function distributeAll(
462
- IJBDirectory directory,
463
- IJBSplits splits,
464
- uint256 projectId,
465
- address hookAddress,
466
- address token,
467
- uint256 amount,
468
- uint256 decimals,
469
- bytes calldata encodedSplitData
470
- )
471
- external
472
- {
473
- // For ERC20 tokens, pull from terminal using the allowance it granted via _beforeTransferTo.
474
- if (token != JBConstants.NATIVE_TOKEN) {
475
- uint256 balanceBefore = IERC20(token).balanceOf(address(this));
476
- SafeERC20.safeTransferFrom({token: IERC20(token), from: msg.sender, to: address(this), value: amount});
477
- uint256 receivedAmount = IERC20(token).balanceOf(address(this)) - balanceBefore;
478
- if (receivedAmount != amount) {
479
- revert JB721TiersHookLib_TokenTransferAmountMismatch(amount, receivedAmount);
480
- }
481
- }
482
-
483
- (uint16[] memory tierIds, uint256[] memory amounts) = abi.decode(encodedSplitData, (uint16[], uint256[]));
484
-
485
- for (uint256 i; i < tierIds.length;) {
486
- if (amounts[i] == 0) {
487
- unchecked {
488
- ++i;
489
- }
490
- continue;
491
- }
492
- uint256 groupId = uint256(uint160(hookAddress)) | (uint256(tierIds[i]) << 160);
493
- _distributeSingleSplit({
494
- directory: directory,
495
- splitsContract: splits,
496
- projectId: projectId,
497
- token: token,
498
- groupId: groupId,
499
- amount: amounts[i],
500
- decimals: decimals
501
- });
502
-
503
- unchecked {
504
- ++i;
505
- }
506
- }
507
- }
673
+ //*********************************************************************//
674
+ // ----------------------- private helpers --------------------------- //
675
+ //*********************************************************************//
508
676
 
509
677
  /// @notice Distributes funds for a single tier's split group.
510
678
  /// @dev Edge case: if both `_sendPayoutToSplit` returns false (reverting hook/terminal/beneficiary) AND the
@@ -773,52 +941,40 @@ library JB721TiersHookLib {
773
941
  return false;
774
942
  }
775
943
 
776
- /// @notice Resolves the token URI for a given NFT token ID.
777
- /// @dev Extracted to the library to keep JBIpfsDecoder bytecode out of the hook contract (EIP-170 compliance).
778
- /// @param store The 721 tiers hook store.
779
- /// @param hook The hook address.
780
- /// @param baseUri The base URI for IPFS-based token URIs.
781
- /// @param tokenId The token ID to resolve the URI for.
782
- /// @return The resolved token URI string.
783
- function resolveTokenURI(
784
- IJB721TiersHookStore store,
785
- address hook,
786
- string memory baseUri,
787
- uint256 tokenId
944
+ /// @notice Sets split groups in JBSplits for tiers that have splits configured.
945
+ function _setSplitGroupsFor(
946
+ IJBSplits splits,
947
+ uint256 projectId,
948
+ address hookAddress,
949
+ JB721TierConfig[] memory tiersToAdd,
950
+ uint256[] memory tierIdsAdded
788
951
  )
789
- external
790
- view
791
- returns (string memory)
952
+ private
792
953
  {
793
- // Get a reference to the `tokenUriResolver`.
794
- IJB721TokenUriResolver resolver = store.tokenUriResolverOf(hook);
795
-
796
- // If a `tokenUriResolver` is set, use it to resolve the token URI.
797
- if (address(resolver) != address(0)) return resolver.tokenUriOf({nft: hook, tokenId: tokenId});
954
+ uint256 splitGroupCount;
955
+ for (uint256 i; i < tiersToAdd.length;) {
956
+ if (tiersToAdd[i].splits.length != 0) splitGroupCount++;
798
957
 
799
- // Otherwise, return the token URI corresponding with the NFT's tier.
800
- return
801
- JBIpfsDecoder.decode({
802
- baseUri: baseUri, hexString: store.encodedTierIPFSUriOf({hook: hook, tokenId: tokenId})
803
- });
804
- }
958
+ unchecked {
959
+ ++i;
960
+ }
961
+ }
962
+ if (splitGroupCount == 0) return;
805
963
 
806
- event SetDiscountPercent(uint256 indexed tierId, uint256 discountPercent, address caller);
964
+ JBSplitGroup[] memory splitGroups = new JBSplitGroup[](splitGroupCount);
965
+ uint256 groupIndex;
966
+ for (uint256 i; i < tiersToAdd.length;) {
967
+ if (tiersToAdd[i].splits.length != 0) {
968
+ splitGroups[groupIndex] = JBSplitGroup({
969
+ groupId: uint256(uint160(hookAddress)) | (tierIdsAdded[i] << 160), splits: tiersToAdd[i].splits
970
+ });
971
+ groupIndex++;
972
+ }
807
973
 
808
- /// @notice Set the discount percent for a tier, emitting an event and recording it in the store.
809
- /// @param store The 721 tiers hook store.
810
- /// @param tierId The ID of the tier.
811
- /// @param discountPercent The discount percent to set.
812
- /// @param caller The msg.sender of the original call.
813
- function setDiscountPercentOf(
814
- IJB721TiersHookStore store,
815
- uint256 tierId,
816
- uint256 discountPercent,
817
- address caller
818
- )
819
- external
820
- {
821
- emit SetDiscountPercent({tierId: tierId, discountPercent: discountPercent, caller: caller});
822
- store.recordSetDiscountPercentOf({tierId: tierId, discountPercent: discountPercent});
974
+ unchecked {
975
+ ++i;
976
+ }
977
+ }
978
+ splits.setSplitGroupsOf({projectId: projectId, rulesetId: 0, splitGroups: splitGroups});
823
979
  }
824
980
  }
@@ -0,0 +1,182 @@
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
+
7
+ /// @notice Tests that the 721 hook resolves the relay beneficiary from payment metadata.
8
+ contract Test_relayBeneficiary_Unit is UnitTestSetup {
9
+ /// @notice The metadata ID the 721 hook uses to look up the relay beneficiary.
10
+ /// Must match `_721_BENEFICIARY_METADATA_ID` in JB721TiersHook.
11
+ /// @notice Must match `BENEFICIARY_METADATA_ID` in JB721TiersHook.
12
+ bytes4 constant BENEFICIARY_METADATA_ID = bytes4(keccak256("JB_721_BENEFICIARY"));
13
+
14
+ address relayUser = makeAddr("relayUser");
15
+ address sucker = makeAddr("sucker");
16
+
17
+ function setUp() public override {
18
+ super.setUp();
19
+
20
+ // Mock directory: terminal is valid for all calls.
21
+ vm.mockCall(
22
+ address(mockJBDirectory),
23
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
24
+ abi.encode(true)
25
+ );
26
+ }
27
+
28
+ /// @notice When metadata contains a relay beneficiary, the spec should use that address (not context.beneficiary).
29
+ function test_beforePay_relayBeneficiary_usedInSpec() public {
30
+ JB721TiersHook tiersHook = _initHookDefaultTiers(3);
31
+
32
+ // Build metadata with tier selection + relay beneficiary.
33
+ uint16[] memory tierIdsToMint = new uint16[](1);
34
+ tierIdsToMint[0] = 1;
35
+
36
+ // Encode tier selection metadata (for the hook's own metadata ID).
37
+ bytes memory tierData = abi.encode(false, tierIdsToMint, new bytes[](0));
38
+
39
+ // Build metadata with two entries: hook tier data + relay beneficiary.
40
+ bytes4[] memory ids = new bytes4[](2);
41
+ bytes[] memory datas = new bytes[](2);
42
+
43
+ ids[0] = JBMetadataResolver.getId({purpose: "pay", target: address(tiersHook)});
44
+ datas[0] = tierData;
45
+ ids[1] = BENEFICIARY_METADATA_ID;
46
+ datas[1] = abi.encode(relayUser);
47
+
48
+ bytes memory metadata = metadataHelper.createMetadata(ids, datas);
49
+
50
+ // Build pay context with sucker as beneficiary (simulating cross-chain payment).
51
+ JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
52
+ terminal: mockTerminalAddress,
53
+ payer: sucker,
54
+ amount: JBTokenAmount({
55
+ token: JBConstants.NATIVE_TOKEN,
56
+ value: 10,
57
+ decimals: 18,
58
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
59
+ }),
60
+ projectId: projectId,
61
+ rulesetId: block.timestamp,
62
+ beneficiary: sucker,
63
+ weight: 10e18,
64
+ reservedPercent: 5000,
65
+ metadata: metadata
66
+ });
67
+
68
+ (, JBPayHookSpecification[] memory specs) = tiersHook.beforePayRecordedWith(context);
69
+
70
+ // The spec's metadata should encode relayUser as the beneficiary, not sucker.
71
+ assertEq(specs.length, 1, "should return one spec");
72
+ (address specBeneficiary,,) = abi.decode(specs[0].metadata, (address, address, bytes));
73
+ assertEq(specBeneficiary, relayUser, "spec should use relay beneficiary, not context.beneficiary");
74
+ }
75
+
76
+ /// @notice When no relay metadata is present, the spec should use context.beneficiary as-is.
77
+ function test_beforePay_noRelayMetadata_usesContextBeneficiary() public {
78
+ JB721TiersHook tiersHook = _initHookDefaultTiers(3);
79
+
80
+ // Build metadata with only tier selection (no relay beneficiary).
81
+ uint16[] memory tierIdsToMint = new uint16[](1);
82
+ tierIdsToMint[0] = 1;
83
+ bytes memory tierData = abi.encode(false, tierIdsToMint, new bytes[](0));
84
+
85
+ bytes4[] memory ids = new bytes4[](1);
86
+ bytes[] memory datas = new bytes[](1);
87
+ ids[0] = JBMetadataResolver.getId({purpose: "pay", target: address(tiersHook)});
88
+ datas[0] = tierData;
89
+
90
+ bytes memory metadata = metadataHelper.createMetadata(ids, datas);
91
+
92
+ JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
93
+ terminal: mockTerminalAddress,
94
+ payer: makeAddr("payer"),
95
+ amount: JBTokenAmount({
96
+ token: JBConstants.NATIVE_TOKEN,
97
+ value: 10,
98
+ decimals: 18,
99
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
100
+ }),
101
+ projectId: projectId,
102
+ rulesetId: block.timestamp,
103
+ beneficiary: beneficiary,
104
+ weight: 10e18,
105
+ reservedPercent: 5000,
106
+ metadata: metadata
107
+ });
108
+
109
+ (, JBPayHookSpecification[] memory specs) = tiersHook.beforePayRecordedWith(context);
110
+
111
+ assertEq(specs.length, 1, "should return one spec");
112
+ (address specBeneficiary,,) = abi.decode(specs[0].metadata, (address, address, bytes));
113
+ assertEq(specBeneficiary, beneficiary, "spec should use context.beneficiary when no relay metadata");
114
+ }
115
+
116
+ /// @notice When relay metadata is present but the address is zero, fall back to context.beneficiary.
117
+ function test_beforePay_relayBeneficiaryZero_fallsBackToContext() public {
118
+ JB721TiersHook tiersHook = _initHookDefaultTiers(3);
119
+
120
+ uint16[] memory tierIdsToMint = new uint16[](1);
121
+ tierIdsToMint[0] = 1;
122
+ bytes memory tierData = abi.encode(false, tierIdsToMint, new bytes[](0));
123
+
124
+ bytes4[] memory ids = new bytes4[](2);
125
+ bytes[] memory datas = new bytes[](2);
126
+ ids[0] = JBMetadataResolver.getId({purpose: "pay", target: address(tiersHook)});
127
+ datas[0] = tierData;
128
+ ids[1] = BENEFICIARY_METADATA_ID;
129
+ datas[1] = abi.encode(address(0));
130
+
131
+ bytes memory metadata = metadataHelper.createMetadata(ids, datas);
132
+
133
+ JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
134
+ terminal: mockTerminalAddress,
135
+ payer: sucker,
136
+ amount: JBTokenAmount({
137
+ token: JBConstants.NATIVE_TOKEN,
138
+ value: 10,
139
+ decimals: 18,
140
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
141
+ }),
142
+ projectId: projectId,
143
+ rulesetId: block.timestamp,
144
+ beneficiary: beneficiary,
145
+ weight: 10e18,
146
+ reservedPercent: 5000,
147
+ metadata: metadata
148
+ });
149
+
150
+ (, JBPayHookSpecification[] memory specs) = tiersHook.beforePayRecordedWith(context);
151
+
152
+ (address specBeneficiary,,) = abi.decode(specs[0].metadata, (address, address, bytes));
153
+ assertEq(specBeneficiary, beneficiary, "zero relay address should fall back to context.beneficiary");
154
+ }
155
+
156
+ /// @notice When metadata is empty, use context.beneficiary.
157
+ function test_beforePay_emptyMetadata_usesContextBeneficiary() public {
158
+ JB721TiersHook tiersHook = _initHookDefaultTiers(3);
159
+
160
+ JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
161
+ terminal: mockTerminalAddress,
162
+ payer: makeAddr("payer"),
163
+ amount: JBTokenAmount({
164
+ token: JBConstants.NATIVE_TOKEN,
165
+ value: 10,
166
+ decimals: 18,
167
+ currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
168
+ }),
169
+ projectId: projectId,
170
+ rulesetId: block.timestamp,
171
+ beneficiary: beneficiary,
172
+ weight: 10e18,
173
+ reservedPercent: 5000,
174
+ metadata: ""
175
+ });
176
+
177
+ (, JBPayHookSpecification[] memory specs) = tiersHook.beforePayRecordedWith(context);
178
+
179
+ (address specBeneficiary,,) = abi.decode(specs[0].metadata, (address, address, bytes));
180
+ assertEq(specBeneficiary, beneficiary, "empty metadata should use context.beneficiary");
181
+ }
182
+ }