@croptop/core-v6 0.0.15 → 0.0.17

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.
@@ -0,0 +1,458 @@
1
+ # croptop-core-v6 -- Audit Instructions
2
+
3
+ Audit preparation document for experienced Solidity auditors. This repo contains the Croptop content publishing system: three contracts that allow permissioned posting of NFT content as 721 tiers to Juicebox V6 projects, with fee accounting, an allowlist system, and a data hook proxy for cross-chain cash-out interception.
4
+
5
+ Compiler: `solc 0.8.26`. Framework: Foundry.
6
+
7
+ ---
8
+
9
+ ## 1. Architecture Overview
10
+
11
+ Croptop is a thin orchestration layer on top of Juicebox V6 core and the 721 tiers hook system. It adds content-posting semantics (price floors, supply bounds, poster allowlists, split caps) and a 5% fee on every mint.
12
+
13
+ ### Contract Table
14
+
15
+ | Contract | File | Lines | Role |
16
+ |----------|------|-------|------|
17
+ | **CTPublisher** | `src/CTPublisher.sol` | ~580 | Core publishing engine. Validates posts against bit-packed allowances, creates 721 tiers, mints first copies, routes fees. Inherits `JBPermissioned`, `ERC2771Context`. |
18
+ | **CTDeployer** | `src/CTDeployer.sol` | ~427 | Factory that deploys a JB project + 721 hook + posting criteria in one transaction. Acts as `IJBRulesetDataHook` proxy: forwards pay/cash-out calls to the underlying hook while granting fee-free cash outs to suckers. Inherits `JBPermissioned`, `ERC2771Context`, `IERC721Receiver`. |
19
+ | **CTProjectOwner** | `src/CTProjectOwner.sol` | ~82 | Receives project ownership NFT via `safeTransferFrom` and permanently grants `CTPublisher` the `ADJUST_721_TIERS` permission. Implements `IERC721Receiver`. |
20
+
21
+ ### Struct Table
22
+
23
+ | Struct | File | Key Fields |
24
+ |--------|------|------------|
25
+ | `CTAllowedPost` | `src/structs/CTAllowedPost.sol` | `hook` (address), `category` (uint24), `minimumPrice` (uint104), `minimumTotalSupply` (uint32), `maximumTotalSupply` (uint32), `maximumSplitPercent` (uint32), `allowedAddresses` (address[]) |
26
+ | `CTDeployerAllowedPost` | `src/structs/CTDeployerAllowedPost.sol` | Same as `CTAllowedPost` minus `hook` (inferred during deployment) |
27
+ | `CTPost` | `src/structs/CTPost.sol` | `encodedIPFSUri` (bytes32), `totalSupply` (uint32), `price` (uint104), `category` (uint24), `splitPercent` (uint32), `splits` (JBSplit[]) |
28
+ | `CTProjectConfig` | `src/structs/CTProjectConfig.sol` | `terminalConfigurations` (JBTerminalConfig[]), `projectUri` (string), `allowedPosts` (CTDeployerAllowedPost[]), `contractUri` (string), `name` (string), `symbol` (string), `salt` (bytes32) |
29
+ | `CTSuckerDeploymentConfig` | `src/structs/CTSuckerDeploymentConfig.sol` | `deployerConfigurations` (JBSuckerDeployerConfig[]), `salt` (bytes32) |
30
+
31
+ ### Dependency Map
32
+
33
+ ```
34
+ CTPublisher
35
+ ├── JBPermissioned (access control)
36
+ ├── ERC2771Context (meta-transactions)
37
+ ├── IJBDirectory (terminal lookup)
38
+ ├── IJBTerminal (payments)
39
+ ├── IJB721TiersHook (tier adjustment, mint)
40
+ ├── IJB721TiersHookStore (tier reads)
41
+ ├── JBMetadataResolver (metadata encoding)
42
+ └── JBOwnable (hook owner reads)
43
+
44
+ CTDeployer
45
+ ├── JBPermissioned (access control)
46
+ ├── ERC2771Context (meta-transactions)
47
+ ├── IJBRulesetDataHook (pay/cashout interception)
48
+ ├── IERC721Receiver (project NFT receipt)
49
+ ├── IJBProjects (project NFT operations)
50
+ ├── IJB721TiersHookDeployer (hook deployment)
51
+ ├── IJBController (project launch)
52
+ ├── IJBSuckerRegistry (sucker verification, deployment)
53
+ ├── JBOwnable (hook ownership transfer)
54
+ └── ICTPublisher (posting criteria delegation)
55
+
56
+ CTProjectOwner
57
+ ├── IERC721Receiver (project NFT receipt)
58
+ ├── IJBPermissions (permission grants)
59
+ └── IJBProjects (sender validation)
60
+ ```
61
+
62
+ ---
63
+
64
+ ## 2. Content Posting Flow
65
+
66
+ The core flow is `CTPublisher.mintFrom()`. This is the primary entry point for all content publishing.
67
+
68
+ ### Step-by-step execution (CTPublisher.sol lines 307-420)
69
+
70
+ ```
71
+ Poster calls mintFrom(hook, posts[], nftBeneficiary, feeBeneficiary, additionalPayMetadata, feeMetadata)
72
+ with msg.value = sum(post prices) + 5% fee
73
+
74
+ 1. Read projectId from hook.PROJECT_ID() [line 326]
75
+ 2. _setupPosts(hook, posts) returns: [line 330-331]
76
+ (tiersToAdd[], tierIdsToMint[], totalPrice)
77
+
78
+ For each post in the batch:
79
+ a. Revert if encodedIPFSUri == bytes32("") [line 463-464]
80
+ b. O(i) duplicate check against all prior posts in batch [line 468-472]
81
+ c. Look up tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri] [line 477]
82
+ - If tier exists and NOT removed: reuse tier ID, [line 486]
83
+ accumulate store.tierOf().price (on-chain price) [line 491]
84
+ - If tier exists but removed: delete stale mapping, [line 484]
85
+ fall through to new tier creation
86
+ - If no tier exists: validate against allowance: [line 500-539]
87
+ * Category must be configured (minimumTotalSupply > 0)
88
+ * price >= minimumPrice
89
+ * totalSupply >= minimumTotalSupply
90
+ * totalSupply <= maximumTotalSupply
91
+ * splitPercent <= maximumSplitPercent
92
+ * caller in allowedAddresses (if non-empty)
93
+ Then build JB721TierConfig and accumulate post.price [line 543-569]
94
+ d. Store tierIdForEncodedIPFSUriOf mapping for new tiers [line 566]
95
+
96
+ Assembly-resize tiersToAdd if some posts reused existing tiers [line 574-578]
97
+
98
+ 3. If projectId != FEE_PROJECT_ID: [line 333]
99
+ payValue = msg.value - (totalPrice / FEE_DIVISOR) [line 338]
100
+ Else: payValue = msg.value (no fee for fee project)
101
+
102
+ 4. Revert if totalPrice > payValue [line 342-344]
103
+
104
+ 5. hook.adjustTiers(tiersToAdd, []) [line 348]
105
+
106
+ 6. Build mint metadata: [line 356-367]
107
+ - JBMetadataResolver.addToMetadata with tier IDs
108
+ - Assembly: write FEE_PROJECT_ID into first 32 bytes (referral)
109
+
110
+ 7. Emit Mint event [line 370-379]
111
+
112
+ 8. Look up project's primary ETH terminal via DIRECTORY [line 383-384]
113
+ terminal.pay{value: payValue}(...) [line 388-396]
114
+
115
+ 9. If address(this).balance != 0: [line 403]
116
+ Look up fee project's primary ETH terminal [line 405-406]
117
+ feeTerminal.pay{value: address(this).balance}(...) [line 410-418]
118
+ ```
119
+
120
+ ### Fee Calculation
121
+
122
+ - `FEE_DIVISOR = 20` (5% fee)
123
+ - Fee = `totalPrice / 20` (integer division, truncates)
124
+ - Maximum rounding loss: 19 wei per transaction
125
+ - Fee is deducted from `msg.value` before the project payment
126
+ - Residual `address(this).balance` (fee + any force-sent ETH) goes to fee project
127
+ - Fee is skipped entirely when `projectId == FEE_PROJECT_ID`
128
+
129
+ ---
130
+
131
+ ## 3. Tier Creation Mechanics
132
+
133
+ ### New Tier Path
134
+
135
+ When a post's `encodedIPFSUri` has no existing mapping (or the mapped tier was removed), a new tier is created:
136
+
137
+ 1. Posting criteria are read from bit-packed `_packedAllowanceFor[hook][category]` (CTPublisher.sol lines 174-187)
138
+ 2. Each field is validated against the post's parameters
139
+ 3. A `JB721TierConfig` is constructed with the post's values (lines 543-560)
140
+ 4. The tier ID is computed as `startingTierId + numberOfTiersBeingAdded++` (line 563)
141
+ 5. The mapping `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set (line 566)
142
+ 6. All new tiers are committed to the hook via `hook.adjustTiers()` after the loop (line 348)
143
+
144
+ ### Existing Tier Path
145
+
146
+ When a post's `encodedIPFSUri` already has a mapping to a live (non-removed) tier:
147
+
148
+ 1. The tier ID is reused (line 486)
149
+ 2. The fee is calculated from `store.tierOf().price` -- the on-chain price, not `post.price` (line 491)
150
+ 3. No new `JB721TierConfig` is added to `tiersToAdd`
151
+ 4. The poster still gets a mint of the existing tier
152
+
153
+ ### Stale Tier Cleanup
154
+
155
+ If a tier was removed externally via `adjustTiers()`, the publisher detects this via `hook.STORE().isTierRemoved()` (line 483) and deletes the stale mapping (line 484), allowing the URI to be posted as a new tier.
156
+
157
+ ---
158
+
159
+ ## 4. Bit-Packed Allowance Storage
160
+
161
+ Posting criteria are packed into a single `uint256` per hook/category:
162
+
163
+ ```
164
+ Bits 0-103 (104 bits): minimumPrice (uint104)
165
+ Bits 104-135 ( 32 bits): minimumTotalSupply (uint32)
166
+ Bits 136-167 ( 32 bits): maximumTotalSupply (uint32)
167
+ Bits 168-199 ( 32 bits): maximumSplitPercent(uint32)
168
+ Bits 200-255 ( 56 bits): unused
169
+ ```
170
+
171
+ Packing logic: CTPublisher.sol lines 271-281
172
+ Unpacking logic: CTPublisher.sol lines 174-187
173
+
174
+ The address allowlist is stored separately in `_allowedAddresses[hook][category]` (a dynamic array).
175
+
176
+ ---
177
+
178
+ ## 5. Allowlist System
179
+
180
+ ### Configuration
181
+
182
+ `configurePostingCriteriaFor()` (line 240) accepts an array of `CTAllowedPost` structs. For each:
183
+
184
+ 1. Emits `ConfigurePostingCriteria` event (line 249)
185
+ 2. Checks `ADJUST_721_TIERS` permission from `JBOwnable(hook).owner()` (lines 253-257)
186
+ 3. Validates `minimumTotalSupply > 0` (line 260)
187
+ 4. Validates `minimumTotalSupply <= maximumTotalSupply` (line 265)
188
+ 5. Packs numeric fields into `_packedAllowanceFor` (lines 271-281)
189
+ 6. Replaces the entire `_allowedAddresses` array (delete + push loop, lines 286-293)
190
+
191
+ ### Enforcement
192
+
193
+ In `_setupPosts()` at line 537:
194
+
195
+ ```solidity
196
+ if (addresses.length != 0 && !_isAllowed({addrs: _msgSender(), addresses: addresses})) {
197
+ revert CTPublisher_NotInAllowList(_msgSender(), addresses);
198
+ }
199
+ ```
200
+
201
+ `_isAllowed()` (lines 207-217) is a linear scan: O(n) where n = allowlist size.
202
+
203
+ ### Key Behavior
204
+
205
+ - Empty `allowedAddresses` means anyone can post (permissionless)
206
+ - Reconfiguring a category fully replaces the previous criteria and allowlist
207
+ - Categories with `minimumTotalSupply == 0` are treated as unconfigured (posting reverts)
208
+ - There is no mechanism to fully disable a configured category (NM-006, documented as won't-fix)
209
+
210
+ ---
211
+
212
+ ## 6. Data Hook Proxy (CTDeployer)
213
+
214
+ ### Architecture
215
+
216
+ CTDeployer registers itself as the `dataHook` for every project it deploys (CTDeployer.sol line 286). It implements `IJBRulesetDataHook` and proxies calls:
217
+
218
+ - **`beforePayRecordedWith(context)`** (line 160): Forwards directly to `dataHookOf[context.projectId]` (the JB721TiersHook).
219
+ - **`beforeCashOutRecordedWith(context)`** (line 132): Checks `SUCKER_REGISTRY.isSuckerOf()` first. If the holder is a sucker, returns `cashOutTaxRate = 0` (fee-free). Otherwise forwards to the data hook.
220
+ - **`hasMintPermissionFor(projectId, ruleset, addr)`** (line 176): Returns `true` if `addr` is a registered sucker.
221
+
222
+ ### Failure Scenarios
223
+
224
+ **Critical: Data hook forwarding has no try-catch.** If `dataHookOf[projectId]` reverts for any reason:
225
+
226
+ - All `pay()` calls to the project will revert (line 168)
227
+ - All `cashOut()` calls (for non-sucker holders) will revert (line 150)
228
+ - Since `dataHookOf` is write-once (set at line 303, no setter), the project is permanently bricked
229
+
230
+ **Scenarios that could trigger this:**
231
+
232
+ 1. The 721 hook has a bug in `beforePayRecordedWith()` or `beforeCashOutRecordedWith()`
233
+ 2. The hook's dependencies (store, prices, rulesets) revert due to bad state
234
+ 3. An upgrade to a dependency contract breaks ABI compatibility
235
+
236
+ **Mitigations:**
237
+
238
+ - The hook is deployed via Create2 with deterministic bytecode (no proxy, no upgrade)
239
+ - The hook's logic is well-tested in the `nana-721-hook-v6` repo
240
+ - Sucker cash-outs bypass the hook entirely (they return before the forwarding call)
241
+
242
+ ---
243
+
244
+ ## 7. Sucker Impersonation Risks
245
+
246
+ ### Trust Chain
247
+
248
+ ```
249
+ CTDeployer.beforeCashOutRecordedWith()
250
+ └── SUCKER_REGISTRY.isSuckerOf(projectId, context.holder)
251
+ └── Registry tracks suckers deployed by allowed deployers
252
+ └── allowSuckerDeployer() restricted to registry owner (multisig)
253
+ ```
254
+
255
+ ### Attack Surface
256
+
257
+ If an attacker can make `SUCKER_REGISTRY.isSuckerOf()` return `true` for their address:
258
+
259
+ 1. They call `cashOut()` on any Croptop project
260
+ 2. CTDeployer intercepts the cash-out, sees the attacker as a "sucker"
261
+ 3. Returns `cashOutTaxRate = 0` instead of forwarding to the hook
262
+ 4. The attacker receives full treasury value without paying the project's cash-out tax
263
+
264
+ ### Risk Factors
265
+
266
+ - `MAP_SUCKER_TOKEN` permission is granted as wildcard (`projectId: 0`) at CTDeployer construction (line 105)
267
+ - The sucker registry is a shared singleton controlled by the protocol multisig
268
+ - Once a sucker deployer is allowed, it can deploy suckers for any project
269
+ - Compromising the multisig or a sucker deployer would affect all Croptop projects
270
+
271
+ ### What to Verify
272
+
273
+ - That `isSuckerOf()` cannot be manipulated without multisig action
274
+ - That the wildcard `MAP_SUCKER_TOKEN` permission cannot be abused to register arbitrary addresses
275
+ - That the `hasMintPermissionFor()` function (which also trusts the sucker registry) cannot be exploited to mint tokens without payment
276
+
277
+ ---
278
+
279
+ ## 8. Allowlist Gas Scaling
280
+
281
+ ### Current Implementation
282
+
283
+ `_isAllowed()` at CTPublisher.sol lines 207-217:
284
+
285
+ ```solidity
286
+ function _isAllowed(address addrs, address[] memory addresses) internal pure returns (bool) {
287
+ uint256 numberOfAddresses = addresses.length;
288
+ for (uint256 i; i < numberOfAddresses; i++) {
289
+ if (addrs == addresses[i]) return true;
290
+ }
291
+ return false;
292
+ }
293
+ ```
294
+
295
+ ### Gas Analysis
296
+
297
+ - Each comparison: ~3 gas (MLOAD + EQ)
298
+ - Per-address overhead: ~100 gas (loop counter, bounds check, memory access)
299
+ - 100 addresses: ~10,000 gas additional
300
+ - 1,000 addresses: ~100,000 gas additional
301
+ - 10,000 addresses: ~1,000,000 gas additional
302
+ - Block gas limit (~30M mainnet): effective cap of ~300,000 addresses before tx becomes infeasible
303
+
304
+ ### Storage Scaling
305
+
306
+ The `_allowedAddresses` array is also written during `configurePostingCriteriaFor()` via a push loop (lines 290-292). For large allowlists, the configuration transaction gas cost could also become prohibitive.
307
+
308
+ ### Recommendation for Auditors
309
+
310
+ Check that no realistic usage pattern could cause a revert due to gas limits. The recommended practical cap is 100 addresses per category. A Merkle proof pattern would scale to millions of addresses but was not implemented (complexity vs. expected usage tradeoff).
311
+
312
+ ---
313
+
314
+ ## 9. Priority Audit Areas
315
+
316
+ ### P0 -- Critical (fund loss or permanent DoS)
317
+
318
+ 1. **Fee accounting correctness in `_setupPosts()`** (CTPublisher.sol lines 432-579). Verify:
319
+ - `totalPrice` is always computed from on-chain tier prices for existing tiers (not user-supplied `post.price`)
320
+ - `totalPrice` is always computed from `post.price` for new tiers
321
+ - No path exists where `totalPrice` can be manipulated to be less than the actual value of tiers being minted
322
+ - The duplicate URI check (lines 468-472) covers all batch orderings
323
+
324
+ 2. **Fee deduction and routing** (CTPublisher.sol lines 333-418). Verify:
325
+ - `payValue = msg.value - (totalPrice / FEE_DIVISOR)` cannot underflow
326
+ - The check `totalPrice > payValue` correctly prevents underpayment
327
+ - `address(this).balance` after the project payment equals exactly the fee amount (plus any force-sent ETH)
328
+ - The fee terminal payment cannot silently fail
329
+
330
+ 3. **Data hook proxy forwarding** (CTDeployer.sol lines 132-169). Verify:
331
+ - `dataHookOf[projectId]` is always set before any pay/cashout can occur for that project
332
+ - No path exists where `dataHookOf[projectId]` is `address(0)` and a forwarding call is made
333
+ - The sucker check correctly short-circuits before the forwarding call
334
+
335
+ ### P1 -- High (access control bypass, permission escalation)
336
+
337
+ 4. **Sucker fee-free cash-out** (CTDeployer.sol lines 143-146). Verify:
338
+ - Only legitimate suckers can trigger the zero-tax path
339
+ - The `hasMintPermissionFor()` function cannot be abused for unauthorized minting
340
+
341
+ 5. **Permission enforcement in `configurePostingCriteriaFor()`** (CTPublisher.sol lines 253-257). Verify:
342
+ - The permission check uses `JBOwnable(hook).owner()` and `IJB721TiersHook(hook).PROJECT_ID()` correctly
343
+ - No one besides the hook owner (or permissioned delegate) can modify posting criteria
344
+
345
+ 6. **CTProjectOwner permission grant** (CTProjectOwner.sol lines 47-80). Verify:
346
+ - The permission granted is scoped to the correct project ID
347
+ - The `uint64(tokenId)` cast does not truncate for realistic project IDs
348
+ - Any address can transfer a project NFT to CTProjectOwner (no `from == address(0)` check), effectively burning ownership permanently
349
+
350
+ ### P2 -- Medium (economic manipulation, griefing)
351
+
352
+ 7. **Tier spam / unbounded tier creation** (R-1 in RISKS.md). When allowlist is empty, anyone meeting price/supply floors can create unlimited tiers. Assess impact on hook gas costs.
353
+
354
+ 8. **Bit-packing correctness** in `_packedAllowanceFor` storage (CTPublisher.sol lines 271-281 write, lines 174-187 read). Verify no field overlap or silent truncation.
355
+
356
+ 9. **Assembly metadata injection** (CTPublisher.sol lines 365-367). Verify the `mstore` correctly places `FEE_PROJECT_ID` in the referral position without corrupting the JBMetadataResolver lookup table.
357
+
358
+ 10. **Project deployment front-running** (CTDeployer.sol lines 258, 291-300). Verify the `assert(projectId == ...)` check correctly prevents ID mismatch without permanent fund loss.
359
+
360
+ ### P3 -- Low (informational, code quality)
361
+
362
+ 11. **`uint56` vs `uint64` cast inconsistency** between CTProjectOwner (line 74) and CTDeployer (line 338). Confirm no truncation risk for realistic project IDs.
363
+
364
+ 12. **Force-sent ETH routing** (CTPublisher.sol lines 403-418). Confirm this is the intended behavior and cannot be exploited.
365
+
366
+ 13. **Allowlist overwrite behavior** (CTPublisher.sol lines 286-293). Verify that `delete` followed by `push` loop correctly replaces the array with no residual state.
367
+
368
+ ---
369
+
370
+ ## 10. Invariants
371
+
372
+ These properties should hold across all operations. They are suitable targets for fuzz testing and formal verification.
373
+
374
+ ### Fee Invariants
375
+
376
+ 1. **Fee correctness:** For any `mintFrom()` where `projectId != FEE_PROJECT_ID`, the fee project receives at least `totalPrice / FEE_DIVISOR - 19 wei` and at most `totalPrice / FEE_DIVISOR` ETH.
377
+
378
+ 2. **No fee for fee project:** When `projectId == FEE_PROJECT_ID`, the full `msg.value` is sent to the project terminal (zero deducted for fees).
379
+
380
+ 3. **ETH conservation:** For every `mintFrom()` call, `msg.value == payValue + feeAmount + dust`, where `dust <= 19 wei`. No ETH remains in the CTPublisher contract after the call completes (unless an external `selfdestruct` force-sends ETH between the two `pay()` calls).
381
+
382
+ ### Posting Invariants
383
+
384
+ 4. **Allowance enforcement:** A `mintFrom()` call succeeds for a new tier only if every post satisfies: `price >= minimumPrice`, `totalSupply >= minimumTotalSupply`, `totalSupply <= maximumTotalSupply`, `splitPercent <= maximumSplitPercent`, and (if allowlist non-empty) `_msgSender()` is in `allowedAddresses`.
385
+
386
+ 5. **Duplicate rejection:** Within a single `mintFrom()` batch, no two posts can have the same `encodedIPFSUri`.
387
+
388
+ 6. **Existing tier price integrity:** For existing tiers, `totalPrice` accumulates `store.tierOf().price` (the on-chain price), never `post.price`.
389
+
390
+ 7. **Tier uniqueness:** After `_setupPosts()` completes, every `encodedIPFSUri` in the batch maps to a unique tier ID via `tierIdForEncodedIPFSUriOf`.
391
+
392
+ ### Ownership Invariants
393
+
394
+ 8. **Transient deployer ownership:** CTDeployer owns a project NFT only during `deployProjectFor()` execution. By function return, ownership has been transferred to the specified `owner`.
395
+
396
+ 9. **Data hook immutability:** `dataHookOf[projectId]` is set exactly once (during `deployProjectFor`) and never modified afterward. There is no setter function.
397
+
398
+ 10. **Permission scoping:** CTProjectOwner grants `ADJUST_721_TIERS` permission scoped to the specific `tokenId` (project ID) received, not globally.
399
+
400
+ ---
401
+
402
+ ## 11. Testing Setup
403
+
404
+ ### Running Tests
405
+
406
+ ```bash
407
+ cd croptop-core-v6
408
+ forge install
409
+ forge test
410
+ ```
411
+
412
+ For fork tests (requires RPC URL):
413
+ ```bash
414
+ ETHEREUM_RPC_URL=<your-rpc> forge test --match-contract ForkTest --fork-url $ETHEREUM_RPC_URL
415
+ ```
416
+
417
+ ### Test File Overview
418
+
419
+ | Test File | Focus | Tests |
420
+ |-----------|-------|-------|
421
+ | `test/CTPublisher.t.sol` | Allowance round-trip, bit packing fuzz, permission checks, split validation | 18 tests including fuzz |
422
+ | `test/CroptopAttacks.t.sol` | Adversarial input validation, allowlist bypass, split percent enforcement | 12 tests |
423
+ | `test/Fork.t.sol` | Full deployment integration with real JB infrastructure | 2 fork tests |
424
+ | `test/Test_MetadataGeneration.t.sol` | Metadata assembly correctness | 1 test |
425
+ | `test/regression/DuplicateUriFeeEvasion.t.sol` | NM-001 fix: duplicate URI detection | 5 tests including fuzz |
426
+ | `test/regression/FeeEvasion.t.sol` | H-19 fix: existing tier price used for fees | 2 tests |
427
+ | `test/regression/StaleTierIdMapping.t.sol` | L-52 fix: stale mapping cleanup | 2 tests |
428
+
429
+ ### Coverage Gaps (no existing tests)
430
+
431
+ - Data hook proxy forwarding failure (CTDeployer -> hook reverts)
432
+ - Force-sent ETH handling via selfdestruct
433
+ - Allowlist gas scaling benchmarks
434
+ - Sucker fee-free cash-out abuse / impersonation
435
+ - CTProjectOwner receiving transfers (not just mints)
436
+ - `deployProjectFor` front-running race condition
437
+ - Multiple hooks sharing the same CTPublisher instance
438
+ - Cross-category posting in a single batch (different categories, different allowlists)
439
+ - `configurePostingCriteriaFor()` called with a very large allowlist (storage gas)
440
+ - Edge case: `totalPrice == 0` when all posts reuse existing free (price=0) tiers
441
+
442
+ ### Testing Approach Used
443
+
444
+ Tests use Foundry's `vm.mockCall()` to isolate CTPublisher from its dependencies (hook, store, permissions, directory, terminal). The fork test (`Fork.t.sol`) deploys all JB infrastructure fresh within a mainnet fork for integration testing. Regression tests target specific audit findings with dedicated attack reproductions.
445
+
446
+ ---
447
+
448
+ ## 12. Previous Audit Findings
449
+
450
+ Three findings were fixed and have regression tests. Two findings remain open (low severity). See `RISKS.md` for full details.
451
+
452
+ | ID | Severity | Status | Description |
453
+ |----|----------|--------|-------------|
454
+ | NM-001 | MEDIUM | FIXED | Duplicate URI fee evasion in batch mints |
455
+ | H-19 | HIGH | FIXED | Fee evasion on existing tier mints via `post.price = 0` |
456
+ | L-52 | LOW | FIXED | Stale tier ID mapping after external tier removal |
457
+ | NM-005 | LOW | OPEN | `uint56` vs `uint64` project ID cast inconsistency |
458
+ | NM-006 | LOW | OPEN | Cannot fully disable posting for a configured category |