@croptop/core-v6 0.0.16 → 0.0.18

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/CHANGE_LOG.md ADDED
@@ -0,0 +1,253 @@
1
+ # croptop-core-v6 Changelog (v5 → v6)
2
+
3
+ This document describes all changes between `croptop-core` (v5) and `croptop-core-v6` (v6).
4
+
5
+ ---
6
+
7
+ ## 1. Breaking Changes
8
+
9
+ ### Solidity Version
10
+ - Compiler version bumped from `0.8.23` to `0.8.26` across all implementation contracts (`CTDeployer`, `CTProjectOwner`, `CTPublisher`).
11
+
12
+ ### Dependency Namespace Migration
13
+ All imports updated from v5 to v6 namespaces:
14
+ - `@bananapus/core-v5` → `@bananapus/core-v6`
15
+ - `@bananapus/721-hook-v5` → `@bananapus/721-hook-v6`
16
+ - `@bananapus/ownable-v5` → `@bananapus/ownable-v6`
17
+ - `@bananapus/permission-ids-v5` → `@bananapus/permission-ids-v6`
18
+ - `@bananapus/suckers-v5` → `@bananapus/suckers-v6`
19
+
20
+ ### `ICTPublisher.allowanceFor` Return Signature Changed
21
+ - **v5:** Returns 4 values — `(uint256 minimumPrice, uint256 minimumTotalSupply, uint256 maximumTotalSupply, address[] memory allowedAddresses)`
22
+ - **v6:** Returns 5 values — `(uint256 minimumPrice, uint256 minimumTotalSupply, uint256 maximumTotalSupply, uint256 maximumSplitPercent, address[] memory allowedAddresses)`
23
+ - The new `maximumSplitPercent` return value is inserted before `allowedAddresses`. Any consumer destructuring this return value will break.
24
+
25
+ ### `ICTPublisher.mintFrom` Parameter Data Location Changed
26
+ - **v5:** `CTPost[] memory posts`
27
+ - **v6:** `CTPost[] calldata posts`
28
+
29
+ ### `CTPost` Struct Has New Fields
30
+ - **v5:** `{ bytes32 encodedIPFSUri, uint32 totalSupply, uint104 price, uint24 category }`
31
+ - **v6:** `{ bytes32 encodedIPFSUri, uint32 totalSupply, uint104 price, uint24 category, uint32 splitPercent, JBSplit[] splits }`
32
+ - Adds `splitPercent` (uint32) and `splits` (JBSplit[] from `@bananapus/core-v6`). This changes the ABI encoding of `CTPost` and all functions that accept it.
33
+
34
+ ### `CTAllowedPost` Struct Has New Field
35
+ - **v5:** `{ address hook, uint24 category, uint104 minimumPrice, uint32 minimumTotalSupply, uint32 maximumTotalSupply, address[] allowedAddresses }`
36
+ - **v6:** `{ address hook, uint24 category, uint104 minimumPrice, uint32 minimumTotalSupply, uint32 maximumTotalSupply, uint32 maximumSplitPercent, address[] allowedAddresses }`
37
+ - Adds `maximumSplitPercent` (uint32) before `allowedAddresses`. This changes the ABI encoding of `CTAllowedPost` and all functions that accept it.
38
+
39
+ ### `CTDeployerAllowedPost` Struct Has New Field
40
+ - **v5:** `{ uint24 category, uint104 minimumPrice, uint32 minimumTotalSupply, uint32 maximumTotalSupply, address[] allowedAddresses }`
41
+ - **v6:** `{ uint24 category, uint104 minimumPrice, uint32 minimumTotalSupply, uint32 maximumTotalSupply, uint32 maximumSplitPercent, address[] allowedAddresses }`
42
+ - Adds `maximumSplitPercent` (uint32) before `allowedAddresses`, mirroring `CTAllowedPost`.
43
+
44
+ ### `CTProjectOwner.onERC721Received` — `projectId` Cast Width Changed
45
+ - **v5:** `projectId: uint56(tokenId)`
46
+ - **v6:** `projectId: uint64(tokenId)`
47
+ - This aligns with the v6 `JBPermissionsData` struct which uses `uint64` for `projectId` (was `uint56` in v5).
48
+
49
+ ### `CTDeployer.deployProjectFor` — Data Hook and Cash Out Behavior Changed
50
+ - **v5:** Sets `metadata.dataHook = address(hook)` (the 721 hook itself is the data hook). Does NOT set `cashOutTaxRate` or `useDataHookForCashOut`.
51
+ - **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
+ - 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
+
54
+ ### `JB721InitTiersConfig` — `prices` Field Removed
55
+ - **v5:** `JB721InitTiersConfig({ tiers, currency, decimals, prices: controller.PRICES() })`
56
+ - **v6:** `JB721InitTiersConfig({ tiers, currency, decimals })` — the `prices` field no longer exists in the v6 721 hook config struct.
57
+
58
+ ### `JB721TiersHookFlags` — New `issueTokensForSplits` Flag
59
+ - **v5:** `JB721TiersHookFlags({ noNewTiersWithReserves, noNewTiersWithVotes, noNewTiersWithOwnerMinting, preventOverspending })`
60
+ - **v6:** Adds `issueTokensForSplits: false` as a fifth flag.
61
+
62
+ ### `ICTDeployer.deployProjectFor` — Parameter Renamed
63
+ - **v5:** `projectConfigurations` parameter name
64
+ - **v6:** `projectConfig` parameter name
65
+
66
+ ---
67
+
68
+ ## 2. New Features
69
+
70
+ ### Split Percent Support for Posts
71
+ Posts can now include a `splitPercent` and an array of `splits` (JBSplit[]) that route a percentage of the tier's price to specified recipients when the NFT is minted. This is enforced against a per-category `maximumSplitPercent` configured by the project owner.
72
+
73
+ - `CTPost.splitPercent` — percent of tier price to route to splits (out of `JBConstants.SPLITS_TOTAL_PERCENT`).
74
+ - `CTPost.splits` — the split recipients for the tier.
75
+ - `CTAllowedPost.maximumSplitPercent` — the maximum split percent a poster can set (0 = splits not allowed).
76
+ - `CTDeployerAllowedPost.maximumSplitPercent` — same as above, for deployer-configured posts.
77
+ - `JB721TierConfig` in v6 now accepts `splitPercent` and `splits` fields, which are populated from the post.
78
+
79
+ ### Duplicate Post Detection
80
+ - v6 adds an explicit duplicate check within `_setupPosts`: if two posts in the same batch share the same `encodedIPFSUri`, the transaction reverts with `CTPublisher_DuplicatePost`. This prevents fee evasion by submitting duplicate URIs in a single `mintFrom` call.
81
+
82
+ ### Stale Tier Cleanup
83
+ - v6 adds logic to detect when a tier referenced by `tierIdForEncodedIPFSUriOf` has been removed externally (via `adjustTiers`). If `hook.STORE().isTierRemoved()` returns true, the stale mapping is deleted and a new tier is created for that URI.
84
+
85
+ ### Fee Evasion Prevention for Existing Tiers
86
+ - **v5:** When minting from an existing tier, `totalPrice` was accumulated using `post.price` (user-supplied).
87
+ - **v6:** When minting from an existing tier, `totalPrice` is accumulated using the actual tier price fetched from `store.tierOf()`. This prevents a caller from passing `price=0` for an existing tier to evade fees.
88
+
89
+ ### CTDeployer as Data Hook Proxy
90
+ - The CTDeployer now implements the data hook pattern directly, acting as a proxy that forwards `beforePayRecordedWith` and `beforeCashOutRecordedWith` to the stored `dataHookOf[projectId]`. For cash outs from suckers (verified via `SUCKER_REGISTRY.isSuckerOf`), it returns 0% tax rate, enabling sucker-based cross-chain operations without cash out taxes.
91
+
92
+ ---
93
+
94
+ ## 3. Event Changes
95
+
96
+ No event signatures were changed. Both versions emit the same two events:
97
+ - `ConfigurePostingCriteria(address indexed hook, CTAllowedPost allowedPost, address caller)` — note that the `CTAllowedPost` struct gained a `maximumSplitPercent` field, which changes the ABI encoding of this event's data.
98
+ - `Mint(uint256 indexed projectId, IJB721TiersHook indexed hook, address indexed nftBeneficiary, address feeBeneficiary, CTPost[] posts, uint256 postValue, uint256 txValue, address caller)` — note that the `CTPost` struct gained `splitPercent` and `splits` fields, which changes the ABI encoding of this event's data.
99
+
100
+ ---
101
+
102
+ ## 4. Error Changes
103
+
104
+ ### New Errors
105
+
106
+ | Error | Contract | Description |
107
+ |-------|----------|-------------|
108
+ | `CTPublisher_DuplicatePost(bytes32 encodedIPFSUri)` | `CTPublisher` | Reverts when two posts in the same `mintFrom` batch share the same encoded IPFS URI. |
109
+ | `CTPublisher_SplitPercentExceedsMaximum(uint256 splitPercent, uint256 maximumSplitPercent)` | `CTPublisher` | Reverts when a post's split percent exceeds the category's configured maximum. |
110
+
111
+ ### Unchanged Errors
112
+ - `CTDeployer_NotOwnerOfProject(uint256 projectId, address hook, address caller)` — unchanged.
113
+ - `CTPublisher_EmptyEncodedIPFSUri()` — unchanged.
114
+ - `CTPublisher_InsufficientEthSent(uint256 expected, uint256 sent)` — signature unchanged; v6 adds an explicit fee validation check before the subtraction (`if (payValue < fee) revert`) so this error now fires with a descriptive message instead of a panic on underflow.
115
+ - `CTPublisher_MaxTotalSupplyLessThanMin(uint256 min, uint256 max)` — unchanged.
116
+ - `CTPublisher_NotInAllowList(address addr, address[] allowedAddresses)` — unchanged.
117
+ - `CTPublisher_PriceTooSmall(uint256 price, uint256 minimumPrice)` — unchanged.
118
+ - `CTPublisher_TotalSupplyTooBig(uint256 totalSupply, uint256 maximumTotalSupply)` — unchanged.
119
+ - `CTPublisher_TotalSupplyTooSmall(uint256 totalSupply, uint256 minimumTotalSupply)` — unchanged.
120
+ - `CTPublisher_UnauthorizedToPostInCategory()` — unchanged.
121
+ - `CTPublisher_ZeroTotalSupply()` — unchanged.
122
+
123
+ ---
124
+
125
+ ## 5. Struct Changes
126
+
127
+ ### `CTPost`
128
+ | Field | v5 | v6 |
129
+ |-------|----|----|
130
+ | `encodedIPFSUri` | `bytes32` | `bytes32` |
131
+ | `totalSupply` | `uint32` | `uint32` |
132
+ | `price` | `uint104` | `uint104` |
133
+ | `category` | `uint24` | `uint24` |
134
+ | `splitPercent` | -- | `uint32` (new) |
135
+ | `splits` | -- | `JBSplit[]` (new) |
136
+
137
+ New import: `JBSplit` from `@bananapus/core-v6/src/structs/JBSplit.sol`.
138
+
139
+ ### `CTAllowedPost`
140
+ | Field | v5 | v6 |
141
+ |-------|----|----|
142
+ | `hook` | `address` | `address` |
143
+ | `category` | `uint24` | `uint24` |
144
+ | `minimumPrice` | `uint104` | `uint104` |
145
+ | `minimumTotalSupply` | `uint32` | `uint32` |
146
+ | `maximumTotalSupply` | `uint32` | `uint32` |
147
+ | `maximumSplitPercent` | -- | `uint32` (new) |
148
+ | `allowedAddresses` | `address[]` | `address[]` |
149
+
150
+ ### `CTDeployerAllowedPost`
151
+ | Field | v5 | v6 |
152
+ |-------|----|----|
153
+ | `category` | `uint24` | `uint24` |
154
+ | `minimumPrice` | `uint104` | `uint104` |
155
+ | `minimumTotalSupply` | `uint32` | `uint32` |
156
+ | `maximumTotalSupply` | `uint32` | `uint32` |
157
+ | `maximumSplitPercent` | -- | `uint32` (new) |
158
+ | `allowedAddresses` | `address[]` | `address[]` |
159
+
160
+ ### `CTProjectConfig`
161
+ No field changes. Import path updated from `@bananapus/core-v5` to `@bananapus/core-v6` for `JBTerminalConfig`.
162
+
163
+ ### `CTSuckerDeploymentConfig`
164
+ No field changes. Import path updated from `@bananapus/suckers-v5` to `@bananapus/suckers-v6` for `JBSuckerDeployerConfig`.
165
+
166
+ ---
167
+
168
+ ## 6. Implementation Changes (Non-Interface)
169
+
170
+ ### `CTPublisher._setupPosts`
171
+
172
+ #### Store Reference Caching
173
+ - **v5:** Calls `hook.STORE().maxTierIdOf(...)` inline, accessing the store through the hook each time.
174
+ - **v6:** Caches `IJB721TiersHookStore store = hook.STORE()` once and reuses it. Also imports `IJB721TiersHookStore` explicitly.
175
+
176
+ #### Duplicate Post Detection
177
+ - **v6 only:** Adds an O(n^2) check at the start of each post iteration that scans all previous posts for matching `encodedIPFSUri`. Reverts with `CTPublisher_DuplicatePost` on match.
178
+
179
+ #### Stale Tier Recovery
180
+ - **v5:** If `tierIdForEncodedIPFSUriOf` returns a nonzero tier ID, it is used unconditionally.
181
+ - **v6:** Checks `hook.STORE().isTierRemoved(address(hook), tierId)`. If removed, deletes the stale mapping and falls through to create a new tier.
182
+
183
+ #### Fee-Accurate Price for Existing Tiers
184
+ - **v5:** `totalPrice += post.price` for all posts (new and existing).
185
+ - **v6:** For existing tiers, `totalPrice += store.tierOf(...).price` (uses actual on-chain price). For new tiers, `totalPrice += post.price`.
186
+
187
+ #### Split Validation
188
+ - **v6 only:** Checks `post.splitPercent > maximumSplitPercent` and reverts with `CTPublisher_SplitPercentExceedsMaximum` if exceeded.
189
+
190
+ #### `JB721TierConfig` Construction
191
+ - **v5:** 14 fields in `JB721TierConfig`.
192
+ - **v6:** 16 fields — adds `splitPercent: post.splitPercent` and `splits: post.splits`.
193
+
194
+ ### `CTPublisher.allowanceFor` — Packed Storage Layout Extended
195
+ - **v5:** Packs 3 fields into `_packedAllowanceFor`: bits 0-103 (minimumPrice), 104-135 (minimumTotalSupply), 136-167 (maximumTotalSupply). Total: 168 bits.
196
+ - **v6:** Packs 4 fields: bits 0-103, 104-135, 136-167 as before, plus bits 168-199 (maximumSplitPercent, 32 bits). Total: 200 bits.
197
+
198
+ ### `CTPublisher.configurePostingCriteriaFor` — Packs `maximumSplitPercent`
199
+ - **v6 only:** Adds `packed |= uint256(allowedPost.maximumSplitPercent) << 168;` when storing allowance data.
200
+
201
+ ### `CTDeployer._configurePostingCriteriaFor` — Passes `maximumSplitPercent`
202
+ - **v5:** `CTAllowedPost` construction has 6 fields.
203
+ - **v6:** `CTAllowedPost` construction has 7 fields — adds `maximumSplitPercent: post.maximumSplitPercent`.
204
+
205
+ ### `CTDeployer.deployProjectFor` — Ruleset Configuration Changes
206
+ - **v5:** Sets `metadata.dataHook = address(hook)` and `metadata.useDataHookForPay = true`.
207
+ - **v6:** Sets `metadata.cashOutTaxRate = JBConstants.MAX_CASH_OUT_TAX_RATE`, `metadata.dataHook = address(this)`, `metadata.useDataHookForPay = true`, and `metadata.useDataHookForCashOut = true`. Imports `JBConstants` for this.
208
+
209
+ ### `CTDeployer.deployProjectFor` — Named Arguments in Function Calls
210
+ - **v6:** Uses named arguments consistently (e.g., `PROJECTS.transferFrom({from: ..., to: ..., tokenId: ...})` instead of positional arguments).
211
+
212
+ ### `CTDeployer` — Function Ordering
213
+ - **v5:** `beforePayRecordedWith` appears before `beforeCashOutRecordedWith` in source.
214
+ - **v6:** `beforeCashOutRecordedWith` appears before `beforePayRecordedWith`. Similarly, `claimCollectionOwnershipOf` appears before `deployProjectFor` in v6 (reversed from v5).
215
+
216
+ ### `CTDeployer.beforeCashOutRecordedWith` — Named Arguments
217
+ - **v5:** `SUCKER_REGISTRY.isSuckerOf(context.projectId, context.holder)`
218
+ - **v6:** `SUCKER_REGISTRY.isSuckerOf({projectId: context.projectId, addr: context.holder})`
219
+
220
+ ### `CTDeployer.hasMintPermissionFor` — Named Arguments
221
+ - **v5:** `SUCKER_REGISTRY.isSuckerOf(projectId, addr)`
222
+ - **v6:** `SUCKER_REGISTRY.isSuckerOf({projectId: projectId, addr: addr})`
223
+
224
+ ### `CTPublisher.tiersFor` — Named Arguments
225
+ - **v5:** `IJB721TiersHook(hook).STORE().tierOf(hook, tierId, false)`
226
+ - **v6:** `IJB721TiersHook(hook).STORE().tierOf({hook: hook, id: tierId, includeResolvedUri: false})`
227
+
228
+ ### `CTPublisher.mintFrom` — Named Arguments
229
+ - **v6:** Uses named arguments for `DIRECTORY.primaryTerminalOf(...)`, `hook.adjustTiers(...)`, `JBMetadataResolver.getId(...)`, and `_isAllowed(...)`.
230
+
231
+ ### NatDoc / Comments
232
+ - **v6:** Adds extensive NatDoc comments to all interface functions, events, and struct fields. Adds `forge-lint` disable comments for mixed-case variables. Adds explanatory comments for design decisions (e.g., fee rounding behavior, force-sent ETH handling, category irrevocability, linear scan scaling).
233
+
234
+ ---
235
+
236
+ ## 7. Migration Table
237
+
238
+ | v5 Identifier | v6 Identifier | Change |
239
+ |---------------|---------------|--------|
240
+ | `CTPost.{4 fields}` | `CTPost.{6 fields}` | Added `splitPercent`, `splits` |
241
+ | `CTAllowedPost.{6 fields}` | `CTAllowedPost.{7 fields}` | Added `maximumSplitPercent` |
242
+ | `CTDeployerAllowedPost.{5 fields}` | `CTDeployerAllowedPost.{6 fields}` | Added `maximumSplitPercent` |
243
+ | `ICTPublisher.allowanceFor` (4 returns) | `ICTPublisher.allowanceFor` (5 returns) | Added `maximumSplitPercent` return |
244
+ | `ICTPublisher.mintFrom(... CTPost[] memory ...)` | `ICTPublisher.mintFrom(... CTPost[] calldata ...)` | `memory` → `calldata` |
245
+ | `CTProjectOwner`: `uint56(tokenId)` | `CTProjectOwner`: `uint64(tokenId)` | Cast width for projectId |
246
+ | `CTDeployer`: `dataHook = address(hook)` | `CTDeployer`: `dataHook = address(this)` | CTDeployer is now the data hook |
247
+ | `CTDeployer`: no cashout config | `CTDeployer`: `cashOutTaxRate = MAX`, `useDataHookForCashOut = true` | Enables sucker 0% tax cashout |
248
+ | `JB721InitTiersConfig`: has `prices` | `JB721InitTiersConfig`: no `prices` | Field removed in v6 721 hook |
249
+ | `JB721TiersHookFlags`: 4 flags | `JB721TiersHookFlags`: 5 flags | Added `issueTokensForSplits` |
250
+ | -- | `CTPublisher_DuplicatePost` | New error |
251
+ | -- | `CTPublisher_SplitPercentExceedsMaximum` | New error |
252
+ | Solidity `0.8.23` | Solidity `0.8.26` | Compiler bump |
253
+ | `@bananapus/*-v5` | `@bananapus/*-v6` | All dependency namespaces |
package/RISKS.md CHANGED
@@ -1,238 +1,49 @@
1
- # croptop-core-v6 -- Risks
1
+ # RISKS.md -- croptop-core-v6
2
2
 
