@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.
@@ -0,0 +1,598 @@
1
+ # croptop-core-v6 -- User Journeys
2
+
3
+ Complete user path documentation for auditors. Each journey describes the entry point, parameters, state changes, external calls, and edge cases.
4
+
5
+ ---
6
+
7
+ ## Journey 1: Deploy a Croptop Project
8
+
9
+ **Actor:** Project creator
10
+ **Entry point:** `CTDeployer.deployProjectFor(owner, projectConfig, suckerDeploymentConfiguration, controller)`
11
+ **Source:** `src/CTDeployer.sol` lines 241-342
12
+
13
+ ### Parameters
14
+
15
+ | Parameter | Type | Description |
16
+ |-----------|------|-------------|
17
+ | `owner` | `address` | Final owner of the project NFT after deployment |
18
+ | `projectConfig` | `CTProjectConfig` | Name, symbol, URIs, terminal configs, allowed posts, salt |
19
+ | `suckerDeploymentConfiguration` | `CTSuckerDeploymentConfig` | Cross-chain sucker deployer configs + salt (set salt to `bytes32(0)` to skip) |
20
+ | `controller` | `IJBController` | The JB controller that will manage the project |
21
+
22
+ ### Execution Flow
23
+
24
+ 1. **Controller validation** (line 251): Reverts if `controller.PROJECTS() != PROJECTS`.
25
+
26
+ 2. **Ruleset configuration** (lines 253-288):
27
+ - Weight: `1_000_000 * 10^18`
28
+ - Base currency: ETH
29
+ - Cash-out tax rate: `MAX_CASH_OUT_TAX_RATE` (100%)
30
+ - Data hook: `address(this)` (CTDeployer)
31
+ - `useDataHookForPay = true`, `useDataHookForCashOut = true`
32
+
33
+ 3. **Project ID prediction** (line 258): `projectId = PROJECTS.count() + 1`
34
+
35
+ 4. **Hook deployment** (lines 262-283):
36
+ ```
37
+ DEPLOYER.deployHookFor(projectId, config, salt)
38
+ ```
39
+ - Salt: `keccak256(abi.encode(projectConfig.salt, _msgSender()))`
40
+ - Deployed with empty tiers, ETH currency, 18 decimals
41
+ - No reserves, no votes, no owner minting, no overspend prevention
42
+
43
+ 5. **Project launch** (lines 291-300):
44
+ ```
45
+ controller.launchProjectFor(owner: address(this), ...)
46
+ ```
47
+ - CTDeployer receives the project NFT temporarily
48
+ - `assert(projectId == returned ID)` -- reverts on mismatch (front-running protection)
49
+
50
+ 6. **Data hook registration** (line 303):
51
+ ```
52
+ dataHookOf[projectId] = IJBRulesetDataHook(hook)
53
+ ```
54
+ This is write-once. No setter exists.
55
+
56
+ 7. **Posting criteria** (lines 306-308): If `projectConfig.allowedPosts.length > 0`, calls internal `_configurePostingCriteriaFor()` which formats `CTDeployerAllowedPost` into `CTAllowedPost` (adding the hook address) and delegates to `PUBLISHER.configurePostingCriteriaFor()`.
57
+
58
+ 8. **Sucker deployment** (lines 314-321): If `suckerDeploymentConfiguration.salt != bytes32(0)`:
59
+ ```
60
+ SUCKER_REGISTRY.deploySuckersFor(projectId, salt, configurations)
61
+ ```
62
+
63
+ 9. **Ownership transfer** (line 324):
64
+ ```
65
+ PROJECTS.transferFrom(address(this), owner, projectId)
66
+ ```
67
+
68
+ 10. **Permission grants** (lines 327-341): Grants `owner` four permissions from CTDeployer's account:
69
+ - `ADJUST_721_TIERS`
70
+ - `SET_721_METADATA`
71
+ - `MINT_721`
72
+ - `SET_721_DISCOUNT_PERCENT`
73
+
74
+ ### State Changes
75
+
76
+ | Storage | Change |
77
+ |---------|--------|
78
+ | `CTDeployer.dataHookOf[projectId]` | Set to the deployed hook address (permanent) |
79
+ | `JBProjects` (ERC-721) | New token minted, transferred from CTDeployer to `owner` |
80
+ | `JBPermissions` | 5 permission entries set (1 for sucker registry, 1 for publisher, 4 for owner) |
81
+ | `CTPublisher._packedAllowanceFor` | Set for each allowed post category (if any) |
82
+ | `CTPublisher._allowedAddresses` | Set for each allowed post category with allowlists (if any) |
83
+
84
+ ### Edge Cases
85
+
86
+ - **Front-running:** If another project is created between `PROJECTS.count()` and `launchProjectFor()`, the `assert` fails and the transaction reverts. No funds are lost.
87
+ - **`owner = address(0)`:** The project NFT transfer to `address(0)` would revert (ERC-721 constraint). The deployment fails.
88
+ - **`owner` is a contract without `onERC721Received`:** The `transferFrom` (not `safeTransferFrom`) succeeds even if the owner cannot handle ERC-721s. The project NFT could become stuck.
89
+ - **Sucker deployment failure:** If `deploySuckersFor` reverts, the entire deployment reverts. The sucker deployer uses a try-catch cascade internally, but registry-level reverts propagate.
90
+ - **Empty `terminalConfigurations`:** The project launches with no terminals. Payments and cash-outs are not possible until terminals are added separately.
91
+ - **Salt collision:** If `keccak256(abi.encode(salt, _msgSender()))` collides with a previously deployed hook, `DEPLOYER.deployHookFor()` reverts (Create2 collision).
92
+
93
+ ---
94
+
95
+ ## Journey 2: Post Content (Mint NFTs)
96
+
97
+ **Actor:** Content poster (any address, or allowlisted address)
98
+ **Entry point:** `CTPublisher.mintFrom(hook, posts, nftBeneficiary, feeBeneficiary, additionalPayMetadata, feeMetadata)`
99
+ **Source:** `src/CTPublisher.sol` lines 307-420
100
+ **Value:** Must send `msg.value >= sum(tier prices) + 5% fee`
101
+
102
+ ### Parameters
103
+
104
+ | Parameter | Type | Description |
105
+ |-----------|------|-------------|
106
+ | `hook` | `IJB721TiersHook` | The 721 hook to post to |
107
+ | `posts` | `CTPost[]` | Array of posts (URI, supply, price, category, splits) |
108
+ | `nftBeneficiary` | `address` | Receives the minted NFTs |
109
+ | `feeBeneficiary` | `address` | Receives fee project tokens |
110
+ | `additionalPayMetadata` | `bytes` | Extra metadata appended to the payment |
111
+ | `feeMetadata` | `bytes` | Metadata sent with the fee payment |
112
+
113
+ ### Execution Flow
114
+
115
+ **Phase 1: Validation and setup** (`_setupPosts`, lines 432-579)
116
+
117
+ For each post in the batch:
118
+
119
+ 1. **URI check:** `encodedIPFSUri != bytes32("")` or revert `CTPublisher_EmptyEncodedIPFSUri`
120
+ 2. **Duplicate check:** O(i) scan against all prior posts. Revert `CTPublisher_DuplicatePost` on match.
121
+ 3. **Existing tier lookup:** Check `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]`
122
+ - **Tier exists and live:** Reuse tier ID. Accumulate `store.tierOf().price`.
123
+ - **Tier exists but removed:** Delete mapping. Fall through to new tier.
124
+ - **No tier:** Validate against category allowance, create `JB721TierConfig`.
125
+
126
+ **Phase 2: Fee calculation** (lines 336-354)
127
+
128
+ ```
129
+ fee = totalPrice / FEE_DIVISOR (integer division)
130
+ require(payValue >= fee) (reverts CTPublisher_InsufficientEthSent if not)
131
+ payValue = msg.value - fee (if projectId != FEE_PROJECT_ID)
132
+ require(totalPrice <= payValue) (reverts CTPublisher_InsufficientEthSent if not)
133
+ ```
134
+
135
+ **Phase 3: Tier creation** (line 348)
136
+
137
+ ```
138
+ hook.adjustTiers(tiersToAdd, [])
139
+ ```
140
+
141
+ **Phase 4: Metadata construction** (lines 356-367)
142
+
143
+ Build JBMetadataResolver-compatible metadata with tier IDs and referral ID.
144
+
145
+ **Phase 5: Project payment** (lines 388-396)
146
+
147
+ ```
148
+ projectTerminal.pay{value: payValue}(projectId, NATIVE_TOKEN, payValue, nftBeneficiary, 0, "Minted from Croptop", mintMetadata)
149
+ ```
150
+
151
+ **Phase 6: Fee payment** (lines 403-418)
152
+
153
+ ```
154
+ if (address(this).balance != 0) {
155
+ feeTerminal.pay{value: address(this).balance}(FEE_PROJECT_ID, ...)
156
+ }
157
+ ```
158
+
159
+ ### State Changes
160
+
161
+ | Storage | Change |
162
+ |---------|--------|
163
+ | `CTPublisher.tierIdForEncodedIPFSUriOf[hook][uri]` | Set for each new tier created |
164
+ | `CTPublisher.tierIdForEncodedIPFSUriOf[hook][uri]` | Deleted if stale mapping detected (removed tier) |
165
+ | `JB721TiersHookStore` (external) | New tiers added via `adjustTiers` |
166
+ | Project terminal (external) | Balance increased by `payValue` |
167
+ | Fee project terminal (external) | Balance increased by fee amount |
168
+
169
+ ### Edge Cases
170
+
171
+ - **Empty posts array:** `_setupPosts` returns with `totalPrice = 0`, `tiersToAdd` and `tierIdsToMint` both empty. `adjustTiers` is called with an empty array (no-op). The project terminal receives `msg.value` (no fee deducted since `totalPrice / 20 = 0`). The fee terminal receives nothing.
172
+ - **All posts reuse existing tiers:** No new tiers are created. `tiersToAdd` is resized to length 0 via assembly. `adjustTiers` is a no-op. Fees are calculated from on-chain tier prices.
173
+ - **Mixed new and existing tiers:** `tiersToAdd` is resized via assembly to contain only new tiers. `tierIdsToMint` contains a mix of new and existing IDs.
174
+ - **`msg.value` exceeds required amount:** Excess ETH is sent to the fee project (via `address(this).balance`). The poster overpays the fee project.
175
+ - **`msg.value` is exactly right:** `address(this).balance` after the project payment equals the fee. Fee project receives the correct amount.
176
+ - **`projectId == FEE_PROJECT_ID`:** No fee is deducted. Full `msg.value` goes to the project terminal. `address(this).balance` is 0 after the project payment (no fee payment occurs).
177
+ - **Tier price is 0:** Posting criteria allow `minimumPrice = 0`. A post with `price = 0` creates a free tier. Fee on a free tier is 0. The poster sends 0 ETH (or only ETH for other posts in the batch).
178
+ - **`nftBeneficiary = address(0)`:** The terminal payment may succeed (depending on terminal implementation), but the NFTs would be minted to `address(0)`, effectively burning them.
179
+ - **`hook.adjustTiers()` reverts:** The entire transaction reverts. No state changes are committed. The poster's ETH is returned.
180
+ - **Terminal payment reverts:** The entire transaction reverts. The `adjustTiers` state change is also rolled back.
181
+ - **Fee terminal payment reverts:** This occurs after the project payment has already succeeded. If the fee terminal reverts, the entire transaction reverts, undoing the project payment too.
182
+ - **Batch with posts in different categories:** Each post is validated against its own category's allowance independently. A batch can contain posts in multiple categories.
183
+ - **Re-posting a removed tier's URI:** The stale mapping is cleared and a new tier is created. The new tier may have different price/supply/splits than the original.
184
+
185
+ ---
186
+
187
+ ## Journey 3: Configure Posting Criteria (Allowlist Setup)
188
+
189
+ **Actor:** Hook owner (or permissioned delegate)
190
+ **Entry point:** `CTPublisher.configurePostingCriteriaFor(allowedPosts)`
191
+ **Source:** `src/CTPublisher.sol` lines 240-295
192
+
193
+ ### Parameters
194
+
195
+ | Parameter | Type | Description |
196
+ |-----------|------|-------------|
197
+ | `allowedPosts` | `CTAllowedPost[]` | Array of per-category posting rules |
198
+
199
+ Each `CTAllowedPost` contains:
200
+
201
+ | Field | Type | Constraints |
202
+ |-------|------|-------------|
203
+ | `hook` | `address` | Must be a JBOwnable + IJB721TiersHook |
204
+ | `category` | `uint24` | 0 to 16,777,215 |
205
+ | `minimumPrice` | `uint104` | Must fit in 104 bits |
206
+ | `minimumTotalSupply` | `uint32` | Must be > 0 |
207
+ | `maximumTotalSupply` | `uint32` | Must be >= `minimumTotalSupply` |
208
+ | `maximumSplitPercent` | `uint32` | 0 = splits disabled, up to `SPLITS_TOTAL_PERCENT` (1,000,000,000) |
209
+ | `allowedAddresses` | `address[]` | Empty = permissionless, non-empty = restricted |
210
+
211
+ ### Execution Flow
212
+
213
+ For each `CTAllowedPost` in the array:
214
+
215
+ 1. **Emit event** (line 249): `ConfigurePostingCriteria(hook, allowedPost, caller)`
216
+
217
+ 2. **Permission check** (lines 253-257):
218
+ ```
219
+ _requirePermissionFrom(
220
+ account: JBOwnable(hook).owner(),
221
+ projectId: IJB721TiersHook(hook).PROJECT_ID(),
222
+ permissionId: JBPermissionIds.ADJUST_721_TIERS
223
+ )
224
+ ```
225
+
226
+ 3. **Validation:**
227
+ - `minimumTotalSupply > 0` or revert `CTPublisher_ZeroTotalSupply` (line 260)
228
+ - `minimumTotalSupply <= maximumTotalSupply` or revert `CTPublisher_MaxTotalSupplyLessThanMin` (line 265)
229
+
230
+ 4. **Pack and store** (lines 271-281):
231
+ ```
232
+ packed = minimumPrice | (minimumTotalSupply << 104) | (maximumTotalSupply << 136) | (maximumSplitPercent << 168)
233
+ _packedAllowanceFor[hook][category] = packed
234
+ ```
235
+
236
+ 5. **Allowlist storage** (lines 284-293):
237
+ ```
238
+ delete _allowedAddresses[hook][category]
239
+ for each address in allowedAddresses:
240
+ _allowedAddresses[hook][category].push(address)
241
+ ```
242
+
243
+ ### State Changes
244
+
245
+ | Storage | Change |
246
+ |---------|--------|
247
+ | `_packedAllowanceFor[hook][category]` | Overwritten with new packed values |
248
+ | `_allowedAddresses[hook][category]` | Deleted and repopulated |
249
+
250
+ ### Edge Cases
251
+
252
+ - **Overwriting existing criteria:** The entire packed value and allowlist are replaced. There is no merge or append behavior.
253
+ - **Multiple categories in one call:** Each `CTAllowedPost` can target a different hook/category pair. A single call can configure multiple categories across multiple hooks (provided the caller has permission for each).
254
+ - **Same category twice in one call:** The second entry overwrites the first. No duplicate check on the input array.
255
+ - **`maximumSplitPercent = 0`:** Splits are disabled. Any post with `splitPercent > 0` will revert with `CTPublisher_SplitPercentExceedsMaximum`.
256
+ - **`maximumTotalSupply = type(uint32).max`:** Effectively unlimited supply (4,294,967,295).
257
+ - **Large allowlist:** Stored via a push loop. A 1,000-address allowlist costs approximately 20M gas for the SSTORE operations. A 10,000-address list is infeasible in a single transaction.
258
+ - **Empty allowlist after previously non-empty:** `delete _allowedAddresses[hook][category]` clears the array. Posting becomes permissionless for that category.
259
+ - **Cannot disable category:** There is no way to set `minimumTotalSupply = 0` (it reverts). To effectively disable a category, set `minimumPrice = type(uint104).max` and `minimumTotalSupply = maximumTotalSupply = 1`. This makes posting economically infeasible.
260
+ - **Permission check uses `hook.owner()`:** If hook ownership has been transferred (e.g., to the project via `claimCollectionOwnershipOf`), the new owner (or their delegate) must call this function.
261
+ - **ERC2771 context:** `_msgSender()` is used for the permission check. If a trusted forwarder relays the call, the original sender (appended to calldata) is checked against permissions.
262
+
263
+ ---
264
+
265
+ ## Journey 4: Collect Posting Fees
266
+
267
+ **Actor:** Passive (fee project). Fees are collected automatically during `mintFrom()`.
268
+ **Entry point:** Triggered within `CTPublisher.mintFrom()` at lines 403-418
269
+ **Beneficiary:** The project with ID `FEE_PROJECT_ID` (immutable, set at construction)
270
+
271
+ ### Fee Calculation
272
+
273
+ ```
274
+ totalPrice = sum of all post prices in the batch
275
+ (on-chain tier price for existing tiers, post.price for new tiers)
276
+
277
+ fee = totalPrice / FEE_DIVISOR (FEE_DIVISOR = 20, so fee = 5%)
278
+ payValue = msg.value - fee (deducted before project payment)
279
+ ```
280
+
281
+ ### Fee Routing
282
+
283
+ After the project payment completes:
284
+
285
+ ```solidity
286
+ if (address(this).balance != 0) {
287
+ IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf(FEE_PROJECT_ID, NATIVE_TOKEN);
288
+ feeTerminal.pay{value: address(this).balance}({
289
+ projectId: FEE_PROJECT_ID,
290
+ amount: address(this).balance,
291
+ token: NATIVE_TOKEN,
292
+ beneficiary: feeBeneficiary,
293
+ minReturnedTokens: 0,
294
+ memo: "",
295
+ metadata: feeMetadata
296
+ });
297
+ }
298
+ ```
299
+
300
+ ### Fee Project Token Distribution
301
+
302
+ The `feeBeneficiary` parameter in `mintFrom()` determines who receives the fee project's tokens minted from the fee payment. The `feeMetadata` parameter allows the caller to pass arbitrary metadata to the fee payment (e.g., for data hooks on the fee project).
303
+
304
+ ### Fee Accounting Details
305
+
306
+ | Scenario | Fee Behavior |
307
+ |----------|-------------|
308
+ | Normal mint (1 post, 1 ETH price) | `fee = 1 ether / 20 = 0.05 ether`. Poster sends >= 1.05 ETH. |
309
+ | Batch mint (3 posts, 1 ETH each) | `fee = 3 ether / 20 = 0.15 ether`. Poster sends >= 3.15 ETH. |
310
+ | Existing tier reuse (1 ETH on-chain price) | Fee uses on-chain price, not `post.price`. `fee = 1 ether / 20`. |
311
+ | Free tier (price = 0) | `fee = 0 / 20 = 0`. No fee payment occurs. |
312
+ | `projectId == FEE_PROJECT_ID` | Fee deduction skipped entirely. Full `msg.value` goes to project. |
313
+ | `msg.value` exceeds requirement | Excess goes to fee project. Poster overpays. |
314
+ | Dust from integer division | Up to 19 wei lost per tx. Fee project receives slightly less. |
315
+
316
+ ### Edge Cases
317
+
318
+ - **Fee project has no primary terminal:** `DIRECTORY.primaryTerminalOf()` returns `address(0)`. The `pay()` call to address(0) reverts. The entire `mintFrom()` transaction reverts (including the project payment).
319
+ - **Fee terminal reverts:** Same as above -- entire `mintFrom()` reverts. No state changes persist.
320
+ - **`address(this).balance == 0` after project payment:** This happens when `fee == 0` (e.g., `totalPrice < FEE_DIVISOR` or `projectId == FEE_PROJECT_ID`). The fee payment is skipped entirely.
321
+ - **Force-sent ETH (via `selfdestruct`):** If ETH was force-sent to CTPublisher before the `mintFrom()` call, it is included in `address(this).balance` and routed to the fee project. CTPublisher has no `receive()` or `fallback()`, so normal sends revert. Only `selfdestruct` (deprecated post-Dencun) can force-send ETH.
322
+
323
+ ---
324
+
325
+ ## Journey 5: Deploy a Croptop Project via CTDeployer with Posting Criteria
326
+
327
+ **Actor:** Project creator
328
+ **Entry point:** `CTDeployer.deployProjectFor()` with non-empty `projectConfig.allowedPosts`
329
+ **Source:** `src/CTDeployer.sol` lines 241-342, internal `_configurePostingCriteriaFor()` at lines 376-405
330
+
331
+ This is an extension of Journey 1 that details the posting criteria configuration during deployment.
332
+
333
+ ### Posting Criteria Flow
334
+
335
+ 1. CTDeployer receives `CTDeployerAllowedPost[]` (which omits the `hook` field, since the hook hasn't been deployed yet).
336
+ 2. After the hook is deployed, `_configurePostingCriteriaFor()` converts each `CTDeployerAllowedPost` to a `CTAllowedPost` by injecting `hook: address(hook)` (lines 392-400).
337
+ 3. Calls `PUBLISHER.configurePostingCriteriaFor(formattedAllowedPosts)` (line 404).
338
+ 4. The publisher validates each entry (supply bounds, permissions) and stores the packed allowances and allowlists.
339
+
340
+ ### Permission Flow
341
+
342
+ The `PUBLISHER.configurePostingCriteriaFor()` call checks `ADJUST_721_TIERS` permission from `JBOwnable(hook).owner()`. At this point in the deployment:
343
+
344
+ - The hook was deployed by `DEPLOYER.deployHookFor()` on behalf of CTDeployer
345
+ - Hook ownership is set to CTDeployer (the deployer is the effective owner after deployment)
346
+ - The permission check passes because CTDeployer is both the caller and the hook owner (or has wildcard permission)
347
+
348
+ After the deployment completes and ownership is transferred to `owner`, only the new owner (or their delegate) can reconfigure posting criteria.
349
+
350
+ ### Edge Cases
351
+
352
+ - **Empty `allowedPosts`:** The `_configurePostingCriteriaFor()` call is skipped (line 306 condition). The project has no posting categories configured. Content cannot be posted until the owner configures criteria manually.
353
+ - **Invalid criteria in deployment:** If any `CTDeployerAllowedPost` has `minimumTotalSupply == 0` or `minimumTotalSupply > maximumTotalSupply`, the publisher reverts, and the entire deployment fails.
354
+
355
+ ---
356
+
357
+ ## Journey 6: Lock Project Ownership (Burn-Lock)
358
+
359
+ **Actor:** Project owner
360
+ **Entry point:** `IERC721(PROJECTS).safeTransferFrom(owner, address(ctProjectOwner), projectId)`
361
+ **Source:** `src/CTProjectOwner.sol` lines 47-80
362
+
363
+ ### Execution Flow
364
+
365
+ 1. The project owner calls `safeTransferFrom` on the JBProjects ERC-721 contract, transferring their project NFT to the CTProjectOwner contract.
366
+ 2. The ERC-721 contract calls `CTProjectOwner.onERC721Received()`.
367
+ 3. **Validation** (line 62): `msg.sender == address(PROJECTS)` -- only accepts tokens from the JBProjects contract.
368
+ 4. **Permission grant** (lines 65-77):
369
+ ```
370
+ PERMISSIONS.setPermissionsFor(
371
+ account: address(this),
372
+ permissionsData: JBPermissionsData({
373
+ operator: address(PUBLISHER),
374
+ projectId: uint64(tokenId),
375
+ permissionIds: [ADJUST_721_TIERS]
376
+ })
377
+ )
378
+ ```
379
+ 5. Returns `IERC721Receiver.onERC721Received.selector`.
380
+
381
+ ### State Changes
382
+
383
+ | Storage | Change |
384
+ |---------|--------|
385
+ | `JBProjects` (ERC-721) | Token transferred from owner to CTProjectOwner |
386
+ | `JBPermissions` | CTPublisher granted `ADJUST_721_TIERS` for this project from CTProjectOwner |
387
+
388
+ ### Consequences
389
+
390
+ - The project NFT is now held by CTProjectOwner. Since CTProjectOwner has no transfer function, ownership is effectively burned.
391
+ - CTPublisher can still adjust tiers (post content) because it has `ADJUST_721_TIERS` permission.
392
+ - The project owner can no longer change rulesets, add terminals, or perform any owner-only operations.
393
+ - This is irreversible. There is no recovery mechanism.
394
+
395
+ ### Edge Cases
396
+
397
+ - **`from != address(0)`:** Unlike CTDeployer, CTProjectOwner does NOT check `from != address(0)`. It accepts both mints and transfers. Any project holder can transfer their project to CTProjectOwner.
398
+ - **`tokenId` truncation:** The `uint64(tokenId)` cast truncates if `tokenId > type(uint64).max`. For realistic sequential project IDs, this is not a concern.
399
+ - **Transfer vs. `safeTransferFrom`:** Only `safeTransferFrom` triggers `onERC721Received`. A raw `transferFrom` would transfer the NFT without granting the publisher permission, leaving the project locked without posting capability.
400
+ - **Double transfer:** If two different project NFTs are transferred to CTProjectOwner, each gets its own scoped permission. The contract can hold multiple projects simultaneously.
401
+ - **Accidental transfer:** There is no confirmation, cooling period, or undo. A user who accidentally sends their project NFT to CTProjectOwner loses ownership permanently.
402
+
403
+ ---
404
+
405
+ ## Journey 7: Claim Hook Collection Ownership
406
+
407
+ **Actor:** Project owner
408
+ **Entry point:** `CTDeployer.claimCollectionOwnershipOf(hook)`
409
+ **Source:** `src/CTDeployer.sol` lines 221-232
410
+
411
+ ### Parameters
412
+
413
+ | Parameter | Type | Description |
414
+ |-----------|------|-------------|
415
+ | `hook` | `IJB721TiersHook` | The 721 hook to claim ownership of |
416
+
417
+ ### Execution Flow
418
+
419
+ 1. **Read project ID** (line 223): `projectId = hook.PROJECT_ID()`
420
+ 2. **Owner check** (lines 226-228): `PROJECTS.ownerOf(projectId) == _msgSender()` or revert `CTDeployer_NotOwnerOfProject`
421
+ 3. **Transfer ownership** (line 231):
422
+ ```
423
+ JBOwnable(address(hook)).transferOwnershipToProject(projectId)
424
+ ```
425
+
426
+ ### State Changes
427
+
428
+ | Storage | Change |
429
+ |---------|--------|
430
+ | Hook's JBOwnable storage | Owner changed from CTDeployer to the project (ownership tied to project NFT) |
431
+
432
+ ### Consequences
433
+
434
+ After claiming, the hook's ownership follows the project NFT. Whoever owns the project NFT can call owner-only functions on the hook (tier adjustments, metadata changes, etc.) directly, without going through CTDeployer.
435
+
436
+ **Important:** After claiming, the project owner must grant CTPublisher the `ADJUST_721_TIERS` permission for the project so that `mintFrom()` continues to work. Without this permission grant, all subsequent posts will revert.
437
+
438
+ ### Edge Cases
439
+
440
+ - **Already claimed:** If hook ownership has already been transferred, `transferOwnershipToProject` may revert (depending on JBOwnable implementation). CTDeployer is no longer the owner.
441
+ - **Project transferred after deployment:** If the project was sold or transferred, the new owner can claim the hook. The original deployer cannot.
442
+ - **Hook not deployed by CTDeployer:** If the `hook` was deployed independently, `JBOwnable(hook).transferOwnershipToProject()` will revert because CTDeployer is not the owner.
443
+
444
+ ---
445
+
446
+ ## Journey 8: Deploy Suckers for Existing Project
447
+
448
+ **Actor:** Project owner (or permissioned delegate)
449
+ **Entry point:** `CTDeployer.deploySuckersFor(projectId, suckerDeploymentConfiguration)`
450
+ **Source:** `src/CTDeployer.sol` lines 348-367
451
+
452
+ ### Parameters
453
+
454
+ | Parameter | Type | Description |
455
+ |-----------|------|-------------|
456
+ | `projectId` | `uint256` | The project to deploy suckers for |
457
+ | `suckerDeploymentConfiguration` | `CTSuckerDeploymentConfig` | Deployer configs + salt |
458
+
459
+ ### Execution Flow
460
+
461
+ 1. **Permission check** (lines 356-358):
462
+ ```
463
+ _requirePermissionFrom(
464
+ account: PROJECTS.ownerOf(projectId),
465
+ projectId: projectId,
466
+ permissionId: JBPermissionIds.DEPLOY_SUCKERS
467
+ )
468
+ ```
469
+
470
+ 2. **Sucker deployment** (lines 362-366):
471
+ ```
472
+ suckers = SUCKER_REGISTRY.deploySuckersFor(
473
+ projectId,
474
+ keccak256(abi.encode(suckerDeploymentConfiguration.salt, _msgSender())),
475
+ suckerDeploymentConfiguration.deployerConfigurations
476
+ )
477
+ ```
478
+
479
+ ### State Changes
480
+
481
+ | Storage | Change |
482
+ |---------|--------|
483
+ | Sucker Registry | New suckers registered for the project |
484
+ | Deployed sucker contracts | New contracts deployed via Create2 |
485
+
486
+ ### Edge Cases
487
+
488
+ - **Permission not granted:** Reverts. The project owner must explicitly grant `DEPLOY_SUCKERS` to the caller, or the caller must be the project owner.
489
+ - **Salt collision:** If the computed salt matches a previously deployed sucker, the Create2 deployment reverts.
490
+ - **Empty `deployerConfigurations`:** The sucker registry call succeeds with zero suckers deployed.
491
+
492
+ ---
493
+
494
+ ## Journey 9: Data Hook Interception (Pay)
495
+
496
+ **Actor:** Anyone paying a Croptop-deployed project
497
+ **Entry point:** Called by JBMultiTerminal during `pay()` flow
498
+ **Source:** `CTDeployer.beforePayRecordedWith(context)` at lines 160-169
499
+
500
+ ### Execution Flow
501
+
502
+ 1. JBMultiTerminal calls `CTDeployer.beforePayRecordedWith(context)` because CTDeployer is registered as the project's data hook.
503
+ 2. CTDeployer forwards the call directly to `dataHookOf[context.projectId]` (line 168), which is the JB721TiersHook.
504
+ 3. The hook returns `(weight, hookSpecifications)` which determine token issuance and pay hook routing.
505
+
506
+ ### Edge Cases
507
+
508
+ - **`dataHookOf[projectId]` is `address(0)`:** If a project was somehow created without setting the data hook (not possible via normal deployment flow), the forwarding call reverts on the zero address.
509
+ - **Hook reverts:** The entire `pay()` call reverts. The payer's ETH is returned. This can cause permanent DoS for a project if the hook is in a broken state.
510
+
511
+ ---
512
+
513
+ ## Journey 10: Data Hook Interception (Cash Out)
514
+
515
+ **Actor:** Token holder cashing out from a Croptop-deployed project
516
+ **Entry point:** Called by JBMultiTerminal during `cashOut()` flow
517
+ **Source:** `CTDeployer.beforeCashOutRecordedWith(context)` at lines 132-151
518
+
519
+ ### Execution Flow
520
+
521
+ 1. JBMultiTerminal calls `CTDeployer.beforeCashOutRecordedWith(context)`.
522
+
523
+ 2. **Sucker check** (line 144):
524
+ ```
525
+ if (SUCKER_REGISTRY.isSuckerOf(projectId, context.holder))
526
+ return (0, context.cashOutCount, context.totalSupply, [])
527
+ ```
528
+ If the holder is a registered sucker: return zero tax rate (fee-free cash out). Skip the hook entirely.
529
+
530
+ 3. **Normal path** (line 150): Forward to `dataHookOf[context.projectId].beforeCashOutRecordedWith(context)`.
531
+
532
+ ### Edge Cases
533
+
534
+ - **Sucker impersonation:** If an attacker can register as a sucker (via compromised registry), they get zero-tax cash outs from any Croptop project.
535
+ - **Sucker registry reverts:** If `isSuckerOf()` reverts, the entire cash-out reverts. This could DoS cash-outs.
536
+ - **Hook reverts (non-sucker path):** Same as Journey 9 -- permanent DoS for non-sucker cash-outs.
537
+ - **Sucker cashing out:** The sucker receives full treasury value without paying the project's configured cash-out tax. This is intentional (cross-chain bridging needs lossless value transfer).
538
+
539
+ ---
540
+
541
+ ## Journey 11: Read Posting Allowance
542
+
543
+ **Actor:** Anyone (view function)
544
+ **Entry point:** `CTPublisher.allowanceFor(hook, category)`
545
+ **Source:** `src/CTPublisher.sol` lines 158-190
546
+
547
+ ### Parameters
548
+
549
+ | Parameter | Type | Description |
550
+ |-----------|------|-------------|
551
+ | `hook` | `address` | The hook contract |
552
+ | `category` | `uint256` | The posting category |
553
+
554
+ ### Returns
555
+
556
+ | Return | Type | Description |
557
+ |--------|------|-------------|
558
+ | `minimumPrice` | `uint256` | Extracted from bits 0-103 of packed storage |
559
+ | `minimumTotalSupply` | `uint256` | Extracted from bits 104-135 |
560
+ | `maximumTotalSupply` | `uint256` | Extracted from bits 136-167 |
561
+ | `maximumSplitPercent` | `uint256` | Extracted from bits 168-199 |
562
+ | `allowedAddresses` | `address[]` | Full copy of the allowlist array |
563
+
564
+ ### Edge Cases
565
+
566
+ - **Unconfigured category:** Returns all zeros and empty array. A `minimumTotalSupply` of 0 means posting is not allowed.
567
+ - **Gas cost for large allowlists:** The function copies the entire `_allowedAddresses` array to memory. For a 10,000-address list, this is approximately 200,000 gas for the memory copy.
568
+
569
+ ---
570
+
571
+ ## Journey 12: Look Up Tiers by IPFS URI
572
+
573
+ **Actor:** Anyone (view function)
574
+ **Entry point:** `CTPublisher.tiersFor(hook, encodedIPFSUris)`
575
+ **Source:** `src/CTPublisher.sol` lines 115-142
576
+
577
+ ### Parameters
578
+
579
+ | Parameter | Type | Description |
580
+ |-----------|------|-------------|
581
+ | `hook` | `address` | The hook contract |
582
+ | `encodedIPFSUris` | `bytes32[]` | Array of encoded IPFS URIs to look up |
583
+
584
+ ### Returns
585
+
586
+ `JB721Tier[]` -- one tier per URI. Empty tier (all zeros) if the URI has no associated tier.
587
+
588
+ ### Execution Flow
589
+
590
+ For each URI:
591
+ 1. Look up `tierIdForEncodedIPFSUriOf[hook][uri]`
592
+ 2. If non-zero, call `hook.STORE().tierOf(hook, tierId, false)` (line 139)
593
+ 3. If zero, return an empty `JB721Tier`
594
+
595
+ ### Edge Cases
596
+
597
+ - **Stale mapping:** If a tier was removed but the mapping was not yet cleared (only cleared on re-post), `tierOf()` may return a tier with `remainingSupply = 0` or the store may revert.
598
+ - **Large array:** Each URI requires an external call to the store. Gas scales linearly with array length.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@croptop/core-v6",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,13 +16,13 @@
16
16
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'croptop-core-v5'"
17
17
  },
