@bananapus/721-hook-v6 0.0.13 → 0.0.15
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/ARCHITECTURE.md +7 -2
- package/RISKS.md +6 -6
- package/SKILLS.md +5 -4
- package/STYLE_GUIDE.md +131 -43
- package/foundry.toml +3 -3
- package/package.json +5 -5
- package/remappings.txt +1 -1
- package/script/Deploy.s.sol +1 -1
- package/script/helpers/Hook721DeploymentLib.sol +1 -1
- package/src/JB721TiersHook.sol +23 -0
- package/src/JB721TiersHookProjectDeployer.sol +3 -3
- package/src/libraries/JB721TiersHookLib.sol +44 -0
- package/src/structs/JBPayDataHookRulesetMetadata.sol +3 -0
- package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +2 -0
- package/test/Fork.t.sol +14 -18
- package/test/fork/ERC20TierSplitFork.t.sol +539 -0
- package/test/invariants/TierLifecycleInvariant.t.sol +0 -2
- package/test/unit/pay_CrossCurrency_Unit.t.sol +498 -0
- package/test/unit/tierSplitRouting_Unit.t.sol +257 -0
- /package/test/regression/{L35_CacheTierLookup.t.sol → CacheTierLookup.t.sol} +0 -0
- /package/test/regression/{L34_ReserveBeneficiaryOverwrite.t.sol → ReserveBeneficiaryOverwrite.t.sol} +0 -0
- /package/test/regression/{L36_SplitNoBeneficiary.t.sol → SplitNoBeneficiary.t.sol} +0 -0
- /package/test/unit/{M6_TierSupplyCheck.t.sol → TierSupplyReserveCheck.t.sol} +0 -0
|
@@ -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
|
+
}
|