3
- Deep implementation-level risk analysis. References are to source files under `src/` and test files under `test/`.
3
+ ## 1. Trust Assumptions
4
4
 
5
- ## Trust Assumptions
5
+ - **Trusted forwarder.** ERC-2771 `_msgSender()` is trusted in both CTPublisher and CTDeployer for permission checks, allowlist validation, and payment routing. A compromised forwarder can post as any allowed address, deploy projects as any owner, and redirect payments.
6
+ - **CTDeployer as permanent data hook proxy.** `CTDeployer` sets itself as the data hook for projects it deploys. `dataHookOf[projectId]` is set once during `deployProjectFor` and has no setter to update it. If the underlying data hook needs to change, there is no mechanism to do so without redeploying.
7
+ - **Sucker registry.** `CTDeployer.beforeCashOutRecordedWith` trusts `SUCKER_REGISTRY.isSuckerOf()` for 0% tax cashouts, same risk as the omnichain deployer.
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
+ - **JBDirectory / Terminal resolution.** `CTPublisher.mintFrom` resolves terminals via `DIRECTORY.primaryTerminalOf()`. A compromised directory could redirect payment and fee flows.
6
10
 
7
- ### 1. CTDeployer as Data Hook (CRITICAL trust surface)
11
+ ## 2. Economic / Manipulation Risks
8
12
 
