@croptop/core-v6 0.0.20 → 0.0.21

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.
@@ -2,7 +2,7 @@
2
2
 
3
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
4
 
5
- Compiler: `solc 0.8.26`. Framework: Foundry.
5
+ Compiler: `solc ^0.8.26`. Framework: Foundry.
6
6
 
7
7
  ---
8
8
 
@@ -14,8 +14,8 @@ Croptop is a thin orchestration layer on top of Juicebox V6 core and the 721 tie
14
14
 
15
15
  | Contract | File | Lines | Role |
16
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`. |
17
+ | **CTPublisher** | `src/CTPublisher.sol` | ~590 | 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` | ~433 | 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
19
  | **CTProjectOwner** | `src/CTProjectOwner.sol` | ~82 | Receives project ownership NFT via `safeTransferFrom` and permanently grants `CTPublisher` the `ADJUST_721_TIERS` permission. Implements `IERC721Receiver`. |
20
20
 
21
21
  ### Struct Table
@@ -61,60 +61,74 @@ CTProjectOwner
61
61
 
62
62
  ---
63
63
 
64
+ ## Value Extraction Paths
65
+
66
+ Quick reference for where the money is:
67
+
68
+ | Path | Entry Point | Value at Risk | What to Verify |
69
+ |------|------------|---------------|----------------|
70
+ | Fee evasion | `CTPublisher.mintFrom()` | 5% fee on every mint | `totalPrice` computed from on-chain tier prices for existing tiers, not user-supplied `post.price` |
71
+ | Fee-free cashout | `CTDeployer.beforeCashOutRecordedWith()` | Full treasury value | Only legitimate suckers (via registry) get 0% tax |
72
+ | Unauthorized minting | `CTDeployer.hasMintPermissionFor()` | Arbitrary token minting | Only registered suckers get mint permission |
73
+ | Tier spam | `CTPublisher._setupPosts()` | Gas griefing, hook degradation | Allowlist + price/supply floors gate tier creation |
74
+ | Fee routing failure | `CTPublisher.mintFrom()` residual balance | Fee project loses 5% | `address(this).balance` after project payment is sent to fee project |
75
+
76
+ ---
77
+
64
78
  ## 2. Content Posting Flow
65
79
 
66
80
  The core flow is `CTPublisher.mintFrom()`. This is the primary entry point for all content publishing.
67
81
 
68
- ### Step-by-step execution (CTPublisher.sol lines 307-420)
82
+ ### Step-by-step execution (CTPublisher.sol lines 310-430)
69
83
 
70
84
  ```
71
85
  Poster calls mintFrom(hook, posts[], nftBeneficiary, feeBeneficiary, additionalPayMetadata, feeMetadata)
72
86
  with msg.value = sum(post prices) + 5% fee
73
87
 
74
- 1. Read projectId from hook.PROJECT_ID() [line 326]
75
- 2. _setupPosts(hook, posts) returns: [line 330-331]
88
+ 1. Read projectId from hook.PROJECT_ID() [line 329]
89
+ 2. _setupPosts(hook, posts) returns: [line 333-334]
76
90
  (tiersToAdd[], tierIdsToMint[], totalPrice)
77
91
 
78
92
  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]
93
+ a. Revert if encodedIPFSUri == bytes32("") [line 473-474]
94
+ b. O(i) duplicate check against all prior posts in batch [line 478-482]
95
+ c. Look up tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri] [line 487]
96
+ - If tier exists and NOT removed: reuse tier ID, [line 496]
97
+ accumulate store.tierOf().price (on-chain price) [line 501]
98
+ - If tier exists but removed: delete stale mapping, [line 494]
85
99
  fall through to new tier creation
86
- - If no tier exists: validate against allowance: [line 500-539]
100
+ - If no tier exists: validate against allowance: [line 510-549]
87
101
  * Category must be configured (minimumTotalSupply > 0)
88
102
  * price >= minimumPrice
89
103
  * totalSupply >= minimumTotalSupply
90
104
  * totalSupply <= maximumTotalSupply
91
105
  * splitPercent <= maximumSplitPercent
92
106
  * 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]
107
+ Then build JB721TierConfig and accumulate post.price [line 553-579]
108
+ d. Store tierIdForEncodedIPFSUriOf mapping for new tiers [line 576]
95
109
 
96
- Assembly-resize tiersToAdd if some posts reused existing tiers [line 574-578]
110
+ Assembly-resize tiersToAdd if some posts reused existing tiers [line 584-588]
97
111
 
98
- 3. If projectId != FEE_PROJECT_ID: [line 333]
99
- payValue = msg.value - (totalPrice / FEE_DIVISOR) [line 338]
112
+ 3. If projectId != FEE_PROJECT_ID: [line 336]
113
+ payValue = msg.value - (totalPrice / FEE_DIVISOR) [line 341/348]
100
114
  Else: payValue = msg.value (no fee for fee project)
101
115
 
102
- 4. Revert if totalPrice > payValue [line 342-344]
116
+ 4. Revert if totalPrice > payValue [line 352-354]
103
117
 
104
- 5. hook.adjustTiers(tiersToAdd, []) [line 348]
118
+ 5. hook.adjustTiers(tiersToAdd, []) [line 358]
105
119
 
106
- 6. Build mint metadata: [line 356-367]
120
+ 6. Build mint metadata: [line 366-370]
107
121
  - JBMetadataResolver.addToMetadata with tier IDs
108
122
  - Assembly: write FEE_PROJECT_ID into first 32 bytes (referral)
109
123
 
110
- 7. Emit Mint event [line 370-379]
124
+ 7. Emit Mint event [line 380-389]
111
125
 
112
- 8. Look up project's primary ETH terminal via DIRECTORY [line 383-384]
113
- terminal.pay{value: payValue}(...) [line 388-396]
126
+ 8. Look up project's primary ETH terminal via DIRECTORY [line 393-394]
127
+ terminal.pay{value: payValue}(...) [line 398-406]
114
128
 
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]
129
+ 9. If address(this).balance != 0: [line 413]
130
+ Look up fee project's primary ETH terminal [line 415-416]
131
+ feeTerminal.pay{value: address(this).balance}(...) [line 420-428]
118
132
  ```
