@bananapus/721-hook-v6 0.0.41 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/foundry.lock +1 -7
  2. package/foundry.toml +1 -1
  3. package/package.json +20 -9
  4. package/script/Deploy.s.sol +2 -2
  5. package/src/JB721Checkpoints.sol +60 -18
  6. package/src/JB721CheckpointsDeployer.sol +10 -5
  7. package/src/JB721TiersHook.sol +4 -1
  8. package/src/JB721TiersHookProjectDeployer.sol +68 -30
  9. package/src/JB721TiersHookStore.sol +1 -4
  10. package/src/interfaces/IJB721Checkpoints.sol +21 -14
  11. package/src/interfaces/IJB721CheckpointsDeployer.sol +6 -2
  12. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +2 -0
  13. package/test/utils/AccessJBLib.sol +49 -0
  14. package/test/utils/ForTest_JB721TiersHook.sol +246 -0
  15. package/test/utils/TestBaseWorkflow.sol +213 -0
  16. package/test/utils/UnitTestSetup.sol +805 -0
  17. package/.gas-snapshot +0 -152
  18. package/ADMINISTRATION.md +0 -87
  19. package/ARCHITECTURE.md +0 -98
  20. package/AUDIT_INSTRUCTIONS.md +0 -77
  21. package/RISKS.md +0 -118
  22. package/SKILLS.md +0 -43
  23. package/STYLE_GUIDE.md +0 -610
  24. package/USER_JOURNEYS.md +0 -121
  25. package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
  26. package/slither-ci.config.json +0 -10
  27. package/test/721HookAttacks.t.sol +0 -408
  28. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
  29. package/test/Fork.t.sol +0 -2346
  30. package/test/TestAuditGaps.sol +0 -1075
  31. package/test/TestCheckpoints.t.sol +0 -341
  32. package/test/TestSafeTransferReentrancy.t.sol +0 -305
  33. package/test/TestVotingUnitsLifecycle.t.sol +0 -313
  34. package/test/audit/AuditRegressions.t.sol +0 -83
  35. package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
  36. package/test/audit/FreshAudit.t.sol +0 -197
  37. package/test/audit/FutureTierPoC.t.sol +0 -39
  38. package/test/audit/FutureTierRemoval.t.sol +0 -47
  39. package/test/audit/Pass12L18.t.sol +0 -80
  40. package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
  41. package/test/audit/ProjectDeployerAuth.t.sol +0 -266
  42. package/test/audit/RepoFindings.t.sol +0 -195
  43. package/test/audit/ReserveActivation.t.sol +0 -87
  44. package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
  45. package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
  46. package/test/audit/SplitCreditsMismatch.t.sol +0 -219
  47. package/test/audit/SplitFailureRedistribution.t.sol +0 -143
  48. package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
  49. package/test/fork/ERC20CashOutFork.t.sol +0 -633
  50. package/test/fork/ERC20TierSplitFork.t.sol +0 -596
  51. package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
  52. package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
  53. package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
  54. package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
  55. package/test/invariants/handlers/TierStoreHandler.sol +0 -165
  56. package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
  57. package/test/regression/CacheTierLookup.t.sol +0 -190
  58. package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
  59. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
  60. package/test/regression/SplitDistributionBugs.t.sol +0 -751
  61. package/test/regression/SplitNoBeneficiary.t.sol +0 -140
  62. package/test/unit/AuditFixes_Unit.t.sol +0 -624
  63. package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
  64. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
  65. package/test/unit/JBBitmap.t.sol +0 -170
  66. package/test/unit/JBIpfsDecoder.t.sol +0 -136
  67. package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
  68. package/test/unit/adjustTier_Unit.t.sol +0 -1942
  69. package/test/unit/deployer_Unit.t.sol +0 -114
  70. package/test/unit/getters_constructor_Unit.t.sol +0 -593
  71. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
  72. package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
  73. package/test/unit/pay_Unit.t.sol +0 -1661
  74. package/test/unit/redeem_Unit.t.sol +0 -473
  75. package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
  76. package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
  77. package/test/unit/tierSplitRouting_Unit.t.sol +0 -757