9
- CTDeployer acts as `IJBRulesetDataHook` for every project it deploys (`CTDeployer.sol` line 290: `rulesetConfigurations[0].metadata.dataHook = address(this)`). This means:
13
+ - **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
+ - **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
+ - **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.
10
17
 
11
- - All pay and cashout calls for every CTDeployer-launched project are routed through CTDeployer's `beforePayRecordedWith()` (line 162) and `beforeCashOutRecordedWith()` (line 134).
12
- - CTDeployer forwards these to `dataHookOf[projectId]` (line 152, 170), which is the JB721TiersHook.
13
- - If CTDeployer has a bug, all Croptop projects are affected simultaneously.
14
- - `dataHookOf` is write-once (no setter function), so a bug cannot be patched per-project.
18
+ ## 3. Access Control
15
19
 
16
- ### 2. Sucker Registry (MEDIUM trust surface)
20
+ - **Allowlist is O(n) linear scan.** `_isAllowed` iterates the entire allowlist array. Acceptable for small lists but gas-expensive for large allowlists. No Merkle proof alternative.
21
+ - **Categories cannot be disabled.** Once `configurePostingCriteriaFor` is called for a category, it can only be restricted by setting very high `minimumPrice` or `minimumTotalSupply`, but never fully removed.
22
+ - **CTDeployer grants broad permissions.** Constructor grants `MAP_SUCKER_TOKEN` (wildcard, projectId=0) to sucker registry and `ADJUST_721_TIERS` (wildcard, projectId=0) to publisher. These permissions apply to ALL projects deployed by this CTDeployer instance.
23
+ - **CTDeployer.deployProjectFor permission gap.** No explicit permission check -- anyone can call `deployProjectFor` and create a project. A griefer could deploy many projects with arbitrary owners.
24
+ - **CTDeployer.claimCollectionOwnershipOf.** Only checks `PROJECTS.ownerOf(projectId) == _msgSender()`. No Juicebox permission check. If the project NFT is transferred, the new owner can claim collection ownership. After claiming, the project owner must grant CTPublisher the `ADJUST_721_TIERS` permission for the project so that `mintFrom()` continues to work — without this, all subsequent posts revert.
17
25
 