18
18
  "dependencies": {
19
- "@bananapus/721-hook-v6": "^0.0.16",
20
- "@bananapus/buyback-hook-v6": "^0.0.12",
21
- "@bananapus/core-v6": "^0.0.16",
22
- "@bananapus/ownable-v6": "^0.0.9",
23
- "@bananapus/permission-ids-v6": "^0.0.9",
24
- "@bananapus/router-terminal-v6": "^0.0.11",
25
- "@bananapus/suckers-v6": "^0.0.10",
19
+ "@bananapus/721-hook-v6": "^0.0.17",
20
+ "@bananapus/buyback-hook-v6": "^0.0.13",
21
+ "@bananapus/core-v6": "^0.0.17",
22
+ "@bananapus/ownable-v6": "^0.0.10",
23
+ "@bananapus/permission-ids-v6": "^0.0.10",
24
+ "@bananapus/router-terminal-v6": "^0.0.13",
25
+ "@bananapus/suckers-v6": "^0.0.11",
26
26
  "@openzeppelin/contracts": "^5.6.1"
27
27
  },
28
28
  "devDependencies": {
@@ -237,9 +237,8 @@ contract ConfigureFeeProjectScript is Script, Sphinx {
237
237
  JBTokenMapping[] memory tokenMappings = new JBTokenMapping[](1);
238
238
  tokenMappings[0] = JBTokenMapping({
239
239
  localToken: JBConstants.NATIVE_TOKEN,
240
- remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN))),
241
240
  minGas: 200_000,
242
- minBridgeAmount: 0.01 ether
241
+ remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))
243
242
  });
