@croptop/core-v6 0.0.38 → 0.0.39

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 (40) hide show
  1. package/README.md +2 -2
  2. package/foundry.toml +2 -1
  3. package/package.json +25 -13
  4. package/script/ConfigureFeeProject.s.sol +8 -5
  5. package/src/CTDeployer.sol +52 -51
  6. package/src/interfaces/ICTDeployer.sol +2 -2
  7. package/ADMINISTRATION.md +0 -94
  8. package/ARCHITECTURE.md +0 -96
  9. package/AUDIT_INSTRUCTIONS.md +0 -88
  10. package/RISKS.md +0 -78
  11. package/SKILLS.md +0 -46
  12. package/STYLE_GUIDE.md +0 -610
  13. package/USER_JOURNEYS.md +0 -134
  14. package/foundry.lock +0 -11
  15. package/slither-ci.config.json +0 -10
  16. package/sphinx.lock +0 -507
  17. package/test/CTDeployer.t.sol +0 -616
  18. package/test/CTProjectOwner.t.sol +0 -185
  19. package/test/CTPublisher.t.sol +0 -869
  20. package/test/ClaimCollectionOwnership.t.sol +0 -315
  21. package/test/CroptopAttacks.t.sol +0 -437
  22. package/test/Fork.t.sol +0 -227
  23. package/test/TestAuditGaps.sol +0 -696
  24. package/test/Test_MetadataGeneration.t.sol +0 -79
  25. package/test/audit/CodexNemesisCroptopPublisherBoundary.t.sol +0 -329
  26. package/test/audit/CodexNemesisCurrencyPoCs.t.sol +0 -371
  27. package/test/audit/CodexNemesisFreshRound.t.sol +0 -395
  28. package/test/audit/CodexNemesisMetadataShadow.t.sol +0 -196
  29. package/test/audit/CodexNemesisPoCs.t.sol +0 -263
  30. package/test/audit/CodexNemesisPolicyReuse.t.sol +0 -168
  31. package/test/audit/CodexNemesisUriDrift.t.sol +0 -252
  32. package/test/audit/DeployerPermissionBypass.t.sol +0 -213
  33. package/test/audit/EmptyPostFeeBypass.t.sol +0 -53
  34. package/test/audit/FeeBeneficiaryReentrancy.t.sol +0 -247
  35. package/test/audit/FeeFallbackBlackhole.t.sol +0 -263
  36. package/test/audit/Pass12Fixes.t.sol +0 -388
  37. package/test/fork/PublishFork.t.sol +0 -440
  38. package/test/regression/DuplicateUriFeeEvasion.t.sol +0 -312
  39. package/test/regression/FeeEvasion.t.sol +0 -286
  40. package/test/regression/StaleTierIdMapping.t.sol +0 -228
