@bananapus/721-hook-v6 0.0.13 → 0.0.14

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.
@@ -0,0 +1,498 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import "../utils/UnitTestSetup.sol";
5
+
6
+ /// @notice Cross-currency unit tests for the 721 hook's normalizePaymentValue path.
7
+ /// Verifies correct behavior when payment token currency differs from tier pricing currency.
8
+ contract Test_crossCurrencyPay_Unit is UnitTestSetup {
9
+ using stdStorage for StdStorage;
10
+
11
+ // -- Currency constants
12
+ uint32 nativeCurrency = uint32(uint160(JBConstants.NATIVE_TOKEN));
13
+
14
+ // -- Mock USDC address
15
+ address constant MOCK_USDC = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
16
+ uint32 usdcCurrency = uint32(uint160(MOCK_USDC));
17
+
18
+ // -- Mock prices contract
19
+ address mockPrices = makeAddr("mockPrices");
20
+
21
+ /// @notice Test 1: USDC payment -> USD-priced tier. normalizePaymentValue works with 1:1 USDC/USD.
22
+ function test_normalizePaymentValue_usdcPayment_usdTier() public {
23
+ // Initialize hook with USD-priced tiers + prices oracle.
24
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
25
+
26
+ // Mock directory call.
27
+ mockAndExpect(
28
+ address(mockJBDirectory),
29
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
30
+ abi.encode(true)
31
+ );
32
+
33
+ // Mock pricePerUnitOf: USDC -> USD, 6 decimals. Returns 1e6 (1:1 USDC/USD).
34
+ vm.mockCall(
35
+ mockPrices,
36
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, usdcCurrency, USD(), uint256(6)),
37
+ abi.encode(uint256(1e6))
38
+ );
39
+
40
+ // Tier price is 10 (the default: tierId * 10 = 1 * 10 = 10).
41
+ // Pay 10 USDC (6 decimals) -> normalized to 10e18 USD -> matches tier price of 10 (18 decimals).
42
+ // But default tier price is 10 with 18-decimal pricing -> need exactly 10 as normalized value.
43
+ // normalizePaymentValue: mulDiv(10e6, 1e18, pricePerUnitOf(_, usdcCurrency, USD, 6))
44
+ // = mulDiv(10e6, 1e18, 1e6) = 10e18. But tier price with USD/18 decimals is 10 (raw).
45
+ // Actually the tier prices from default config are: tiers[0].price = 10. With 18-decimal pricing,
46
+ // the normalized value needs to be >= 10. normalizePaymentValue returns mulDiv(10e6, 1e18, 1e6) = 10e18.
47
+ // 10e18 >= 10 → NFT minted.
48
+
49
+ uint16[] memory tierIdsToMint = new uint16[](1);
50
+ tierIdsToMint[0] = 1;
51
+ bytes[] memory data = new bytes[](1);
52
+ data[0] = abi.encode(true, tierIdsToMint);
53
+ bytes4[] memory ids = new bytes4[](1);
54
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
55
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
56
+
57
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
58
+ payer: beneficiary,
59
+ projectId: projectId,
60
+ rulesetId: 0,
61
+ amount: JBTokenAmount({token: MOCK_USDC, value: 10e6, decimals: 6, currency: usdcCurrency}),
62
+ forwardedAmount: JBTokenAmount({token: MOCK_USDC, value: 0, decimals: 6, currency: usdcCurrency}),
63
+ weight: 10 ** 18,
64
+ newlyIssuedTokenCount: 0,
65
+ beneficiary: beneficiary,
66
+ hookMetadata: bytes(""),
67
+ payerMetadata: hookMetadata
68
+ });
69
+
70
+ vm.prank(mockTerminalAddress);
71
+ crossHook.afterPayRecordedWith(payContext);
72
+
73
+ assertEq(crossHook.balanceOf(beneficiary), 1, "1 NFT minted from USDC -> USD tier");
74
+ }
75
+
76
+ /// @notice Test 2: ETH payment -> USD-priced tier (2000:1 ratio).
77
+ function test_normalizePaymentValue_ethPayment_usdTier() public {
78
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
79
+
80
+ mockAndExpect(
81
+ address(mockJBDirectory),
82
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
83
+ abi.encode(true)
84
+ );
85
+
86
+ // Mock: pricePerUnitOf(_, nativeCurrency, USD, 18) = 5e14 (inverse of $2000).
87
+ // "1 nativeCurrency unit costs 5e14 USD units" which represents 1/2000 of a USD unit in 18 decimals.
88
+ vm.mockCall(
89
+ mockPrices,
90
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
91
+ abi.encode(uint256(5e14))
92
+ );
93
+
94
+ // Pay 1 ETH -> normalizePaymentValue: mulDiv(1e18, 1e18, 5e14) = 2000e18.
95
+ // Default tier price = 10 with 18 decimals. 2000e18 >= 10 → NFT minted.
96
+ uint16[] memory tierIdsToMint = new uint16[](1);
97
+ tierIdsToMint[0] = 1;
98
+ bytes[] memory data = new bytes[](1);
99
+ data[0] = abi.encode(true, tierIdsToMint);
100
+ bytes4[] memory ids = new bytes4[](1);
101
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
102
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
103
+
104
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
105
+ payer: beneficiary,
106
+ projectId: projectId,
107
+ rulesetId: 0,
108
+ amount: JBTokenAmount({
109
+ token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
110
+ }),
111
+ forwardedAmount: JBTokenAmount({
112
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
113
+ }),
114
+ weight: 10 ** 18,
115
+ newlyIssuedTokenCount: 0,
116
+ beneficiary: beneficiary,
117
+ hookMetadata: bytes(""),
118
+ payerMetadata: hookMetadata
119
+ });
120
+
121
+ vm.prank(mockTerminalAddress);
122
+ crossHook.afterPayRecordedWith(payContext);
123
+
124
+ assertEq(crossHook.balanceOf(beneficiary), 1, "1 NFT minted from ETH -> USD tier");
125
+ }
126
+
127
+ /// @notice Test 3: Payment normalizes to exactly the tier price -> NFT minted.
128
+ function test_normalizePaymentValue_exactTierBoundary() public {
129
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
130
+
131
+ mockAndExpect(
132
+ address(mockJBDirectory),
133
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
134
+ abi.encode(true)
135
+ );
136
+
137
+ // Price feed returns exactly the amount needed for tier price of 10.
138
+ // If we pay 10 units with a 1:1 ratio, normalized = 10. Tier price = 10 → exact match.
139
+ vm.mockCall(
140
+ mockPrices,
141
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
142
+ abi.encode(uint256(1e18)) // 1:1 ratio
143
+ );
144
+
145
+ uint16[] memory tierIdsToMint = new uint16[](1);
146
+ tierIdsToMint[0] = 1;
147
+ bytes[] memory data = new bytes[](1);
148
+ data[0] = abi.encode(true, tierIdsToMint);
149
+ bytes4[] memory ids = new bytes4[](1);
150
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
151
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
152
+
153
+ // Pay exactly 10 units → normalizePaymentValue = mulDiv(10, 1e18, 1e18) = 10.
154
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
155
+ payer: beneficiary,
156
+ projectId: projectId,
157
+ rulesetId: 0,
158
+ amount: JBTokenAmount({token: JBConstants.NATIVE_TOKEN, value: 10, decimals: 18, currency: nativeCurrency}),
159
+ forwardedAmount: JBTokenAmount({
160
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
161
+ }),
162
+ weight: 10 ** 18,
163
+ newlyIssuedTokenCount: 0,
164
+ beneficiary: beneficiary,
165
+ hookMetadata: bytes(""),
166
+ payerMetadata: hookMetadata
167
+ });
168
+
169
+ vm.prank(mockTerminalAddress);
170
+ crossHook.afterPayRecordedWith(payContext);
171
+
172
+ assertEq(crossHook.balanceOf(beneficiary), 1, "NFT minted at exact tier price boundary");
173
+ }
174
+
175
+ /// @notice Test 4: Payment normalizes to 1 wei below tier price -> no NFT minted (stored as credit).
176
+ function test_normalizePaymentValue_justBelowTierPrice() public {
177
+ // preventOverspending = false so it doesn't revert, just skips the tier.
178
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
179
+
180
+ mockAndExpect(
181
+ address(mockJBDirectory),
182
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
183
+ abi.encode(true)
184
+ );
185
+
186
+ vm.mockCall(
187
+ mockPrices,
188
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
189
+ abi.encode(uint256(1e18)) // 1:1
190
+ );
191
+
192
+ // Pay 9 units → normalized = 9. Tier price = 10 → below threshold.
193
+ // No metadata = no explicit tier selection, overspending allowed → 0 NFTs (just credit).
194
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
195
+ payer: beneficiary,
196
+ projectId: projectId,
197
+ rulesetId: 0,
198
+ amount: JBTokenAmount({token: JBConstants.NATIVE_TOKEN, value: 9, decimals: 18, currency: nativeCurrency}),
199
+ forwardedAmount: JBTokenAmount({
200
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
201
+ }),
202
+ weight: 10 ** 18,
203
+ newlyIssuedTokenCount: 0,
204
+ beneficiary: beneficiary,
205
+ hookMetadata: bytes(""),
206
+ payerMetadata: new bytes(0) // no metadata → auto-mint path
207
+ });
208
+
209
+ vm.prank(mockTerminalAddress);
210
+ crossHook.afterPayRecordedWith(payContext);
211
+
212
+ assertEq(crossHook.balanceOf(beneficiary), 0, "no NFT minted below tier price");
213
+ }
214
+
215
+ /// @notice Test 5: prices=address(0) + currencies differ -> normalizePaymentValue returns (0, false).
216
+ function test_normalizePaymentValue_noPricesContract() public {
217
+ // Initialize with address(0) as prices, but USD currency (differs from native token currency).
218
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, address(0));
219
+
220
+ mockAndExpect(
221
+ address(mockJBDirectory),
222
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
223
+ abi.encode(true)
224
+ );
225
+
226
+ // Pay with native token (currency != USD). prices=address(0) → normalizePaymentValue returns (0, false).
227
+ // No NFTs minted.
228
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
229
+ payer: beneficiary,
230
+ projectId: projectId,
231
+ rulesetId: 0,
232
+ amount: JBTokenAmount({
233
+ token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
234
+ }),
235
+ forwardedAmount: JBTokenAmount({
236
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
237
+ }),
238
+ weight: 10 ** 18,
239
+ newlyIssuedTokenCount: 0,
240
+ beneficiary: beneficiary,
241
+ hookMetadata: bytes(""),
242
+ payerMetadata: new bytes(0)
243
+ });
244
+
245
+ vm.prank(mockTerminalAddress);
246
+ crossHook.afterPayRecordedWith(payContext);
247
+
248
+ assertEq(crossHook.balanceOf(beneficiary), 0, "no NFT minted (prices=0, currencies differ)");
249
+ }
250
+
251
+ /// @notice Test 6: Extreme high price ratio (1e27) -> no overflow, correct normalization.
252
+ function test_normalizePaymentValue_extremeHighPrice() public {
253
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
254
+
255
+ mockAndExpect(
256
+ address(mockJBDirectory),
257
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
258
+ abi.encode(true)
259
+ );
260
+
261
+ // Price feed returns 1e27 (extreme ratio: 1 unit of pricingCurrency costs 1e27 units of unitCurrency).
262
+ // pricePerUnitOf returns 1e27 with 18 decimals.
263
+ // normalizePaymentValue: mulDiv(1e18, 1e18, 1e27) = 1e9.
264
+ // 1e9 >= tier price 10 → NFT minted.
265
+ vm.mockCall(
266
+ mockPrices,
267
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
268
+ abi.encode(uint256(1e27))
269
+ );
270
+
271
+ uint16[] memory tierIdsToMint = new uint16[](1);
272
+ tierIdsToMint[0] = 1;
273
+ bytes[] memory data = new bytes[](1);
274
+ data[0] = abi.encode(true, tierIdsToMint);
275
+ bytes4[] memory ids = new bytes4[](1);
276
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
277
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
278
+
279
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
280
+ payer: beneficiary,
281
+ projectId: projectId,
282
+ rulesetId: 0,
283
+ amount: JBTokenAmount({
284
+ token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
285
+ }),
286
+ forwardedAmount: JBTokenAmount({
287
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
288
+ }),
289
+ weight: 10 ** 18,
290
+ newlyIssuedTokenCount: 0,
291
+ beneficiary: beneficiary,
292
+ hookMetadata: bytes(""),
293
+ payerMetadata: hookMetadata
294
+ });
295
+
296
+ vm.prank(mockTerminalAddress);
297
+ crossHook.afterPayRecordedWith(payContext);
298
+
299
+ assertEq(crossHook.balanceOf(beneficiary), 1, "NFT minted with extreme high price ratio");
300
+ }
301
+
302
+ /// @notice Test 7: Extreme low price ratio (1 wei) -> large normalized value, no revert.
303
+ function test_normalizePaymentValue_extremeLowPrice() public {
304
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
305
+
306
+ mockAndExpect(
307
+ address(mockJBDirectory),
308
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
309
+ abi.encode(true)
310
+ );
311
+
312
+ // Price feed returns 1 (near-zero: 1 unit costs 1 wei of the pricing currency).
313
+ // normalizePaymentValue: mulDiv(1e18, 1e18, 1) = 1e36. This is a very large number.
314
+ // 1e36 >= tier price 10 → NFT minted.
315
+ vm.mockCall(
316
+ mockPrices,
317
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
318
+ abi.encode(uint256(1))
319
+ );
320
+
321
+ uint16[] memory tierIdsToMint = new uint16[](1);
322
+ tierIdsToMint[0] = 1;
323
+ bytes[] memory data = new bytes[](1);
324
+ data[0] = abi.encode(true, tierIdsToMint);
325
+ bytes4[] memory ids = new bytes4[](1);
326
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
327
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
328
+
329
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
330
+ payer: beneficiary,
331
+ projectId: projectId,
332
+ rulesetId: 0,
333
+ amount: JBTokenAmount({
334
+ token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
335
+ }),
336
+ forwardedAmount: JBTokenAmount({
337
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
338
+ }),
339
+ weight: 10 ** 18,
340
+ newlyIssuedTokenCount: 0,
341
+ beneficiary: beneficiary,
342
+ hookMetadata: bytes(""),
343
+ payerMetadata: hookMetadata
344
+ });
345
+
346
+ vm.prank(mockTerminalAddress);
347
+ crossHook.afterPayRecordedWith(payContext);
348
+
349
+ assertEq(crossHook.balanceOf(beneficiary), 1, "NFT minted with extreme low price ratio");
350
+ }
351
+
352
+ /// @notice Test 8: Reverting price feed blocks afterPayRecordedWith (DoS, not fund loss).
353
+ function test_revertingPriceFeed_blocksPayment() public {
354
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
355
+
356
+ mockAndExpect(
357
+ address(mockJBDirectory),
358
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
359
+ abi.encode(true)
360
+ );
361
+
362
+ // Price feed reverts (stale data, sequencer down, etc.).
363
+ vm.mockCallRevert(
364
+ mockPrices,
365
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector),
366
+ abi.encodeWithSignature("Error(string)", "stale price")
367
+ );
368
+
369
+ uint16[] memory tierIdsToMint = new uint16[](1);
370
+ tierIdsToMint[0] = 1;
371
+ bytes[] memory data = new bytes[](1);
372
+ data[0] = abi.encode(true, tierIdsToMint);
373
+ bytes4[] memory ids = new bytes4[](1);
374
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
375
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
376
+
377
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
378
+ payer: beneficiary,
379
+ projectId: projectId,
380
+ rulesetId: 0,
381
+ amount: JBTokenAmount({
382
+ token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
383
+ }),
384
+ forwardedAmount: JBTokenAmount({
385
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
386
+ }),
387
+ weight: 10 ** 18,
388
+ newlyIssuedTokenCount: 0,
389
+ beneficiary: beneficiary,
390
+ hookMetadata: bytes(""),
391
+ payerMetadata: hookMetadata
392
+ });
393
+
394
+ vm.prank(mockTerminalAddress);
395
+ vm.expectRevert();
396
+ crossHook.afterPayRecordedWith(payContext);
397
+ }
398
+
399
+ /// @notice Test 9: Reverting price feed blocks beforePayRecordedWith when tier has splits.
400
+ function test_revertingPriceFeed_blocksSplitConversion() public {
401
+ // Set splitPercent on the default tier config so the tier has a non-zero split.
402
+ defaultTierConfig.splitPercent = 500_000_000; // 50%
403
+ JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18, mockPrices);
404
+
405
+ // Build payer metadata requesting tier 1.
406
+ uint16[] memory mintIds = new uint16[](1);
407
+ mintIds[0] = 1;
408
+ bytes[] memory payMetadata = new bytes[](1);
409
+ payMetadata[0] = abi.encode(false, mintIds);
410
+ bytes4[] memory metaIds = new bytes4[](1);
411
+ metaIds[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
412
+ bytes memory pMeta = metadataHelper.createMetadata(metaIds, payMetadata);
413
+
414
+ // Price feed reverts (stale data, sequencer down, etc.).
415
+ vm.mockCallRevert(
416
+ mockPrices,
417
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector),
418
+ abi.encodeWithSignature("Error(string)", "stale price")
419
+ );
420
+
421
+ JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
422
+ terminal: mockTerminalAddress,
423
+ payer: beneficiary,
424
+ amount: JBTokenAmount({
425
+ token: JBConstants.NATIVE_TOKEN, value: 200, decimals: 18, currency: nativeCurrency
426
+ }),
427
+ projectId: projectId,
428
+ rulesetId: 0,
429
+ beneficiary: beneficiary,
430
+ weight: 10e18,
431
+ reservedPercent: 0,
432
+ metadata: pMeta
433
+ });
434
+
435
+ // convertSplitAmounts calls pricePerUnitOf → reverts.
436
+ vm.expectRevert();
437
+ crossHook.beforePayRecordedWith(context);
438
+
439
+ // Reset for other tests.
440
+ defaultTierConfig.splitPercent = 0;
441
+ }
442
+
443
+ /// @notice Test 10: Mint multiple tiers at different USD prices, pay with ETH equivalent.
444
+ function test_crossCurrency_mintMultipleTiers() public {
445
+ // Create 3 tiers with different USD prices.
446
+ tiers[0].price = 100; // Tier 1: 100 USD units
447
+ tiers[1].price = 200; // Tier 2: 200 USD units
448
+ tiers[2].price = 500; // Tier 3: 500 USD units
449
+
450
+ JB721TiersHook crossHook = _initHookDefaultTiers(3, false, uint32(USD()), 18, mockPrices);
451
+
452
+ mockAndExpect(
453
+ address(mockJBDirectory),
454
+ abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
455
+ abi.encode(true)
456
+ );
457
+
458
+ // Mock 1:1 USD/native ratio for simplicity.
459
+ vm.mockCall(
460
+ mockPrices,
461
+ abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
462
+ abi.encode(uint256(1e18))
463
+ );
464
+
465
+ // Pay 800 units (= 100 + 200 + 500) -> should mint all 3 tiers.
466
+ uint16[] memory tierIdsToMint = new uint16[](3);
467
+ tierIdsToMint[0] = 1;
468
+ tierIdsToMint[1] = 2;
469
+ tierIdsToMint[2] = 3;
470
+ bytes[] memory data = new bytes[](1);
471
+ data[0] = abi.encode(true, tierIdsToMint);
472
+ bytes4[] memory ids = new bytes4[](1);
473
+ ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
474
+ bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
475
+
476
+ JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
477
+ payer: beneficiary,
478
+ projectId: projectId,
479
+ rulesetId: 0,
480
+ amount: JBTokenAmount({
481
+ token: JBConstants.NATIVE_TOKEN, value: 800, decimals: 18, currency: nativeCurrency
482
+ }),
483
+ forwardedAmount: JBTokenAmount({
484
+ token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
485
+ }),
486
+ weight: 10 ** 18,
487
+ newlyIssuedTokenCount: 0,
488
+ beneficiary: beneficiary,
489
+ hookMetadata: bytes(""),
490
+ payerMetadata: hookMetadata
491
+ });
492
+
493
+ vm.prank(mockTerminalAddress);
494
+ crossHook.afterPayRecordedWith(payContext);
495
+
496
+ assertEq(crossHook.balanceOf(beneficiary), 3, "3 NFTs minted across different USD-priced tiers");
497
+ }
498
+ }