@croptop/core-v6 0.0.20 → 0.0.22
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.
- package/ADMINISTRATION.md +41 -27
- package/ARCHITECTURE.md +141 -28
- package/AUDIT_INSTRUCTIONS.md +118 -70
- package/CHANGE_LOG.md +14 -2
- package/README.md +45 -6
- package/RISKS.md +21 -4
- package/SKILLS.md +23 -11
- package/STYLE_GUIDE.md +2 -2
- package/USER_JOURNEYS.md +246 -132
- package/foundry.toml +1 -1
- package/package.json +9 -8
- package/script/ConfigureFeeProject.s.sol +1 -1
- package/script/Deploy.s.sol +1 -1
- package/script/helpers/CroptopDeploymentLib.sol +1 -1
- package/src/CTDeployer.sol +14 -4
- package/src/CTProjectOwner.sol +1 -1
- package/src/CTPublisher.sol +11 -10
- package/src/interfaces/ICTDeployer.sol +2 -2
- package/src/interfaces/ICTPublisher.sol +1 -1
- package/test/CTDeployer.t.sol +15 -8
- package/test/CTProjectOwner.t.sol +1 -1
- package/test/CTPublisher.t.sol +1 -1
- package/test/ClaimCollectionOwnership.t.sol +1 -1
- package/test/CroptopAttacks.t.sol +1 -1
- package/test/TestAuditGaps.sol +16 -10
- package/test/audit/CodexFeeBeneficiaryReentrancy.t.sol +243 -0
- package/test/regression/DuplicateUriFeeEvasion.t.sol +1 -1
- package/test/regression/FeeEvasion.t.sol +1 -1
- package/test/regression/StaleTierIdMapping.t.sol +1 -1
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -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.
|
|
5
|
+
Compiler: `solc 0.8.28`. 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` | ~
|
|
18
|
-
| **CTDeployer** | `src/CTDeployer.sol` | ~
|
|
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
|
|
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
|
|
75
|
-
2. _setupPosts(hook, posts) returns: [line
|
|
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
|
|
80
|
-
b. O(i) duplicate check against all prior posts in batch [line
|
|
81
|
-
c. Look up tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri] [line
|
|
82
|
-
- If tier exists and NOT removed: reuse tier ID, [line
|
|
83
|
-
accumulate store.tierOf().price (on-chain price) [line
|
|
84
|
-
- If tier exists but removed: delete stale mapping, [line
|
|
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
|
|
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
|
|
94
|
-
d. Store tierIdForEncodedIPFSUriOf mapping for new tiers [line
|
|
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
|
|
110
|
+
Assembly-resize tiersToAdd if some posts reused existing tiers [line 584-588]
|
|
97
111
|
|
|
98
|
-
3. If projectId != FEE_PROJECT_ID: [line
|
|
99
|
-
payValue = msg.value - (totalPrice / FEE_DIVISOR) [line
|
|
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
|
|
116
|
+
4. Revert if totalPrice > payValue [line 352-354]
|
|
103
117
|
|
|
104
|
-
5. hook.adjustTiers(tiersToAdd, []) [line
|
|
118
|
+
5. hook.adjustTiers(tiersToAdd, []) [line 358]
|
|
105
119
|
|
|
106
|
-
6. Build mint metadata: [line
|
|
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
|
|
124
|
+
7. Emit Mint event [line 380-389]
|
|
111
125
|
|
|
112
|
-
8. Look up project's primary ETH terminal via DIRECTORY [line
|
|
113
|
-
terminal.pay{value: payValue}(...) [line
|
|
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
|
|
116
|
-
Look up fee project's primary ETH terminal [line
|
|
117
|
-
feeTerminal.pay{value: address(this).balance}(...) [line
|
|
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
|
|
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
|
|
140
|
-
4. The tier ID is computed as `startingTierId + numberOfTiersBeingAdded++` (line
|
|
141
|
-
5. The mapping `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set (line
|
|
142
|
-
6. All new tiers are committed to the hook via `hook.adjustTiers()` after the loop (line
|
|
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
|
|
149
|
-
2. The fee is calculated from `store.tierOf().price` -- the on-chain price, not `post.price` (line
|
|
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
|
|
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
|
|
172
|
-
Unpacking logic: CTPublisher.sol lines
|
|
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
|
|
196
|
+
`configurePostingCriteriaFor()` (line 243) accepts an array of `CTAllowedPost` structs. For each:
|
|
183
197
|
|
|
184
|
-
1. Emits `ConfigurePostingCriteria` event (line
|
|
185
|
-
2. Checks `ADJUST_721_TIERS` permission from `JBOwnable(hook).owner()` (lines
|
|
186
|
-
3. Validates `minimumTotalSupply > 0` (line
|
|
187
|
-
4. Validates `minimumTotalSupply <= maximumTotalSupply` (line
|
|
188
|
-
5. Packs numeric fields into `_packedAllowanceFor` (lines
|
|
189
|
-
6. Replaces the entire `_allowedAddresses` array (delete + push loop, lines
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
336
|
+
- The duplicate URI check (lines 478-482) covers all batch orderings
|
|
323
337
|
|
|
324
|
-
2. **Fee deduction and routing** (CTPublisher.sol lines
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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. **`
|
|
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
|
|
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
|
|
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 |
|
|
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
|
-
|
|
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 |
|
|
455
|
-
|
|
|
456
|
-
|
|
|
457
|
-
| NM-
|
|
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.28
|
|
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.
|
|
18
|
+
- Compiler version bumped from `0.8.23` to `0.8.28` 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.
|
|
262
|
+
| Solidity `0.8.23` | Solidity `0.8.28` | 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 (
|
|
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 (~
|
|
94
|
-
CTDeployer.sol # Project factory + data hook proxy (~
|
|
95
|
-
CTProjectOwner.sol # Burn-lock ownership helper (~
|
|
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 (~
|
|
108
|
-
|
|
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
|
-
- **
|
|
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.
|
|
34
|
+
## 5. Reentrancy Surface
|
|
34
35
|
|
|
35
|
-
-
|
|
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
|
-
##
|
|
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).
|