@bananapus/721-hook-v6 0.0.42 → 0.0.45

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 (86) 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 +61 -19
  6. package/src/JB721CheckpointsDeployer.sol +10 -5
  7. package/src/JB721TiersHook.sol +66 -53
  8. package/src/JB721TiersHookDeployer.sol +8 -5
  9. package/src/JB721TiersHookProjectDeployer.sol +87 -46
  10. package/src/JB721TiersHookStore.sol +137 -107
  11. package/src/abstract/JB721Hook.sol +8 -6
  12. package/src/interfaces/IJB721Checkpoints.sol +21 -14
  13. package/src/interfaces/IJB721CheckpointsDeployer.sol +7 -3
  14. package/src/interfaces/IJB721TiersHook.sol +3 -3
  15. package/src/interfaces/IJB721TiersHookProjectDeployer.sol +4 -2
  16. package/src/interfaces/IJB721TiersHookStore.sol +11 -11
  17. package/src/libraries/JB721TiersHookLib.sol +1 -1
  18. package/src/structs/JB721TiersHookFlags.sol +1 -1
  19. package/src/structs/JBPayDataHookRulesetMetadata.sol +1 -1
  20. package/test/utils/AccessJBLib.sol +49 -0
  21. package/test/utils/ForTest_JB721TiersHook.sol +246 -0
  22. package/test/utils/TestBaseWorkflow.sol +213 -0
  23. package/test/utils/UnitTestSetup.sol +805 -0
  24. package/.gas-snapshot +0 -152
  25. package/ADMINISTRATION.md +0 -87
  26. package/ARCHITECTURE.md +0 -98
  27. package/AUDIT_INSTRUCTIONS.md +0 -77
  28. package/RISKS.md +0 -118
  29. package/SKILLS.md +0 -43
  30. package/STYLE_GUIDE.md +0 -610
  31. package/USER_JOURNEYS.md +0 -121
  32. package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +0 -83
  33. package/slither-ci.config.json +0 -10
  34. package/test/721HookAttacks.t.sol +0 -408
  35. package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -985
  36. package/test/Fork.t.sol +0 -2346
  37. package/test/TestAuditGaps.sol +0 -1075
  38. package/test/TestCheckpoints.t.sol +0 -341
  39. package/test/TestSafeTransferReentrancy.t.sol +0 -305
  40. package/test/TestVotingUnitsLifecycle.t.sol +0 -313
  41. package/test/audit/AuditRegressions.t.sol +0 -83
  42. package/test/audit/CodexNemesisReserveSellout.t.sol +0 -66
  43. package/test/audit/CrossCurrencySplitNoPrices.t.sol +0 -123
  44. package/test/audit/FreshAudit.t.sol +0 -197
  45. package/test/audit/FutureTierPoC.t.sol +0 -39
  46. package/test/audit/FutureTierRemoval.t.sol +0 -47
  47. package/test/audit/Pass12L18.t.sol +0 -80
  48. package/test/audit/PayCreditsBypassTierSplits.t.sol +0 -200
  49. package/test/audit/ProjectDeployerAuth.t.sol +0 -266
  50. package/test/audit/RepoFindings.t.sol +0 -195
  51. package/test/audit/ReserveActivation.t.sol +0 -87
  52. package/test/audit/ReserveSlotProtection.t.sol +0 -273
  53. package/test/audit/RetroactiveReserveBeneficiaryDilution.t.sol +0 -149
  54. package/test/audit/SameCurrencyDecimalMismatch.t.sol +0 -249
  55. package/test/audit/SplitCreditsMismatch.t.sol +0 -219
  56. package/test/audit/SplitFailureRedistribution.t.sol +0 -143
  57. package/test/audit/USDTVoidReturnCompat.t.sol +0 -301
  58. package/test/fork/ERC20CashOutFork.t.sol +0 -633
  59. package/test/fork/ERC20TierSplitFork.t.sol +0 -596
  60. package/test/fork/IssueTokensForSplitsFork.t.sol +0 -516
  61. package/test/invariants/TierLifecycleInvariant.t.sol +0 -188
  62. package/test/invariants/TieredHookStoreInvariant.t.sol +0 -86
  63. package/test/invariants/handlers/TierLifecycleHandler.sol +0 -300
  64. package/test/invariants/handlers/TierStoreHandler.sol +0 -165
  65. package/test/regression/BrokenTerminalDoesNotDos.t.sol +0 -277
  66. package/test/regression/CacheTierLookup.t.sol +0 -190
  67. package/test/regression/ProjectDeployerRulesets.t.sol +0 -358
  68. package/test/regression/ReserveBeneficiaryOverwrite.t.sol +0 -155
  69. package/test/regression/SplitDistributionBugs.t.sol +0 -751
  70. package/test/regression/SplitNoBeneficiary.t.sol +0 -140
  71. package/test/unit/AuditFixes_Unit.t.sol +0 -624
  72. package/test/unit/JB721CheckpointsDeployer_AccessControl.t.sol +0 -116
  73. package/test/unit/JB721TiersRulesetMetadataResolver.t.sol +0 -144
  74. package/test/unit/JBBitmap.t.sol +0 -170
  75. package/test/unit/JBIpfsDecoder.t.sol +0 -136
  76. package/test/unit/TierSupplyReserveCheck.t.sol +0 -221
  77. package/test/unit/adjustTier_Unit.t.sol +0 -1942
  78. package/test/unit/deployer_Unit.t.sol +0 -114
  79. package/test/unit/getters_constructor_Unit.t.sol +0 -593
  80. package/test/unit/mintFor_mintReservesFor_Unit.t.sol +0 -452
  81. package/test/unit/pay_CrossCurrency_Unit.t.sol +0 -530
  82. package/test/unit/pay_Unit.t.sol +0 -1661
  83. package/test/unit/redeem_Unit.t.sol +0 -473
  84. package/test/unit/relayBeneficiary_Unit.t.sol +0 -182
  85. package/test/unit/splitHookDistribution_Unit.t.sol +0 -604
  86. 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
- }