@croptop/core-v6 0.0.26 → 0.0.28
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 +28 -1
- package/ARCHITECTURE.md +48 -157
- package/AUDIT_INSTRUCTIONS.md +69 -485
- package/CHANGELOG.md +57 -0
- package/README.md +48 -137
- package/RISKS.md +18 -1
- package/SKILLS.md +28 -187
- package/STYLE_GUIDE.md +56 -17
- package/USER_JOURNEYS.md +37 -708
- package/package.json +3 -3
- package/references/operations.md +25 -0
- package/references/runtime.md +27 -0
- package/script/ConfigureFeeProject.s.sol +0 -1
- package/src/CTPublisher.sol +10 -7
- package/test/CTPublisher.t.sol +10 -7
- package/test/regression/FeeEvasion.t.sol +15 -10
- package/test/regression/StaleTierIdMapping.t.sol +8 -5
- package/CHANGE_LOG.md +0 -273
package/AUDIT_INSTRUCTIONS.md
CHANGED
|
@@ -1,508 +1,92 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Audit Instructions
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Croptop is a publishing layer on top of Juicebox projects and the 721 hook stack. Audit it as a permissions and fee-routing system, not just a content app.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Objective
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Find issues that:
|
|
8
|
+
- let publishers create or mint posts outside configured criteria
|
|
9
|
+
- let users evade Croptop fees or route them incorrectly
|
|
10
|
+
- grant fee-free or privileged cash-outs to the wrong actors
|
|
11
|
+
- create stale, duplicate, or abusive tier reuse across posts
|
|
12
|
+
- break ownership handoff or permanently lock a project in an unintended admin state
|
|
8
13
|
|
|
9
|
-
##
|
|
14
|
+
## Scope
|
|
10
15
|
|
|
11
|
-
|
|
16
|
+
In scope:
|
|
17
|
+
- `src/CTPublisher.sol`
|
|
18
|
+
- `src/CTDeployer.sol`
|
|
19
|
+
- `src/CTProjectOwner.sol`
|
|
20
|
+
- `src/interfaces/`
|
|
21
|
+
- `src/structs/`
|
|
22
|
+
- deployment scripts in `script/`
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
External integrations that matter:
|
|
25
|
+
- `nana-core-v6`
|
|
26
|
+
- `nana-721-hook-v6`
|
|
27
|
+
- `nana-ownable-v6`
|
|
28
|
+
- `nana-suckers-v6`
|
|
14
29
|
|
|
15
|
-
|
|
16
|
-
|----------|------|-------|------|
|
|
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
|
-
| **CTProjectOwner** | `src/CTProjectOwner.sol` | ~82 | Receives project ownership NFT via `safeTransferFrom` and permanently grants `CTPublisher` the `ADJUST_721_TIERS` permission. Implements `IERC721Receiver`. |
|
|
30
|
+
## System Model
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
Croptop has three roles:
|
|
33
|
+
- `CTPublisher`: validates post configuration, creates or adjusts tiers, mints the first copy, and routes fees
|
|
34
|
+
- `CTDeployer`: launches a project, wires hook ownership and post criteria, and acts as a data-hook proxy where required
|
|
35
|
+
- `CTProjectOwner`: ownership helper for projects that want Croptop-controlled administration
|
|
22
36
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
| `CTSuckerDeploymentConfig` | `src/structs/CTSuckerDeploymentConfig.sol` | `deployerConfigurations` (JBSuckerDeployerConfig[]), `salt` (bytes32) |
|
|
37
|
+
The system relies on project-specific posting criteria such as:
|
|
38
|
+
- minimum price
|
|
39
|
+
- supply bounds
|
|
40
|
+
- category restrictions
|
|
41
|
+
- split limits
|
|
42
|
+
- optional address allowlists
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
## Critical Invariants
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
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)
|
|
46
|
+
1. Post criteria are binding
|
|
47
|
+
No publish path should bypass configured minimum price, total supply bounds, split caps, or allowlist restrictions.
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
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)
|
|
49
|
+
2. Fee collection is complete
|
|
50
|
+
Each Croptop mint should either pay the configured fee or take the documented fallback path. Users must not be able to mint while underpaying Croptop.
|
|
55
51
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
├── IJBPermissions (permission grants)
|
|
59
|
-
└── IJBProjects (sender validation)
|
|
60
|
-
```
|
|
52
|
+
3. Tier reuse is safe
|
|
53
|
+
Existing tiers must not be reusable in a way that evades fees, stale criteria, or duplicate-content protections.
|
|
61
54
|
|
|
62
|
-
|
|
55
|
+
4. Sucker privileges stay narrow
|
|
56
|
+
Any cash-out tax exemptions or mint permissions intended for legitimate suckers must not be reachable by arbitrary callers or spoofed registry state.
|
|
63
57
|
|
|
64
|
-
|
|
58
|
+
5. Ownership transitions are intentional
|
|
59
|
+
Burn-lock or project-owner helper flows must not grant broader privileges than intended or accidentally strand project administration.
|
|
65
60
|
|
|
66
|
-
|
|
61
|
+
## Threat Model
|
|
67
62
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
| Fee routing failure | `CTPublisher.mintFrom()` pre-computed fee | Fee project loses 5% | Pre-computed fee (`msg.value - payValue`) sent via try-catch to fee terminal, with fallback to `feeBeneficiary` then `msg.sender` |
|
|
63
|
+
Prioritize:
|
|
64
|
+
- malicious publishers choosing edge-case prices, split structures, or reused metadata
|
|
65
|
+
- malicious project owners misconfiguring rules and then trying to escape them
|
|
66
|
+
- fake or stale sucker registrations
|
|
67
|
+
- fee-recipient failures that alter control flow
|
|
68
|
+
- reentrancy through fee routing or tier-adjustment side effects
|
|
75
69
|
|
|
76
|
-
|
|
70
|
+
## Hotspots
|
|
77
71
|
|
|
78
|
-
|
|
72
|
+
- `CTPublisher.mintFrom` and its validation pipeline
|
|
73
|
+
- any code path that computes fees from user-provided versus on-chain values
|
|
74
|
+
- tier creation or adjustment against prior post state
|
|
75
|
+
- `CTDeployer` data-hook behavior for pay and cash-out flows
|
|
76
|
+
- permission grants made during deployment or project-owner handoff
|
|
77
|
+
- any one-way lock or burn-based ownership design
|
|
79
78
|
|
|
80
|
-
|
|
79
|
+
## Build And Verification
|
|
81
80
|
|
|
82
|
-
|
|
81
|
+
Standard workflow:
|
|
82
|
+
- `npm install`
|
|
83
|
+
- `forge build`
|
|
84
|
+
- `forge test`
|
|
83
85
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
Current tests emphasize:
|
|
87
|
+
- fee evasion
|
|
88
|
+
- stale tier mappings
|
|
89
|
+
- reentrancy and attacker-controlled publish flows
|
|
90
|
+
- fork and omnichain composition
|
|
87
91
|
|
|
88
|
-
|
|
89
|
-
2. _setupPosts(hook, posts) returns: [line 333-334]
|
|
90
|
-
(tiersToAdd[], tierIdsToMint[], totalPrice)
|
|
91
|
-
|
|
92
|
-
For each post in the batch:
|
|
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]
|
|
99
|
-
fall through to new tier creation
|
|
100
|
-
- If no tier exists: validate against allowance: [line 510-549]
|
|
101
|
-
* Category must be configured (minimumTotalSupply > 0)
|
|
102
|
-
* price >= minimumPrice
|
|
103
|
-
* totalSupply >= minimumTotalSupply
|
|
104
|
-
* totalSupply <= maximumTotalSupply
|
|
105
|
-
* splitPercent <= maximumSplitPercent
|
|
106
|
-
* caller in allowedAddresses (if non-empty)
|
|
107
|
-
Then build JB721TierConfig and accumulate post.price [line 553-579]
|
|
108
|
-
d. Store tierIdForEncodedIPFSUriOf mapping for new tiers [line 576]
|
|
109
|
-
|
|
110
|
-
Assembly-resize tiersToAdd if some posts reused existing tiers [line 584-588]
|
|
111
|
-
|
|
112
|
-
3. If projectId != FEE_PROJECT_ID: [line 336]
|
|
113
|
-
payValue = msg.value - (totalPrice / FEE_DIVISOR) [line 341/348]
|
|
114
|
-
Else: payValue = msg.value (no fee for fee project)
|
|
115
|
-
|
|
116
|
-
4. Revert if totalPrice > payValue [line 352-354]
|
|
117
|
-
|
|
118
|
-
5. hook.adjustTiers(tiersToAdd, []) [line 358]
|
|
119
|
-
|
|
120
|
-
6. Build mint metadata: [line 366-370]
|
|
121
|
-
- JBMetadataResolver.addToMetadata with tier IDs
|
|
122
|
-
- Assembly: write FEE_PROJECT_ID into first 32 bytes (referral)
|
|
123
|
-
|
|
124
|
-
7. Emit Mint event [line 380-389]
|
|
125
|
-
|
|
126
|
-
8. Look up project's primary ETH terminal via DIRECTORY [line 393-394]
|
|
127
|
-
terminal.pay{value: payValue}(...) [line 398-406]
|
|
128
|
-
|
|
129
|
-
9. payValue = msg.value - payValue (pre-computed fee) [line 411]
|
|
130
|
-
If payValue != 0: [line 414]
|
|
131
|
-
Look up fee project's primary ETH terminal [line 416-417]
|
|
132
|
-
try feeTerminal.pay{value: payValue}(...) {} [line 421-429]
|
|
133
|
-
catch { feeBeneficiary.call{value}; fallback msg.sender.call } [line 430-437]
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
### Fee Calculation
|
|
137
|
-
|
|
138
|
-
- `FEE_DIVISOR = 20` (5% fee)
|
|
139
|
-
- Fee = `totalPrice / 20` (integer division, truncates)
|
|
140
|
-
- Maximum rounding loss: 19 wei per transaction
|
|
141
|
-
- Fee is deducted from `msg.value` before the project payment
|
|
142
|
-
- Pre-computed fee (`msg.value - payValue`) goes to fee terminal via try-catch, with fallback to `feeBeneficiary` then `msg.sender`
|
|
143
|
-
- Fee is skipped entirely when `projectId == FEE_PROJECT_ID`
|
|
144
|
-
|
|
145
|
-
---
|
|
146
|
-
|
|
147
|
-
## 3. Tier Creation Mechanics
|
|
148
|
-
|
|
149
|
-
### New Tier Path
|
|
150
|
-
|
|
151
|
-
When a post's `encodedIPFSUri` has no existing mapping (or the mapped tier was removed), a new tier is created:
|
|
152
|
-
|
|
153
|
-
1. Posting criteria are read from bit-packed `_packedAllowanceFor[hook][category]` (CTPublisher.sol lines 177-190)
|
|
154
|
-
2. Each field is validated against the post's parameters
|
|
155
|
-
3. A `JB721TierConfig` is constructed with the post's values (lines 553-570)
|
|
156
|
-
4. The tier ID is computed as `startingTierId + numberOfTiersBeingAdded++` (line 573)
|
|
157
|
-
5. The mapping `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set (line 576)
|
|
158
|
-
6. All new tiers are committed to the hook via `hook.adjustTiers()` after the loop (line 358)
|
|
159
|
-
|
|
160
|
-
### Existing Tier Path
|
|
161
|
-
|
|
162
|
-
When a post's `encodedIPFSUri` already has a mapping to a live (non-removed) tier:
|
|
163
|
-
|
|
164
|
-
1. The tier ID is reused (line 496)
|
|
165
|
-
2. The fee is calculated from `store.tierOf().price` -- the on-chain price, not `post.price` (line 501)
|
|
166
|
-
3. No new `JB721TierConfig` is added to `tiersToAdd`
|
|
167
|
-
4. The poster still gets a mint of the existing tier
|
|
168
|
-
|
|
169
|
-
### Stale Tier Cleanup
|
|
170
|
-
|
|
171
|
-
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.
|
|
172
|
-
|
|
173
|
-
---
|
|
174
|
-
|
|
175
|
-
## 4. Bit-Packed Allowance Storage
|
|
176
|
-
|
|
177
|
-
Posting criteria are packed into a single `uint256` per hook/category:
|
|
178
|
-
|
|
179
|
-
```
|
|
180
|
-
Bits 0-103 (104 bits): minimumPrice (uint104)
|
|
181
|
-
Bits 104-135 ( 32 bits): minimumTotalSupply (uint32)
|
|
182
|
-
Bits 136-167 ( 32 bits): maximumTotalSupply (uint32)
|
|
183
|
-
Bits 168-199 ( 32 bits): maximumSplitPercent(uint32)
|
|
184
|
-
Bits 200-255 ( 56 bits): unused
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
Packing logic: CTPublisher.sol lines 274-282
|
|
188
|
-
Unpacking logic: CTPublisher.sol lines 177-190
|
|
189
|
-
|
|
190
|
-
The address allowlist is stored separately in `_allowedAddresses[hook][category]` (a dynamic array).
|
|
191
|
-
|
|
192
|
-
---
|
|
193
|
-
|
|
194
|
-
## 5. Allowlist System
|
|
195
|
-
|
|
196
|
-
### Configuration
|
|
197
|
-
|
|
198
|
-
`configurePostingCriteriaFor()` (line 243) accepts an array of `CTAllowedPost` structs. For each:
|
|
199
|
-
|
|
200
|
-
1. Emits `ConfigurePostingCriteria` event (line 252)
|
|
201
|
-
2. Checks `ADJUST_721_TIERS` permission from `JBOwnable(hook).owner()` (lines 256-260)
|
|
202
|
-
3. Validates `minimumTotalSupply > 0` (line 263)
|
|
203
|
-
4. Validates `minimumTotalSupply <= maximumTotalSupply` (line 268)
|
|
204
|
-
5. Packs numeric fields into `_packedAllowanceFor` (lines 274-284)
|
|
205
|
-
6. Replaces the entire `_allowedAddresses` array (delete + push loop, lines 289-296)
|
|
206
|
-
|
|
207
|
-
### Enforcement
|
|
208
|
-
|
|
209
|
-
In `_setupPosts()` at line 547:
|
|
210
|
-
|
|
211
|
-
```solidity
|
|
212
|
-
if (addresses.length != 0 && !_isAllowed({addrs: _msgSender(), addresses: addresses})) {
|
|
213
|
-
revert CTPublisher_NotInAllowList(_msgSender(), addresses);
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
`_isAllowed()` (lines 210-220) is a linear scan: O(n) where n = allowlist size.
|
|
218
|
-
|
|
219
|
-
### Key Behavior
|
|
220
|
-
|
|
221
|
-
- Empty `allowedAddresses` means anyone can post (permissionless)
|
|
222
|
-
- Reconfiguring a category fully replaces the previous criteria and allowlist
|
|
223
|
-
- Categories with `minimumTotalSupply == 0` are treated as unconfigured (posting reverts)
|
|
224
|
-
- There is no mechanism to fully disable a configured category (NM-006, documented as won't-fix)
|
|
225
|
-
|
|
226
|
-
---
|
|
227
|
-
|
|
228
|
-
## 6. Data Hook Proxy (CTDeployer)
|
|
229
|
-
|
|
230
|
-
### Architecture
|
|
231
|
-
|
|
232
|
-
CTDeployer registers itself as the `dataHook` for every project it deploys (CTDeployer.sol line 289). It implements `IJBRulesetDataHook` and proxies calls:
|
|
233
|
-
|
|
234
|
-
- **`beforePayRecordedWith(context)`** (line 160): Forwards directly to `dataHookOf[context.projectId]` (the JB721TiersHook).
|
|
235
|
-
- **`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.
|
|
236
|
-
- **`hasMintPermissionFor(projectId, ruleset, addr)`** (line 176): Returns `true` if `addr` is a registered sucker.
|
|
237
|
-
|
|
238
|
-
### Failure Scenarios
|
|
239
|
-
|
|
240
|
-
**Critical: Data hook forwarding has no try-catch.** If `dataHookOf[projectId]` reverts for any reason:
|
|
241
|
-
|
|
242
|
-
- All `pay()` calls to the project will revert (line 168)
|
|
243
|
-
- All `cashOut()` calls (for non-sucker holders) will revert (line 150)
|
|
244
|
-
- Since `dataHookOf` is write-once (set at line 306, no setter), the project is permanently bricked
|
|
245
|
-
|
|
246
|
-
**Scenarios that could trigger this:**
|
|
247
|
-
|
|
248
|
-
1. The 721 hook has a bug in `beforePayRecordedWith()` or `beforeCashOutRecordedWith()`
|
|
249
|
-
2. The hook's dependencies (store, prices, rulesets) revert due to bad state
|
|
250
|
-
3. An upgrade to a dependency contract breaks ABI compatibility
|
|
251
|
-
|
|
252
|
-
**Mitigations:**
|
|
253
|
-
|
|
254
|
-
- The hook is deployed via Create2 with deterministic bytecode (no proxy, no upgrade)
|
|
255
|
-
- The hook's logic is well-tested in the `nana-721-hook-v6` repo
|
|
256
|
-
- Sucker cash-outs bypass the hook entirely (they return before the forwarding call)
|
|
257
|
-
|
|
258
|
-
---
|
|
259
|
-
|
|
260
|
-
## 7. Sucker Impersonation Risks
|
|
261
|
-
|
|
262
|
-
### Trust Chain
|
|
263
|
-
|
|
264
|
-
```
|
|
265
|
-
CTDeployer.beforeCashOutRecordedWith()
|
|
266
|
-
└── SUCKER_REGISTRY.isSuckerOf(projectId, context.holder)
|
|
267
|
-
└── Registry tracks suckers deployed by allowed deployers
|
|
268
|
-
└── allowSuckerDeployer() restricted to registry owner (multisig)
|
|
269
|
-
```
|
|
270
|
-
|
|
271
|
-
### Attack Surface
|
|
272
|
-
|
|
273
|
-
If an attacker can make `SUCKER_REGISTRY.isSuckerOf()` return `true` for their address:
|
|
274
|
-
|
|
275
|
-
1. They call `cashOut()` on any Croptop project
|
|
276
|
-
2. CTDeployer intercepts the cash-out, sees the attacker as a "sucker"
|
|
277
|
-
3. Returns `cashOutTaxRate = 0` instead of forwarding to the hook
|
|
278
|
-
4. The attacker receives full treasury value without paying the project's cash-out tax
|
|
279
|
-
|
|
280
|
-
### Risk Factors
|
|
281
|
-
|
|
282
|
-
- `MAP_SUCKER_TOKEN` permission is granted as wildcard (`projectId: 0`) at CTDeployer construction (line 105)
|
|
283
|
-
- The sucker registry is a shared singleton controlled by the protocol multisig
|
|
284
|
-
- Once a sucker deployer is allowed, it can deploy suckers for any project
|
|
285
|
-
- Compromising the multisig or a sucker deployer would affect all Croptop projects
|
|
286
|
-
|
|
287
|
-
### What to Verify
|
|
288
|
-
|
|
289
|
-
- That `isSuckerOf()` cannot be manipulated without multisig action
|
|
290
|
-
- That the wildcard `MAP_SUCKER_TOKEN` permission cannot be abused to register arbitrary addresses
|
|
291
|
-
- That the `hasMintPermissionFor()` function (which also trusts the sucker registry) cannot be exploited to mint tokens without payment
|
|
292
|
-
|
|
293
|
-
---
|
|
294
|
-
|
|
295
|
-
## 8. Allowlist Gas Scaling
|
|
296
|
-
|
|
297
|
-
### Current Implementation
|
|
298
|
-
|
|
299
|
-
`_isAllowed()` at CTPublisher.sol lines 210-220:
|
|
300
|
-
|
|
301
|
-
```solidity
|
|
302
|
-
function _isAllowed(address addrs, address[] memory addresses) internal pure returns (bool) {
|
|
303
|
-
uint256 numberOfAddresses = addresses.length;
|
|
304
|
-
for (uint256 i; i < numberOfAddresses; i++) {
|
|
305
|
-
if (addrs == addresses[i]) return true;
|
|
306
|
-
}
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
### Gas Analysis
|
|
312
|
-
|
|
313
|
-
- Each comparison: ~3 gas (MLOAD + EQ)
|
|
314
|
-
- Per-address overhead: ~100 gas (loop counter, bounds check, memory access)
|
|
315
|
-
- 100 addresses: ~10,000 gas additional
|
|
316
|
-
- 1,000 addresses: ~100,000 gas additional
|
|
317
|
-
- 10,000 addresses: ~1,000,000 gas additional
|
|
318
|
-
- Block gas limit (~30M mainnet): effective cap of ~300,000 addresses before tx becomes infeasible
|
|
319
|
-
|
|
320
|
-
### Storage Scaling
|
|
321
|
-
|
|
322
|
-
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.
|
|
323
|
-
|
|
324
|
-
### Recommendation for Auditors
|
|
325
|
-
|
|
326
|
-
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).
|
|
327
|
-
|
|
328
|
-
---
|
|
329
|
-
|
|
330
|
-
## 9. Priority Audit Areas
|
|
331
|
-
|
|
332
|
-
### P0 -- Critical (fund loss or permanent DoS)
|
|
333
|
-
|
|
334
|
-
1. **Fee accounting correctness in `_setupPosts()`** (CTPublisher.sol lines 442-589). Verify:
|
|
335
|
-
- `totalPrice` is always computed from on-chain tier prices for existing tiers (not user-supplied `post.price`)
|
|
336
|
-
- `totalPrice` is always computed from `post.price` for new tiers
|
|
337
|
-
- No path exists where `totalPrice` can be manipulated to be less than the actual value of tiers being minted
|
|
338
|
-
- The duplicate URI check (lines 478-482) covers all batch orderings
|
|
339
|
-
|
|
340
|
-
2. **Fee deduction and routing** (CTPublisher.sol lines 336-428). Verify:
|
|
341
|
-
- `payValue = msg.value - (totalPrice / FEE_DIVISOR)` cannot underflow
|
|
342
|
-
- The check `totalPrice > payValue` correctly prevents underpayment
|
|
343
|
-
- The pre-computed fee (`msg.value - payValue`) equals exactly the intended fee amount, independent of `address(this).balance`
|
|
344
|
-
- The fee terminal payment is wrapped in try-catch with fallback to `feeBeneficiary` then `msg.sender` — verify the fallback chain cannot lose ETH
|
|
345
|
-
|
|
346
|
-
3. **Data hook proxy forwarding** (CTDeployer.sol lines 132-169). Verify:
|
|
347
|
-
- `dataHookOf[projectId]` is always set before any pay/cashout can occur for that project
|
|
348
|
-
- No path exists where `dataHookOf[projectId]` is `address(0)` and a forwarding call is made
|
|
349
|
-
- The sucker check correctly short-circuits before the forwarding call
|
|
350
|
-
|
|
351
|
-
### P1 -- High (access control bypass, permission escalation)
|
|
352
|
-
|
|
353
|
-
4. **Sucker fee-free cash-out** (CTDeployer.sol lines 143-146). Verify:
|
|
354
|
-
- Only legitimate suckers can trigger the zero-tax path
|
|
355
|
-
- The `hasMintPermissionFor()` function cannot be abused for unauthorized minting
|
|
356
|
-
|
|
357
|
-
5. **Permission enforcement in `configurePostingCriteriaFor()`** (CTPublisher.sol lines 256-260). Verify:
|
|
358
|
-
- The permission check uses `JBOwnable(hook).owner()` and `IJB721TiersHook(hook).PROJECT_ID()` correctly
|
|
359
|
-
- No one besides the hook owner (or permissioned delegate) can modify posting criteria
|
|
360
|
-
|
|
361
|
-
6. **CTProjectOwner permission grant** (CTProjectOwner.sol lines 47-80). Verify:
|
|
362
|
-
- The permission granted is scoped to the correct project ID
|
|
363
|
-
- The `uint64(tokenId)` cast does not truncate for realistic project IDs
|
|
364
|
-
- Any address can transfer a project NFT to CTProjectOwner (no `from == address(0)` check), effectively burning ownership permanently
|
|
365
|
-
|
|
366
|
-
### P2 -- Medium (economic manipulation, griefing)
|
|
367
|
-
|
|
368
|
-
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.
|
|
369
|
-
|
|
370
|
-
8. **Bit-packing correctness** in `_packedAllowanceFor` storage (CTPublisher.sol lines 274-282 write, lines 177-190 read). Verify no field overlap or silent truncation.
|
|
371
|
-
|
|
372
|
-
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.
|
|
373
|
-
|
|
374
|
-
10. **Project deployment front-running** (CTDeployer.sol lines 261, 294-303). Verify the `assert(projectId == ...)` check correctly prevents ID mismatch without permanent fund loss.
|
|
375
|
-
|
|
376
|
-
### P3 -- Low (informational, code quality)
|
|
377
|
-
|
|
378
|
-
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.
|
|
379
|
-
|
|
380
|
-
12. **Force-sent ETH stranding** (CTPublisher.sol lines 409-438). Fee is now pre-computed from `msg.value`, not `address(this).balance`. Force-sent ETH remains stranded. Confirm this is acceptable and the try-catch fallback chain cannot lose ETH from `msg.value`.
|
|
381
|
-
|
|
382
|
-
13. **Allowlist overwrite behavior** (CTPublisher.sol lines 289-296). Verify that `delete` followed by `push` loop correctly replaces the array with no residual state.
|
|
383
|
-
|
|
384
|
-
---
|
|
385
|
-
|
|
386
|
-
## 10. Invariants
|
|
387
|
-
|
|
388
|
-
These properties should hold across all operations. They are suitable targets for fuzz testing and formal verification.
|
|
389
|
-
|
|
390
|
-
### Fee Invariants
|
|
391
|
-
|
|
392
|
-
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.
|
|
393
|
-
|
|
394
|
-
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).
|
|
395
|
-
|
|
396
|
-
3. **ETH conservation:** For every `mintFrom()` call, `msg.value == payValue + feeAmount + dust`, where `dust <= 19 wei`. The fee amount is pre-computed as `msg.value - payValue` and routed via try-catch to the fee terminal, then `feeBeneficiary`, then `msg.sender`. No ETH from `msg.value` is lost. Force-sent ETH (via `selfdestruct`) is not routed and remains stranded in the contract.
|
|
397
|
-
|
|
398
|
-
### Posting Invariants
|
|
399
|
-
|
|
400
|
-
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`.
|
|
401
|
-
|
|
402
|
-
5. **Duplicate rejection:** Within a single `mintFrom()` batch, no two posts can have the same `encodedIPFSUri`.
|
|
403
|
-
|
|
404
|
-
6. **Existing tier price integrity:** For existing tiers, `totalPrice` accumulates `store.tierOf().price` (the on-chain price), never `post.price`.
|
|
405
|
-
|
|
406
|
-
7. **Tier uniqueness:** After `_setupPosts()` completes, every `encodedIPFSUri` in the batch maps to a unique tier ID via `tierIdForEncodedIPFSUriOf`.
|
|
407
|
-
|
|
408
|
-
### Ownership Invariants
|
|
409
|
-
|
|
410
|
-
8. **Transient deployer ownership:** CTDeployer owns a project NFT only during `deployProjectFor()` execution. By function return, ownership has been transferred to the specified `owner`.
|
|
411
|
-
|
|
412
|
-
9. **Data hook immutability:** `dataHookOf[projectId]` is set exactly once (during `deployProjectFor`) and never modified afterward. There is no setter function.
|
|
413
|
-
|
|
414
|
-
10. **Permission scoping:** CTProjectOwner grants `ADJUST_721_TIERS` permission scoped to the specific `tokenId` (project ID) received, not globally.
|
|
415
|
-
|
|
416
|
-
---
|
|
417
|
-
|
|
418
|
-
## 11. Testing Setup
|
|
419
|
-
|
|
420
|
-
### Running Tests
|
|
421
|
-
|
|
422
|
-
```bash
|
|
423
|
-
cd croptop-core-v6
|
|
424
|
-
forge install
|
|
425
|
-
forge test
|
|
426
|
-
```
|
|
427
|
-
|
|
428
|
-
For fork tests (requires RPC URL):
|
|
429
|
-
```bash
|
|
430
|
-
ETHEREUM_RPC_URL=<your-rpc> forge test --match-contract ForkTest --fork-url $ETHEREUM_RPC_URL
|
|
431
|
-
```
|
|
432
|
-
|
|
433
|
-
### Test File Overview
|
|
434
|
-
|
|
435
|
-
| Test File | Focus | Tests |
|
|
436
|
-
|-----------|-------|-------|
|
|
437
|
-
| `test/CTPublisher.t.sol` | Allowance round-trip, bit packing fuzz, permission checks, split validation | 26 tests including fuzz |
|
|
438
|
-
| `test/CTDeployer.t.sol` | Deploy flow, data hook proxy, sucker permissions, onERC721Received, supportsInterface | 21 tests |
|
|
439
|
-
| `test/CTProjectOwner.t.sol` | Permission grants on NFT receipt, rejection of non-project NFTs, rejection of transfers | 7 tests |
|
|
440
|
-
| `test/ClaimCollectionOwnership.t.sol` | NM-002 scenario: ownership transfer, permission breakage, recovery path | 6 tests |
|
|
441
|
-
| `test/TestAuditGaps.sol` | Data hook proxy forwarding failure, sucker impersonation, allowlist gas scaling, force-sent ETH | 19 tests |
|
|
442
|
-
| `test/CroptopAttacks.t.sol` | Adversarial input validation, allowlist bypass, split percent enforcement | 12 tests |
|
|
443
|
-
| `test/Fork.t.sol` | Full deployment integration with real JB infrastructure | 2 fork tests |
|
|
444
|
-
| `test/fork/PublishFork.t.sol` | End-to-end mint flow, fee distribution, duplicate post reuse on mainnet fork | 4 fork tests |
|
|
445
|
-
| `test/Test_MetadataGeneration.t.sol` | Metadata assembly correctness | 1 test |
|
|
446
|
-
| `test/regression/DuplicateUriFeeEvasion.t.sol` | NM-001 fix: duplicate URI detection | 5 tests including fuzz |
|
|
447
|
-
| `test/regression/FeeEvasion.t.sol` | H-19 fix: existing tier price used for fees | 2 tests |
|
|
448
|
-
| `test/regression/StaleTierIdMapping.t.sol` | L-52 fix: stale mapping cleanup | 2 tests |
|
|
449
|
-
|
|
450
|
-
### Coverage Gaps (no existing tests)
|
|
451
|
-
|
|
452
|
-
- Force-sent ETH handling via selfdestruct (fee is now pre-computed from `msg.value`, not `address(this).balance`, so force-sent ETH remains stranded)
|
|
453
|
-
- `deployProjectFor` front-running race condition
|
|
454
|
-
- Multiple hooks sharing the same CTPublisher instance
|
|
455
|
-
- Cross-category posting in a single batch (different categories, different allowlists)
|
|
456
|
-
- `configurePostingCriteriaFor()` called with a very large allowlist (storage gas)
|
|
457
|
-
- Edge case: `totalPrice == 0` when all posts reuse existing free (price=0) tiers
|
|
458
|
-
|
|
459
|
-
### Testing Approach Used
|
|
460
|
-
|
|
461
|
-
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.
|
|
462
|
-
|
|
463
|
-
---
|
|
464
|
-
|
|
465
|
-
## 12. Previous Audit Findings
|
|
466
|
-
|
|
467
|
-
Six Nemesis findings plus two regression-test findings. See `.audit/findings/nemesis-verified.md` for full Nemesis details and `RISKS.md` for context.
|
|
468
|
-
|
|
469
|
-
| ID | Severity | Status | Description |
|
|
470
|
-
|----|----------|--------|-------------|
|
|
471
|
-
| NM-001 | MEDIUM | FALSE POSITIVE | `dataHookOf` write-once = permanent project bricking -- project owner can queue new ruleset to escape (`useDataHookForPay = false`) |
|
|
472
|
-
| NM-002 | MEDIUM | OPEN | `claimCollectionOwnershipOf` breaks all `CTPublisher.mintFrom` calls -- hook ownership transfer does not update CTPublisher permissions |
|
|
473
|
-
| NM-003 | LOW | OPEN | Permission grants to initial owner stale after project NFT transfer -- old owner retains 4 permissions |
|
|
474
|
-
| NM-004 | LOW | OPEN | Stale `tierIdForEncodedIPFSUriOf` in `tiersFor()` view -- removed tiers still returned to off-chain consumers |
|
|
475
|
-
| 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 |
|
|
476
|
-
| NM-006 | LOW | OPEN | Cannot fully disable posting for a configured category |
|
|
477
|
-
| H-19 | HIGH | FIXED | Fee evasion on existing tier mints via `post.price = 0` *(regression test naming, not from Nemesis audit)* |
|
|
478
|
-
| L-52 | LOW | FIXED | Stale tier ID mapping after external tier removal *(regression test naming, not from Nemesis audit)* |
|
|
479
|
-
|
|
480
|
-
---
|
|
481
|
-
|
|
482
|
-
## Compiler and Version Info
|
|
483
|
-
|
|
484
|
-
- **Solidity**: 0.8.28
|
|
485
|
-
- **EVM target**: Cancun
|
|
486
|
-
- **Optimizer**: 200 runs
|
|
487
|
-
- **Dependencies**: OpenZeppelin 5.x, nana-core-v6, nana-721-hook-v6, nana-suckers-v6
|
|
488
|
-
- **Build**: `forge build` (Foundry)
|
|
489
|
-
|
|
490
|
-
---
|
|
491
|
-
|
|
492
|
-
## How to Report Findings
|
|
493
|
-
|
|
494
|
-
For each finding:
|
|
495
|
-
|
|
496
|
-
1. **Title** -- one line, starts with severity (CRITICAL/HIGH/MEDIUM/LOW)
|
|
497
|
-
2. **Affected contract(s)** -- exact file path and line numbers
|
|
498
|
-
3. **Description** -- what is wrong, in plain language
|
|
499
|
-
4. **Trigger sequence** -- step-by-step
|
|
500
|
-
5. **Impact** -- what an attacker gains, what the project/fee project loses
|
|
501
|
-
6. **Proof** -- code trace or Foundry test
|
|
502
|
-
7. **Fix** -- minimal code change
|
|
503
|
-
|
|
504
|
-
**Severity guide:**
|
|
505
|
-
- **CRITICAL**: Fee evasion at scale, unauthorized treasury drain, permanent project DoS.
|
|
506
|
-
- **HIGH**: Conditional fee bypass, sucker impersonation, broken posting criteria.
|
|
507
|
-
- **MEDIUM**: Tier spam, gas griefing, rounding errors in fee calculation.
|
|
508
|
-
- **LOW**: Informational, cosmetic, testnet-only.
|
|
92
|
+
Strong findings usually show either fee loss, unauthorized publishing power, or a project entering a control configuration it cannot safely escape.
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Scope
|
|
4
|
+
|
|
5
|
+
This file describes the verified change from `croptop-core-v5` to the current `croptop-core-v6` repo.
|
|
6
|
+
|
|
7
|
+
## Current v6 surface
|
|
8
|
+
|
|
9
|
+
- `CTDeployer`
|
|
10
|
+
- `CTProjectOwner`
|
|
11
|
+
- `CTPublisher`
|
|
12
|
+
- `CTAllowedPost`
|
|
13
|
+
- `CTDeployerAllowedPost`
|
|
14
|
+
- `CTPost`
|
|
15
|
+
|
|
16
|
+
## Summary
|
|
17
|
+
|
|
18
|
+
- `CTPost` and the related allowlist structs now carry split-routing data, so a post can route part of its payment through `JBSplit[]` recipients.
|
|
19
|
+
- The deployer now acts as the data-hook entry point instead of wiring the 721 hook directly, which is what enables the intended omnichain and sucker-aware cash-out behavior.
|
|
20
|
+
- v6 closes several correctness gaps that were easy to miss in v5: duplicate posts in a batch are rejected, existing tiers use on-chain pricing instead of caller-supplied pricing, and stale tier mappings are recreated when tiers were removed externally.
|
|
21
|
+
- The repo was moved to the v6 dependency set and Solidity `0.8.28`.
|
|
22
|
+
|
|
23
|
+
## Verified deltas
|
|
24
|
+
|
|
25
|
+
- `CTPost` gained `splitPercent` and `JBSplit[] splits`.
|
|
26
|
+
- `CTAllowedPost` and `CTDeployerAllowedPost` gained `maximumSplitPercent`.
|
|
27
|
+
- `ICTPublisher.allowanceFor(...)` now returns five values instead of four because `maximumSplitPercent` is part of the result.
|
|
28
|
+
- `CTDeployer` now points project metadata to itself as the data hook instead of pointing directly at the 721 hook.
|
|
29
|
+
- The repo carries dedicated regression tests for duplicate-URI fee evasion, stale tier mappings, and existing-tier pricing.
|
|
30
|
+
|
|
31
|
+
## Breaking ABI changes
|
|
32
|
+
|
|
33
|
+
- `CTPost` is not v5-compatible because it now includes `splitPercent` and `splits`.
|
|
34
|
+
- `CTAllowedPost` and `CTDeployerAllowedPost` are not v5-compatible because they now include `maximumSplitPercent`.
|
|
35
|
+
- `ICTPublisher.allowanceFor(...)` return decoding changed because of the added field.
|
|
36
|
+
|
|
37
|
+
## Indexer impact
|
|
38
|
+
|
|
39
|
+
- Any event or log decoding path that embeds `CTPost` or `CTAllowedPost` must be updated for the new struct layouts.
|
|
40
|
+
- Post-publishing integrations should not assume the old "all payment goes to treasury" model once split-bearing posts are live.
|
|
41
|
+
|
|
42
|
+
## Migration notes
|
|
43
|
+
|
|
44
|
+
- Rebuild any ABI or indexer code that decodes `CTPost` or `CTAllowedPost`. Their layouts are not v5-compatible.
|
|
45
|
+
- If you integrated the deployer as if the 721 hook were the direct data hook, update that assumption. The deployer is now part of the routing path.
|
|
46
|
+
- Re-check any fee logic that trusted caller-supplied prices for existing tiers. That is not the v6 behavior.
|
|
47
|
+
|
|
48
|
+
## ABI appendix
|
|
49
|
+
|
|
50
|
+
- Changed structs
|
|
51
|
+
- `CTPost`
|
|
52
|
+
- `CTAllowedPost`
|
|
53
|
+
- `CTDeployerAllowedPost`
|
|
54
|
+
- Changed decoding expectations
|
|
55
|
+
- `ICTPublisher.allowanceFor(...)`
|
|
56
|
+
- Behaviorally important surface shift
|
|
57
|
+
- deployer acts as the data-hook entrypoint
|