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