@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,1075 +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 {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
8
- import {JBSplitHookContext} from "@bananapus/core-v6/src/structs/JBSplitHookContext.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 {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
13
- import {JB721TierConfigFlags} from "../src/structs/JB721TierConfigFlags.sol";
14
-
15
- // =====================================================================
16
- // Malicious split hook that attempts reentrancy during fund distribution
17
- // =====================================================================
18
-
19
- /// @notice A split hook that re-enters the hook's afterPayRecordedWith during split distribution.
20
- contract ReentrantSplitHook is IJBSplitHook {
21
- address public target;
22
- bytes public reentrantCalldata;
23
- uint256 public callCount;
24
- bool public reentryAttempted;
25
- bool public reentrySucceeded;
26
-
27
- constructor(address _target, bytes memory _calldata) {
28
- target = _target;
29
- reentrantCalldata = _calldata;
30
- }
31
-
32
- function processSplitWith(JBSplitHookContext calldata) external payable override {
33
- callCount++;
34
- // Attempt reentrancy on the first call only.
35
- if (callCount == 1) {
36
- reentryAttempted = true;
37
- // Try to re-enter the hook contract by calling afterPayRecordedWith again.
38
- // This should revert because msg.sender is not a terminal.
39
- (bool success,) = target.call{value: 0}(reentrantCalldata);
40
- reentrySucceeded = success;
41
- }
42
- }
43
-
44
- function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
45
- return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId;
46
- }
47
-
48
- receive() external payable {}
49
- }
50
-
51
- /// @notice A split hook that attempts to re-enter adjustTiers during split distribution.
52
- contract ReentrantAdjustTiersSplitHook is IJBSplitHook {
53
- address public hookTarget;
54
- uint256 public callCount;
55
- bool public reentryAttempted;
56
- bool public reentryReverted;
57
-
58
- constructor(address _hookTarget) {
59
- hookTarget = _hookTarget;
60
- }
61
-
62
- function processSplitWith(JBSplitHookContext calldata) external payable override {
63
- callCount++;
64
- if (callCount == 1) {
65
- reentryAttempted = true;
66
- // Try to re-enter via adjustTiers (remove tier 1).
67
- uint256[] memory tierIdsToRemove = new uint256[](1);
68
- tierIdsToRemove[0] = 1;
69
- // This should revert because caller is not the owner/permissioned.
70
- try IJB721TiersHook(hookTarget).adjustTiers(new JB721TierConfig[](0), tierIdsToRemove) {
71
- reentryReverted = false;
72
- } catch {
73
- reentryReverted = true;
74
- }
75
- }
76
- }
77
-
78
- function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
79
- return interfaceId == type(IJBSplitHook).interfaceId || interfaceId == type(IERC165).interfaceId;
80
- }
81
-
82
- receive() external payable {}
83
- }
84
-
85
- // =====================================================================
86
- // Test Contract: Reentrancy on Split Distribution
87
- // =====================================================================
88
-
89
- /// @title TestAuditGaps_Reentrancy
90
- /// @notice Tests that malicious split hooks cannot exploit reentrancy during NFT split fund distribution.
91
- contract TestAuditGaps_Reentrancy is UnitTestSetup {
92
- using stdStorage for StdStorage;
93
-
94
- function setUp() public override {
95
- super.setUp();
96
- vm.etch(mockJBSplits, new bytes(0x69));
97
- }
98
-
99
- // ---------------------------------------------------------------
100
- // Helpers
101
- // ---------------------------------------------------------------
102
-
103
- function _tierConfigWithSplit(
104
- uint104 price,
105
- uint32 splitPercent
106
- )
107
- internal
108
- pure
109
- returns (JB721TierConfig memory config)
110
- {
111
- config.price = price;
112
- config.initialSupply = uint32(100);
113
- config.category = uint24(1);
114
- config.encodedIPFSUri = bytes32(uint256(0x1234));
115
- config.splitPercent = splitPercent;
116
- }
117
-
118
- function _buildPayerMetadata(
119
- address hookAddress,
120
- uint16[] memory tierIdsToMint
121
- )
122
- internal
123
- view
124
- returns (bytes memory)
125
- {
126
- bytes[] memory data = new bytes[](1);
127
- data[0] = abi.encode(false, tierIdsToMint);
128
- bytes4[] memory ids = new bytes4[](1);
129
- ids[0] = metadataHelper.getId("pay", hookAddress);
130
- return metadataHelper.createMetadata(ids, data);
131
- }
132
-
133
- /// @dev Build a full JBAfterPayRecordedContext with split forwarding.
134
- function _buildPayContextWithSplits(
135
- address hookAddress,
136
- uint16[] memory mintIds,
137
- uint16[] memory splitTierIds,
138
- uint256[] memory splitAmounts,
139
- uint256 payValue,
140
- uint256 forwardValue
141
- )
142
- internal
143
- view
144
- returns (JBAfterPayRecordedContext memory)
145
- {
146
- bytes memory payerMetadata = _buildPayerMetadata(hookAddress, mintIds);
147
-
148
- return JBAfterPayRecordedContext({
149
- payer: beneficiary,
150
- projectId: projectId,
151
- rulesetId: 0,
152
- amount: JBTokenAmount({
153
- token: JBConstants.NATIVE_TOKEN,
154
- value: payValue,
155
- decimals: 18,
156
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
157
- }),
158
- forwardedAmount: JBTokenAmount({
159
- token: JBConstants.NATIVE_TOKEN,
160
- value: forwardValue,
161
- decimals: 18,
162
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
163
- }),
164
- weight: 10e18,
165
- newlyIssuedTokenCount: 0,
166
- beneficiary: beneficiary,
167
- hookMetadata: abi.encode(beneficiary, beneficiary, abi.encode(splitTierIds, splitAmounts)),
168
- payerMetadata: payerMetadata
169
- });
170
- }
171
-
172
- // ---------------------------------------------------------------
173
- // Test 1: Reentrant split hook cannot re-call afterPayRecordedWith
174
- // ---------------------------------------------------------------
175
- /// @notice A malicious split hook tries to re-enter afterPayRecordedWith during
176
- /// fund distribution. The reentrancy is blocked because the split hook's address
177
- /// is not registered as a terminal in the directory.
178
- function test_reentrancy_splitHook_cannotReenterAfterPay() public {
179
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
180
- IJB721TiersHookStore hookStore = testHook.STORE();
181
-
182
- // Add tier with 100% split, priced at 1 ETH.
183
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
184
- tierConfigs[0] = _tierConfigWithSplit(1 ether, 1_000_000_000);
185
- vm.prank(address(testHook));
186
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
187
-
188
- // Mock the terminal check for the legitimate terminal.
189
- mockAndExpect(
190
- address(mockJBDirectory),
191
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
192
- abi.encode(true)
193
- );
194
-
195
- // Build reentrant calldata (a second afterPayRecordedWith call).
196
- uint16[] memory mintIds = new uint16[](1);
197
- mintIds[0] = uint16(tierIds[0]);
198
-
199
- uint16[] memory splitTierIds = new uint16[](1);
200
- splitTierIds[0] = uint16(tierIds[0]);
201
- uint256[] memory splitAmounts = new uint256[](1);
202
- splitAmounts[0] = 1 ether;
203
-
204
- JBAfterPayRecordedContext memory reentrantContext =
205
- _buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 1 ether);
206
-
207
- // Create the reentrant split hook.
208
- ReentrantSplitHook reentrantHook = new ReentrantSplitHook(
209
- address(testHook), abi.encodeCall(testHook.afterPayRecordedWith, (reentrantContext))
210
- );
211
-
212
- // Set up splits: 100% to the malicious hook.
213
- JBSplit[] memory splits = new JBSplit[](1);
214
- splits[0] = JBSplit({
215
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
216
- projectId: 0,
217
- beneficiary: payable(address(0)),
218
- preferAddToBalance: false,
219
- lockedUntil: 0,
220
- hook: IJBSplitHook(address(reentrantHook))
221
- });
222
-
223
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
224
- mockAndExpect(
225
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
226
- );
227
-
228
- // The reentrant split hook is NOT a terminal, so mock it as such.
229
- vm.mockCall(
230
- address(mockJBDirectory),
231
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, address(reentrantHook)),
232
- abi.encode(false)
233
- );
234
-
235
- // Execute the payment.
236
- JBAfterPayRecordedContext memory payContext =
237
- _buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 1 ether);
238
-
239
- vm.deal(mockTerminalAddress, 1 ether);
240
- vm.prank(mockTerminalAddress);
241
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
242
-
243
- // Verify the split hook was called.
244
- assertEq(reentrantHook.callCount(), 1, "Split hook should be called once");
245
-
246
- // Verify reentrancy was attempted but failed (the hook contract checks msg.sender is a terminal).
247
- assertTrue(reentrantHook.reentryAttempted(), "Reentrancy should have been attempted");
248
- assertFalse(reentrantHook.reentrySucceeded(), "Reentrancy should have failed");
249
- }
250
-
251
- // ---------------------------------------------------------------
252
- // Test 2: Reentrant split hook cannot re-call adjustTiers
253
- // ---------------------------------------------------------------
254
- /// @notice A malicious split hook tries to call adjustTiers during fund distribution.
255
- /// The call is blocked by permission checks (caller is the hook library, not the owner).
256
- function test_reentrancy_splitHook_cannotReenterAdjustTiers() public {
257
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
258
- IJB721TiersHookStore hookStore = testHook.STORE();
259
-
260
- // Add tier with 100% split, priced at 1 ETH.
261
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](1);
262
- tierConfigs[0] = _tierConfigWithSplit(1 ether, 1_000_000_000);
263
- vm.prank(address(testHook));
264
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
265
-
266
- mockAndExpect(
267
- address(mockJBDirectory),
268
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
269
- abi.encode(true)
270
- );
271
-
272
- // Create the malicious hook that tries adjustTiers.
273
- ReentrantAdjustTiersSplitHook maliciousHook = new ReentrantAdjustTiersSplitHook(address(testHook));
274
-
275
- // Mock permissions: the malicious hook does NOT have permission.
276
- vm.mockCall(
277
- mockJBPermissions,
278
- abi.encodeWithSelector(IJBPermissions.hasPermission.selector, address(maliciousHook)),
279
- abi.encode(false)
280
- );
281
-
282
- JBSplit[] memory splits = new JBSplit[](1);
283
- splits[0] = JBSplit({
284
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
285
- projectId: 0,
286
- beneficiary: payable(address(0)),
287
- preferAddToBalance: false,
288
- lockedUntil: 0,
289
- hook: IJBSplitHook(address(maliciousHook))
290
- });
291
-
292
- uint256 groupId = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
293
- mockAndExpect(
294
- mockJBSplits, abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId), abi.encode(splits)
295
- );
296
-
297
- uint16[] memory mintIds = new uint16[](1);
298
- mintIds[0] = uint16(tierIds[0]);
299
-
300
- uint16[] memory splitTierIds = new uint16[](1);
301
- splitTierIds[0] = uint16(tierIds[0]);
302
- uint256[] memory splitAmounts = new uint256[](1);
303
- splitAmounts[0] = 1 ether;
304
-
305
- JBAfterPayRecordedContext memory payContext =
306
- _buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 1 ether);
307
-
308
- vm.deal(mockTerminalAddress, 1 ether);
309
- vm.prank(mockTerminalAddress);
310
- testHook.afterPayRecordedWith{value: 1 ether}(payContext);
311
-
312
- // Verify the split hook was called and the reentrancy was attempted.
313
- assertEq(maliciousHook.callCount(), 1, "Split hook should be called once");
314
- assertTrue(maliciousHook.reentryAttempted(), "Reentrancy should have been attempted");
315
- assertTrue(maliciousHook.reentryReverted(), "adjustTiers reentrancy should have reverted");
316
- }
317
-
318
- // ---------------------------------------------------------------
319
- // Test 3: Split hook with multiple tiers cannot manipulate state
320
- // ---------------------------------------------------------------
321
- /// @notice With multiple tiers having splits, a malicious hook on the first tier cannot
322
- /// affect the distribution of the second tier. State should be consistent after distribution.
323
- function test_reentrancy_multiTierSplit_stateConsistent() public {
324
- ForTest_JB721TiersHook testHook = _initializeForTestHook(0);
325
- IJB721TiersHookStore hookStore = testHook.STORE();
326
-
327
- // Add two tiers, each with 50% split, priced at 0.5 ETH each.
328
- JB721TierConfig[] memory tierConfigs = new JB721TierConfig[](2);
329
- tierConfigs[0] = _tierConfigWithSplit(0.5 ether, 500_000_000); // 50% split
330
- tierConfigs[0].category = 1;
331
- tierConfigs[1] = _tierConfigWithSplit(0.5 ether, 500_000_000); // 50% split
332
- tierConfigs[1].category = 2;
333
- vm.prank(address(testHook));
334
- uint256[] memory tierIds = hookStore.recordAddTiers(tierConfigs);
335
-
336
- mockAndExpect(
337
- address(mockJBDirectory),
338
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
339
- abi.encode(true)
340
- );
341
-
342
- // Create the reentrant split hook for tier 1, and a clean beneficiary for tier 2.
343
- ReentrantAdjustTiersSplitHook maliciousHook = new ReentrantAdjustTiersSplitHook(address(testHook));
344
- address cleanBeneficiary = makeAddr("cleanBeneficiary");
345
-
346
- // Tier 1 splits: 100% to malicious hook.
347
- JBSplit[] memory splits1 = new JBSplit[](1);
348
- splits1[0] = JBSplit({
349
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
350
- projectId: 0,
351
- beneficiary: payable(address(0)),
352
- preferAddToBalance: false,
353
- lockedUntil: 0,
354
- hook: IJBSplitHook(address(maliciousHook))
355
- });
356
-
357
- // Tier 2 splits: 100% to clean beneficiary.
358
- JBSplit[] memory splits2 = new JBSplit[](1);
359
- splits2[0] = JBSplit({
360
- percent: uint32(JBConstants.SPLITS_TOTAL_PERCENT),
361
- projectId: 0,
362
- beneficiary: payable(cleanBeneficiary),
363
- preferAddToBalance: false,
364
- lockedUntil: 0,
365
- hook: IJBSplitHook(address(0))
366
- });
367
-
368
- uint256 groupId1 = uint256(uint160(address(testHook))) | (uint256(tierIds[0]) << 160);
369
- uint256 groupId2 = uint256(uint160(address(testHook))) | (uint256(tierIds[1]) << 160);
370
-
371
- mockAndExpect(
372
- mockJBSplits,
373
- abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId1),
374
- abi.encode(splits1)
375
- );
376
- mockAndExpect(
377
- mockJBSplits,
378
- abi.encodeWithSelector(IJBSplits.splitsOf.selector, projectId, 0, groupId2),
379
- abi.encode(splits2)
380
- );
381
-
382
- // Mock permissions to deny the malicious hook.
383
- vm.mockCall(
384
- mockJBPermissions,
385
- abi.encodeWithSelector(IJBPermissions.hasPermission.selector, address(maliciousHook)),
386
- abi.encode(false)
387
- );
388
-
389
- uint16[] memory mintIds = new uint16[](2);
390
- mintIds[0] = uint16(tierIds[0]);
391
- mintIds[1] = uint16(tierIds[1]);
392
-
393
- uint16[] memory splitTierIds = new uint16[](2);
394
- splitTierIds[0] = uint16(tierIds[0]);
395
- splitTierIds[1] = uint16(tierIds[1]);
396
- uint256[] memory splitAmounts = new uint256[](2);
397
- splitAmounts[0] = 0.25 ether; // 50% of 0.5 ETH
398
- splitAmounts[1] = 0.25 ether;
399
-
400
- JBAfterPayRecordedContext memory payContext =
401
- _buildPayContextWithSplits(address(testHook), mintIds, splitTierIds, splitAmounts, 1 ether, 0.5 ether);
402
-
403
- vm.deal(mockTerminalAddress, 1 ether);
404
- vm.prank(mockTerminalAddress);
405
- testHook.afterPayRecordedWith{value: 0.5 ether}(payContext);
406
-
407
- // Verify both splits were distributed.
408
- assertEq(maliciousHook.callCount(), 1, "Malicious hook should be called for tier 1");
409
- assertEq(address(maliciousHook).balance, 0.25 ether, "Malicious hook should get tier 1 split");
410
- assertEq(cleanBeneficiary.balance, 0.25 ether, "Clean beneficiary should get tier 2 split");
411
-
412
- // Verify NFTs were minted (state is consistent).
413
- assertEq(testHook.balanceOf(beneficiary), 2, "Beneficiary should have 2 NFTs");
414
- }
415
- }
416
-
417
- // =====================================================================
418
- // Test Contract: Gas Limits with Hundreds of Tiers
419
- // =====================================================================
420
-
421
- /// @title TestAuditGaps_GasLimits
422
- /// @notice Tests that operations with 100+ tiers do not hit gas limits or behave unexpectedly.
423
- contract TestAuditGaps_GasLimits is UnitTestSetup {
424
- using stdStorage for StdStorage;
425
-
426
- /// @dev The block gas limit on mainnet is 30M. We use a generous limit for safety.
427
- uint256 constant BLOCK_GAS_LIMIT = 30_000_000;
428
- uint256 constant OPERATING_ENVELOPE_SOFT_LIMIT = 200;
429
-
430
- // ---------------------------------------------------------------
431
- // Test 1: Add 100 tiers in a single adjustTiers call
432
- // ---------------------------------------------------------------
433
- /// @notice Adding 100 tiers in a single transaction should succeed within the block gas limit.
434
- function test_gasLimit_add100Tiers() public {
435
- defaultTierConfig.initialSupply = 10;
436
- defaultTierConfig.reserveFrequency = 0;
437
-
438
- JB721TiersHook targetHook = _initHookDefaultTiers(0);
439
-
440
- // Build 100 tier configs, sorted by category.
441
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
442
- for (uint256 i; i < 100; i++) {
443
- newTiers[i] = JB721TierConfig({
444
- price: uint104((i + 1) * 1e15), // Different prices
445
- initialSupply: uint32(10),
446
- votingUnits: 0,
447
- reserveFrequency: 0,
448
- reserveBeneficiary: reserveBeneficiary,
449
- encodedIPFSUri: tokenUris[i % 10],
450
- // forge-lint: disable-next-line(unsafe-typecast)
451
- // forge-lint: disable-next-line(unsafe-typecast)
452
- category: uint24(i + 1), // Ascending categories
453
- discountPercent: 0,
454
- flags: JB721TierConfigFlags({
455
- allowOwnerMint: false,
456
- useReserveBeneficiaryAsDefault: false,
457
- transfersPausable: false,
458
- useVotingUnits: false,
459
- cantBeRemoved: false,
460
- cantIncreaseDiscountPercent: false,
461
- cantBuyWithCredits: false
462
- }),
463
- splitPercent: 0,
464
- splits: new JBSplit[](0)
465
- });
466
- }
467
-
468
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
469
-
470
- uint256 gasBefore = gasleft();
471
- vm.prank(owner);
472
- targetHook.adjustTiers(newTiers, new uint256[](0));
473
- uint256 gasUsed = gasBefore - gasleft();
474
-
475
- // Verify all 100 tiers were added.
476
- IJB721TiersHookStore hookStore = targetHook.STORE();
477
- assertEq(hookStore.maxTierIdOf(address(targetHook)), 100, "Should have 100 tiers");
478
-
479
- // Verify gas usage is within block gas limit.
480
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Adding 100 tiers should fit within block gas limit");
481
-
482
- // Log gas for visibility.
483
- emit log_named_uint("Gas used to add 100 tiers", gasUsed);
484
- }
485
-
486
- // ---------------------------------------------------------------
487
- // Test 2: Read tiersOf with 100+ tiers
488
- // ---------------------------------------------------------------
489
- /// @notice Reading all tiers via tiersOf should succeed with 100+ tiers.
490
- function test_gasLimit_readTiersOf_100() public {
491
- defaultTierConfig.initialSupply = 10;
492
- defaultTierConfig.reserveFrequency = 0;
493
-
494
- JB721TiersHook targetHook = _initHookDefaultTiers(0);
495
-
496
- // Add 100 tiers.
497
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
498
- for (uint256 i; i < 100; i++) {
499
- newTiers[i] = JB721TierConfig({
500
- price: uint104((i + 1) * 1e15),
501
- initialSupply: uint32(10),
502
- votingUnits: 0,
503
- reserveFrequency: 0,
504
- reserveBeneficiary: reserveBeneficiary,
505
- encodedIPFSUri: tokenUris[i % 10],
506
- // forge-lint: disable-next-line(unsafe-typecast)
507
- // forge-lint: disable-next-line(unsafe-typecast)
508
- category: uint24(i + 1),
509
- discountPercent: 0,
510
- flags: JB721TierConfigFlags({
511
- allowOwnerMint: false,
512
- useReserveBeneficiaryAsDefault: false,
513
- transfersPausable: false,
514
- useVotingUnits: false,
515
- cantBeRemoved: false,
516
- cantIncreaseDiscountPercent: false,
517
- cantBuyWithCredits: false
518
- }),
519
- splitPercent: 0,
520
- splits: new JBSplit[](0)
521
- });
522
- }
523
-
524
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
525
-
526
- vm.prank(owner);
527
- targetHook.adjustTiers(newTiers, new uint256[](0));
528
-
529
- IJB721TiersHookStore hookStore = targetHook.STORE();
530
-
531
- // Read all 100 tiers.
532
- uint256 gasBefore = gasleft();
533
- JB721Tier[] memory allTiers = hookStore.tiersOf(address(targetHook), new uint256[](0), false, 0, 100);
534
- uint256 gasUsed = gasBefore - gasleft();
535
-
536
- assertEq(allTiers.length, 100, "Should return 100 tiers");
537
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Reading 100 tiers should fit within block gas limit");
538
-
539
- emit log_named_uint("Gas used to read 100 tiers", gasUsed);
540
- }
541
-
542
- // ---------------------------------------------------------------
543
- // Test 3: totalCashOutWeight with 100 tiers (some minted)
544
- // ---------------------------------------------------------------
545
- /// @notice totalCashOutWeight iterates all tiers. With 100 tiers and some minted, it should not
546
- /// exceed gas limits.
547
- function test_gasLimit_totalCashOutWeight_100tiers() public {
548
- defaultTierConfig.initialSupply = 10;
549
- defaultTierConfig.reserveFrequency = 0;
550
-
551
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
552
- IJB721TiersHookStore hookStore = targetHook.STORE();
553
-
554
- // Add 100 tiers.
555
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
556
- for (uint256 i; i < 100; i++) {
557
- newTiers[i] = JB721TierConfig({
558
- price: uint104((i + 1) * 1e15),
559
- initialSupply: uint32(10),
560
- votingUnits: 0,
561
- reserveFrequency: 0,
562
- reserveBeneficiary: reserveBeneficiary,
563
- encodedIPFSUri: tokenUris[i % 10],
564
- // forge-lint: disable-next-line(unsafe-typecast)
565
- // forge-lint: disable-next-line(unsafe-typecast)
566
- category: uint24(i + 1),
567
- discountPercent: 0,
568
- flags: JB721TierConfigFlags({
569
- allowOwnerMint: false,
570
- useReserveBeneficiaryAsDefault: false,
571
- transfersPausable: false,
572
- useVotingUnits: false,
573
- cantBeRemoved: false,
574
- cantIncreaseDiscountPercent: false,
575
- cantBuyWithCredits: false
576
- }),
577
- splitPercent: 0,
578
- splits: new JBSplit[](0)
579
- });
580
- }
581
-
582
- vm.prank(address(targetHook));
583
- hookStore.recordAddTiers(newTiers);
584
-
585
- // Mock the directory for terminal auth.
586
- mockAndExpect(
587
- address(mockJBDirectory),
588
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
589
- abi.encode(true)
590
- );
591
-
592
- // Mint 1 NFT from each of the first 10 tiers.
593
- uint16[] memory tierIdsToMint = new uint16[](10);
594
- uint256 totalCost;
595
- for (uint256 i; i < 10; i++) {
596
- // forge-lint: disable-next-line(unsafe-typecast)
597
- // forge-lint: disable-next-line(unsafe-typecast)
598
- tierIdsToMint[i] = uint16(i + 1);
599
- totalCost += (i + 1) * 1e15;
600
- }
601
-
602
- bytes[] memory data = new bytes[](1);
603
- data[0] = abi.encode(false, tierIdsToMint);
604
- bytes4[] memory ids = new bytes4[](1);
605
- ids[0] = metadataHelper.getId("pay", address(targetHook));
606
- bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
607
-
608
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
609
- payer: beneficiary,
610
- projectId: projectId,
611
- rulesetId: 0,
612
- amount: JBTokenAmount({
613
- token: JBConstants.NATIVE_TOKEN,
614
- value: totalCost,
615
- decimals: 18,
616
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
617
- }),
618
- forwardedAmount: JBTokenAmount({
619
- token: JBConstants.NATIVE_TOKEN,
620
- value: 0,
621
- decimals: 18,
622
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
623
- }),
624
- weight: 10e18,
625
- newlyIssuedTokenCount: 0,
626
- beneficiary: beneficiary,
627
- hookMetadata: bytes(""),
628
- payerMetadata: payerMetadata
629
- });
630
-
631
- vm.prank(mockTerminalAddress);
632
- targetHook.afterPayRecordedWith(payContext);
633
-
634
- assertEq(targetHook.balanceOf(beneficiary), 10, "10 NFTs minted");
635
-
636
- // Now measure gas for totalCashOutWeight.
637
- uint256 gasBefore = gasleft();
638
- uint256 weight = hookStore.totalCashOutWeight(address(targetHook));
639
- uint256 gasUsed = gasBefore - gasleft();
640
-
641
- assertTrue(weight > 0, "Cash out weight should be non-zero");
642
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "totalCashOutWeight should fit within block gas limit");
643
-
644
- emit log_named_uint("Gas used for totalCashOutWeight (100 tiers, 10 minted)", gasUsed);
645
- }
646
-
647
- // ---------------------------------------------------------------
648
- // Test 4: balanceOf with 100 tiers
649
- // ---------------------------------------------------------------
650
- /// @notice balanceOf iterates all tiers. With 100 tiers it should not be too expensive.
651
- function test_gasLimit_balanceOf_100tiers() public {
652
- defaultTierConfig.initialSupply = 10;
653
- defaultTierConfig.reserveFrequency = 0;
654
-
655
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
656
- IJB721TiersHookStore hookStore = targetHook.STORE();
657
-
658
- // Add 100 tiers.
659
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
660
- for (uint256 i; i < 100; i++) {
661
- newTiers[i] = JB721TierConfig({
662
- price: uint104((i + 1) * 1e15),
663
- initialSupply: uint32(10),
664
- votingUnits: 0,
665
- reserveFrequency: 0,
666
- reserveBeneficiary: reserveBeneficiary,
667
- encodedIPFSUri: tokenUris[i % 10],
668
- // forge-lint: disable-next-line(unsafe-typecast)
669
- // forge-lint: disable-next-line(unsafe-typecast)
670
- category: uint24(i + 1),
671
- discountPercent: 0,
672
- flags: JB721TierConfigFlags({
673
- allowOwnerMint: false,
674
- useReserveBeneficiaryAsDefault: false,
675
- transfersPausable: false,
676
- useVotingUnits: false,
677
- cantBeRemoved: false,
678
- cantIncreaseDiscountPercent: false,
679
- cantBuyWithCredits: false
680
- }),
681
- splitPercent: 0,
682
- splits: new JBSplit[](0)
683
- });
684
- }
685
-
686
- vm.prank(address(targetHook));
687
- hookStore.recordAddTiers(newTiers);
688
-
689
- // Measure gas for balanceOf with 100 tiers (user has 0 NFTs).
690
- uint256 gasBefore = gasleft();
691
- uint256 balance = hookStore.balanceOf(address(targetHook), beneficiary);
692
- uint256 gasUsed = gasBefore - gasleft();
693
-
694
- assertEq(balance, 0, "Balance should be 0");
695
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "balanceOf with 100 tiers should be within gas limit");
696
-
697
- emit log_named_uint("Gas used for balanceOf (100 tiers, 0 NFTs)", gasUsed);
698
- }
699
-
700
- // ---------------------------------------------------------------
701
- // Test 5: Add 200 tiers and verify store correctness
702
- // ---------------------------------------------------------------
703
- /// @notice Adding 200 tiers should still work and the store should report correct data.
704
- function test_gasLimit_add200Tiers_storeCorrectness() public {
705
- defaultTierConfig.initialSupply = 5;
706
- defaultTierConfig.reserveFrequency = 0;
707
-
708
- JB721TiersHook targetHook = _initHookDefaultTiers(0);
709
-
710
- // Build 200 tiers in a single batch.
711
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](200);
712
- for (uint256 i; i < 200; i++) {
713
- newTiers[i] = JB721TierConfig({
714
- price: uint104((i + 1) * 1e14),
715
- initialSupply: uint32(5),
716
- votingUnits: 0,
717
- reserveFrequency: 0,
718
- reserveBeneficiary: reserveBeneficiary,
719
- encodedIPFSUri: tokenUris[i % 10],
720
- // forge-lint: disable-next-line(unsafe-typecast)
721
- // forge-lint: disable-next-line(unsafe-typecast)
722
- category: uint24(i + 1),
723
- discountPercent: 0,
724
- flags: JB721TierConfigFlags({
725
- allowOwnerMint: false,
726
- useReserveBeneficiaryAsDefault: false,
727
- transfersPausable: false,
728
- useVotingUnits: false,
729
- cantBeRemoved: false,
730
- cantIncreaseDiscountPercent: false,
731
- cantBuyWithCredits: false
732
- }),
733
- splitPercent: 0,
734
- splits: new JBSplit[](0)
735
- });
736
- }
737
-
738
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
739
-
740
- uint256 gasBefore = gasleft();
741
- vm.prank(owner);
742
- targetHook.adjustTiers(newTiers, new uint256[](0));
743
- uint256 gasUsed = gasBefore - gasleft();
744
-
745
- IJB721TiersHookStore hookStore = targetHook.STORE();
746
-
747
- // Verify all 200 tiers were added.
748
- assertEq(hookStore.maxTierIdOf(address(targetHook)), 200, "Should have 200 tiers");
749
-
750
- // Spot check first and last tier.
751
- JB721Tier memory firstTier = hookStore.tierOf(address(targetHook), 1, false);
752
- assertEq(firstTier.price, 1e14, "First tier price should be correct");
753
- assertEq(firstTier.initialSupply, 5, "First tier supply should be 5");
754
-
755
- JB721Tier memory lastTier = hookStore.tierOf(address(targetHook), 200, false);
756
- assertEq(lastTier.price, 200 * 1e14, "Last tier price should be correct");
757
- assertEq(lastTier.initialSupply, 5, "Last tier supply should be 5");
758
-
759
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Adding 200 tiers should fit within block gas limit");
760
-
761
- emit log_named_uint("Gas used to add 200 tiers", gasUsed);
762
- }
763
-
764
- // ---------------------------------------------------------------
765
- // Test 6: Remove many tiers and verify gas is bounded
766
- // ---------------------------------------------------------------
767
- /// @notice Removing 50 tiers from a 100-tier collection should be gas-efficient.
768
- function test_gasLimit_remove50TiersFrom100() public {
769
- defaultTierConfig.initialSupply = 10;
770
- defaultTierConfig.reserveFrequency = 0;
771
-
772
- JB721TiersHook targetHook = _initHookDefaultTiers(0);
773
-
774
- // Add 100 tiers.
775
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](100);
776
- for (uint256 i; i < 100; i++) {
777
- newTiers[i] = JB721TierConfig({
778
- price: uint104((i + 1) * 1e15),
779
- initialSupply: uint32(10),
780
- votingUnits: 0,
781
- reserveFrequency: 0,
782
- reserveBeneficiary: reserveBeneficiary,
783
- encodedIPFSUri: tokenUris[i % 10],
784
- // forge-lint: disable-next-line(unsafe-typecast)
785
- // forge-lint: disable-next-line(unsafe-typecast)
786
- category: uint24(i + 1),
787
- discountPercent: 0,
788
- flags: JB721TierConfigFlags({
789
- allowOwnerMint: false,
790
- useReserveBeneficiaryAsDefault: false,
791
- transfersPausable: false,
792
- useVotingUnits: false,
793
- cantBeRemoved: false,
794
- cantIncreaseDiscountPercent: false,
795
- cantBuyWithCredits: false
796
- }),
797
- splitPercent: 0,
798
- splits: new JBSplit[](0)
799
- });
800
- }
801
-
802
- vm.mockCall(mockJBPermissions, abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true));
803
-
804
- vm.prank(owner);
805
- targetHook.adjustTiers(newTiers, new uint256[](0));
806
-
807
- // Now remove 50 tiers (odd-numbered tiers: 1, 3, 5, ..., 99).
808
- uint256[] memory tierIdsToRemove = new uint256[](50);
809
- for (uint256 i; i < 50; i++) {
810
- tierIdsToRemove[i] = (i * 2) + 1; // 1, 3, 5, ...
811
- }
812
-
813
- uint256 gasBefore = gasleft();
814
- vm.prank(owner);
815
- targetHook.adjustTiers(new JB721TierConfig[](0), tierIdsToRemove);
816
- uint256 gasUsed = gasBefore - gasleft();
817
-
818
- IJB721TiersHookStore hookStore = targetHook.STORE();
819
-
820
- // Verify the removed tiers are marked as removed.
821
- assertTrue(hookStore.isTierRemoved(address(targetHook), 1), "Tier 1 should be removed");
822
- assertTrue(hookStore.isTierRemoved(address(targetHook), 99), "Tier 99 should be removed");
823
-
824
- // Verify even tiers are still active.
825
- assertFalse(hookStore.isTierRemoved(address(targetHook), 2), "Tier 2 should still be active");
826
- assertFalse(hookStore.isTierRemoved(address(targetHook), 100), "Tier 100 should still be active");
827
-
828
- // Reading the active tiers should return 50 (the even-numbered ones).
829
- JB721Tier[] memory activeTiers = hookStore.tiersOf(address(targetHook), new uint256[](0), false, 0, 100);
830
- assertEq(activeTiers.length, 50, "Should have 50 active tiers after removing 50");
831
-
832
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Removing 50 tiers should fit within block gas limit");
833
-
834
- emit log_named_uint("Gas used to remove 50 tiers from 100", gasUsed);
835
- }
836
-
837
- // ---------------------------------------------------------------
838
- // Test 7: Mint from many different tiers in a single payment
839
- // ---------------------------------------------------------------
840
- /// @notice Minting 1 NFT from each of 50 different tiers in a single payment should not
841
- /// exceed gas limits.
842
- function test_gasLimit_mintFrom50TiersInSinglePayment() public {
843
- defaultTierConfig.initialSupply = 10;
844
- defaultTierConfig.reserveFrequency = 0;
845
-
846
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
847
- IJB721TiersHookStore hookStore = targetHook.STORE();
848
-
849
- // Add 50 tiers.
850
- JB721TierConfig[] memory newTiers = new JB721TierConfig[](50);
851
- for (uint256 i; i < 50; i++) {
852
- newTiers[i] = JB721TierConfig({
853
- price: uint104((i + 1) * 1e15),
854
- initialSupply: uint32(10),
855
- votingUnits: 0,
856
- reserveFrequency: 0,
857
- reserveBeneficiary: reserveBeneficiary,
858
- encodedIPFSUri: tokenUris[i % 10],
859
- // forge-lint: disable-next-line(unsafe-typecast)
860
- // forge-lint: disable-next-line(unsafe-typecast)
861
- category: uint24(i + 1),
862
- discountPercent: 0,
863
- flags: JB721TierConfigFlags({
864
- allowOwnerMint: false,
865
- useReserveBeneficiaryAsDefault: false,
866
- transfersPausable: false,
867
- useVotingUnits: false,
868
- cantBeRemoved: false,
869
- cantIncreaseDiscountPercent: false,
870
- cantBuyWithCredits: false
871
- }),
872
- splitPercent: 0,
873
- splits: new JBSplit[](0)
874
- });
875
- }
876
-
877
- vm.prank(address(targetHook));
878
- hookStore.recordAddTiers(newTiers);
879
-
880
- mockAndExpect(
881
- address(mockJBDirectory),
882
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
883
- abi.encode(true)
884
- );
885
-
886
- // Mint 1 NFT from each of the 50 tiers.
887
- uint16[] memory tierIdsToMint = new uint16[](50);
888
- uint256 totalCost;
889
- for (uint256 i; i < 50; i++) {
890
- // forge-lint: disable-next-line(unsafe-typecast)
891
- // forge-lint: disable-next-line(unsafe-typecast)
892
- tierIdsToMint[i] = uint16(i + 1);
893
- totalCost += (i + 1) * 1e15;
894
- }
895
-
896
- bytes[] memory data = new bytes[](1);
897
- data[0] = abi.encode(false, tierIdsToMint);
898
- bytes4[] memory metaIds = new bytes4[](1);
899
- metaIds[0] = metadataHelper.getId("pay", address(targetHook));
900
- bytes memory payerMetadata = metadataHelper.createMetadata(metaIds, data);
901
-
902
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
903
- payer: beneficiary,
904
- projectId: projectId,
905
- rulesetId: 0,
906
- amount: JBTokenAmount({
907
- token: JBConstants.NATIVE_TOKEN,
908
- value: totalCost,
909
- decimals: 18,
910
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
911
- }),
912
- forwardedAmount: JBTokenAmount({
913
- token: JBConstants.NATIVE_TOKEN,
914
- value: 0,
915
- decimals: 18,
916
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
917
- }),
918
- weight: 10e18,
919
- newlyIssuedTokenCount: 0,
920
- beneficiary: beneficiary,
921
- hookMetadata: bytes(""),
922
- payerMetadata: payerMetadata
923
- });
924
-
925
- uint256 gasBefore = gasleft();
926
- vm.prank(mockTerminalAddress);
927
- targetHook.afterPayRecordedWith(payContext);
928
- uint256 gasUsed = gasBefore - gasleft();
929
-
930
- assertEq(targetHook.balanceOf(beneficiary), 50, "Should have minted 50 NFTs");
931
- assertTrue(gasUsed < BLOCK_GAS_LIMIT, "Minting from 50 tiers should fit within block gas limit");
932
-
933
- emit log_named_uint("Gas used to mint from 50 tiers in single payment", gasUsed);
934
- }
935
-
936
- /// @notice The expensive read paths scale with tier count, not just with the beneficiary's holdings.
937
- /// This test exists to prove that a 100-tier catalog is materially more expensive than a 10-tier catalog even
938
- /// when the queried user owns zero NFTs.
939
- function test_operatingEnvelope_balanceOf_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
940
- uint256 gasFor10 = _measureBalanceOfGas({tierCount: 10});
941
- uint256 gasFor100 = _measureBalanceOfGas({tierCount: 100});
942
-
943
- assertGt(gasFor100, gasFor10 * 4, "100-tier balanceOf should be materially more expensive than 10 tiers");
944
- emit log_named_uint("Gas used for balanceOf (10 tiers)", gasFor10);
945
- emit log_named_uint("Gas used for balanceOf (100 tiers)", gasFor100);
946
- }
947
-
948
- /// @notice Cash-out accounting also scales with the catalog size because totalCashOutWeight walks the tier set.
949
- /// We use a ratio check instead of an absolute snapshot so the test stays stable across compiler changes while
950
- /// still proving the production-scale cost increase.
951
- function test_operatingEnvelope_totalCashOutWeight_100tiersIsMateriallyMoreExpensiveThan10tiers() public {
952
- uint256 gasFor10 = _measureTotalCashOutWeightGas({tierCount: 10, mintedCount: 10});
953
- uint256 gasFor100 = _measureTotalCashOutWeightGas({tierCount: 100, mintedCount: 10});
954
-
955
- assertGt(
956
- gasFor100, gasFor10 * 4, "100-tier totalCashOutWeight should be materially more expensive than 10 tiers"
957
- );
958
- emit log_named_uint("Gas used for totalCashOutWeight (10 tiers)", gasFor10);
959
- emit log_named_uint("Gas used for totalCashOutWeight (100 tiers)", gasFor100);
960
- }
961
-
962
- function _measureBalanceOfGas(uint256 tierCount) internal returns (uint256 gasUsed) {
963
- defaultTierConfig.initialSupply = 10;
964
- defaultTierConfig.reserveFrequency = 0;
965
-
966
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
967
- IJB721TiersHookStore hookStore = targetHook.STORE();
968
-
969
- vm.prank(address(targetHook));
970
- hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
971
-
972
- uint256 gasBefore = gasleft();
973
- hookStore.balanceOf(address(targetHook), beneficiary);
974
- gasUsed = gasBefore - gasleft();
975
- }
976
-
977
- function _measureTotalCashOutWeightGas(uint256 tierCount, uint256 mintedCount) internal returns (uint256 gasUsed) {
978
- defaultTierConfig.initialSupply = 10;
979
- defaultTierConfig.reserveFrequency = 0;
980
-
981
- ForTest_JB721TiersHook targetHook = _initializeForTestHook(0);
982
- IJB721TiersHookStore hookStore = targetHook.STORE();
983
-
984
- vm.prank(address(targetHook));
985
- hookStore.recordAddTiers(_sequentialTierConfigs(tierCount, 1e15, 10));
986
-
987
- mockAndExpect(
988
- address(mockJBDirectory),
989
- abi.encodeWithSelector(IJBDirectory.isTerminalOf.selector, projectId, mockTerminalAddress),
990
- abi.encode(true)
991
- );
992
-
993
- uint16[] memory tierIdsToMint = new uint16[](mintedCount);
994
- uint256 totalCost;
995
- for (uint256 i; i < mintedCount; i++) {
996
- // forge-lint: disable-next-line(unsafe-typecast)
997
- tierIdsToMint[i] = uint16(i + 1);
998
- totalCost += (i + 1) * 1e15;
999
- }
1000
-
1001
- bytes[] memory data = new bytes[](1);
1002
- data[0] = abi.encode(false, tierIdsToMint);
1003
- bytes4[] memory ids = new bytes4[](1);
1004
- ids[0] = metadataHelper.getId("pay", address(targetHook));
1005
- bytes memory payerMetadata = metadataHelper.createMetadata(ids, data);
1006
-
1007
- JBAfterPayRecordedContext memory payContext = JBAfterPayRecordedContext({
1008
- payer: beneficiary,
1009
- projectId: projectId,
1010
- rulesetId: 0,
1011
- amount: JBTokenAmount({
1012
- token: JBConstants.NATIVE_TOKEN,
1013
- value: totalCost,
1014
- decimals: 18,
1015
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1016
- }),
1017
- forwardedAmount: JBTokenAmount({
1018
- token: JBConstants.NATIVE_TOKEN,
1019
- value: 0,
1020
- decimals: 18,
1021
- currency: uint32(uint160(JBConstants.NATIVE_TOKEN))
1022
- }),
1023
- weight: 10e18,
1024
- newlyIssuedTokenCount: 0,
1025
- beneficiary: beneficiary,
1026
- hookMetadata: bytes(""),
1027
- payerMetadata: payerMetadata
1028
- });
1029
-
1030
- vm.prank(mockTerminalAddress);
1031
- targetHook.afterPayRecordedWith(payContext);
1032
-
1033
- uint256 gasBefore = gasleft();
1034
- hookStore.totalCashOutWeight(address(targetHook));
1035
- gasUsed = gasBefore - gasleft();
1036
- }
1037
-
1038
- function _sequentialTierConfigs(
1039
- uint256 tierCount,
1040
- uint104 priceStep,
1041
- uint32 initialSupply
1042
- )
1043
- internal
1044
- view
1045
- returns (JB721TierConfig[] memory newTiers)
1046
- {
1047
- require(tierCount <= OPERATING_ENVELOPE_SOFT_LIMIT, "test helper only sized for envelope coverage");
1048
-
1049
- newTiers = new JB721TierConfig[](tierCount);
1050
- for (uint256 i; i < tierCount; i++) {
1051
- newTiers[i] = JB721TierConfig({
1052
- price: uint104((i + 1) * priceStep),
1053
- initialSupply: initialSupply,
1054
- votingUnits: 0,
1055
- reserveFrequency: 0,
1056
- reserveBeneficiary: reserveBeneficiary,
1057
- encodedIPFSUri: tokenUris[i % 10],
1058
- // forge-lint: disable-next-line(unsafe-typecast)
1059
- category: uint24(i + 1),
1060
- discountPercent: 0,
1061
- flags: JB721TierConfigFlags({
1062
- allowOwnerMint: false,
1063
- useReserveBeneficiaryAsDefault: false,
1064
- transfersPausable: false,
1065
- useVotingUnits: false,
1066
- cantBeRemoved: false,
1067
- cantIncreaseDiscountPercent: false,
1068
- cantBuyWithCredits: false
1069
- }),
1070
- splitPercent: 0,
1071
- splits: new JBSplit[](0)
1072
- });
1073
- }
1074
- }
1075
- }