@bananapus/721-hook-v6 0.0.21 → 0.0.23
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/ADMINISTRATION.md +3 -3
- package/ARCHITECTURE.md +3 -3
- package/AUDIT_INSTRUCTIONS.md +13 -13
- package/CHANGE_LOG.md +3 -3
- package/RISKS.md +2 -2
- package/SKILLS.md +7 -6
- package/USER_JOURNEYS.md +3 -4
- package/assets/findings/nana-721-hook-v6-pashov-ai-audit-report-20260330-091257.md +83 -0
- package/package.json +4 -4
- package/src/JB721TiersHook.sol +23 -6
- package/src/JB721TiersHookStore.sol +15 -1
- package/src/libraries/JB721TiersHookLib.sol +143 -88
- package/src/structs/JBDeploy721TiersHookConfig.sol +0 -2
- package/test/E2E/Pay_Mint_Redeem_E2E.t.sol +0 -2
- package/test/Fork.t.sol +0 -1
- package/test/audit/CodexPayCreditsBypassTierSplits.t.sol +196 -0
- package/test/audit/CodexSplitCreditsMismatch.t.sol +214 -0
- package/test/audit/{CodexNemesis_CrossCurrencySplitNoPrices.t.sol → CrossCurrencySplitNoPrices.t.sol} +2 -2
- package/test/audit/SplitFailureRedistribution.t.sol +142 -0
- package/test/fork/ERC20CashOutFork.t.sol +0 -1
- package/test/fork/ERC20TierSplitFork.t.sol +0 -2
- package/test/fork/IssueTokensForSplitsFork.t.sol +0 -1
- package/test/regression/ProjectDeployerRulesets.t.sol +0 -1
- package/test/unit/AuditFixes_Unit.t.sol +611 -0
- package/test/unit/getters_constructor_Unit.t.sol +0 -1
- package/test/unit/pay_CrossCurrency_Unit.t.sol +1 -1
|
@@ -0,0 +1,611 @@
|
|
|
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
|
+
import {IJB721TiersHookStore} from "../../src/interfaces/IJB721TiersHookStore.sol";
|
|
7
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
8
|
+
import {IJBSplitHook} from "@bananapus/core-v6/src/interfaces/IJBSplitHook.sol";
|
|
9
|
+
import {IJBSplits} from "@bananapus/core-v6/src/interfaces/IJBSplits.sol";
|
|
10
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
11
|
+
import {JB721TiersHookFlags} from "../../src/structs/JB721TiersHookFlags.sol";
|
|
12
|
+
import {JB721TiersHookLib} from "../../src/libraries/JB721TiersHookLib.sol";
|
|
13
|
+
import {LibClone} from "solady/src/utils/LibClone.sol";
|
|
14
|
+
|
|
15
|
+
/// @notice Tests for 4 audit fixes: F-2, F-11, F-12, F-13.
|
|
16
|
+
contract Test_AuditFixes_Unit is UnitTestSetup {
|
|
17
|
+
using stdStorage for StdStorage;
|
|
18
|
+
|
|
19
|
+
address alice = makeAddr("alice");
|
|
20
|
+
|
|
21
|
+
function setUp() public override {
|
|
22
|
+
super.setUp();
|
|
23
|
+
vm.etch(mockJBSplits, new bytes(0x69));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─────────────────────────────────────────────────────────
|
|
27
|
+
// Helpers (same patterns as tierSplitRouting_Unit.t.sol)
|
|
28
|
+
// ─────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function _tierConfigWithSplit(
|
|
31
|
+
uint104 price,
|
|
32
|
+
uint32 splitPercent
|
|
33
|
+
)
|
|
34
|
+
internal
|
|
35
|
+
pure
|
|
36
|
+
returns (JB721TierConfig memory config)
|
|
37
|
+
{
|
|
38
|
+
config.price = price;
|
|
39
|
+
config.initialSupply = uint32(100);
|
|
40
|
+
config.category = uint24(1);
|
|
41
|
+
config.encodedIPFSUri = bytes32(uint256(0x1234));
|
|
42
|
+
config.splitPercent = splitPercent;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _buildPayerMetadata(
|
|
46
|
+
address hookAddress,
|
|
47
|
+
uint16[] memory tierIdsToMint
|
|
48
|
+
)
|
|
49
|
+
internal
|
|
50
|
+
view
|
|
51
|
+
returns (bytes memory)
|
|
52
|
+
{
|
|
53
|
+
bytes[] memory data = new bytes[](1);
|
|
54
|
+
data[0] = abi.encode(false, tierIdsToMint);
|
|
55
|
+
bytes4[] memory ids = new bytes4[](1);
|
|
56
|
+
ids[0] = metadataHelper.getId("pay", hookAddress);
|
|
57
|
+
return metadataHelper.createMetadata(ids, data);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ═════════════════════════════════════════════════════════
|
|
61
|
+
// F-2: Proportional split metadata scaling
|
|
62
|
+
// ═════════════════════════════════════════════════════════
|
|
63
|
+
//
|
|
64
|
+
// When totalSplitAmount > context.amount.value (e.g., because pay credits cover the NFT cost
|
|
65
|
+
// but there is not enough real ETH to forward), the per-tier amounts in splitMetadata must
|
|
66
|
+
// be proportionally scaled down so the terminal never attempts to forward more than the
|
|
67
|
+
// actual payment value.
|
|
68
|
+
|
|
69
|
+
/// @notice F-2: When amount.value < totalSplitAmount, per-tier split amounts are scaled down proportionally.
|
|
70
|
+
function test_F2_splitMetadataScaledDown_whenAmountBelowSplitTotal() public {
|
|
71
|
+
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
72
|
+
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
73
|
+
|
|
74
|
+
// Create two tiers with split percentages:
|
|
75
|
+
// Tier A: 2 ETH, 50% split -> split amount = 1 ETH
|
|
76
|
+
// Tier B: 4 ETH, 50% split -> split amount = 2 ETH
|
|
77
|
+
// Total split = 3 ETH
|
|
78
|
+
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](2);
|
|
79
|
+
tierConfigs[0] = _tierConfigWithSplit(2 ether, 500_000_000); // 50%
|
|
80
|
+
tierConfigs[0].category = 1;
|
|
81
|
+
tierConfigs[1] = _tierConfigWithSplit(4 ether, 500_000_000); // 50%
|
|
82
|
+
tierConfigs[1].category = 2;
|
|
83
|
+
vm.prank(address(testHook));
|
|
84
|
+
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
85
|
+
|
|
86
|
+
// Build payer metadata requesting both tiers.
|
|
87
|
+
uint16[] memory mintIds = new uint16[](2);
|
|
88
|
+
mintIds[0] = uint16(tierIds[0]);
|
|
89
|
+
mintIds[1] = uint16(tierIds[1]);
|
|
90
|
+
bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
|
|
91
|
+
|
|
92
|
+
// Payment of 2 ETH - less than totalSplitAmount of 3 ETH.
|
|
93
|
+
// The user may have pay credits that cover the NFT cost (2 + 4 = 6 ETH worth),
|
|
94
|
+
// but only 2 ETH of real value is being sent to the terminal.
|
|
95
|
+
JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
|
|
96
|
+
terminal: mockTerminalAddress,
|
|
97
|
+
payer: beneficiary,
|
|
98
|
+
amount: JBTokenAmount({
|
|
99
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
100
|
+
value: 2 ether, // Less than 3 ETH total split
|
|
101
|
+
decimals: 18,
|
|
102
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
103
|
+
}),
|
|
104
|
+
projectId: projectId,
|
|
105
|
+
rulesetId: 0,
|
|
106
|
+
beneficiary: beneficiary,
|
|
107
|
+
weight: 10e18,
|
|
108
|
+
reservedPercent: 5000,
|
|
109
|
+
metadata: payerMetadata
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
(, JBPayHookSpecification[] memory specs) = testHook.beforePayRecordedWith(context);
|
|
113
|
+
|
|
114
|
+
// The total forwarded amount must be capped at 2 ETH (the actual payment value).
|
|
115
|
+
assertEq(specs[0].amount, 2 ether, "Total split should be capped at payment value");
|
|
116
|
+
|
|
117
|
+
// Decode the per-tier breakdown from hookMetadata.
|
|
118
|
+
(uint16[] memory resultTierIds, uint256[] memory resultAmounts) =
|
|
119
|
+
abi.decode(specs[0].metadata, (uint16[], uint256[]));
|
|
120
|
+
|
|
121
|
+
// Both tiers should be present in the metadata.
|
|
122
|
+
assertEq(resultTierIds.length, 2, "Should have 2 tier entries");
|
|
123
|
+
|
|
124
|
+
// Per-tier amounts should be proportionally scaled:
|
|
125
|
+
// Original: tierA = 1 ETH, tierB = 2 ETH, total = 3 ETH
|
|
126
|
+
// Scaled by (2 ETH / 3 ETH):
|
|
127
|
+
// tierA = 1 * 2/3 = 0.666... ETH
|
|
128
|
+
// tierB = 2 * 2/3 = 1.333... ETH
|
|
129
|
+
uint256 tierAOriginal = 1 ether;
|
|
130
|
+
uint256 tierBOriginal = 2 ether;
|
|
131
|
+
uint256 originalTotal = 3 ether;
|
|
132
|
+
uint256 cappedTotal = 2 ether;
|
|
133
|
+
uint256 expectedA = (tierAOriginal * cappedTotal) / originalTotal;
|
|
134
|
+
uint256 expectedB = (tierBOriginal * cappedTotal) / originalTotal;
|
|
135
|
+
|
|
136
|
+
assertEq(resultAmounts[0], expectedA, "Tier A amount should be proportionally scaled");
|
|
137
|
+
assertEq(resultAmounts[1], expectedB, "Tier B amount should be proportionally scaled");
|
|
138
|
+
|
|
139
|
+
// The sum of scaled amounts should be <= the payment value.
|
|
140
|
+
assertLe(resultAmounts[0] + resultAmounts[1], 2 ether, "Sum of scaled amounts must not exceed payment value");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/// @notice F-2: When amount.value >= totalSplitAmount, no scaling occurs.
|
|
144
|
+
function test_F2_noScaling_whenAmountExceedsSplitTotal() public {
|
|
145
|
+
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
146
|
+
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
147
|
+
|
|
148
|
+
// Tier: 1 ETH, 50% split -> split amount = 0.5 ETH
|
|
149
|
+
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
|
|
150
|
+
tierConfigs[0] = _tierConfigWithSplit(1 ether, 500_000_000);
|
|
151
|
+
vm.prank(address(testHook));
|
|
152
|
+
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
153
|
+
|
|
154
|
+
uint16[] memory mintIds = new uint16[](1);
|
|
155
|
+
mintIds[0] = uint16(tierIds[0]);
|
|
156
|
+
bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
|
|
157
|
+
|
|
158
|
+
// Payment of 1 ETH - greater than 0.5 ETH total split.
|
|
159
|
+
JBBeforePayRecordedContext memory context = JBBeforePayRecordedContext({
|
|
160
|
+
terminal: mockTerminalAddress,
|
|
161
|
+
payer: beneficiary,
|
|
162
|
+
amount: JBTokenAmount({
|
|
163
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
164
|
+
value: 1 ether,
|
|
165
|
+
decimals: 18,
|
|
166
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
167
|
+
}),
|
|
168
|
+
projectId: projectId,
|
|
169
|
+
rulesetId: 0,
|
|
170
|
+
beneficiary: beneficiary,
|
|
171
|
+
weight: 10e18,
|
|
172
|
+
reservedPercent: 5000,
|
|
173
|
+
metadata: payerMetadata
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
(, JBPayHookSpecification[] memory specs) = testHook.beforePayRecordedWith(context);
|
|
177
|
+
|
|
178
|
+
// No scaling needed - the original split amount should be returned.
|
|
179
|
+
assertEq(specs[0].amount, 0.5 ether, "No scaling when payment >= split total");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ═════════════════════════════════════════════════════════
|
|
183
|
+
// F-11: Revert when no terminal for leftover funds
|
|
184
|
+
// ═════════════════════════════════════════════════════════
|
|
185
|
+
//
|
|
186
|
+
// When there are leftover funds after split distribution (splits don't consume 100%),
|
|
187
|
+
// the library looks up directory.primaryTerminalOf() to route the leftovers.
|
|
188
|
+
// If primaryTerminalOf returns address(0), the library now reverts with
|
|
189
|
+
// JB721TiersHookLib_NoTerminalForLeftover instead of silently losing funds.
|
|
190
|
+
|
|
191
|
+
/// @notice F-11: Revert when leftover funds exist but no primary terminal is available.
|
|
192
|
+
function test_F11_revertsWhenNoTerminalForLeftover() public {
|
|
193
|
+
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
194
|
+
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
195
|
+
|
|
196
|
+
// Add a tier with 50% split.
|
|
197
|
+
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
|
|
198
|
+
tierConfigs[0] = _tierConfigWithSplit(1 ether, 500_000_000); // 50%
|
|
199
|
+
vm.prank(address(testHook));
|
|
200
|
+
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
201
|
+
|
|
202
|
+
// Mock directory: isTerminalOf returns true for the mock terminal.
|
|
203
|
+
mockAndExpect(
|
|
204
|
+
address(mockJBDirectory),
|
|
205
|
+
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
206
|
+
abi.encode(true)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
// Splits: 50% to alice (leaves 50% as leftover).
|
|
210
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
211
|
+
splits[0] = JBSplit({
|
|
212
|
+
percent: uint32(500_000_000), // 50%
|
|
213
|
+
projectId: 0,
|
|
214
|
+
beneficiary: payable(alice),
|
|
215
|
+
preferAddToBalance: false,
|
|
216
|
+
lockedUntil: 0,
|
|
217
|
+
hook: IJBSplitHook(address(0))
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
|
|
221
|
+
mockAndExpect(
|
|
222
|
+
mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// Mock directory: primaryTerminalOf returns address(0) -- no terminal available.
|
|
226
|
+
mockAndExpect(
|
|
227
|
+
address(mockJBDirectory),
|
|
228
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, projectId, JBConstants.NATIVE_TOKEN),
|
|
229
|
+
abi.encode(address(0))
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
// Build payer metadata.
|
|
233
|
+
uint16[] memory mintIds = new uint16[](1);
|
|
234
|
+
mintIds[0] = uint16(tierIds[0]);
|
|
235
|
+
bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
|
|
236
|
+
|
|
237
|
+
// Build hook metadata (per-tier split breakdown).
|
|
238
|
+
uint16[] memory splitTierIds = new uint16[](1);
|
|
239
|
+
splitTierIds[0] = uint16(tierIds[0]);
|
|
240
|
+
uint256[] memory splitAmounts = new uint256[](1);
|
|
241
|
+
splitAmounts[0] = 0.5 ether;
|
|
242
|
+
|
|
243
|
+
JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
|
|
244
|
+
payer: beneficiary,
|
|
245
|
+
projectId: projectId,
|
|
246
|
+
rulesetId: 0,
|
|
247
|
+
amount: JBTokenAmount({
|
|
248
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
249
|
+
value: 1 ether,
|
|
250
|
+
decimals: 18,
|
|
251
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
252
|
+
}),
|
|
253
|
+
forwardedAmount: JBTokenAmount({
|
|
254
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
255
|
+
value: 0.5 ether,
|
|
256
|
+
decimals: 18,
|
|
257
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
258
|
+
}),
|
|
259
|
+
weight: 10e18,
|
|
260
|
+
newlyIssuedTokenCount: 0,
|
|
261
|
+
beneficiary: beneficiary,
|
|
262
|
+
hookMetadata: abi.encode(splitTierIds, splitAmounts),
|
|
263
|
+
payerMetadata: payerMetadata
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// Expect the revert from the library (called via DELEGATECALL, so it reverts in the hook's context).
|
|
267
|
+
// The leftover is 50% of 0.5 ETH = 0.25 ETH (alice gets 50% of the 0.5 ETH forwarded).
|
|
268
|
+
vm.expectRevert(
|
|
269
|
+
abi.encodeWithSelector(
|
|
270
|
+
JB721TiersHookLib.JB721TiersHookLib_NoTerminalForLeftover.selector,
|
|
271
|
+
projectId,
|
|
272
|
+
JBConstants.NATIVE_TOKEN,
|
|
273
|
+
0.25 ether
|
|
274
|
+
)
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
vm.deal(mockTerminalAddress, 1 ether);
|
|
278
|
+
vm.prank(mockTerminalAddress);
|
|
279
|
+
testHook.afterPayRecordedWith{value: 0.5 ether}(payContext);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// @notice F-11: No revert when splits consume 100% (no leftover).
|
|
283
|
+
function test_F11_noRevertWhenSplitsConsume100Percent() public {
|
|
284
|
+
ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
|
|
285
|
+
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
286
|
+
|
|
287
|
+
// Add a tier with 100% split.
|
|
288
|
+
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
|
|
289
|
+
tierConfigs[0] = _tierConfigWithSplit(1 ether, 1_000_000_000); // 100%
|
|
290
|
+
vm.prank(address(testHook));
|
|
291
|
+
uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
|
|
292
|
+
|
|
293
|
+
mockAndExpect(
|
|
294
|
+
address(mockJBDirectory),
|
|
295
|
+
abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
|
|
296
|
+
abi.encode(true)
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
// Splits: 100% to alice (no leftover).
|
|
300
|
+
JBSplit[] memory splits = new JBSplit[](1);
|
|
301
|
+
splits[0] = JBSplit({
|
|
302
|
+
percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
|
|
303
|
+
projectId: 0,
|
|
304
|
+
beneficiary: payable(alice),
|
|
305
|
+
preferAddToBalance: false,
|
|
306
|
+
lockedUntil: 0,
|
|
307
|
+
hook: IJBSplitHook(address(0))
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
|
|
311
|
+
mockAndExpect(
|
|
312
|
+
mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
uint16[] memory mintIds = new uint16[](1);
|
|
316
|
+
mintIds[0] = uint16(tierIds[0]);
|
|
317
|
+
bytes memory payerMetadata = _buildPayerMetadata(address(testHook), mintIds);
|
|
318
|
+
|
|
319
|
+
uint16[] memory splitTierIds = new uint16[](1);
|
|
320
|
+
splitTierIds[0] = uint16(tierIds[0]);
|
|
321
|
+
uint256[] memory splitAmounts = new uint256[](1);
|
|
322
|
+
splitAmounts[0] = 1 ether;
|
|
323
|
+
|
|
324
|
+
JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
|
|
325
|
+
payer: beneficiary,
|
|
326
|
+
projectId: projectId,
|
|
327
|
+
rulesetId: 0,
|
|
328
|
+
amount: JBTokenAmount({
|
|
329
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
330
|
+
value: 1 ether,
|
|
331
|
+
decimals: 18,
|
|
332
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
333
|
+
}),
|
|
334
|
+
forwardedAmount: JBTokenAmount({
|
|
335
|
+
token: JBConstants.NATIVE_TOKEN,
|
|
336
|
+
value: 1 ether,
|
|
337
|
+
decimals: 18,
|
|
338
|
+
currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
|
|
339
|
+
}),
|
|
340
|
+
weight: 10e18,
|
|
341
|
+
newlyIssuedTokenCount: 0,
|
|
342
|
+
beneficiary: beneficiary,
|
|
343
|
+
hookMetadata: abi.encode(splitTierIds, splitAmounts),
|
|
344
|
+
payerMetadata: payerMetadata
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Should NOT revert: splits consume 100%, no leftover to route.
|
|
348
|
+
vm.deal(mockTerminalAddress, 1 ether);
|
|
349
|
+
vm.prank(mockTerminalAddress);
|
|
350
|
+
testHook.afterPayRecordedWith{value: 1 ether}(payContext);
|
|
351
|
+
|
|
352
|
+
// Verify alice received the funds.
|
|
353
|
+
assertEq(alice.balance, 1 ether, "Alice should receive full split amount");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ═════════════════════════════════════════════════════════
|
|
357
|
+
// F-12: _initialized flag prevents re-initialization
|
|
358
|
+
// ═════════════════════════════════════════════════════════
|
|
359
|
+
//
|
|
360
|
+
// The implementation contract sets _initialized = true in the constructor, so it cannot
|
|
361
|
+
// be initialized. Clones start with _initialized = false, so they can be initialized once.
|
|
362
|
+
// A second call to initialize() on a clone must revert.
|
|
363
|
+
|
|
364
|
+
/// @notice F-12: The implementation contract cannot be initialized (constructor sets _initialized = true).
|
|
365
|
+
function test_F12_implementationCannotBeInitialized() public {
|
|
366
|
+
// hookOrigin is the implementation contract deployed in setUp().
|
|
367
|
+
// Its constructor already set _initialized = true.
|
|
368
|
+
// PROJECT_ID is 0 on the implementation (never initialized with a project ID).
|
|
369
|
+
vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_AlreadyInitialized.selector, 0));
|
|
370
|
+
|
|
371
|
+
hookOrigin.initialize({
|
|
372
|
+
projectId: 42,
|
|
373
|
+
name: "Test",
|
|
374
|
+
symbol: "TST",
|
|
375
|
+
baseUri: "http://test.com/",
|
|
376
|
+
tokenUriResolver: IJB721TokenUriResolver(address(0)),
|
|
377
|
+
contractUri: "ipfs://test",
|
|
378
|
+
tiersConfig: JB721InitTiersConfig({
|
|
379
|
+
tiers: new JB721TierConfig[](0), currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
|
|
380
|
+
}),
|
|
381
|
+
flags: JB721TiersHookFlags({
|
|
382
|
+
preventOverspending: false,
|
|
383
|
+
issueTokensForSplits: false,
|
|
384
|
+
noNewTiersWithReserves: false,
|
|
385
|
+
noNewTiersWithVotes: false,
|
|
386
|
+
noNewTiersWithOwnerMinting: false
|
|
387
|
+
})
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/// @notice F-12: A clone can be initialized once, but not twice.
|
|
392
|
+
function test_F12_cloneInitializedOnce_secondCallReverts() public {
|
|
393
|
+
// Deploy a fresh clone of the implementation.
|
|
394
|
+
address cloneAddr = LibClone.clone(address(hookOrigin));
|
|
395
|
+
JB721TiersHook cloneHook = JB721TiersHook(cloneAddr);
|
|
396
|
+
|
|
397
|
+
// First initialization should succeed.
|
|
398
|
+
cloneHook.initialize({
|
|
399
|
+
projectId: 42,
|
|
400
|
+
name: "Clone",
|
|
401
|
+
symbol: "CLN",
|
|
402
|
+
baseUri: "http://clone.com/",
|
|
403
|
+
tokenUriResolver: IJB721TokenUriResolver(address(0)),
|
|
404
|
+
contractUri: "ipfs://clone",
|
|
405
|
+
tiersConfig: JB721InitTiersConfig({
|
|
406
|
+
tiers: new JB721TierConfig[](0), currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
|
|
407
|
+
}),
|
|
408
|
+
flags: JB721TiersHookFlags({
|
|
409
|
+
preventOverspending: false,
|
|
410
|
+
issueTokensForSplits: false,
|
|
411
|
+
noNewTiersWithReserves: false,
|
|
412
|
+
noNewTiersWithVotes: false,
|
|
413
|
+
noNewTiersWithOwnerMinting: false
|
|
414
|
+
})
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Verify clone was initialized with the correct project ID.
|
|
418
|
+
assertEq(cloneHook.PROJECT_ID(), 42, "Clone should have projectId 42");
|
|
419
|
+
|
|
420
|
+
// Second initialization should revert with the project ID from the first init.
|
|
421
|
+
vm.expectRevert(abi.encodeWithSelector(JB721TiersHook.JB721TiersHook_AlreadyInitialized.selector, 42));
|
|
422
|
+
|
|
423
|
+
cloneHook.initialize({
|
|
424
|
+
projectId: 99,
|
|
425
|
+
name: "Bad",
|
|
426
|
+
symbol: "BAD",
|
|
427
|
+
baseUri: "http://bad.com/",
|
|
428
|
+
tokenUriResolver: IJB721TokenUriResolver(address(0)),
|
|
429
|
+
contractUri: "ipfs://bad",
|
|
430
|
+
tiersConfig: JB721InitTiersConfig({
|
|
431
|
+
tiers: new JB721TierConfig[](0), currency: uint32(uint160(JBConstants.NATIVE_TOKEN)), decimals: 18
|
|
432
|
+
}),
|
|
433
|
+
flags: JB721TiersHookFlags({
|
|
434
|
+
preventOverspending: false,
|
|
435
|
+
issueTokensForSplits: false,
|
|
436
|
+
noNewTiersWithReserves: false,
|
|
437
|
+
noNewTiersWithVotes: false,
|
|
438
|
+
noNewTiersWithOwnerMinting: false
|
|
439
|
+
})
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ═════════════════════════════════════════════════════════
|
|
444
|
+
// F-13: _startingTierIdOfCategory updated in cleanTiers
|
|
445
|
+
// ═════════════════════════════════════════════════════════
|
|
446
|
+
//
|
|
447
|
+
// When the first tier in a category is removed, `cleanTiers` must update
|
|
448
|
+
// `_startingTierIdOfCategory` to point to the next non-removed tier in that category.
|
|
449
|
+
// Without this fix, tier lookups starting from the category pointer would begin at a
|
|
450
|
+
// removed (invisible) tier, causing incorrect iteration behavior.
|
|
451
|
+
|
|
452
|
+
/// @notice F-13: After removing the first tier of a category and calling cleanTiers,
|
|
453
|
+
/// the category's starting tier ID is updated to the next valid tier.
|
|
454
|
+
function test_F13_cleanTiersUpdatesStartingTierIdOfCategory() public {
|
|
455
|
+
// Initialize a hook with no default tiers.
|
|
456
|
+
JB721TiersHook testHook = _initHookDefaultTiers(0);
|
|
457
|
+
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
458
|
+
|
|
459
|
+
// Add 3 tiers in category 5. They will get sequential IDs (1, 2, 3).
|
|
460
|
+
// All in the same category, so _startingTierIdOfCategory[5] = 1 after addition.
|
|
461
|
+
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](3);
|
|
462
|
+
|
|
463
|
+
tierConfigs[0] = JB721TierConfig({
|
|
464
|
+
price: uint104(10),
|
|
465
|
+
initialSupply: uint32(100),
|
|
466
|
+
votingUnits: uint16(0),
|
|
467
|
+
reserveFrequency: uint16(0),
|
|
468
|
+
reserveBeneficiary: reserveBeneficiary,
|
|
469
|
+
encodedIPFSUri: tokenUris[0],
|
|
470
|
+
category: uint24(5),
|
|
471
|
+
discountPercent: uint8(0),
|
|
472
|
+
allowOwnerMint: false,
|
|
473
|
+
useReserveBeneficiaryAsDefault: false,
|
|
474
|
+
transfersPausable: false,
|
|
475
|
+
useVotingUnits: false,
|
|
476
|
+
cannotBeRemoved: false,
|
|
477
|
+
cannotIncreaseDiscountPercent: false,
|
|
478
|
+
splitPercent: 0,
|
|
479
|
+
splits: new JBSplit[](0)
|
|
480
|
+
});
|
|
481
|
+
tierConfigs[1] = JB721TierConfig({
|
|
482
|
+
price: uint104(20),
|
|
483
|
+
initialSupply: uint32(100),
|
|
484
|
+
votingUnits: uint16(0),
|
|
485
|
+
reserveFrequency: uint16(0),
|
|
486
|
+
reserveBeneficiary: reserveBeneficiary,
|
|
487
|
+
encodedIPFSUri: tokenUris[1],
|
|
488
|
+
category: uint24(5),
|
|
489
|
+
discountPercent: uint8(0),
|
|
490
|
+
allowOwnerMint: false,
|
|
491
|
+
useReserveBeneficiaryAsDefault: false,
|
|
492
|
+
transfersPausable: false,
|
|
493
|
+
useVotingUnits: false,
|
|
494
|
+
cannotBeRemoved: false,
|
|
495
|
+
cannotIncreaseDiscountPercent: false,
|
|
496
|
+
splitPercent: 0,
|
|
497
|
+
splits: new JBSplit[](0)
|
|
498
|
+
});
|
|
499
|
+
tierConfigs[2] = JB721TierConfig({
|
|
500
|
+
price: uint104(30),
|
|
501
|
+
initialSupply: uint32(100),
|
|
502
|
+
votingUnits: uint16(0),
|
|
503
|
+
reserveFrequency: uint16(0),
|
|
504
|
+
reserveBeneficiary: reserveBeneficiary,
|
|
505
|
+
encodedIPFSUri: tokenUris[2],
|
|
506
|
+
category: uint24(5),
|
|
507
|
+
discountPercent: uint8(0),
|
|
508
|
+
allowOwnerMint: false,
|
|
509
|
+
useReserveBeneficiaryAsDefault: false,
|
|
510
|
+
transfersPausable: false,
|
|
511
|
+
useVotingUnits: false,
|
|
512
|
+
cannotBeRemoved: false,
|
|
513
|
+
cannotIncreaseDiscountPercent: false,
|
|
514
|
+
splitPercent: 0,
|
|
515
|
+
splits: new JBSplit[](0)
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
// Add tiers to the store. Tier IDs will be 1, 2, 3.
|
|
519
|
+
vm.prank(address(testHook));
|
|
520
|
+
hookStore.recordAddTiers(tierConfigs);
|
|
521
|
+
|
|
522
|
+
// Verify all 3 tiers exist.
|
|
523
|
+
JB721Tier[] memory allTiers = hookStore.tiersOf(address(testHook), new uint256[](0), false, 0, 10);
|
|
524
|
+
assertEq(allTiers.length, 3, "Should have 3 tiers initially");
|
|
525
|
+
|
|
526
|
+
// Remove tier ID 1 (the first tier in category 5, which is the _startingTierIdOfCategory).
|
|
527
|
+
uint256[] memory tiersToRemove = new uint256[](1);
|
|
528
|
+
tiersToRemove[0] = 1;
|
|
529
|
+
vm.prank(address(testHook));
|
|
530
|
+
hookStore.recordRemoveTierIds(tiersToRemove);
|
|
531
|
+
|
|
532
|
+
// Before cleanTiers: category 5's starting tier still points to the removed tier 1.
|
|
533
|
+
// Verify that tiersOf still correctly returns only non-removed tiers.
|
|
534
|
+
JB721Tier[] memory tiersBeforeClean = hookStore.tiersOf(address(testHook), new uint256[](0), false, 0, 10);
|
|
535
|
+
assertEq(tiersBeforeClean.length, 2, "Should have 2 tiers after removal (before clean)");
|
|
536
|
+
|
|
537
|
+
// Call cleanTiers to update the linked list and _startingTierIdOfCategory.
|
|
538
|
+
hookStore.cleanTiers(address(testHook));
|
|
539
|
+
|
|
540
|
+
// After cleanTiers: verify tiers are correctly iterable.
|
|
541
|
+
JB721Tier[] memory tiersAfterClean = hookStore.tiersOf(address(testHook), new uint256[](0), false, 0, 10);
|
|
542
|
+
assertEq(tiersAfterClean.length, 2, "Should have 2 tiers after clean");
|
|
543
|
+
|
|
544
|
+
// The remaining tiers should be IDs 2 and 3 (sorted by ID ascending within the same category).
|
|
545
|
+
assertEq(tiersAfterClean[0].id, 2, "First tier should be ID 2");
|
|
546
|
+
assertEq(tiersAfterClean[1].id, 3, "Second tier should be ID 3");
|
|
547
|
+
|
|
548
|
+
// Verify individual tier lookups still work for the remaining tiers.
|
|
549
|
+
JB721Tier memory tier2 = hookStore.tierOf(address(testHook), 2, false);
|
|
550
|
+
assertEq(tier2.id, 2, "Tier 2 should be accessible");
|
|
551
|
+
assertEq(tier2.price, 20, "Tier 2 price should be 20");
|
|
552
|
+
|
|
553
|
+
JB721Tier memory tier3 = hookStore.tierOf(address(testHook), 3, false);
|
|
554
|
+
assertEq(tier3.id, 3, "Tier 3 should be accessible");
|
|
555
|
+
assertEq(tier3.price, 30, "Tier 3 price should be 30");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/// @notice F-13: Removing a non-starting tier does not affect _startingTierIdOfCategory.
|
|
559
|
+
function test_F13_removingNonStartingTierPreservesStartingId() public {
|
|
560
|
+
JB721TiersHook testHook = _initHookDefaultTiers(0);
|
|
561
|
+
IJB721TiersHookStore hookStore = testHook.STORE();
|
|
562
|
+
|
|
563
|
+
// Add 3 tiers in category 5.
|
|
564
|
+
JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](3);
|
|
565
|
+
|
|
566
|
+
for (uint256 i; i < 3; i++) {
|
|
567
|
+
tierConfigs[i] = JB721TierConfig({
|
|
568
|
+
price: uint104((i + 1) * 10),
|
|
569
|
+
initialSupply: uint32(100),
|
|
570
|
+
votingUnits: uint16(0),
|
|
571
|
+
reserveFrequency: uint16(0),
|
|
572
|
+
reserveBeneficiary: reserveBeneficiary,
|
|
573
|
+
encodedIPFSUri: tokenUris[i],
|
|
574
|
+
category: uint24(5),
|
|
575
|
+
discountPercent: uint8(0),
|
|
576
|
+
allowOwnerMint: false,
|
|
577
|
+
useReserveBeneficiaryAsDefault: false,
|
|
578
|
+
transfersPausable: false,
|
|
579
|
+
useVotingUnits: false,
|
|
580
|
+
cannotBeRemoved: false,
|
|
581
|
+
cannotIncreaseDiscountPercent: false,
|
|
582
|
+
splitPercent: 0,
|
|
583
|
+
splits: new JBSplit[](0)
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
vm.prank(address(testHook));
|
|
588
|
+
hookStore.recordAddTiers(tierConfigs);
|
|
589
|
+
|
|
590
|
+
// Remove tier ID 2 (the middle tier, NOT the starting tier).
|
|
591
|
+
uint256[] memory tiersToRemove = new uint256[](1);
|
|
592
|
+
tiersToRemove[0] = 2;
|
|
593
|
+
vm.prank(address(testHook));
|
|
594
|
+
hookStore.recordRemoveTierIds(tiersToRemove);
|
|
595
|
+
|
|
596
|
+
hookStore.cleanTiers(address(testHook));
|
|
597
|
+
|
|
598
|
+
// After clean: 2 tiers remain (IDs 1 and 3).
|
|
599
|
+
JB721Tier[] memory tiersAfterClean = hookStore.tiersOf(address(testHook), new uint256[](0), false, 0, 10);
|
|
600
|
+
assertEq(tiersAfterClean.length, 2, "Should have 2 tiers after removing middle tier");
|
|
601
|
+
|
|
602
|
+
// Both remaining tiers should be accessible.
|
|
603
|
+
JB721Tier memory tier1 = hookStore.tierOf(address(testHook), 1, false);
|
|
604
|
+
assertEq(tier1.id, 1, "Tier 1 should still be accessible");
|
|
605
|
+
assertEq(tier1.price, 10, "Tier 1 price should be 10");
|
|
606
|
+
|
|
607
|
+
JB721Tier memory tier3 = hookStore.tierOf(address(testHook), 3, false);
|
|
608
|
+
assertEq(tier3.id, 3, "Tier 3 should still be accessible");
|
|
609
|
+
assertEq(tier3.price, 30, "Tier 3 price should be 30");
|
|
610
|
+
}
|
|
611
|
+
}
|
|
@@ -29,7 +29,6 @@ contract Test_Getters_Constructor_Unit is UnitTestSetup {
|
|
|
29
29
|
tokenUriResolver: IJB721TokenUriResolver(mockTokenUriResolver),
|
|
30
30
|
contractUri: contractUri,
|
|
31
31
|
tiersConfig: JB721InitTiersConfig({tiers: tiers, currency: currency, decimals: decimals}),
|
|
32
|
-
reserveBeneficiary: address(0),
|
|
33
32
|
flags: JB721TiersHookFlags({
|
|
34
33
|
preventOverspending: false,
|
|
35
34
|
issueTokensForSplits: false,
|
|
@@ -463,7 +463,7 @@ contract Test_crossCurrencyPay_Unit is UnitTestSetup {
|
|
|
463
463
|
metadata: pMeta
|
|
464
464
|
});
|
|
465
465
|
|
|
466
|
-
//
|
|
466
|
+
// convertAndCapSplitAmounts calls pricePerUnitOf → reverts.
|
|
467
467
|
vm.expectRevert();
|
|
468
468
|
crossHook.beforePayRecordedWith(context);
|
|
469
469
|
|