119
133
 
120
134
  ### Fee Calculation
@@ -134,25 +148,25 @@ Poster calls mintFrom(hook, posts[], nftBeneficiary, feeBeneficiary, additionalP
134
148
 
135
149
  When a post's `encodedIPFSUri` has no existing mapping (or the mapped tier was removed), a new tier is created:
136
150
 
137
- 1. Posting criteria are read from bit-packed `_packedAllowanceFor[hook][category]` (CTPublisher.sol lines 174-187)
151
+ 1. Posting criteria are read from bit-packed `_packedAllowanceFor[hook][category]` (CTPublisher.sol lines 177-190)
138
152
  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)
153
+ 3. A `JB721TierConfig` is constructed with the post's values (lines 553-570)
154
+ 4. The tier ID is computed as `startingTierId + numberOfTiersBeingAdded++` (line 573)
155
+ 5. The mapping `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set (line 576)
156
+ 6. All new tiers are committed to the hook via `hook.adjustTiers()` after the loop (line 358)
143
157
 
144
158
  ### Existing Tier Path
145
159
 
146
160
  When a post's `encodedIPFSUri` already has a mapping to a live (non-removed) tier:
147
161
 
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)
162
+ 1. The tier ID is reused (line 496)
163
+ 2. The fee is calculated from `store.tierOf().price` -- the on-chain price, not `post.price` (line 501)
150
164
  3. No new `JB721TierConfig` is added to `tiersToAdd`
151
165
  4. The poster still gets a mint of the existing tier
152
166
 
153
167
  ### Stale Tier Cleanup
154
168
 
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.
169
+ If a tier was removed externally via `adjustTiers()`, the publisher detects this via `hook.STORE().isTierRemoved()` (line 493) and deletes the stale mapping (line 494), allowing the URI to be posted as a new tier.
156
170
 
157
171
  ---
158
172
 
@@ -168,8 +182,8 @@ Bits 168-199 ( 32 bits): maximumSplitPercent(uint32)
168
182
  Bits 200-255 ( 56 bits): unused
169
183
  ```
170
184
 
171
- Packing logic: CTPublisher.sol lines 271-281
172
- Unpacking logic: CTPublisher.sol lines 174-187
185
+ Packing logic: CTPublisher.sol lines 274-282
186
+ Unpacking logic: CTPublisher.sol lines 177-190
173
187
 
174
188
  The address allowlist is stored separately in `_allowedAddresses[hook][category]` (a dynamic array).
175
189
 
@@ -179,18 +193,18 @@ The address allowlist is stored separately in `_allowedAddresses[hook][category]
179
193
 
180
194
  ### Configuration
181
195
 
182
- `configurePostingCriteriaFor()` (line 240) accepts an array of `CTAllowedPost` structs. For each:
196
+ `configurePostingCriteriaFor()` (line 243) accepts an array of `CTAllowedPost` structs. For each:
183
197
 
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)
198
+ 1. Emits `ConfigurePostingCriteria` event (line 252)
199
+ 2. Checks `ADJUST_721_TIERS` permission from `JBOwnable(hook).owner()` (lines 256-260)
200
+ 3. Validates `minimumTotalSupply > 0` (line 263)
201
+ 4. Validates `minimumTotalSupply <= maximumTotalSupply` (line 268)
202
+ 5. Packs numeric fields into `_packedAllowanceFor` (lines 274-284)
203
+ 6. Replaces the entire `_allowedAddresses` array (delete + push loop, lines 289-296)
190
204
 
191
205
  ### Enforcement
192
206
 
193
- In `_setupPosts()` at line 537:
207
+ In `_setupPosts()` at line 547:
194
208
 
195
209
  ```solidity