18
- `CTDeployer.beforeCashOutRecordedWith()` at line 146 trusts `SUCKER_REGISTRY.isSuckerOf()` to determine whether a cashout should be tax-free. If the sucker registry is compromised or returns `true` for an attacker's address, that address can cash out with zero tax from any Croptop project.
26
+ ## 4. DoS Vectors
19
27
 
20
- ### 3. JBPermissions (HIGH trust surface)
28
+ - **Large batch posts.** `_setupPosts` iterates all posts with O(n^2) duplicate detection (inner loop `j < i`). A batch of 100+ posts has quadratic gas growth.
29
+ - **External hook calls in loops.** `_setupPosts` calls `hook.STORE().tierOf()` and `hook.STORE().isTierRemoved()` inside the post loop. A reverting or gas-expensive store blocks the entire mint.
30
+ - **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
+ - **adjustTiers revert.** `hook.adjustTiers()` can revert if tiers violate category ordering constraints or other hook-level rules. This blocks the entire `mintFrom` call.
21
32
 
22
- Both CTDeployer and CTPublisher inherit `JBPermissioned` and rely on `_requirePermissionFrom()` for access control. The JBPermissions contract is a shared singleton. If compromised, all permission-gated functions across all projects lose access control.
33
+ ## 5. Integration Risks
23
34
 