244
243
 
245
244
  REVSuckerDeploymentConfig memory suckerDeploymentConfiguration;
@@ -217,6 +217,9 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
217
217
  //*********************************************************************//
218
218
 
219
219
  /// @notice Claim ownership of the collection.
220
+ /// @dev After calling this, the hook's owner becomes the project (resolved via PROJECTS.ownerOf). The project
221
+ /// owner must then grant CTPublisher the ADJUST_721_TIERS permission for the project so that mintFrom() continues
222
+ /// to work. Without this permission grant, all subsequent posts will revert.
220
223
  /// @param hook The hook to claim ownership of.
221
224
  function claimCollectionOwnershipOf(IJB721TiersHook hook) external override {
222
225
  // Get the project ID of the hook.
@@ -308,6 +311,9 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
308
311
  }
309
312
 
310
313
  // Deploy the suckers (if applicable).
314
+ // The L2 sucker deployer fallback cascade (try primary, fall back to secondary) is
315
+ // intentionally ordered. If both deployers fail, the deployment proceeds without suckers rather than reverting,
316
+ // allowing projects to launch on unsupported chains with manual sucker setup later.
311
317
  if (suckerDeploymentConfiguration.salt != bytes32(0)) {
312
318
  // slither-disable-next-line unused-return
313
319
  SUCKER_REGISTRY.deploySuckersFor({
@@ -321,6 +327,9 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
321
327
  PROJECTS.transferFrom({from: address(this), to: owner, tokenId: projectId});
322
328
 
323
329
  // Set permission for the project's owner to do all the NFT things.
330
+ // These permissions are granted from CTDeployer (address(this)), not from the project owner.
331
+ // Transferring the project NFT does not invalidate them because the permission account is CTDeployer,
332
+ // which remains the hook's owner regardless of who holds the project NFT.
324
333
  uint8[] memory permissionIds = new uint8[](4);
325
334
  permissionIds[0] = JBPermissionIds.ADJUST_721_TIERS;
326
335
  permissionIds[1] = JBPermissionIds.SET_721_METADATA;