@@ -1,696 +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 "forge-std/Test.sol";
6
-
7
- import {IERC165} from "@openzeppelin/contracts/utils/introspection/IERC165.sol";
8
- import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
9
- import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
10
- import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
11
- import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
12
- import {IJBRulesetDataHook} from "@bananapus/core-v6/src/interfaces/IJBRulesetDataHook.sol";
13
- import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
14
- import {IJB721Hook} from "@bananapus/721-hook-v6/src/interfaces/IJB721Hook.sol";
15
- import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
16
- import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookDeployer.sol";
17
- import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
18
- import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
19
- import {JBBeforeCashOutRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforeCashOutRecordedContext.sol";
20
- import {JBBeforePayRecordedContext} from "@bananapus/core-v6/src/structs/JBBeforePayRecordedContext.sol";
21
- import {JBCashOutHookSpecification} from "@bananapus/core-v6/src/structs/JBCashOutHookSpecification.sol";
22
- import {JBPayHookSpecification} from "@bananapus/core-v6/src/structs/JBPayHookSpecification.sol";
23
- import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
24
- import {JBTokenAmount} from "@bananapus/core-v6/src/structs/JBTokenAmount.sol";
25
-
26
- import {CTDeployer} from "../src/CTDeployer.sol";
27
- import {CTPublisher} from "../src/CTPublisher.sol";
28
- import {ICTPublisher} from "../src/interfaces/ICTPublisher.sol";
29
- import {CTAllowedPost} from "../src/structs/CTAllowedPost.sol";
30
- import {CTPost} from "../src/structs/CTPost.sol";
31
-
32
- // =============================================================================
33
- // Mock: A data hook that always reverts
34
- // =============================================================================
35
- contract RevertingDataHook is IJBRulesetDataHook {
36
- function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata)
37
- external
38
- pure
39
- override
40
- returns (uint256, uint256, uint256, uint256, JBCashOutHookSpecification[] memory)
41
- {
42
- revert("DATA_HOOK_REVERTED");
43
- }
44
-
45
- function beforePayRecordedWith(JBBeforePayRecordedContext calldata)
46
- external
47
- pure
48
- override
49
- returns (uint256, JBPayHookSpecification[] memory)
50
- {
51
- revert("DATA_HOOK_REVERTED");
52
- }
53
-
54
- function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure returns (bool) {
55
- return false;
56
- }
57
-
58
- function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
59
- return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IERC165).interfaceId;
60
- }
61
- }
62
-
63
- // Need JBRuleset import for hasMintPermissionFor
64
- import {JBRuleset} from "@bananapus/core-v6/src/structs/JBRuleset.sol";
65
-
66
- // =============================================================================
67
- // Mock: A data hook that returns successfully
68
- // =============================================================================
69
- contract SuccessDataHook is IJBRulesetDataHook {
70
- uint256 public immutable TAX_RATE;
71
-
72
- constructor(uint256 taxRate) {
73
- TAX_RATE = taxRate;
74
- }
75
-
76
- function beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context)
77
- external
78
- view
79
- override
80
- returns (
81
- uint256 cashOutTaxRate,
82
- uint256 cashOutCount,
83
- uint256 totalSupply,
84
- uint256 surplusValue,
85
- JBCashOutHookSpecification[] memory hookSpecifications
86
- )
87
- {
88
- return (TAX_RATE, context.cashOutCount, context.totalSupply, context.surplus.value, hookSpecifications);
89
- }
90
-
91
- function beforePayRecordedWith(JBBeforePayRecordedContext calldata context)
92
- external
93
- pure
94
- override
95
- returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)
96
- {
97
- return (context.weight, hookSpecifications);
98
- }
99
-
100
- function hasMintPermissionFor(uint256, JBRuleset memory, address) external pure returns (bool) {
101
- return false;
102
- }
103
-
104
- function supportsInterface(bytes4 interfaceId) external pure override returns (bool) {
105
- return interfaceId == type(IJBRulesetDataHook).interfaceId || interfaceId == type(IERC165).interfaceId;
106
- }
107
- }
108
-
109
- /// @title TestAuditGaps
110
- /// @notice Tests for audit gaps: data hook proxy failures, sucker impersonation, and allowlist gas scaling.
111
- contract TestAuditGaps is Test {
112
- CTDeployer deployer;
113
- CTPublisher publisher;
114
-
115
- IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
116
- IJBDirectory directory = IJBDirectory(makeAddr("directory"));
117
- IJBProjects projects = IJBProjects(makeAddr("projects"));
118
- IJB721TiersHookDeployer hookDeployer = IJB721TiersHookDeployer(makeAddr("hookDeployer"));
119
- IJBSuckerRegistry suckerRegistry = IJBSuckerRegistry(makeAddr("suckerRegistry"));
120
-
121
- address hookOwner = makeAddr("hookOwner");
122
- address hookAddr = makeAddr("hook");
123
- address hookStoreAddr = makeAddr("hookStore");
124
- address terminalAddr = makeAddr("terminal");
125
- address poster = makeAddr("poster");
126
- address unauthorized = makeAddr("unauthorized");
127
- address fakeSucker = makeAddr("fakeSucker");
128
- address realSucker = makeAddr("realSucker");
129
-
130
- uint256 feeProjectId = 1;
131
- uint256 hookProjectId = 42;
132
-
133
- RevertingDataHook revertingHook;
134
- SuccessDataHook successHook;
135
-
136
- function setUp() public {
137
- // Mock permissions for the CTDeployer constructor (it calls setPermissionsFor twice).
138
- vm.mockCall(
139
- address(permissions), abi.encodeWithSelector(IJBPermissions.setPermissionsFor.selector), abi.encode()
140
- );
141
-
142
- // Mock permissions.hasPermission to return true by default.
143
- vm.mockCall(
144
- address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
145
- );
146
-
147
- // Deploy the publisher.
148
- publisher = new CTPublisher(directory, permissions, feeProjectId, address(0));
149
-
150
- // Deploy the CTDeployer.
151
- deployer = new CTDeployer(
152
- permissions, projects, hookDeployer, ICTPublisher(address(publisher)), suckerRegistry, address(0)
153
- );
154
-
155
- // Deploy mock data hooks.
156
- revertingHook = new RevertingDataHook();
157
- successHook = new SuccessDataHook(5000); // 50% tax rate
158
-
159
- // Mock sucker registry: non-sucker addresses return false by default.
160
- vm.mockCall(
161
- address(suckerRegistry), abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector), abi.encode(false)
162
- );
163
-
164
- // Mock hook basics.
165
- vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
166
- vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(hookProjectId));
167
- vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(hookStoreAddr));
168
-
169
- // Fund test accounts.
170
- vm.deal(poster, 100 ether);
171
- vm.deal(unauthorized, 100 ether);
172
- }
173
-
174
- // =========================================================================
175
- // SECTION 1: Data Hook Proxy Failure Tests
176
- // =========================================================================
177
-
178
- /// @notice Helper to build a minimal JBBeforeCashOutRecordedContext.
179
- function _buildCashOutContext(
180
- uint256 projectId,
181
- address holder,
182
- uint256 cashOutCount,
183
- uint256 totalSupply
184
- )
185
- internal
186
- pure
187
- returns (JBBeforeCashOutRecordedContext memory)
188
- {
189
- return JBBeforeCashOutRecordedContext({
190
- terminal: address(0),
191
- holder: holder,
192
- projectId: projectId,
193
- rulesetId: 1,
194
- cashOutCount: cashOutCount,
195
- totalSupply: totalSupply,
196
- surplus: JBTokenAmount({token: address(0), decimals: 18, currency: 0, value: 1 ether}),
197
- useTotalSurplus: false,
198
- cashOutTaxRate: 10_000,
199
- beneficiaryIsFeeless: false,
200
- metadata: ""
201
- });
202
- }
203
-
204
- /// @notice Helper to build a minimal JBBeforePayRecordedContext.
205
- function _buildPayContext(uint256 projectId) internal pure returns (JBBeforePayRecordedContext memory) {
206
- return JBBeforePayRecordedContext({
207
- terminal: address(0),
208
- payer: address(0),
209
- amount: JBTokenAmount({token: address(0), decimals: 18, currency: 0, value: 1 ether}),
210
- projectId: projectId,
211
- rulesetId: 1,
212
- beneficiary: address(0),
213
- weight: 1_000_000 * 1e18,
214
- reservedPercent: 0,
215
- metadata: ""
216
- });
217
- }
218
-
219
- /// @notice When the underlying data hook reverts, beforeCashOutRecordedWith should bubble up the revert.
220
- function test_dataHookProxy_cashOut_revertsWhenDataHookReverts() public {
221
- // Set the data hook for project to revertingHook.
222
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(revertingHook)));
223
-
224
- // Non-sucker caller, so it will forward to the data hook.
225
- JBBeforeCashOutRecordedContext memory context =
226
- _buildCashOutContext(hookProjectId, unauthorized, 100e18, 1000e18);
227
-
228
- vm.expectRevert("DATA_HOOK_REVERTED");
229
- deployer.beforeCashOutRecordedWith(context);
230
- }
231
-
232
- /// @notice When the underlying data hook reverts, beforePayRecordedWith should bubble up the revert.
233
- function test_dataHookProxy_pay_revertsWhenDataHookReverts() public {
234
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(revertingHook)));
235
-
236
- JBBeforePayRecordedContext memory context = _buildPayContext(hookProjectId);
237
-
238
- vm.expectRevert("DATA_HOOK_REVERTED");
239
- deployer.beforePayRecordedWith(context);
240
- }
241
-
242
- /// @notice When the data hook is not set (address(0)), calling beforeCashOutRecordedWith returns defaults.
243
- function test_dataHookProxy_cashOut_returnsDefaultsWhenNoDataHookSet() public {
244
- // dataHookOf[999] is address(0) by default (never set).
245
- JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(999, unauthorized, 100e18, 1000e18);
246
-
247
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,, JBCashOutHookSpecification[] memory specs) =
248
- deployer.beforeCashOutRecordedWith(context);
249
-
250
- assertEq(taxRate, context.cashOutTaxRate, "cashOutTaxRate should be returned as-is from context");
251
- assertEq(cashOutCount, context.cashOutCount, "cashOutCount should be returned as-is from context");
252
- assertEq(totalSupply, context.totalSupply, "totalSupply should be returned as-is from context");
253
- assertEq(specs.length, 0, "hookSpecifications should be empty");
254
- }
255
-
256
- /// @notice When the data hook is not set (address(0)), calling beforePayRecordedWith returns defaults.
257
- function test_dataHookProxy_pay_returnsDefaultsWhenNoDataHookSet() public {
258
- JBBeforePayRecordedContext memory context = _buildPayContext(999);
259
-
260
- (uint256 weight, JBPayHookSpecification[] memory specs) = deployer.beforePayRecordedWith(context);
261
-
262
- assertEq(weight, context.weight, "weight should be returned as-is from context");
263
- assertEq(specs.length, 0, "hookSpecifications should be empty");
264
- }
265
-
266
- /// @notice When the data hook is set and works, the proxy should forward correctly for cash outs.
267
- function test_dataHookProxy_cashOut_forwardsToSuccessfulDataHook() public {
268
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(successHook)));
269
-
270
- JBBeforeCashOutRecordedContext memory context =
271
- _buildCashOutContext(hookProjectId, unauthorized, 100e18, 1000e18);
272
-
273
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(context);
274
-
275
- assertEq(taxRate, 5000, "tax rate should be forwarded from data hook");
276
- assertEq(cashOutCount, 100e18, "cashOutCount should be forwarded");
277
- assertEq(totalSupply, 1000e18, "totalSupply should be forwarded");
278
- }
279
-
280
- /// @notice When the data hook is set and works, the proxy should forward correctly for payments.
281
- function test_dataHookProxy_pay_forwardsToSuccessfulDataHook() public {
282
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(successHook)));
283
-
284
- JBBeforePayRecordedContext memory context = _buildPayContext(hookProjectId);
285
-
286
- (uint256 weight,) = deployer.beforePayRecordedWith(context);
287
-
288
- assertEq(weight, 1_000_000 * 1e18, "weight should be forwarded from data hook");
289
- }
290
-
291
- /// @notice Sucker addresses bypass the data hook proxy entirely for cash outs with 0% tax.
292
- /// Even if the underlying data hook would revert, the sucker path should succeed.
293
- function test_dataHookProxy_cashOut_suckerBypassesRevertingDataHook() public {
294
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(revertingHook)));
295
-
296
- // Mark realSucker as a valid sucker for this project.
297
- vm.mockCall(
298
- address(suckerRegistry),
299
- abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, hookProjectId, realSucker),
300
- abi.encode(true)
301
- );
302
-
303
- JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(hookProjectId, realSucker, 100e18, 1000e18);
304
-
305
- // Should NOT revert because suckers bypass the data hook entirely.
306
- (uint256 taxRate, uint256 cashOutCount, uint256 totalSupply,,) = deployer.beforeCashOutRecordedWith(context);
307
-
308
- assertEq(taxRate, 0, "sucker should get 0% tax rate");
309
- assertEq(cashOutCount, 100e18, "cashOutCount should pass through");
310
- assertEq(totalSupply, 1000e18, "totalSupply should pass through");
311
- }
312
-
313
- // =========================================================================
314
- // SECTION 2: Sucker Impersonation Tests
315
- // =========================================================================
316
-
317
- /// @notice Non-sucker address should NOT get the 0% tax rate bypass.
318
- function test_suckerImpersonation_nonSuckerGetsTaxed() public {
319
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(successHook)));
320
-
321
- // fakeSucker is NOT registered as a sucker (default mock returns false).
322
- JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(hookProjectId, fakeSucker, 100e18, 1000e18);
323
-
324
- (uint256 taxRate,,,,) = deployer.beforeCashOutRecordedWith(context);
325
-
326
- // Should get the data hook's tax rate (5000), not 0.
327
- assertEq(taxRate, 5000, "non-sucker should not bypass tax");
328
- }
329
-
330
- /// @notice A sucker for a DIFFERENT project should NOT get the 0% tax rate for this project.
331
- function test_suckerImpersonation_wrongProjectSucker() public {
332
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(successHook)));
333
-
334
- // realSucker is registered as a sucker for project 99, not hookProjectId.
335
- vm.mockCall(
336
- address(suckerRegistry),
337
- abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, 99, realSucker),
338
- abi.encode(true)
339
- );
340
- // But NOT for hookProjectId (default mock returns false).
341
-
342
- JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(hookProjectId, realSucker, 100e18, 1000e18);
343
-
344
- (uint256 taxRate,,,,) = deployer.beforeCashOutRecordedWith(context);
345
-
346
- // Should get the data hook's tax rate, not 0.
347
- assertEq(taxRate, 5000, "sucker from wrong project should not bypass tax");
348
- }
349
-
350
- /// @notice A registered sucker for the correct project should get 0% tax.
351
- function test_suckerImpersonation_validSuckerGetsZeroTax() public {
352
- _setDataHookForProject(hookProjectId, IJBRulesetDataHook(address(successHook)));
353
-
354
- // Register realSucker for the correct project.
355
- vm.mockCall(
356
- address(suckerRegistry),
357
- abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, hookProjectId, realSucker),
358
- abi.encode(true)
359
- );
360
-
361
- JBBeforeCashOutRecordedContext memory context = _buildCashOutContext(hookProjectId, realSucker, 100e18, 1000e18);
362
-
363
- (uint256 taxRate,,,,) = deployer.beforeCashOutRecordedWith(context);
364
-
365
- assertEq(taxRate, 0, "valid sucker should get 0% tax");
366
- }
367
-
368
- /// @notice hasMintPermissionFor should only return true for valid suckers.
369
- function test_suckerImpersonation_mintPermission_nonSuckerDenied() public {
370
- JBRuleset memory ruleset;
371
-
372
- bool allowed = deployer.hasMintPermissionFor(hookProjectId, ruleset, fakeSucker);
373
- assertFalse(allowed, "non-sucker should not have mint permission");
374
- }
375
-
376
- /// @notice hasMintPermissionFor should return true for a valid sucker.
377
- function test_suckerImpersonation_mintPermission_validSuckerAllowed() public {
378
- vm.mockCall(
379
- address(suckerRegistry),
380
- abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, hookProjectId, realSucker),
381
- abi.encode(true)
382
- );
383
-
384
- JBRuleset memory ruleset;
385
-
386
- bool allowed = deployer.hasMintPermissionFor(hookProjectId, ruleset, realSucker);
387
- assertTrue(allowed, "valid sucker should have mint permission");
388
- }
389
-
390
- /// @notice hasMintPermissionFor should return false for a sucker registered to a different project.
391
- function test_suckerImpersonation_mintPermission_wrongProjectDenied() public {
392
- // realSucker is registered for project 99, not hookProjectId.
393
- vm.mockCall(
394
- address(suckerRegistry),
395
- abi.encodeWithSelector(IJBSuckerRegistry.isSuckerOf.selector, 99, realSucker),
396
- abi.encode(true)
397
- );
398
- // Default mock for hookProjectId returns false.
399
-
400
- JBRuleset memory ruleset;
401
-
402
- bool allowed = deployer.hasMintPermissionFor(hookProjectId, ruleset, realSucker);
403
- assertFalse(allowed, "sucker for wrong project should not have mint permission");
404
- }
405
-
406
- // =========================================================================
407
- // SECTION 3: Allowlist Gas Scaling Tests
408
- // =========================================================================
409
-
410
- /// @notice Measure gas cost of configuring allowlists of various sizes.
411
- /// Ensures that even at 200 addresses, configuration does not hit an unreasonable gas limit.
412
- function test_allowlistGas_configureScaling() public {
413
- uint256[] memory sizes = new uint256[](4);
414
- sizes[0] = 10;
415
- sizes[1] = 50;
416
- sizes[2] = 100;
417
- sizes[3] = 200;
418
-
419
- for (uint256 s; s < sizes.length; s++) {
420
- uint256 size = sizes[s];
421
-
422
- address[] memory allowlist = new address[](size);
423
- for (uint256 i; i < size; i++) {
424
- // forge-lint: disable-next-line(unsafe-typecast)
425
- allowlist[i] = address(uint160(0x1000 + i));
426
- }
427
-
428
- CTAllowedPost[] memory posts = new CTAllowedPost[](1);
429
- posts[0] = CTAllowedPost({
430
- hook: hookAddr,
431
- // Use different category for each size to avoid interference.
432
- // forge-lint: disable-next-line(unsafe-typecast)
433
- category: uint24(s + 1),
434
- minimumPrice: 0,
435
- minimumTotalSupply: 1,
436
- maximumTotalSupply: 100,
437
- maximumSplitPercent: 0,
438
- allowedAddresses: allowlist
439
- });
440
-
441
- vm.prank(hookOwner);
442
- uint256 gasBefore = gasleft();
443
- publisher.configurePostingCriteriaFor(posts);
444
- uint256 gasUsed = gasBefore - gasleft();
445
-
446
- // Verify the allowlist was stored correctly.
447
- // forge-lint: disable-next-line(unsafe-typecast)
448
- (,,,, address[] memory stored) = publisher.allowanceFor(hookAddr, uint24(s + 1));
449
- assertEq(stored.length, size, "stored allowlist length should match");
450
-
451
- // Log gas for reference. We just need this to not revert (no DoS).
452
- // Gas should scale roughly linearly with allowlist size.
453
- emit log_named_uint(string(abi.encodePacked("Gas for allowlist size ", vm.toString(size))), gasUsed);
454
- }
455
- }
456
-
457
- /// @notice Test gas cost of _isAllowed (via mintFrom) with increasing allowlist sizes.
458
- /// The allowed address is always the last one to exercise the worst-case linear scan.
459
- function test_allowlistGas_mintFromWorstCaseScan() public {
460
- _setupMintMocks();
461
-
462
- uint256[] memory sizes = new uint256[](3);
463
- sizes[0] = 10;
464
- sizes[1] = 50;
465
- sizes[2] = 100;
466
-
467
- uint256[] memory gasResults = new uint256[](3);
468
-
469
- for (uint256 s; s < sizes.length; s++) {
470
- uint256 size = sizes[s];
471
-
472
- // Build allowlist where poster is the LAST entry (worst case).
473
- address[] memory allowlist = new address[](size);
474
- for (uint256 i; i < size - 1; i++) {
475
- // forge-lint: disable-next-line(unsafe-typecast)
476
- allowlist[i] = address(uint160(0x2000 + i));
477
- }
478
- allowlist[size - 1] = poster;
479
-
480
- CTAllowedPost[] memory posts = new CTAllowedPost[](1);
481
- posts[0] = CTAllowedPost({
482
- hook: hookAddr,
483
- // forge-lint: disable-next-line(unsafe-typecast)
484
- category: uint24(10 + s),
485
- minimumPrice: 0,
486
- minimumTotalSupply: 1,
487
- maximumTotalSupply: 100,
488
- maximumSplitPercent: 0,
489
- allowedAddresses: allowlist
490
- });
491
-
492
- vm.prank(hookOwner);
493
- publisher.configurePostingCriteriaFor(posts);
494
-
495
- // Generate a unique IPFS URI per test case.
496
- bytes32 uri = keccak256(abi.encode("gas-test", s));
497
-
498
- CTPost[] memory mintPosts = new CTPost[](1);
499
- mintPosts[0] = CTPost({
500
- encodedIPFSUri: uri,
501
- totalSupply: 10,
502
- price: 0.01 ether,
503
- // forge-lint: disable-next-line(unsafe-typecast)
504
- category: uint24(10 + s),
505
- splitPercent: 0,
506
- splits: new JBSplit[](0)
507
- });
508
-
509
- vm.prank(poster);
510
- uint256 gasBefore = gasleft();
511
- // This may revert downstream (mock terminal), but the allowlist check happens before that.
512
- // We use try-catch to capture the gas used for the allowlist check path.
513
- try publisher.mintFrom{value: 0.02 ether}(IJB721TiersHook(hookAddr), mintPosts, poster, poster, "", "") {}
514
- catch {}
515
- uint256 gasUsed = gasBefore - gasleft();
516
-
517
- gasResults[s] = gasUsed;
518
- emit log_named_uint(
519
- string(abi.encodePacked("Gas for mintFrom with allowlist size ", vm.toString(size))), gasUsed
520
- );
521
- }
522
-
523
- // Verify gas scales approximately linearly. The gas for size=100 should be less than
524
- // 5x the gas for size=10 (generous bound to account for fixed costs).
525
- // This is a sanity check, not a strict bound.
526
- assertTrue(gasResults[2] < gasResults[0] * 5, "gas should scale roughly linearly, not quadratically");
527
- }
528
-
529
- /// @notice Verify that an empty allowlist means everyone is allowed.
530
- function test_allowlistGas_emptyAllowlistAllowsEveryone() public {
531
- _setupMintMocks();
532
-
533
- CTAllowedPost[] memory posts = new CTAllowedPost[](1);
534
- posts[0] = CTAllowedPost({
535
- hook: hookAddr,
536
- category: 50,
537
- minimumPrice: 0,
538
- minimumTotalSupply: 1,
539
- maximumTotalSupply: 100,
540
- maximumSplitPercent: 0,
541
- allowedAddresses: new address[](0) // Empty = everyone allowed
542
- });
543
-
544
- vm.prank(hookOwner);
545
- publisher.configurePostingCriteriaFor(posts);
546
-
547
- CTPost[] memory mintPosts = new CTPost[](1);
548
- mintPosts[0] = CTPost({
549
- encodedIPFSUri: keccak256("anyone-can-post"),
550
- totalSupply: 10,
551
- price: 0.01 ether,
552
- category: 50,
553
- splitPercent: 0,
554
- splits: new JBSplit[](0)
555
- });
556
-
557
- // Anyone (even unauthorized) should pass the allowlist check.
558
- // The call may revert downstream in mocked terminal calls, but NOT with NotInAllowList.
559
- vm.prank(unauthorized);
560
- try publisher.mintFrom{value: 0.02 ether}(
561
- IJB721TiersHook(hookAddr), mintPosts, unauthorized, unauthorized, "", ""
562
- ) {}
563
- catch (bytes memory reason) {
564
- // Make sure it did NOT revert with CTPublisher_NotInAllowList.
565
- assertTrue(
566
- // forge-lint: disable-next-line(unsafe-typecast)
567
- reason.length < 4 || bytes4(reason) != CTPublisher.CTPublisher_NotInAllowList.selector,
568
- "empty allowlist should not restrict any address"
569
- );
570
- }
571
- }
572
-
573
- /// @notice Verify that a non-empty allowlist blocks addresses not in the list.
574
- function test_allowlistGas_nonEmptyAllowlistBlocksUnauthorized() public {
575
- _setupMintMocks();
576
-
577
- address[] memory allowlist = new address[](1);
578
- allowlist[0] = poster;
579
-
580
- CTAllowedPost[] memory posts = new CTAllowedPost[](1);
581
- posts[0] = CTAllowedPost({
582
- hook: hookAddr,
583
- category: 51,
584
- minimumPrice: 0,
585
- minimumTotalSupply: 1,
586
- maximumTotalSupply: 100,
587
- maximumSplitPercent: 0,
588
- allowedAddresses: allowlist
589
- });
590
-
591
- vm.prank(hookOwner);
592
- publisher.configurePostingCriteriaFor(posts);
593
-
594
- CTPost[] memory mintPosts = new CTPost[](1);
595
- mintPosts[0] = CTPost({
596
- encodedIPFSUri: keccak256("restricted-post"),
597
- totalSupply: 10,
598
- price: 0.01 ether,
599
- category: 51,
600
- splitPercent: 0,
601
- splits: new JBSplit[](0)
602
- });
603
-
604
- vm.prank(unauthorized);
605
- vm.expectRevert();
606
- publisher.mintFrom{value: 0.02 ether}(IJB721TiersHook(hookAddr), mintPosts, unauthorized, unauthorized, "", "");
607
- }
608
-
609
- /// @notice Reconfiguring the allowlist should fully replace the old one.
610
- function test_allowlistGas_reconfigureReplacesOldAllowlist() public {
611
- // First: allowlist with poster.
612
- address[] memory allowlist1 = new address[](1);
613
- allowlist1[0] = poster;
614
-
615
- CTAllowedPost[] memory posts1 = new CTAllowedPost[](1);
616
- posts1[0] = CTAllowedPost({
617
- hook: hookAddr,
618
- category: 52,
619
- minimumPrice: 0,
620
- minimumTotalSupply: 1,
621
- maximumTotalSupply: 100,
622
- maximumSplitPercent: 0,
623
- allowedAddresses: allowlist1
624
- });
625
- vm.prank(hookOwner);
626
- publisher.configurePostingCriteriaFor(posts1);
627
-
628
- // Verify poster is in the allowlist.
629
- (,,,, address[] memory stored1) = publisher.allowanceFor(hookAddr, 52);
630
- assertEq(stored1.length, 1);
631
- assertEq(stored1[0], poster);
632
-
633
- // Second: replace allowlist with unauthorized only.
634
- address[] memory allowlist2 = new address[](1);
635
- allowlist2[0] = unauthorized;
636
-
637
- CTAllowedPost[] memory posts2 = new CTAllowedPost[](1);
638
- posts2[0] = CTAllowedPost({
639
- hook: hookAddr,
640
- category: 52,
641
- minimumPrice: 0,
642
- minimumTotalSupply: 1,
643
- maximumTotalSupply: 100,
644
- maximumSplitPercent: 0,
645
- allowedAddresses: allowlist2
646
- });
647
- vm.prank(hookOwner);
648
- publisher.configurePostingCriteriaFor(posts2);
649
-
650
- // Verify allowlist was fully replaced.
651
- (,,,, address[] memory stored2) = publisher.allowanceFor(hookAddr, 52);
652
- assertEq(stored2.length, 1, "allowlist should have 1 entry");
653
- assertEq(stored2[0], unauthorized, "allowlist should now contain unauthorized");
654
- }
655
-
656
- // =========================================================================
657
- // SECTION 4: CTDeployer interface compliance
658
- // =========================================================================
659
-
660
- /// @notice CTDeployer should correctly report ERC165 support for its interfaces.
661
- function test_supportsInterface() public {
662
- assertTrue(deployer.supportsInterface(type(IJBRulesetDataHook).interfaceId), "should support data hook");
663
- assertTrue(deployer.supportsInterface(type(IERC721Receiver).interfaceId), "should support ERC721Receiver");
664
- assertFalse(deployer.supportsInterface(bytes4(0xdeadbeef)), "should not support random interface");
665
- }
666
-
667
- // =========================================================================
668
- // Internal helpers
669
- // =========================================================================
670
-
671
- /// @dev Use vm.store to set the dataHookOf mapping directly.
672
- /// Mapping slot: dataHookOf is at slot determined by the contract layout.
673
- /// Since we cannot easily calculate the slot for CTDeployer's mapping,
674
- /// we deploy with a known data hook via the mock approach instead.
675
- function _setDataHookForProject(uint256 projectId, IJBRulesetDataHook hook) internal {
676
- // dataHookOf is the first (and only) non-immutable storage variable in CTDeployer, so it is at slot 0.
677
- // For mapping(uint256 => address) at slot 0, the storage slot is keccak256(abi.encode(key, slot)).
678
- bytes32 slot = keccak256(abi.encode(projectId, uint256(0)));
679
- vm.store(address(deployer), slot, bytes32(uint256(uint160(address(hook)))));
680
- }
681
-
682
- /// @dev Set up mocks for mintFrom path on the publisher.
683
- function _setupMintMocks() internal {
684
- vm.mockCall(
685
- hookStoreAddr, abi.encodeWithSelector(IJB721TiersHookStore.maxTierIdOf.selector), abi.encode(uint256(0))
686
- );
687
- vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.adjustTiers.selector), abi.encode());
688
- vm.mockCall(hookAddr, abi.encodeWithSelector(bytes4(keccak256("METADATA_ID_TARGET()"))), abi.encode(address(0)));
689
- vm.mockCall(
690
- address(directory),
691
- abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector),
692
- abi.encode(terminalAddr)
693
- );
694
- vm.mockCall(terminalAddr, "", abi.encode(uint256(0)));
695
- }
696
- }