196
210
  if (addresses.length != 0 && !_isAllowed({addrs: _msgSender(), addresses: addresses})) {
@@ -198,7 +212,7 @@ if (addresses.length != 0 && !_isAllowed({addrs: _msgSender(), addresses: addres
198
212
  }
199
213
  ```
200
214
 
201
- `_isAllowed()` (lines 207-217) is a linear scan: O(n) where n = allowlist size.
215
+ `_isAllowed()` (lines 210-220) is a linear scan: O(n) where n = allowlist size.
202
216
 
203
217
  ### Key Behavior
204
218
 
@@ -213,7 +227,7 @@ if (addresses.length != 0 && !_isAllowed({addrs: _msgSender(), addresses: addres
213
227
 
214
228
  ### Architecture
215
229
 
216
- CTDeployer registers itself as the `dataHook` for every project it deploys (CTDeployer.sol line 286). It implements `IJBRulesetDataHook` and proxies calls:
230
+ CTDeployer registers itself as the `dataHook` for every project it deploys (CTDeployer.sol line 289). It implements `IJBRulesetDataHook` and proxies calls:
217
231
 
218
232
  - **`beforePayRecordedWith(context)`** (line 160): Forwards directly to `dataHookOf[context.projectId]` (the JB721TiersHook).
219
233
  - **`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.
@@ -225,7 +239,7 @@ CTDeployer registers itself as the `dataHook` for every project it deploys (CTDe
225
239
 
226
240
  - All `pay()` calls to the project will revert (line 168)
227
241
  - 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
242
+ - Since `dataHookOf` is write-once (set at line 306, no setter), the project is permanently bricked
229
243
 
230
244
  **Scenarios that could trigger this:**
231
245
 
@@ -280,7 +294,7 @@ If an attacker can make `SUCKER_REGISTRY.isSuckerOf()` return `true` for their a
280
294
 
281
295
  ### Current Implementation
282
296
 
283
- `_isAllowed()` at CTPublisher.sol lines 207-217:
297
+ `_isAllowed()` at CTPublisher.sol lines 210-220:
284
298
 
285
299
  ```solidity
286
300
  function _isAllowed(address addrs, address[] memory addresses) internal pure returns (bool) {
@@ -303,7 +317,7 @@ function _isAllowed(address addrs, address[] memory addresses) internal pure ret
303
317
 
304
318
  ### Storage Scaling
305
319
 
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.
320
+ The `_allowedAddresses` array is also written during `configurePostingCriteriaFor()` via a push loop (lines 293-295). For large allowlists, the configuration transaction gas cost could also become prohibitive.
307
321
 
308
322
  ### Recommendation for Auditors
309
323
 
@@ -315,13 +329,13 @@ Check that no realistic usage pattern could cause a revert due to gas limits. Th
315
329
 
316
330
  ### P0 -- Critical (fund loss or permanent DoS)
317
331
 
318
- 1. **Fee accounting correctness in `_setupPosts()`** (CTPublisher.sol lines 432-579). Verify:
332
+ 1. **Fee accounting correctness in `_setupPosts()`** (CTPublisher.sol lines 442-589). Verify:
319
333
  - `totalPrice` is always computed from on-chain tier prices for existing tiers (not user-supplied `post.price`)
320
334
  - `totalPrice` is always computed from `post.price` for new tiers
321
335
  - 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
336
+ - The duplicate URI check (lines 478-482) covers all batch orderings
323
337
 
324
- 2. **Fee deduction and routing** (CTPublisher.sol lines 333-418). Verify:
338
+ 2. **Fee deduction and routing** (CTPublisher.sol lines 336-428). Verify:
325
339
  - `payValue = msg.value - (totalPrice / FEE_DIVISOR)` cannot underflow
326
340
  - The check `totalPrice > payValue` correctly prevents underpayment
327
341
  - `address(this).balance` after the project payment equals exactly the fee amount (plus any force-sent ETH)
@@ -338,7 +352,7 @@ Check that no realistic usage pattern could cause a revert due to gas limits. Th
338
352
  - Only legitimate suckers can trigger the zero-tax path
339
353
  - The `hasMintPermissionFor()` function cannot be abused for unauthorized minting
340
354
 
341
- 5. **Permission enforcement in `configurePostingCriteriaFor()`** (CTPublisher.sol lines 253-257). Verify:
355
+ 5. **Permission enforcement in `configurePostingCriteriaFor()`** (CTPublisher.sol lines 256-260). Verify:
342
356
  - The permission check uses `JBOwnable(hook).owner()` and `IJB721TiersHook(hook).PROJECT_ID()` correctly
343
357
  - No one besides the hook owner (or permissioned delegate) can modify posting criteria
344
358
 
@@ -351,19 +365,19 @@ Check that no realistic usage pattern could cause a revert due to gas limits. Th
351
365
 
352
366
  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
367
 
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.
368
+ 8. **Bit-packing correctness** in `_packedAllowanceFor` storage (CTPublisher.sol lines 274-282 write, lines 177-190 read). Verify no field overlap or silent truncation.
355
369
 
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.
370
+ 9. **Assembly metadata injection** (CTPublisher.sol lines 375-377). Verify the `mstore` correctly places `FEE_PROJECT_ID` in the referral position without corrupting the JBMetadataResolver lookup table.
357
371
 
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.
372
+ 10. **Project deployment front-running** (CTDeployer.sol lines 261, 294-303). Verify the `assert(projectId == ...)` check correctly prevents ID mismatch without permanent fund loss.
359
373
 
360
374
  ### P3 -- Low (informational, code quality)
361
375
 
362
- 11. **`uint56` vs `uint64` cast inconsistency** between CTProjectOwner (line 74) and CTDeployer (line 338). Confirm no truncation risk for realistic project IDs.
376
+ 11. **`uint64` project ID cast** in CTProjectOwner (line 77) and CTDeployer (line 344). Both now use `uint64`. Confirm no truncation risk for realistic project IDs.
363
377
 
364
- 12. **Force-sent ETH routing** (CTPublisher.sol lines 403-418). Confirm this is the intended behavior and cannot be exploited.
378
+ 12. **Force-sent ETH routing** (CTPublisher.sol lines 413-428). Confirm this is the intended behavior and cannot be exploited.
365
379
 
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.
380
+ 13. **Allowlist overwrite behavior** (CTPublisher.sol lines 289-296). Verify that `delete` followed by `push` loop correctly replaces the array with no residual state.
367
381
 
368
382
  ---
369
383
 
@@ -418,9 +432,14 @@ ETHEREUM_RPC_URL=<your-rpc> forge test --match-contract ForkTest --fork-url $ETH
418
432
 
419
433
  | Test File | Focus | Tests |
420
434
  |-----------|-------|-------|
421
- | `test/CTPublisher.t.sol` | Allowance round-trip, bit packing fuzz, permission checks, split validation | 18 tests including fuzz |
435
+ | `test/CTPublisher.t.sol` | Allowance round-trip, bit packing fuzz, permission checks, split validation | 26 tests including fuzz |
436
+ | `test/CTDeployer.t.sol` | Deploy flow, data hook proxy, sucker permissions, onERC721Received, supportsInterface | 21 tests |
437
+ | `test/CTProjectOwner.t.sol` | Permission grants on NFT receipt, rejection of non-project NFTs, rejection of transfers | 7 tests |
438
+ | `test/ClaimCollectionOwnership.t.sol` | NM-002 scenario: ownership transfer, permission breakage, recovery path | 6 tests |
439
+ | `test/TestAuditGaps.sol` | Data hook proxy forwarding failure, sucker impersonation, allowlist gas scaling, force-sent ETH | 19 tests |
422
440
  | `test/CroptopAttacks.t.sol` | Adversarial input validation, allowlist bypass, split percent enforcement | 12 tests |
423
441
  | `test/Fork.t.sol` | Full deployment integration with real JB infrastructure | 2 fork tests |
442
+ | `test/fork/PublishFork.t.sol` | End-to-end mint flow, fee distribution, duplicate post reuse on mainnet fork | 4 fork tests |
424
443
  | `test/Test_MetadataGeneration.t.sol` | Metadata assembly correctness | 1 test |
425
444
  | `test/regression/DuplicateUriFeeEvasion.t.sol` | NM-001 fix: duplicate URI detection | 5 tests including fuzz |
426
445
  | `test/regression/FeeEvasion.t.sol` | H-19 fix: existing tier price used for fees | 2 tests |
@@ -428,11 +447,7 @@ ETHEREUM_RPC_URL=<your-rpc> forge test --match-contract ForkTest --fork-url $ETH
428
447
 
429
448
  ### Coverage Gaps (no existing tests)
430
449
 
431
- - Data hook proxy forwarding failure (CTDeployer -> hook reverts)
432
450
  - 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
451
  - `deployProjectFor` front-running race condition
437
452
  - Multiple hooks sharing the same CTPublisher instance
438
453
  - Cross-category posting in a single batch (different categories, different allowlists)
@@ -447,12 +462,45 @@ Tests use Foundry's `vm.mockCall()` to isolate CTPublisher from its dependencies
447
462
 
448
463
  ## 12. Previous Audit Findings
449
464
 
450
- Three findings were fixed and have regression tests. Two findings remain open (low severity). See `RISKS.md` for full details.
465
+ Six Nemesis findings plus two regression-test findings. See `.audit/findings/nemesis-verified.md` for full Nemesis details and `RISKS.md` for context.
451
466
 
452
467
  | ID | Severity | Status | Description |
453
468
  |----|----------|--------|-------------|
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 |
469
+ | NM-001 | MEDIUM | FALSE POSITIVE | `dataHookOf` write-once = permanent project bricking -- project owner can queue new ruleset to escape (`useDataHookForPay = false`) |
470
+ | NM-002 | MEDIUM | OPEN | `claimCollectionOwnershipOf` breaks all `CTPublisher.mintFrom` calls -- hook ownership transfer does not update CTPublisher permissions |
471
+ | NM-003 | LOW | OPEN | Permission grants to initial owner stale after project NFT transfer -- old owner retains 4 permissions |
472
+ | NM-004 | LOW | OPEN | Stale `tierIdForEncodedIPFSUriOf` in `tiersFor()` view -- removed tiers still returned to off-chain consumers |
473
+ | NM-005 | LOW | FIXED | Fee underflow gives generic panic (`0x11`) instead of custom `CTPublisher_InsufficientEthSent` error -- the `if (payValue < fee)` check now guards the subtraction |
458
474
  | NM-006 | LOW | OPEN | Cannot fully disable posting for a configured category |
475
+ | H-19 | HIGH | FIXED | Fee evasion on existing tier mints via `post.price = 0` *(regression test naming, not from Nemesis audit)* |
476
+ | L-52 | LOW | FIXED | Stale tier ID mapping after external tier removal *(regression test naming, not from Nemesis audit)* |
477
+
478
+ ---
479
+
480
+ ## Compiler and Version Info
481
+
482
+ - **Solidity**: ^0.8.26
483
+ - **EVM target**: Cancun
484
+ - **Optimizer**: 200 runs
485
+ - **Dependencies**: OpenZeppelin 5.x, nana-core-v6, nana-721-hook-v6, nana-suckers-v6
486
+ - **Build**: `forge build` (Foundry)
487
+
488
+ ---
489
+
490
+ ## How to Report Findings
491
+
492
+ For each finding:
493
+
494
+ 1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
495
+ 2. **Affected contract(s)** -- exact file path and line numbers
496
+ 3. **Description** -- what is wrong, in plain language
497
+ 4. **Trigger sequence** -- step-by-step
498
+ 5. **Impact** -- what an attacker gains, what the project/fee project loses
499
+ 6. **Proof** -- code trace or Foundry test
500
+ 7. **Fix** -- minimal code change
501
+
502
+ **Severity guide:**
503
+ - **CRITICAL**: Fee evasion at scale, unauthorized treasury drain, permanent project DoS.
504
+ - **HIGH**: Conditional fee bypass, sucker impersonation, broken posting criteria.
505
+ - **MEDIUM**: Tier spam, gas griefing, rounding errors in fee calculation.
506
+ - **LOW**: Informational, cosmetic, testnet-only.
package/CHANGE_LOG.md CHANGED
@@ -2,12 +2,20 @@
2
2
 
3
3
  This document describes all changes between `croptop-core` (v5) and `croptop-core-v6` (v6).
4
4
 
5
+ ## Summary
6
+
7
+ - **Data hook proxy activated**: `CTDeployer` now sets itself as the data hook (`metadata.dataHook = address(this)`) instead of pointing directly to the 721 hook — enables sucker cashouts at 0% tax rate for cross-chain operations.
8
+ - **Split support for posts**: `CTPost` gained `splitPercent` and `splits` fields, allowing poster-defined payment routing per NFT tier (bounded by `maximumSplitPercent`).
9
+ - **Fee evasion fixes**: Existing tier mints now use on-chain price (not user-supplied), and duplicate posts within a batch are rejected.
10
+ - **Stale tier recovery**: Externally-removed tiers are detected and re-created instead of silently failing.
11
+ - **`projectId` cast widened**: `uint56` → `uint64` to match v6 `JBPermissionsData`.
12
+
5
13
  ---
6
14
 
7
15
  ## 1. Breaking Changes
8
16
 
9
17
  ### Solidity Version
10
- - Compiler version bumped from `0.8.23` to `0.8.26` across all implementation contracts (`CTDeployer`, `CTProjectOwner`, `CTPublisher`).
18
+ - Compiler version bumped from `0.8.23` to `^0.8.26` across all implementation contracts (`CTDeployer`, `CTProjectOwner`, `CTPublisher`).
11
19
 
12
20
  ### Dependency Namespace Migration
13
21
  All imports updated from v5 to v6 namespaces:
@@ -51,6 +59,8 @@ All imports updated from v5 to v6 namespaces:
51
59
  - **v6:** Sets `metadata.dataHook = address(this)` (the CTDeployer itself is the data hook). Sets `metadata.cashOutTaxRate = JBConstants.MAX_CASH_OUT_TAX_RATE` and `metadata.useDataHookForCashOut = true`.
52
60
  - The CTDeployer now acts as a data hook proxy, forwarding pay/cashout calls to the stored `dataHookOf[projectId]`, while intercepting sucker cash outs to grant 0% tax rate. This is a fundamental architectural change.
53
61
 
62
+ > **Why this change**: In v5, the CTDeployer already had the proxy methods (`beforePayRecordedWith`, `beforeCashOutRecordedWith`, `hasMintPermissionFor`) and the `dataHookOf` mapping, but `deployProjectFor` pointed `metadata.dataHook` directly at the 721 hook, bypassing the proxy entirely. v6 activates the proxy so the deployer can intercept sucker cashouts (verified via `SUCKER_REGISTRY.isSuckerOf`) and return a 0% tax rate for cross-chain operations. Without this, cross-chain token bridging via suckers would incur the full `MAX_CASH_OUT_TAX_RATE`, making omnichain projects economically unviable.
63
+
54
64
  ### `JB721InitTiersConfig` — `prices` Field Removed
55
65
  - **v5:** `JB721InitTiersConfig({ tiers, currency, decimals, prices: controller.PRICES() })`
56
66
  - **v6:** `JB721InitTiersConfig({ tiers, currency, decimals })` — the `prices` field no longer exists in the v6 721 hook config struct.
@@ -249,5 +259,7 @@ No field changes. Import path updated from `@bananapus/suckers-v5` to `@bananapu
249
259
  | `JB721TiersHookFlags`: 4 flags | `JB721TiersHookFlags`: 5 flags | Added `issueTokensForSplits` |
250
260
  | -- | `CTPublisher_DuplicatePost` | New error |
251
261
  | -- | `CTPublisher_SplitPercentExceedsMaximum` | New error |
252
- | Solidity `0.8.23` | Solidity `0.8.26` | Compiler bump |
262
+ | Solidity `0.8.23` | Solidity `^0.8.26` | Compiler bump |
253
263
  | `@bananapus/*-v5` | `@bananapus/*-v6` | All dependency namespaces |
264
+
265
+ > **Cross-repo impact**: The `CTPost.splitPercent` and `splits` fields feed directly into `nana-721-hook-v6`'s tier splits system. `nana-suckers-v6` suckers are detected via `SUCKER_REGISTRY.isSuckerOf` for the 0% tax cashout path. `nana-permission-ids-v6` `uint64` projectId width change drove the `CTProjectOwner` cast update.
package/README.md CHANGED
@@ -4,6 +4,8 @@ Permissioned NFT publishing for Juicebox projects -- anyone can post content as
4
4
 
5
5
  [Docs](https://docs.juicebox.money) | [Discord](https://discord.gg/juicebox) | [Croptop](https://croptop.eth.limo)
6
6
 
7
+ **Supported Chains:** Ethereum, Optimism, Base, Arbitrum (mainnets) and Ethereum Sepolia, Optimism Sepolia, Base Sepolia, Arbitrum Sepolia (testnets). See `script/Deploy.s.sol` for the Sphinx deployment configuration.
8
+
7
9
  ## Conceptual Overview
8
10
 
9
11
  Croptop turns any Juicebox project with a 721 tiers hook into a permissioned content marketplace. Project owners define posting criteria -- minimum price, supply bounds, address allowlists -- and anyone who meets those criteria can publish new NFT tiers on the project. The poster's content becomes a mintable NFT tier, and the first copy is minted to them automatically.
@@ -29,6 +31,33 @@ Croptop turns any Juicebox project with a 721 tiers hook into a permissioned con
29
31
  → Standard 721 tier minting via the project's hook
30
32
  ```
31
33
 
34
+ ```mermaid
35
+ sequenceDiagram
36
+ participant Poster
37
+ participant CTPublisher
38
+ participant Hook as 721 Tiers Hook
39
+ participant Terminal as Project Terminal
40
+ participant FeeTerminal as Fee Project Terminal
41
+
42
+ Poster->>CTPublisher: mintFrom(hook, posts, ...)
43
+ CTPublisher->>CTPublisher: Validate each post against category criteria
44
+ loop For each post
45
+ CTPublisher->>Hook: adjustTiersOf() -- create new tier (or reuse existing)
46
+ end
47
+ CTPublisher->>Terminal: pay() -- total price minus fee
48
+ Note over Terminal: Mints first copy of each tier to poster
49
+ CTPublisher->>FeeTerminal: pay() -- 5% fee (totalPrice / 20)
50
+ Note over FeeTerminal: Routes fee to designated fee project
51
+ ```
52
+
53
+ ### Fee Structure
54
+
55
+ Every `mintFrom` call collects a 5% fee on the total tier price. The fee is calculated as `totalPrice / FEE_DIVISOR` where `FEE_DIVISOR = 20`. The fee is paid to the primary ETH terminal of a designated fee project (`FEE_PROJECT_ID`, set at deployment). The remainder goes to the target project's primary terminal as a normal payment.
56
+
57
+ - If the project being posted to **is** the fee project, no fee is collected (avoids circular payments).
58
+ - Integer division truncates, so the fee loses up to 19 wei of dust per mint.
59
+ - Any ETH remaining in the contract after the main payment (including force-sent ETH) is forwarded to the fee project terminal.
60
+
32
61
  ### One-Click Deployment
33
62
 
34
63
  `CTDeployer` creates a complete Juicebox project + 721 hook + posting criteria in a single transaction. It also:
@@ -83,16 +112,16 @@ forge install
83
112
  | Command | Description |
84
113
  |---------|-------------|
85
114
  | `forge build` | Compile contracts |
86
- | `forge test` | Run all tests (4 test files covering publishing, attacks, fork integration, metadata) |
115
+ | `forge test` | Run all tests (12 test files covering publishing, deployer, attacks, fork integration, metadata, and regressions) |
87
116
  | `forge test -vvv` | Run tests with full trace |
88
117
 
89
118
  ## Repository Layout
90
119
 
91
120
  ```
92
121
  src/
93
- CTPublisher.sol # Core publishing engine (~540 lines)
94
- CTDeployer.sol # Project factory + data hook proxy (~425 lines)
95
- CTProjectOwner.sol # Burn-lock ownership helper (~79 lines)
122
+ CTPublisher.sol # Core publishing engine (~590 lines)
123
+ CTDeployer.sol # Project factory + data hook proxy (~433 lines)
124
+ CTProjectOwner.sol # Burn-lock ownership helper (~84 lines)
96
125
  interfaces/
97
126
  ICTPublisher.sol # Publisher interface + events
98
127
  ICTDeployer.sol # Factory interface
@@ -104,10 +133,20 @@ src/
104
133
  CTProjectConfig.sol # Full project deployment config
105
134
  CTSuckerDeploymentConfig.sol # Cross-chain sucker config
106
135
  test/
107
- CTPublisher.t.sol # Unit tests (~672 lines, ~22 cases)
108
- CroptopAttacks.t.sol # Security/adversarial tests (~440 lines, ~12 cases)
136
+ CTPublisher.t.sol # Unit tests (~865 lines, ~26 cases)
137
+ CTDeployer.t.sol # Deployer tests (~608 lines)
138
+ CTProjectOwner.t.sol # Project owner tests (~185 lines)
139
+ ClaimCollectionOwnership.t.sol # Collection ownership claim tests (~315 lines)
140
+ CroptopAttacks.t.sol # Security/adversarial tests (~437 lines, ~12 cases)
109
141
  Fork.t.sol # Mainnet fork integration tests
142
+ TestAuditGaps.sol # Audit gap coverage tests (~689 lines)
110
143
  Test_MetadataGeneration.t.sol # JBMetadataResolver roundtrip tests
144
+ fork/
145
+ PublishFork.t.sol # Fork-based publish tests (~437 lines)
146
+ regression/
147
+ DuplicateUriFeeEvasion.t.sol # Duplicate URI fee evasion regression (~312 lines)
148
+ FeeEvasion.t.sol # Fee evasion regression (~279 lines)
149
+ StaleTierIdMapping.t.sol # Stale tier ID mapping regression (~214 lines)
111
150
  script/
112
151
  Deploy.s.sol # Sphinx multi-chain deployment
113
152
  ConfigureFeeProject.s.sol # Fee project configuration
package/RISKS.md CHANGED
@@ -7,12 +7,13 @@
7
7
  - **Sucker registry.** `CTDeployer.beforeCashOutRecordedWith` trusts `SUCKER_REGISTRY.isSuckerOf()` for 0% tax cashouts, same risk as the omnichain deployer.
8
8
  - **CTProjectOwner as burn target.** Projects transferred to `CTProjectOwner` grant `ADJUST_721_TIERS` to `PUBLISHER`. The project NFT cannot be recovered -- this is intentional but irreversible.
9
9
  - **JBDirectory / Terminal resolution.** `CTPublisher.mintFrom` resolves terminals via `DIRECTORY.primaryTerminalOf()`. A compromised directory could redirect payment and fee flows.
10
+ - **721 hook store.** `_setupPosts` calls `hook.STORE().tierOf()` and `hook.STORE().isTierRemoved()`. The store is trusted to return accurate tier data. A malicious hook returning a fake store can report manipulated prices, supply limits, and removal status, causing `_setupPosts` to miscalculate `totalPrice` or skip duplicate detection.
10
11
 
11
12
  ## 2. Economic / Manipulation Risks
12
13
 
13
14
  - **Fee evasion via duplicate posts across hooks.** `tierIdForEncodedIPFSUriOf` is keyed per hook. The same `encodedIPFSUri` can be posted to different hooks without duplicate detection, potentially creating fee-arbitrage opportunities.
14
15
  - **Fee calculation rounding.** Fee is `totalPrice / FEE_DIVISOR` (FEE_DIVISOR=20, so 5% fee). Integer division truncates, losing up to 19 wei per post. Negligible individually but could compound across many micro-priced posts. Explicit validation: reverts `CTPublisher_InsufficientEthSent` if `msg.value < fee` (before subtraction) or if `msg.value - fee < totalPrice` (after subtraction).
15
- - **Balance-based fee routing.** `CTPublisher.mintFrom` sends fees based on `address(this).balance` after the main payment. Force-sent ETH (via selfdestruct) is routed to the fee project.
16
+ - **Pre-computed fee routing.** `CTPublisher.mintFrom` computes the fee as `msg.value - payValue` before the external payment call, so the fee amount is determined from `msg.value` alone. Force-sent ETH (via selfdestruct) does not affect fee calculation.
16
17
  - **Split percent manipulation.** Posters can set `splitPercent` up to `maximumSplitPercent`. Splits route funds away from the project treasury to poster-specified addresses. If `maximumSplitPercent` is set high, posters can redirect most of the tier revenue.
17
18
 
18
19
  ## 3. Access Control
@@ -30,15 +31,31 @@
30
31
  - **Terminal resolution failure.** If `DIRECTORY.primaryTerminalOf()` returns `address(0)` for the project or fee project, the `pay()` call will revert with a low-level error.
31
32
  - **adjustTiers revert.** `hook.adjustTiers()` can revert if tiers violate category ordering constraints or other hook-level rules. This blocks the entire `mintFrom` call.
32
33
 
33
- ## 5. Integration Risks
34
+ ## 5. Reentrancy Surface
34
35
 
35
- - **CTDeployer forwards all pay/cashout calls to `dataHookOf`.** `beforePayRecordedWith` and `beforeCashOutRecordedWith` delegate to the stored data hook without try-catch. If the data hook reverts, all payments/cashouts for the project are blocked.
36
+ - **`mintFrom` external call chain.** `mintFrom` makes three categories of external calls: (1) `hook.adjustTiers()` to create new tiers, (2) `terminal.pay{value}()` to pay the project, (3) `terminal.pay{value}()` to pay the fee project. The first `terminal.pay` can trigger pay hooks on the target project, which could call back into `CTPublisher`. However, `mintFrom` has no mutable state between the tier adjustment and the payment — `totalPrice` and `payValue` are computed from local variables before the external calls. A re-entrant `mintFrom` call would process independently.
37
+ - **Fee payment ordering.** The fee is sent AFTER the main payment (line ordering in `mintFrom`). If the main payment's pay hook re-enters and calls `mintFrom` again, the fee for the first call has not yet been sent. This is safe because the fee is pre-computed from `msg.value` before the external call (`msg.value - payValue`), and each call independently computes its own fee from its own `msg.value`. Force-sent ETH (via selfdestruct) does not affect fee calculation since the fee is derived from `msg.value`, not `address(this).balance`.
38
+ - **No `ReentrancyGuard`.** The publisher relies on independent local state per call. This is safe for the current implementation but fragile if mutable contract storage is added in future versions.
39
+
40
+ ## 6. Integration Risks
41
+
42
+ - **CTDeployer forwards pay/cashout calls to `dataHookOf` with null check.** `beforePayRecordedWith` and `beforeCashOutRecordedWith` check for a null `dataHookOf` and return defaults (context weight, empty specs) instead of reverting. If a non-null data hook reverts, payments/cashouts for the project are still blocked.
36
43
  - **No mechanism for hook migration.** `dataHookOf` is written once in `deployProjectFor` and never updated. If the data hook becomes compromised, there is no governance path to replace it without deploying a new project.
37
44
  - **Tier ID prediction.** `_setupPosts` predicts new tier IDs as `maxTierIdOf(hook) + 1 + i`. If another transaction adds tiers between `maxTierIdOf` read and `adjustTiers` execution, tier IDs shift and the wrong tiers are minted. This is a race condition in concurrent posting.
38
45
  - **CTProjectOwner accepts any project NFT.** `onERC721Received` grants `ADJUST_721_TIERS` to `PUBLISHER` for whatever tokenId is received. If a non-Croptop project is accidentally transferred to `CTProjectOwner`, the publisher gains tier adjustment permission for it.
39
46
  - **Fee payment destination.** Fees are routed to `FEE_PROJECT_ID` via its primary terminal. If the fee project changes its terminal or token acceptance, fee payments could fail and block all minting.
40
47
 
41
- ## 6. Invariants to Verify
48
+ ## 7. Accepted Behaviors
49
+
50
+ ### 7.1 O(n^2) duplicate detection in `_setupPosts` (bounded by practical limits)
51
+
52
+ `_setupPosts` uses an inner loop (`j < i`) to detect duplicate `encodedIPFSUri` values within a single batch. This is O(n^2) in the number of posts. For typical batch sizes (1-20 posts), gas cost is negligible (~2k gas per comparison). At 100 posts, the quadratic cost adds ~10M gas. The practical limit is ~150 posts per batch before approaching block gas limits. No mitigation is needed because: (1) the quadratic detection prevents duplicate NFT tiers which would corrupt tier ID tracking, (2) real-world posting batches are small (marketplace UX limits), and (3) the gas cost is borne by the poster, not the protocol.
53
+
54
+ ### 7.2 Tier ID prediction assumes no concurrent transactions
55
+
56
+ `_setupPosts` predicts new tier IDs as `maxTierIdOf(hook) + 1 + i`. A concurrent `adjustTiers` call between the `maxTierIdOf` read and the `adjustTiers` execution shifts all predicted IDs, causing the wrong tiers to be minted. This is a known race condition. Mitigation is at the application layer: frontends should use nonce-based transaction ordering or warn users about concurrent posting. The hook-level `adjustTiers` is atomic (all-or-nothing), so a failed prediction reverts the entire batch cleanly.
57
+
58
+ ## 8. Invariants to Verify
42
59
 
43
60
  - `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set exactly once per (hook, encodedIPFSUri) pair and points to a valid, non-removed tier.
44
61
  - `totalPrice` accumulated in `_setupPosts` equals the sum of prices for all posts (new tier price for new posts, existing tier price for existing posts).