@@ -1,530 +0,0 @@
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 Cross-currency unit tests for the 721 hook's normalizePaymentValue path.
8
- /// Verifies correct behavior when payment token currency differs from tier pricing currency.
9
- contract Test_crossCurrencyPay_Unit is UnitTestSetup {
10
- using stdStorage for StdStorage;
11
-
12
- // -- Currency constants
13
- uint32 nativeCurrency = uint32(uint160(JBConstants.NATIVE_TOKEN));
14
-
15
- // -- Mock USDC address
16
- address constant MOCK_USDC = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
17
- // forge-lint: disable-next-line(unsafe-typecast)
18
- uint32 usdcCurrency = uint32(uint160(MOCK_USDC));
19
-
20
- /// @notice Test 1: USDC payment -> USD-priced tier. normalizePaymentValue works with 1:1 USDC/USD.
21
- function test_normalizePaymentValue_usdcPayment_usdTier() public {
22
- // Initialize hook with USD-priced tiers + prices oracle.
23
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
24
-
25
- // Mock directory call.
26
- mockAndExpect(
27
- address(mockJBDirectory),
28
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
29
- abi.encode(true)
30
- );
31
-
32
- // Mock pricePerUnitOf: USDC -> USD, 6 decimals. Returns 1e6 (1:1 USDC/USD).
33
- vm.mockCall(
34
- mockJBPrices,
35
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, usdcCurrency, USD(), uint256(6)),
36
- abi.encode(uint256(1e6))
37
- );
38
-
39
- // Tier price is 10 (the default: tierId * 10 = 1 * 10 = 10).
40
- // Pay 10 USDC (6 decimals) -> normalized to 10e18 USD -> matches tier price of 10 (18 decimals).
41
- // But default tier price is 10 with 18-decimal pricing -> need exactly 10 as normalized value.
42
- // normalizePaymentValue: mulDiv(10e6, 1e18, pricePerUnitOf(_, usdcCurrency, USD, 6))
43
- // = mulDiv(10e6, 1e18, 1e6) = 10e18. But tier price with USD/18 decimals is 10 (raw).
44
- // Actually the tier prices from default config are: tiers[0].price = 10. With 18-decimal pricing,
45
- // the normalized value needs to be >= 10. normalizePaymentValue returns mulDiv(10e6, 1e18, 1e6) = 10e18.
46
- // 10e18 >= 10 → NFT minted.
47
-
48
- uint16[] memory tierIdsToMint = new uint16[](1);
49
- tierIdsToMint[0] = 1;
50
- bytes[] memory data = new bytes[](1);
51
- data[0] = abi.encode(true, tierIdsToMint);
52
- bytes4[] memory ids = new bytes4[](1);
53
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
54
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
55
-
56
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
57
- payer: beneficiary,
58
- projectId: projectId,
59
- rulesetId: 0,
60
- amount: JBTokenAmount({token: MOCK_USDC, value: 10e6, decimals: 6, currency: usdcCurrency}),
61
- forwardedAmount: JBTokenAmount({token: MOCK_USDC, value: 0, decimals: 6, currency: usdcCurrency}),
62
- weight: 10 ** 18,
63
- newlyIssuedTokenCount: 0,
64
- beneficiary: beneficiary,
65
- hookMetadata: bytes(""),
66
- payerMetadata: hookMetadata
67
- });
68
-
69
- vm.prank(mockTerminalAddress);
70
- crossHook.afterPayRecordedWith(payContext);
71
-
72
- assertEq(crossHook.balanceOf(beneficiary), 1, "1 NFT minted from USDC -> USD tier");
73
- }
74
-
75
- /// @notice Test 2: ETH payment -> USD-priced tier (2000:1 ratio).
76
- function test_normalizePaymentValue_ethPayment_usdTier() public {
77
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
78
-
79
- mockAndExpect(
80
- address(mockJBDirectory),
81
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
82
- abi.encode(true)
83
- );
84
-
85
- // Mock: pricePerUnitOf(_, nativeCurrency, USD, 18) = 5e14 (inverse of $2000).
86
- // "1 nativeCurrency unit costs 5e14 USD units" which represents 1/2000 of a USD unit in 18 decimals.
87
- vm.mockCall(
88
- mockJBPrices,
89
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
90
- abi.encode(uint256(5e14))
91
- );
92
-
93
- // Pay 1 ETH -> normalizePaymentValue: mulDiv(1e18, 1e18, 5e14) = 2000e18.
94
- // Default tier price = 10 with 18 decimals. 2000e18 >= 10 → NFT minted.
95
- uint16[] memory tierIdsToMint = new uint16[](1);
96
- tierIdsToMint[0] = 1;
97
- bytes[] memory data = new bytes[](1);
98
- data[0] = abi.encode(true, tierIdsToMint);
99
- bytes4[] memory ids = new bytes4[](1);
100
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
101
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
102
-
103
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
104
- payer: beneficiary,
105
- projectId: projectId,
106
- rulesetId: 0,
107
- amount: JBTokenAmount({
108
- token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
109
- }),
110
- forwardedAmount: JBTokenAmount({
111
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
112
- }),
113
- weight: 10 ** 18,
114
- newlyIssuedTokenCount: 0,
115
- beneficiary: beneficiary,
116
- hookMetadata: bytes(""),
117
- payerMetadata: hookMetadata
118
- });
119
-
120
- vm.prank(mockTerminalAddress);
121
- crossHook.afterPayRecordedWith(payContext);
122
-
123
- assertEq(crossHook.balanceOf(beneficiary), 1, "1 NFT minted from ETH -> USD tier");
124
- }
125
-
126
- /// @notice Test 3: Payment normalizes to exactly the tier price -> NFT minted.
127
- function test_normalizePaymentValue_exactTierBoundary() public {
128
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
129
-
130
- mockAndExpect(
131
- address(mockJBDirectory),
132
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
133
- abi.encode(true)
134
- );
135
-
136
- // Price feed returns exactly the amount needed for tier price of 10.
137
- // If we pay 10 units with a 1:1 ratio, normalized = 10. Tier price = 10 → exact match.
138
- vm.mockCall(
139
- mockJBPrices,
140
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
141
- abi.encode(uint256(1e18)) // 1:1 ratio
142
- );
143
-
144
- uint16[] memory tierIdsToMint = new uint16[](1);
145
- tierIdsToMint[0] = 1;
146
- bytes[] memory data = new bytes[](1);
147
- data[0] = abi.encode(true, tierIdsToMint);
148
- bytes4[] memory ids = new bytes4[](1);
149
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
150
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
151
-
152
- // Pay exactly 10 units → normalizePaymentValue = mulDiv(10, 1e18, 1e18) = 10.
153
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
154
- payer: beneficiary,
155
- projectId: projectId,
156
- rulesetId: 0,
157
- amount: JBTokenAmount({token: JBConstants.NATIVE_TOKEN, value: 10, decimals: 18, currency: nativeCurrency}),
158
- forwardedAmount: JBTokenAmount({
159
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
160
- }),
161
- weight: 10 ** 18,
162
- newlyIssuedTokenCount: 0,
163
- beneficiary: beneficiary,
164
- hookMetadata: bytes(""),
165
- payerMetadata: hookMetadata
166
- });
167
-
168
- vm.prank(mockTerminalAddress);
169
- crossHook.afterPayRecordedWith(payContext);
170
-
171
- assertEq(crossHook.balanceOf(beneficiary), 1, "NFT minted at exact tier price boundary");
172
- }
173
-
174
- /// @notice Test 4: Payment normalizes to 1 wei below tier price -> no NFT minted (stored as credit).
175
- function test_normalizePaymentValue_justBelowTierPrice() public {
176
- // preventOverspending = false so it doesn't revert, just skips the tier.
177
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
178
-
179
- mockAndExpect(
180
- address(mockJBDirectory),
181
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
182
- abi.encode(true)
183
- );
184
-
185
- vm.mockCall(
186
- mockJBPrices,
187
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
188
- abi.encode(uint256(1e18)) // 1:1
189
- );
190
-
191
- // Pay 9 units → normalized = 9. Tier price = 10 → below threshold.
192
- // No metadata = no explicit tier selection, overspending allowed → 0 NFTs (just credit).
193
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
194
- payer: beneficiary,
195
- projectId: projectId,
196
- rulesetId: 0,
197
- amount: JBTokenAmount({token: JBConstants.NATIVE_TOKEN, value: 9, decimals: 18, currency: nativeCurrency}),
198
- forwardedAmount: JBTokenAmount({
199
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
200
- }),
201
- weight: 10 ** 18,
202
- newlyIssuedTokenCount: 0,
203
- beneficiary: beneficiary,
204
- hookMetadata: bytes(""),
205
- payerMetadata: new bytes(0) // no metadata → auto-mint path
206
- });
207
-
208
- vm.prank(mockTerminalAddress);
209
- crossHook.afterPayRecordedWith(payContext);
210
-
211
- assertEq(crossHook.balanceOf(beneficiary), 0, "no NFT minted below tier price");
212
- }
213
-
214
- /// @notice Test 5: prices=address(0) + currencies differ -> normalizePaymentValue returns (0, false).
215
- function test_normalizePaymentValue_noPricesContract() public {
216
- // Deploy a separate hook origin with address(0) as PRICES to test the no-prices path.
217
- JB721TiersHook noPricesOrigin = new JB721TiersHook(
218
- IJBDirectory(mockJBDirectory),
219
- IJBPermissions(mockJBPermissions),
220
- IJBPrices(address(0)),
221
- IJBRulesets(mockJBRulesets),
222
- IJB721TiersHookStore(store),
223
- IJBSplits(mockJBSplits),
224
- IJB721CheckpointsDeployer(address(new JB721CheckpointsDeployer())),
225
- trustedForwarder
226
- );
227
-
228
- // Create a fresh proxy address and etch the no-prices bytecode.
229
- address noPricesProxy = makeAddr("noPricesProxy");
230
- vm.etch(noPricesProxy, address(noPricesOrigin).code);
231
- JB721TiersHook crossHook = JB721TiersHook(noPricesProxy);
232
-
233
- // Initialize tiers with USD currency.
234
- (JB721TierConfig[] memory tierConfigs,) = _createTiers(defaultTierConfig, 1);
235
- crossHook.initialize(
236
- projectId,
237
- name,
238
- symbol,
239
- baseUri,
240
- IJB721TokenUriResolver(mockTokenUriResolver),
241
- contractUri,
242
- JB721InitTiersConfig({tiers: tierConfigs, currency: uint32(USD()), decimals: 18}),
243
- JB721TiersHookFlags({
244
- preventOverspending: false,
245
- issueTokensForSplits: false,
246
- noNewTiersWithReserves: false,
247
- noNewTiersWithVotes: false,
248
- noNewTiersWithOwnerMinting: false
249
- })
250
- );
251
-
252
- mockAndExpect(
253
- address(mockJBDirectory),
254
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
255
- abi.encode(true)
256
- );
257
-
258
- // Pay with native token (currency != USD). prices=address(0) → normalizePaymentValue returns (0, false).
259
- // No NFTs minted.
260
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
261
- payer: beneficiary,
262
- projectId: projectId,
263
- rulesetId: 0,
264
- amount: JBTokenAmount({
265
- token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
266
- }),
267
- forwardedAmount: JBTokenAmount({
268
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
269
- }),
270
- weight: 10 ** 18,
271
- newlyIssuedTokenCount: 0,
272
- beneficiary: beneficiary,
273
- hookMetadata: bytes(""),
274
- payerMetadata: new bytes(0)
275
- });
276
-
277
- vm.prank(mockTerminalAddress);
278
- crossHook.afterPayRecordedWith(payContext);
279
-
280
- assertEq(crossHook.balanceOf(beneficiary), 0, "no NFT minted (prices=0, currencies differ)");
281
- }
282
-
283
- /// @notice Test 6: Extreme high price ratio (1e27) -> no overflow, correct normalization.
284
- function test_normalizePaymentValue_extremeHighPrice() public {
285
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
286
-
287
- mockAndExpect(
288
- address(mockJBDirectory),
289
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
290
- abi.encode(true)
291
- );
292
-
293
- // Price feed returns 1e27 (extreme ratio: 1 unit of pricingCurrency costs 1e27 units of unitCurrency).
294
- // pricePerUnitOf returns 1e27 with 18 decimals.
295
- // normalizePaymentValue: mulDiv(1e18, 1e18, 1e27) = 1e9.
296
- // 1e9 >= tier price 10 → NFT minted.
297
- vm.mockCall(
298
- mockJBPrices,
299
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
300
- abi.encode(uint256(1e27))
301
- );
302
-
303
- uint16[] memory tierIdsToMint = new uint16[](1);
304
- tierIdsToMint[0] = 1;
305
- bytes[] memory data = new bytes[](1);
306
- data[0] = abi.encode(true, tierIdsToMint);
307
- bytes4[] memory ids = new bytes4[](1);
308
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
309
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
310
-
311
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
312
- payer: beneficiary,
313
- projectId: projectId,
314
- rulesetId: 0,
315
- amount: JBTokenAmount({
316
- token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
317
- }),
318
- forwardedAmount: JBTokenAmount({
319
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
320
- }),
321
- weight: 10 ** 18,
322
- newlyIssuedTokenCount: 0,
323
- beneficiary: beneficiary,
324
- hookMetadata: bytes(""),
325
- payerMetadata: hookMetadata
326
- });
327
-
328
- vm.prank(mockTerminalAddress);
329
- crossHook.afterPayRecordedWith(payContext);
330
-
331
- assertEq(crossHook.balanceOf(beneficiary), 1, "NFT minted with extreme high price ratio");
332
- }
333
-
334
- /// @notice Test 7: Extreme low price ratio (1 wei) -> large normalized value, no revert.
335
- function test_normalizePaymentValue_extremeLowPrice() public {
336
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
337
-
338
- mockAndExpect(
339
- address(mockJBDirectory),
340
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
341
- abi.encode(true)
342
- );
343
-
344
- // Price feed returns 1 (near-zero: 1 unit costs 1 wei of the pricing currency).
345
- // normalizePaymentValue: mulDiv(1e18, 1e18, 1) = 1e36. This is a very large number.
346
- // 1e36 >= tier price 10 → NFT minted.
347
- vm.mockCall(
348
- mockJBPrices,
349
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
350
- abi.encode(uint256(1))
351
- );
352
-
353
- uint16[] memory tierIdsToMint = new uint16[](1);
354
- tierIdsToMint[0] = 1;
355
- bytes[] memory data = new bytes[](1);
356
- data[0] = abi.encode(true, tierIdsToMint);
357
- bytes4[] memory ids = new bytes4[](1);
358
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
359
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
360
-
361
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
362
- payer: beneficiary,
363
- projectId: projectId,
364
- rulesetId: 0,
365
- amount: JBTokenAmount({
366
- token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
367
- }),
368
- forwardedAmount: JBTokenAmount({
369
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
370
- }),
371
- weight: 10 ** 18,
372
- newlyIssuedTokenCount: 0,
373
- beneficiary: beneficiary,
374
- hookMetadata: bytes(""),
375
- payerMetadata: hookMetadata
376
- });
377
-
378
- vm.prank(mockTerminalAddress);
379
- crossHook.afterPayRecordedWith(payContext);
380
-
381
- assertEq(crossHook.balanceOf(beneficiary), 1, "NFT minted with extreme low price ratio");
382
- }
383
-
384
- /// @notice Test 8: Reverting price feed blocks afterPayRecordedWith (DoS, not fund loss).
385
- function test_revertingPriceFeed_blocksPayment() public {
386
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
387
-
388
- mockAndExpect(
389
- address(mockJBDirectory),
390
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
391
- abi.encode(true)
392
- );
393
-
394
- // Price feed reverts (stale data, sequencer down, etc.).
395
- vm.mockCallRevert(
396
- mockJBPrices,
397
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector),
398
- abi.encodeWithSignature("Error(string)", "stale price")
399
- );
400
-
401
- uint16[] memory tierIdsToMint = new uint16[](1);
402
- tierIdsToMint[0] = 1;
403
- bytes[] memory data = new bytes[](1);
404
- data[0] = abi.encode(true, tierIdsToMint);
405
- bytes4[] memory ids = new bytes4[](1);
406
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
407
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
408
-
409
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
410
- payer: beneficiary,
411
- projectId: projectId,
412
- rulesetId: 0,
413
- amount: JBTokenAmount({
414
- token: JBConstants.NATIVE_TOKEN, value: 1e18, decimals: 18, currency: nativeCurrency
415
- }),
416
- forwardedAmount: JBTokenAmount({
417
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
418
- }),
419
- weight: 10 ** 18,
420
- newlyIssuedTokenCount: 0,
421
- beneficiary: beneficiary,
422
- hookMetadata: bytes(""),
423
- payerMetadata: hookMetadata
424
- });
425
-
426
- vm.prank(mockTerminalAddress);
427
- vm.expectRevert();
428
- crossHook.afterPayRecordedWith(payContext);
429
- }
430
-
431
- /// @notice Test 9: Reverting price feed blocks beforePayRecordedWith when tier has splits.
432
- function test_revertingPriceFeed_blocksSplitConversion() public {
433
- // Set splitPercent on the default tier config so the tier has a non-zero split.
434
- defaultTierConfig.splitPercent = 500_000_000; // 50%
435
- JB721TiersHook crossHook = _initHookDefaultTiers(1, false, uint32(USD()), 18);
436
-
437
- // Build payer metadata requesting tier 1.
438
- uint16[] memory mintIds = new uint16[](1);
439
- mintIds[0] = 1;
440
- bytes[] memory payMetadata = new bytes[](1);
441
- payMetadata[0] = abi.encode(false, mintIds);
442
- bytes4[] memory metaIds = new bytes4[](1);
443
- metaIds[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
444
- bytes memory pMeta = metadataHelper.createMetadata(metaIds, payMetadata);
445
-
446
- // Price feed reverts (stale data, sequencer down, etc.).
447
- vm.mockCallRevert(
448
- mockJBPrices,
449
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector),
450
- abi.encodeWithSignature("Error(string)", "stale price")
451
- );
452
-
453
- JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
454
- terminal: mockTerminalAddress,
455
- payer: beneficiary,
456
- amount: JBTokenAmount({
457
- token: JBConstants.NATIVE_TOKEN, value: 200, decimals: 18, currency: nativeCurrency
458
- }),
459
- projectId: projectId,
460
- rulesetId: 0,
461
- beneficiary: beneficiary,
462
- weight: 10e18,
463
- reservedPercent: 0,
464
- metadata: pMeta
465
- });
466
-
467
- // convertAndCapSplitAmounts calls pricePerUnitOf → reverts.
468
- vm.expectRevert();
469
- crossHook.beforePayRecordedWith(context);
470
-
471
- // Reset for other tests.
472
- defaultTierConfig.splitPercent = 0;
473
- }
474
-
475
- /// @notice Test 10: Mint multiple tiers at different USD prices, pay with ETH equivalent.
476
- function test_crossCurrency_mintMultipleTiers() public {
477
- // Create 3 tiers with different USD prices.
478
- tiers[0].price = 100; // Tier 1: 100 USD units
479
- tiers[1].price = 200; // Tier 2: 200 USD units
480
- tiers[2].price = 500; // Tier 3: 500 USD units
481
-
482
- JB721TiersHook crossHook = _initHookDefaultTiers(3, false, uint32(USD()), 18);
483
-
484
- mockAndExpect(
485
- address(mockJBDirectory),
486
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
487
- abi.encode(true)
488
- );
489
-
490
- // Mock 1:1 USD/native ratio for simplicity.
491
- vm.mockCall(
492
- mockJBPrices,
493
- abi.encodeWithSelector(IJBPrices.pricePerUnitOf.selector, projectId, nativeCurrency, USD(), uint256(18)),
494
- abi.encode(uint256(1e18))
495
- );
496
-
497
- // Pay 800 units (= 100 + 200 + 500) -> should mint all 3 tiers.
498
- uint16[] memory tierIdsToMint = new uint16[](3);
499
- tierIdsToMint[0] = 1;
500
- tierIdsToMint[1] = 2;
501
- tierIdsToMint[2] = 3;
502
- bytes[] memory data = new bytes[](1);
503
- data[0] = abi.encode(true, tierIdsToMint);
504
- bytes4[] memory ids = new bytes4[](1);
505
- ids[0] = metadataHelper.getId("pay", crossHook.METADATA_ID_TARGET());
506
- bytes memory hookMetadata = metadataHelper.createMetadata(ids, data);
507
-
508
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
509
- payer: beneficiary,
510
- projectId: projectId,
511
- rulesetId: 0,
512
- amount: JBTokenAmount({
513
- token: JBConstants.NATIVE_TOKEN, value: 800, decimals: 18, currency: nativeCurrency
514
- }),
515
- forwardedAmount: JBTokenAmount({
516
- token: JBConstants.NATIVE_TOKEN, value: 0, decimals: 18, currency: nativeCurrency
517
- }),
518
- weight: 10 ** 18,
519
- newlyIssuedTokenCount: 0,
520
- beneficiary: beneficiary,
521
- hookMetadata: bytes(""),
522
- payerMetadata: hookMetadata
523
- });
524
-
525
- vm.prank(mockTerminalAddress);
526
- crossHook.afterPayRecordedWith(payContext);
527
-
528
- assertEq(crossHook.balanceOf(beneficiary), 3, "3 NFTs minted across different USD-priced tiers");
529
- }
530
- }