24
- ### 4. JB721TiersHookStore (MEDIUM trust surface)
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
+ - **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
+ - **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
+ - **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
+ - **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.
25
40
 
26
- CTPublisher reads tier data from the hook's store at `_setupPosts()` line 477 (`store.tierOf(address(hook), tierId, false).price`) and line 469 (`hook.STORE().isTierRemoved()`). If the store returns incorrect data, fee calculations and tier reuse logic break.
41
+ ## 6. Invariants to Verify
27
42
 
28
- ### 5. Publisher-Set Splits (MEDIUM trust surface)
29
-
30
- Publishers set their own `splitPercent` and `splits` array in `CTPost` (line 544, `CTPublisher.sol`). While `splitPercent` is bounded by `maximumSplitPercent` (line 518-519), the actual `splits` array contents are not validated against the percent. The JB721TiersHook and JBSplits contracts downstream are responsible for enforcing that splits sum correctly.
31
-
32
- ### 6. ERC2771 Trusted Forwarder (LOW trust surface)
33
-
34
- Both CTDeployer and CTPublisher use `ERC2771Context` with a trusted forwarder set at construction. If the trusted forwarder is compromised, any `_msgSender()` check can be spoofed, bypassing all permission checks. Mitigated by setting `trustedForwarder = address(0)` in deployments that don't need meta-transactions.
35
-
36
- ## Audited Findings (Nemesis Audit)
37
-
38
- ### Fixed: NM-001 -- Duplicate URI Fee Evasion (MEDIUM, FIXED)
39
-
40
- **Location:** `CTPublisher._setupPosts()` lines 454-469
41
- **Test:** `test/regression/M6_DuplicateUriFeeEvasion.t.sol`
42
-
43
- **Root cause:** Two posts with the same `encodedIPFSUri` in a single `mintFrom()` batch caused a desync between `tierIdForEncodedIPFSUriOf` (written at line 552 during iteration) and the hook store (updated only after `adjustTiers()` is called at line 338). The second post found a non-zero tier ID, but `store.tierOf()` returned `price=0` because the tier hadn't been committed yet.
44
-
45
- **Attack scenario:**
46
- 1. Attacker calls `mintFrom()` with two identical `encodedIPFSUri` posts, each priced at 1 ETH.
47
- 2. First post: `totalPrice += 1 ether`. Mapping written.
48
- 3. Second post: Finds mapping, reads `store.tierOf().price = 0`. `totalPrice` stays at 1 ETH.
49
- 4. Fee calculated on 1 ETH instead of 2 ETH. Attacker evades 50% of fees.
50
-
51
- **Fix applied:** Explicit duplicate detection loop at lines 454-458. Reverts with `CTPublisher_DuplicatePost(encodedIPFSUri)` if any two posts in the batch share an `encodedIPFSUri`.
52
-
53
- **Test coverage:** 5 tests covering adjacent duplicates, non-adjacent duplicates, distinct URIs, single posts, and fuzz over arbitrary URI pairs.
54
-
55
- ### Fixed: H-19 -- Fee Evasion on Existing Tier Mints (HIGH, FIXED)
56
-
57
- **Location:** `CTPublisher._setupPosts()` line 477
58
- **Test:** `test/regression/H19_FeeEvasion.t.sol`
59
-
60
- **Root cause:** When a post reused an existing tier (same `encodedIPFSUri`), the fee was calculated from `post.price` (attacker-controlled) instead of the actual tier price stored on-chain. An attacker could set `post.price = 0` for existing tiers to evade the 5% fee entirely.
61
-
62
- **Fix applied:** For existing tiers, `totalPrice` accumulates `store.tierOf(address(hook), tierId, false).price` (line 477) -- the on-chain price -- not `post.price`.
63
-
64
- **Test coverage:** 2 tests: one proving the attacker can't send 0 ETH for existing tiers, one proving the exact fee boundary (1.05 ETH required for a 1 ETH tier).
65
-
66
- ### Fixed: L-52 -- Stale Tier ID Mapping After External Removal (LOW, FIXED)
67
-
68
- **Location:** `CTPublisher._setupPosts()` lines 466-470
69
- **Test:** `test/regression/L52_StaleTierIdMapping.t.sol`
70
-
71
- **Root cause:** If a tier was removed externally via `adjustTiers()`, the `tierIdForEncodedIPFSUriOf` mapping still pointed to the removed tier ID. Subsequent posts with the same URI would try to mint from a removed tier.
72
-
73
- **Fix applied:** Before reusing a tier, `isTierRemoved()` is checked (line 469). If true, the stale mapping is deleted (line 470), and the post falls through to create a new tier.
74
-
75
- **Test coverage:** 2 tests: mapping cleared on removal, mapping preserved when tier still exists.
76
-
77
- ### Open: NM-005 -- uint56 vs uint64 Project ID Cast (LOW, NOT FIXED)
78
-
79
- **Location:** `CTProjectOwner.sol` line 72 vs `CTDeployer.sol` line 337
80
-
81
- CTProjectOwner casts `tokenId` to `uint56`, while CTDeployer casts `projectId` to `uint64` in `JBPermissionsData`. No practical impact since project IDs are sequential and won't exceed 2^56, but the inconsistency should be harmonized.
82
-
83
- ### Open: NM-006 -- Cannot Fully Disable Posting for a Category (LOW, NOT FIXED)
84
-
85
- **Location:** `CTPublisher.configurePostingCriteriaFor()` line 250-252
86
-
87
- `minimumTotalSupply` must be > 0, so once a category is enabled, there is no clean way to disable it. Workaround: set `minimumPrice` to `type(uint104).max` and `minimumTotalSupply = maximumTotalSupply = 1`.
88
-
89
- ## Active Risk Analysis
90
-
91
- ### R-1: Tier Spam via Permissionless Posting
92
-
93
- **Severity:** MEDIUM
94
- **Location:** `CTPublisher.mintFrom()` line 297, `_setupPosts()` line 419
95
- **Tested:** Partially (CroptopAttacks.t.sol tests input validation, not volume)
96
-
97
- **Description:** When `allowedAddresses` is empty (line 523 condition not met), anyone can call `mintFrom()` and create new tiers as long as they meet price/supply criteria. Each `mintFrom()` call can include multiple posts, each creating a new tier. There is no per-address rate limit or maximum tier count.
98
-
99
- **Impact:** An attacker can create thousands of tiers on a project's hook, increasing gas costs for all tier-related operations (enumeration, minting, removal). The JB721TiersHookStore uses a linked list for tiers, making enumeration O(n).
100
-
101
- **Mitigation:** Project owners should use allowlists for categories. The `minimumPrice` floor provides an economic barrier. Each new tier costs the poster the tier price + 5% fee.
102
-
103
- ### R-2: Fee Rounding Loss (Dust)
104
-
105
- **Severity:** INFORMATIONAL
106
- **Location:** `CTPublisher.mintFrom()` line 328
107
- **Tested:** No dedicated test
108
-
109
- **Description:** Fee calculation `totalPrice / FEE_DIVISOR` uses integer division, which truncates. For `totalPrice = 39 wei`, the fee is `1 wei` instead of `1.95 wei`. This consistently rounds in the payer's favor (fee project receives slightly less).
110
-
111
- **Quantification:** Maximum dust loss per transaction is `FEE_DIVISOR - 1 = 19 wei`. For practical ETH amounts (>= 0.001 ETH), this is < 0.000002% underpayment.
112
-
113
- ### R-3: Allowlist Linear Scan Gas Scaling
114
-
115
- **Severity:** LOW
116
- **Location:** `CTPublisher._isAllowed()` lines 200-210
117
- **Tested:** No gas benchmarks in test suite
118
-
119
- **Description:** Allowlist checking is O(n) linear scan. For an allowlist of 1000 addresses, each `mintFrom()` call pays ~3000 gas per address checked (1000 * 3 gas/comparison) = ~3M additional gas. The EVM block gas limit (~30M on mainnet) imposes an effective cap of ~10,000 addresses before a `mintFrom()` transaction becomes infeasible.
120
-
121
- **Mitigation:** Document the recommendation of < 100 addresses per allowlist (comment at line 197). A Merkle proof pattern would scale better but adds complexity.
122
-
123
- ### R-4: Force-Sent ETH Routed to Fee Project
124
-
125
- **Severity:** LOW (NM-004 from audit)
126
- **Location:** `CTPublisher.mintFrom()` lines 389-404
127
- **Tested:** No dedicated test
128
-
129
- **Description:** If ETH is force-sent to CTPublisher via `selfdestruct` from another contract, it gets included in `address(this).balance` (line 397) and is sent to the fee project terminal on the next `mintFrom()` call. CTPublisher has no `receive()` or `fallback()`, so normal sends revert. Only `selfdestruct` (deprecated in future Ethereum hard forks) can force-send ETH.
130
-
131
- **Impact:** The fee project receives a windfall. No funds are lost to attackers. The mint caller is not charged extra (fee is subtracted from `msg.value` before the main payment, and the residual balance goes to fees).
132
-
133
- ### R-5: Data Hook Proxy Forwarding Failure
134
-
135
- **Severity:** MEDIUM
136
- **Location:** `CTDeployer.beforeCashOutRecordedWith()` line 152, `beforePayRecordedWith()` line 170
137
- **Tested:** No unit test for forwarding failure
138
-
139
- **Description:** CTDeployer forwards data hook calls to `dataHookOf[projectId]`, which is set to the JB721TiersHook. If the hook reverts (e.g., due to a bug or an upgrade in dependent contracts), all pay and cashout operations for the project will revert. There is no try-catch wrapping these forwards.
140
-
141
- **Impact:** A broken or upgraded hook can DoS all payments and cashouts for a project. Since `dataHookOf` has no setter, the project is permanently bricked in this scenario.
142
-
143
- **Mitigation:** The hook is a deterministic deployment (Create2) with no upgrade mechanism, reducing the likelihood of unexpected behavior changes.
144
-
145
- ### R-6: CTProjectOwner Accepts Transfers (Not Just Mints)
146
-
147
- **Severity:** LOW
148
- **Location:** `CTProjectOwner.onERC721Received()` line 47-77
149
- **Tested:** No negative test for transfer vs mint
150
-
151
- **Description:** Unlike `CTDeployer.onERC721Received()` (which checks `from != address(0)` at line 201), `CTProjectOwner.onERC721Received()` only checks that `msg.sender == address(PROJECTS)` (line 62). It does NOT check `from == address(0)`. This means any holder can `safeTransferFrom` their project NFT to `CTProjectOwner`, effectively burning their project ownership. While this is the intended use case (burn-lock), there is no confirmation dialog or cooling period.
152
-
153
- **Impact:** A project owner who accidentally transfers their project to CTProjectOwner loses ownership permanently with no recovery mechanism.
154
-
155
- ### R-7: Sucker Fee-Free Cashout Trust Chain
156
-
157
- **Severity:** MEDIUM
158
- **Location:** `CTDeployer.beforeCashOutRecordedWith()` line 146
159
- **Tested:** No adversarial test for sucker impersonation
160
-
161
- **Description:** The fee-free cashout path relies entirely on `SUCKER_REGISTRY.isSuckerOf({projectId, addr: context.holder})`. The trust chain is:
162
-
163
- 1. `SUCKER_REGISTRY` must correctly track deployed suckers.
164
- 2. `allowSuckerDeployer()` on the registry must only be callable by the registry owner.
165
- 3. Deployed suckers must not be compromisable.
166
-
167
- If any link breaks, an attacker could register a malicious address as a "sucker" and cash out any Croptop project's treasury at 0% tax rate.
168
-
169
- **Mitigation:** The sucker registry is operated by the protocol multisig. The `MAP_SUCKER_TOKEN` permission granted at CTDeployer construction (line 107) is wildcard (`projectId: 0`), which is necessary for cross-chain functionality but broadens the blast radius.
170
-
171
- ### R-8: No Input Validation on `splits` Array Contents
172
-
173
- **Severity:** LOW
174
- **Location:** `CTPublisher._setupPosts()` line 544-545
175
- **Tested:** Split percent bounds are tested (`CTPublisher.t.sol`, `CroptopAttacks.t.sol`), but split array contents are not
176
-
177
- **Description:** While `splitPercent` is validated against `maximumSplitPercent` (line 518-519), the actual `splits` array (line 545) is passed through to the JB721TierConfig without validation. CTPublisher does not verify:
178
- - That split beneficiaries are non-zero addresses.
179
- - That split percentages sum to `SPLITS_TOTAL_PERCENT`.
180
- - That split hooks are valid contracts.
181
-
182
- **Mitigation:** The JB721TiersHook and JBSplits contracts downstream validate split configurations. CTPublisher relies on these downstream checks.
183
-
184
- ### R-9: Project Deployment Front-Running
185
-
186
- **Severity:** LOW
187
- **Location:** `CTDeployer.deployProjectFor()` line 260
188
- **Tested:** Fork test in `Fork.t.sol` but no front-running test
189
-
190
- **Description:** `deployProjectFor()` calculates the expected project ID as `PROJECTS.count() + 1` (line 260) and asserts it matches the actual ID returned by `launchProjectFor()` (line 296). If another project is created between the `count()` read and the `launchProjectFor()` call (front-running), the assertion fails and the entire transaction reverts.
191
-
192
- **Impact:** No fund loss. The deployment simply fails and must be retried. An attacker would pay gas to front-run with no economic benefit.
193
-
194
- ### R-10: Metadata Assembly Correctness
195
-
196
- **Severity:** INFORMATIONAL
197
- **Location:** `CTPublisher.mintFrom()` lines 355-357
198
- **Tested:** `test/Test_MetadataGeneration.t.sol`
199
-
200
- **Description:** `FEE_PROJECT_ID` is written into the first 32 bytes of `mintMetadata` via inline assembly `mstore`. This overwrites whatever was previously in that position (the metadata length prefix is at offset 0, the first data word is at offset 32). The test file confirms this produces valid metadata that can be parsed by `JBMetadataResolver.getDataFor()`.
201
-
202
- **Verified as correct:** False positive FF-002 from the Nemesis audit confirmed this is intentional per the JBMetadataResolver referral ID format.
203
-
204
- ## Test Coverage Summary
205
-
206
- | Risk Area | Test File | Coverage |
207
- |-----------|-----------|----------|
208
- | Posting criteria round-trip | `CTPublisher.t.sol` | 11 tests including fuzz |
209
- | Permission enforcement | `CTPublisher.t.sol`, `CroptopAttacks.t.sol` | 3 tests |
210
- | Input validation (price, supply, URI) | `CroptopAttacks.t.sol` | 6 tests |
211
- | Split percent enforcement | `CTPublisher.t.sol`, `CroptopAttacks.t.sol` | 10 tests including fuzz |
212
- | Fee evasion (existing tiers) | `H19_FeeEvasion.t.sol` | 2 regression tests |
213
- | Fee evasion (duplicate URIs) | `M6_DuplicateUriFeeEvasion.t.sol` | 5 tests including fuzz |
214
- | Stale tier mapping | `L52_StaleTierIdMapping.t.sol` | 2 regression tests |
215
- | Metadata generation | `Test_MetadataGeneration.t.sol` | 1 test |
216
- | Full deployment integration | `Fork.t.sol` | 2 fork tests |
217
- | Data hook proxy forwarding | -- | **Not tested** |
218
- | Force-sent ETH handling | -- | **Not tested** |
219
- | Allowlist gas scaling | -- | **Not tested** |
220
- | Sucker fee-free cashout abuse | -- | **Not tested** |
221
- | CTProjectOwner transfer vs mint | -- | **Not tested** |
222
- | Front-running `deployProjectFor` | -- | **Not tested** |
223
-
224
- ## Invariants
225
-
226
- These invariants should hold for all Croptop operations:
227
-
228
- 1. **Fee invariant:** For any `mintFrom()` where `projectId != FEE_PROJECT_ID`, the fee project receives exactly `totalPrice / FEE_DIVISOR` (minus rounding dust of at most 19 wei).
229
-
230
- 2. **Posting criteria invariant:** A `mintFrom()` call succeeds only if every post satisfies: `price >= minimumPrice`, `totalSupply >= minimumTotalSupply`, `totalSupply <= maximumTotalSupply`, `splitPercent <= maximumSplitPercent`, and (if allowlist non-empty) `msg.sender in allowedAddresses`.
231
-
232
- 3. **Tier uniqueness invariant (post-fix):** Within a single `mintFrom()` batch, no two posts can have the same `encodedIPFSUri`.
233
-
234
- 4. **Fee calculation invariant (post-fix):** For existing tiers, the fee is based on `store.tierOf().price` (on-chain price), not `post.price` (user-supplied).
235
-
236
- 5. **Ownership invariant:** CTDeployer owns the project NFT only transiently during `deployProjectFor()`. By the end of the function, ownership is always transferred to the specified `owner`.
237
-
238
- 6. **Data hook immutability invariant:** `dataHookOf[projectId]` is set exactly once (during `deployProjectFor`) and never modified afterward.
43
+ - `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set exactly once per (hook, encodedIPFSUri) pair and points to a valid, non-removed tier.
44
+ - `totalPrice` accumulated in `_setupPosts` equals the sum of prices for all posts (new tier price for new posts, existing tier price for existing posts).
45
+ - Fee amount: `msg.value - payValue == totalPrice / FEE_DIVISOR` (within 19 wei rounding).
46
+ - For every configured category, `minimumTotalSupply <= maximumTotalSupply` and `minimumTotalSupply > 0`.
47
+ - Packed allowance encoding/decoding round-trips correctly for all valid input ranges.
48
+ - After `CTDeployer.deployProjectFor`, the project NFT is owned by `owner`, and `dataHookOf[projectId]` is the deployed 721 hook.
49
+ - `CTProjectOwner` only grants `ADJUST_721_TIERS` permission, never broader permissions.
package/SKILLS.md CHANGED
@@ -33,7 +33,7 @@ Permissioned NFT publishing system that lets anyone post content as 721 tiers to
33
33
  | Function | What it does |
34
34
  |----------|-------------|
35
35
  | `CTDeployer.deployProjectFor(owner, projectConfig, suckerDeploymentConfiguration, controller)` | Deploys a new Juicebox project with a 721 tiers hook, configures posting criteria, optionally deploys suckers, and transfers project ownership to the specified owner. Uses `CTDeployer` as data hook proxy. Returns `(projectId, hook)`. |
36
- | `CTDeployer.claimCollectionOwnershipOf(hook)` | Transfers hook ownership to the project via `JBOwnable.transferOwnershipToProject`. Only callable by the project owner. |
36
+ | `CTDeployer.claimCollectionOwnershipOf(hook)` | Transfers hook ownership to the project via `JBOwnable.transferOwnershipToProject`. Only callable by the project owner. After claiming, the project owner must grant CTPublisher `ADJUST_721_TIERS` permission for the project so that `mintFrom()` continues to work. |
37
37
  | `CTDeployer.deploySuckersFor(projectId, suckerDeploymentConfiguration)` | Deploys new cross-chain suckers for an existing project. Requires `DEPLOY_SUCKERS` permission. |
38
38
 
39
39
  ### Data Hook Proxy