@croptop/core-v6 0.0.27 → 0.0.29

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/USER_JOURNEYS.md CHANGED
@@ -1,729 +1,58 @@
1
- # croptop-core-v6 -- User Journeys
1
+ # User Journeys
2
2
 
3
- Complete user path documentation for auditors. Each journey describes the entry point, who can call, parameters, state changes, events, external calls, and edge cases.
3
+ ## Who This Repo Serves
4
4
 
5
- ---
5
+ - project owners turning a Juicebox 721 project into a publishing marketplace
6
+ - publishers creating or reusing NFT tiers as posts
7
+ - operators locking project administration into Croptop-specific ownership patterns
6
8
 
7
- ## Journey 1: Deploy a Croptop Project
9
+ ## Journey 1: Turn A Project Into A Croptop Publisher
8
10
 
9
- **Actor:** Project creator
10
- **Entry point:** `CTDeployer.deployProjectFor(address owner, CTProjectConfig calldata projectConfig, CTSuckerDeploymentConfig calldata suckerDeploymentConfiguration, IJBController controller) external returns (uint256 projectId, IJB721TiersHook hook)`
11
- **Who can call:** Anyone. No access control on this function.
12
- **Source:** `src/CTDeployer.sol` lines 244-348
11
+ **Starting state:** a project already exists or is about to launch, and the owner wants category-level posting rules.
13
12
 
14
- ### Parameters
13
+ **Success:** Croptop posting criteria are installed and future posts must satisfy them.
15
14
 
16
- - `owner` (`address`): Final owner of the project NFT after deployment.
17
- - `projectConfig` (`CTProjectConfig`): Name, symbol, URIs, terminal configs, allowed posts, salt.
18
- - `suckerDeploymentConfiguration` (`CTSuckerDeploymentConfig`): Cross-chain sucker deployer configs + salt (set salt to `bytes32(0)` to skip).
19
- - `controller` (`IJBController`): The JB controller that will manage the project.
15
+ **Flow**
16
+ 1. Configure category-level posting constraints such as price floor, supply bounds, split limits, and optional allowlists.
17
+ 2. Install or verify the 721 hook shape the project expects.
18
+ 3. Route the project through Croptop's publisher logic so future post creation is policy-checked instead of free-form tier editing.
20
19
 
21
- ### Execution Flow
20
+ ## Journey 2: Publish Content Into An Existing Croptop Project
22
21
 
23
- 1. **Controller validation** (line 254): Reverts if `controller.PROJECTS() != PROJECTS`.
22
+ **Starting state:** a publisher has a post that satisfies the target project's posting rules.
24
23
 
25
- 2. **Ruleset configuration -- phase 1** (lines 256-258):
26
- - Weight: `1_000_000 * 10^18`
27
- - Base currency: ETH
28
- - The remaining ruleset metadata (data hook, cash-out tax rate) cannot be set yet because the hook address is not known until after deployment (step 4).
24
+ **Success:** the post becomes a valid 721 tier and the first mint settles correctly.
29
25
 
30
- 3. **Project ID prediction** (line 261): `projectId = PROJECTS.count() + 1`
26
+ **Flow**
27
+ 1. The publisher calls `mintFrom(...)` or the equivalent publishing surface with the content URI and pricing data.
28
+ 2. `CTPublisher` checks the post against category rules and fee policy.
29
+ 3. It creates or reuses the underlying 721 tier, mints the first copy, and routes both project revenue and the Croptop fee. If the fee terminal is unavailable, the fee is refunded to `_msgSender()` instead.
31
30
 
32
- 4. **Hook deployment** (lines 265-286):
33
- ```
34
- DEPLOYER.deployHookFor(projectId, config, salt)
35
- ```
36
- - Salt: `keccak256(abi.encode(projectConfig.salt, _msgSender()))`
37
- - Deployed with empty tiers, ETH currency, 18 decimals
38
- - All `false` flags: new tiers with reserves, votes, and owner minting are allowed; overspending is not prevented; tokens are not issued for splits
31
+ **Failure cases that matter:** duplicate URIs, split configurations that evade fees, stale tier mappings, publisher inputs that satisfy the 721 hook but violate Croptop's stricter publishing rules, and callers that cannot receive ETH when a fee refund fallback is needed.
39
32
 
40
- 5. **Ruleset configuration -- phase 2** (lines 288-291): Now that the hook is deployed, the remaining metadata fields are set:
41
- - Cash-out tax rate: `MAX_CASH_OUT_TAX_RATE` (100%)
42
- - Data hook: `address(this)` (CTDeployer)
43
- - `useDataHookForPay = true`, `useDataHookForCashOut = true`
33
+ ## Journey 3: Launch A New Croptop Project End To End
44
34
 
45
- 6. **Project launch** (lines 294-303):
46
- ```
47
- controller.launchProjectFor(owner: address(this), ...)
48
- ```
49
- - CTDeployer receives the project NFT temporarily
50
- - `assert(projectId == returned ID)` -- reverts on mismatch (front-running protection)
35
+ **Starting state:** the product wants a fresh project that already has Croptop deployment choices baked in.
51
36
 
52
- 7. **Data hook registration** (line 306):
53
- ```
54
- dataHookOf[projectId] = IJBRulesetDataHook(hook)
55
- ```
56
- This is write-once. No setter exists.
37
+ **Success:** one deployment flow launches the project, wires the 721 hook, and installs the initial posting rules.
57
38
 
58
- 8. **Posting criteria** (lines 309-311): If `projectConfig.allowedPosts.length > 0`, calls internal `_configurePostingCriteriaFor()` which formats `CTDeployerAllowedPost` into `CTAllowedPost` (adding the hook address) and delegates to `PUBLISHER.configurePostingCriteriaFor()`.
39
+ **Flow**
40
+ 1. Use `CTDeployer` with project config, posting rules, and any omnichain deployment config.
41
+ 2. The deployer launches the Juicebox project, configures the Croptop-specific owner model, and wires in publisher behavior.
42
+ 3. The project is ready for publishers without a manual post-launch setup phase.
59
43
 
60
- 9. **Sucker deployment** (lines 317-324): If `suckerDeploymentConfiguration.salt != bytes32(0)`:
61
- ```
62
- SUCKER_REGISTRY.deploySuckersFor(projectId, salt, configurations)
63
- ```
44
+ ## Journey 4: Lock Administration Into Croptop's Owner Surface
64
45
 
65
- 10. **Ownership transfer** (line 327):
66
- ```
67
- PROJECTS.transferFrom(address(this), owner, projectId)
68
- ```
46
+ **Starting state:** the project should continue operating through Croptop's policy surface instead of ordinary project-owner discretion.
69
47
 
70
- 11. **Permission grants** (lines 329-347): Grants `owner` four permissions from CTDeployer's account:
71
- - `ADJUST_721_TIERS`
72
- - `SET_721_METADATA`
73
- - `MINT_721`
74
- - `SET_721_DISCOUNT_PERCENT`
48
+ **Success:** the project's admin path is burn-locked or otherwise routed through `CTProjectOwner`.
75
49
 
76
- ### Constructor (One-Time Setup)
50
+ **Flow**
51
+ 1. Transfer or configure ownership so Croptop's owner helper controls the relevant admin surface.
52
+ 2. Restrict future edits to the paths Croptop intentionally exposes.
53
+ 3. Accept that this is a product-shaping choice, not a cosmetic deployment detail.
77
54
 
78
- The CTDeployer constructor (lines 82-118) grants two wildcard (`projectId = 0`) permissions from CTDeployer's account. These are set once at contract deployment, not on every `deployProjectFor` call:
55
+ ## Hand-Offs
79
56
 
80
- 1. `JBPermissions` -- sucker registry granted `MAP_SUCKER_TOKEN` (wildcard, all projects).
81
- 2. `JBPermissions` -- CTPublisher granted `ADJUST_721_TIERS` (wildcard, all projects).
82
-
83
- These wildcard permissions allow the sucker registry and publisher to act on behalf of CTDeployer for any project it deploys, without needing per-project permission grants.
84
-
85
- ### State Changes (Per Call)
86
-
87
- 1. `CTDeployer.dataHookOf[projectId]` -- set to the deployed hook address (permanent, write-once).
88
- 2. `JBProjects` (ERC-721) -- new token minted, transferred from CTDeployer to `owner`.
89
- 3. `JBPermissions` -- 4 permission entries set for `owner` from CTDeployer's account (`ADJUST_721_TIERS`, `SET_721_METADATA`, `MINT_721`, `SET_721_DISCOUNT_PERCENT`), scoped to the new `projectId`.
90
- 4. `CTPublisher._packedAllowanceFor[hook][category]` -- set for each allowed post category (if any).
91
- 5. `CTPublisher._allowedAddresses[hook][category]` -- set for each allowed post category with allowlists (if any).
92
-
93
- ### Events
94
-
95
- - `ConfigurePostingCriteria(hook, allowedPost, caller)` -- emitted by CTPublisher for each allowed post entry (only if `projectConfig.allowedPosts` is non-empty). See Journey 3 for the full event signature.
96
- - No events are emitted directly by CTDeployer itself. External calls to `controller.launchProjectFor()`, `DEPLOYER.deployHookFor()`, `PROJECTS.transferFrom()`, and `PERMISSIONS.setPermissionsFor()` emit their own events from those contracts.
97
-
98
- ### Edge Cases
99
-
100
- - **Front-running:** If another project is created between `PROJECTS.count()` and `launchProjectFor()`, the `assert` fails and the transaction reverts. No funds are lost.
101
- - **`owner = address(0)`:** The project NFT transfer to `address(0)` would revert (ERC-721 constraint). The deployment fails.
102
- - **`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.
103
- - **Sucker deployment failure:** If `deploySuckersFor` reverts, the entire deployment reverts. The sucker deployer uses a try-catch cascade internally, but registry-level reverts propagate.
104
- - **Empty `terminalConfigurations`:** The project launches with no terminals. Payments and cash-outs are not possible until terminals are added separately.
105
- - **Salt collision:** If `keccak256(abi.encode(salt, _msgSender()))` collides with a previously deployed hook, `DEPLOYER.deployHookFor()` reverts (Create2 collision).
106
-
107
- ---
108
-
109
- ## Journey 2: Post Content (Mint NFTs)
110
-
111
- **Actor:** Content poster (any address, or allowlisted address)
112
- **Entry point:** `CTPublisher.mintFrom(IJB721TiersHook hook, CTPost[] calldata posts, address nftBeneficiary, address feeBeneficiary, bytes calldata additionalPayMetadata, bytes calldata feeMetadata) external payable`
113
- **Who can call:** Anyone, subject to per-category allowlist restrictions. If a category has a non-empty `allowedAddresses` list, only those addresses may post in that category. Checked via `_isAllowed(_msgSender(), addresses)`.
114
- **Source:** `src/CTPublisher.sol` lines 310-430
115
- **Value:** Must send `msg.value >= sum(tier prices) + 5% fee`
116
-
117
- ### Parameters
118
-
119
- - `hook` (`IJB721TiersHook`): The 721 hook to post to.
120
- - `posts` (`CTPost[]`): Array of posts (URI, supply, price, category, splits).
121
- - `nftBeneficiary` (`address`): Receives the minted NFTs.
122
- - `feeBeneficiary` (`address`): Receives fee project tokens.
123
- - `additionalPayMetadata` (`bytes`): Extra metadata appended to the payment.
124
- - `feeMetadata` (`bytes`): Metadata sent with the fee payment.
125
-
126
- ### Execution Flow
127
-
128
- **Phase 1: Validation and setup** (`_setupPosts`, lines 442-589)
129
-
130
- For each post in the batch:
131
-
132
- 1. **URI check:** `encodedIPFSUri != bytes32("")` or revert `CTPublisher_EmptyEncodedIPFSUri`
133
- 2. **Duplicate check:** O(i) scan against all prior posts. Revert `CTPublisher_DuplicatePost` on match.
134
- 3. **Existing tier lookup:** Check `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]`
135
- - **Tier exists and live:** Reuse tier ID. Accumulate `store.tierOf().price`.
136
- - **Tier exists but removed:** Delete mapping. Fall through to new tier.
137
- - **No tier:** Validate against category allowance, create `JB721TierConfig`.
138
-
139
- **Phase 2: Fee calculation** (lines 336-354)
140
-
141
- ```
142
- payValue = msg.value
143
-
144
- if (projectId != FEE_PROJECT_ID) {
145
- fee = totalPrice / FEE_DIVISOR (integer division, 5%)
146
- require(payValue >= fee) (reverts CTPublisher_InsufficientEthSent if not)
147
- payValue -= fee (fee portion held in contract balance)
148
- }
149
-
150
- require(totalPrice <= payValue) (reverts CTPublisher_InsufficientEthSent if not)
151
- ```
152
-
153
- When `projectId == FEE_PROJECT_ID`, no fee is deducted; `payValue` remains `msg.value` and the full amount goes to the project payment. Any remaining `address(this).balance` after the project payment (which would be 0 in this case) is skipped in Phase 6.
154
-
155
- **Phase 3: Tier creation** (line 358)
156
-
157
- ```
158
- hook.adjustTiers(tiersToAdd, [])
159
- ```
160
-
161
- **Phase 4: Metadata construction** (lines 361-377)
162
-
163
- Build JBMetadataResolver-compatible metadata with tier IDs and referral ID.
164
-
165
- **Phase 5: Project payment** (lines 398-406)
166
-
167
- ```
168
- projectTerminal.pay{value: payValue}(projectId, NATIVE_TOKEN, payValue, nftBeneficiary, 0, "Minted from Croptop", mintMetadata)
169
- ```
170
-
171
- **Phase 6: Fee payment** (lines 409-438)
172
-
173
- ```
174
- payValue = msg.value - payValue // reuse payValue for pre-computed fee amount
175
-
176
- if (payValue != 0) {
177
- feeTerminal = DIRECTORY.primaryTerminalOf(FEE_PROJECT_ID, NATIVE_TOKEN)
178
- try feeTerminal.pay{value: payValue}(FEE_PROJECT_ID, ...) {}
179
- catch {
180
- // Fallback: send fee to feeBeneficiary, then msg.sender
181
- feeBeneficiary.call{value: payValue}("")
182
- // If that fails too, msg.sender.call{value: payValue}("")
183
- }
184
- }
185
- ```
186
-
187
- ### State Changes
188
-
189
- 1. `CTPublisher.tierIdForEncodedIPFSUriOf[hook][uri]` -- set for each new tier created.
190
- 2. `CTPublisher.tierIdForEncodedIPFSUriOf[hook][uri]` -- deleted if stale mapping detected (removed tier).
191
- 3. `JB721TiersHookStore` (external) -- new tiers added via `adjustTiers`.
192
- 4. Project terminal (external) -- balance increased by `payValue`.
193
- 5. Fee project terminal (external) -- balance increased by fee amount.
194
-
195
- ### Events
196
-
197
- - `Mint(projectId, hook, nftBeneficiary, feeBeneficiary, posts, postValue, txValue, caller)` -- emitted at line 380 after setup is complete, before the project payment. Full signature:
198
- ```solidity
199
- event Mint(
200
- uint256 indexed projectId,
201
- IJB721TiersHook indexed hook,
202
- address indexed nftBeneficiary,
203
- address feeBeneficiary,
204
- CTPost[] posts,
205
- uint256 postValue,
206
- uint256 txValue,
207
- address caller
208
- );
209
- ```
210
-
211
- ### Edge Cases
212
-
213
- - **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.
214
- - **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.
215
- - **Mixed new and existing tiers:** `tiersToAdd` is resized via assembly to contain only new tiers. `tierIdsToMint` contains a mix of new and existing IDs.
216
- - **`msg.value` exceeds required amount:** Excess ETH goes to the fee project via the pre-computed fee (`msg.value - payValue`). The poster overpays the fee project.
217
- - **`msg.value` is exactly right:** The pre-computed fee amount equals the expected fee. Fee project receives the correct amount.
218
- - **`projectId == FEE_PROJECT_ID`:** No fee is deducted. Full `msg.value` goes to the project terminal. The pre-computed fee is 0 (no fee payment occurs).
219
- - **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).
220
- - **`nftBeneficiary = address(0)`:** The terminal payment may succeed (depending on terminal implementation), but the NFTs would be minted to `address(0)`, effectively burning them.
221
- - **`hook.adjustTiers()` reverts:** The entire transaction reverts. No state changes are committed. The poster's ETH is returned.
222
- - **Terminal payment reverts:** The entire transaction reverts. The `adjustTiers` state change is also rolled back.
223
- - **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.
224
- - **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.
225
- - **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.
226
-
227
- ---
228
-
229
- ## Journey 3: Configure Posting Criteria (Allowlist Setup)
230
-
231
- **Actor:** Hook owner (or permissioned delegate)
232
- **Entry point:** `CTPublisher.configurePostingCriteriaFor(CTAllowedPost[] memory allowedPosts) external`
233
- **Who can call:** The hook's owner (as returned by `JBOwnable(hook).owner()`) or any address that has been granted the `ADJUST_721_TIERS` permission for the hook's `PROJECT_ID()` from that owner. Checked per-entry via `_requirePermissionFrom(account: JBOwnable(hook).owner(), projectId: hook.PROJECT_ID(), permissionId: JBPermissionIds.ADJUST_721_TIERS)`.
234
- **Source:** `src/CTPublisher.sol` lines 243-298
235
-
236
- ### Parameters
237
-
238
- - `allowedPosts` (`CTAllowedPost[]`): Array of per-category posting rules.
239
-
240
- Each `CTAllowedPost` contains:
241
-
242
- | Field | Type | Constraints |
243
- |-------|------|-------------|
244
- | `hook` | `address` | Must be a JBOwnable + IJB721TiersHook |
245
- | `category` | `uint24` | 0 to 16,777,215 |
246
- | `minimumPrice` | `uint104` | Must fit in 104 bits |
247
- | `minimumTotalSupply` | `uint32` | Must be > 0 |
248
- | `maximumTotalSupply` | `uint32` | Must be >= `minimumTotalSupply` |
249
- | `maximumSplitPercent` | `uint32` | 0 = splits disabled, up to `SPLITS_TOTAL_PERCENT` (1,000,000,000) |
250
- | `allowedAddresses` | `address[]` | Empty = permissionless, non-empty = restricted |
251
-
252
- ### Execution Flow
253
-
254
- For each `CTAllowedPost` in the array:
255
-
256
- 1. **Emit event** (line 252): `ConfigurePostingCriteria(hook, allowedPost, caller)`
257
-
258
- 2. **Permission check** (lines 256-260):
259
- ```
260
- _requirePermissionFrom(
261
- account: JBOwnable(hook).owner(),
262
- projectId: IJB721TiersHook(hook).PROJECT_ID(),
263
- permissionId: JBPermissionIds.ADJUST_721_TIERS
264
- )
265
- ```
266
-
267
- 3. **Validation:**
268
- - `minimumTotalSupply > 0` or revert `CTPublisher_ZeroTotalSupply` (line 263)
269
- - `minimumTotalSupply <= maximumTotalSupply` or revert `CTPublisher_MaxTotalSupplyLessThanMin` (line 268)
270
-
271
- 4. **Pack and store** (lines 274-284):
272
- ```
273
- packed = minimumPrice | (minimumTotalSupply << 104) | (maximumTotalSupply << 136) | (maximumSplitPercent << 168)
274
- _packedAllowanceFor[hook][category] = packed
275
- ```
276
-
277
- 5. **Allowlist storage** (lines 287-296):
278
- ```
279
- delete _allowedAddresses[hook][category]
280
- for each address in allowedAddresses:
281
- _allowedAddresses[hook][category].push(address)
282
- ```
283
-
284
- ### State Changes
285
-
286
- 1. `CTPublisher._packedAllowanceFor[hook][category]` -- overwritten with new packed values.
287
- 2. `CTPublisher._allowedAddresses[hook][category]` -- deleted and repopulated.
288
-
289
- ### Events
290
-
291
- - `ConfigurePostingCriteria(address indexed hook, CTAllowedPost allowedPost, address caller)` -- emitted once per entry in the `allowedPosts` array (line 252), **before** the permission check. Full signature:
292
- ```solidity
293
- event ConfigurePostingCriteria(address indexed hook, CTAllowedPost allowedPost, address caller);
294
- ```
295
- Note: the event is emitted before `_requirePermissionFrom`, so an unauthorized call will emit the event then revert, rolling back the event emission.
296
-
297
- ### Edge Cases
298
-
299
- - **Overwriting existing criteria:** The entire packed value and allowlist are replaced. There is no merge or append behavior.
300
- - **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).
301
- - **Same category twice in one call:** The second entry overwrites the first. No duplicate check on the input array.
302
- - **`maximumSplitPercent = 0`:** Splits are disabled. Any post with `splitPercent > 0` will revert with `CTPublisher_SplitPercentExceedsMaximum`.
303
- - **`maximumTotalSupply = type(uint32).max`:** Effectively unlimited supply (4,294,967,295).
304
- - **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.
305
- - **Empty allowlist after previously non-empty:** `delete _allowedAddresses[hook][category]` clears the array. Posting becomes permissionless for that category.
306
- - **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.
307
- - **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.
308
- - **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.
309
-
310
- ---
311
-
312
- ## Journey 4: Collect Posting Fees
313
-
314
- **Actor:** Passive (fee project). Fees are collected automatically during `mintFrom()`.
315
- **Entry point:** Triggered within `CTPublisher.mintFrom()` at lines 413-429
316
- **Who can call:** N/A -- this is an internal sub-flow of Journey 2, not independently callable.
317
- **Beneficiary:** The project with ID `FEE_PROJECT_ID` (immutable, set at construction)
318
-
319
- ### Fee Calculation
320
-
321
- ```
322
- totalPrice = sum of all post prices in the batch
323
- (on-chain tier price for existing tiers, post.price for new tiers)
324
-
325
- payValue = msg.value
326
-
327
- if (projectId != FEE_PROJECT_ID) {
328
- fee = totalPrice / FEE_DIVISOR (FEE_DIVISOR = 20, so fee = 5%)
329
- require(payValue >= fee) (reverts if msg.value < fee)
330
- payValue -= fee (fee held in contract balance for later routing)
331
- }
332
-
333
- require(totalPrice <= payValue) (reverts if insufficient ETH for the posts)
334
- ```
335
-
336
- When `projectId == FEE_PROJECT_ID`, the fee calculation is skipped entirely. The full `msg.value` is sent as `payValue` to the project terminal. After that payment, `address(this).balance` is 0, so the fee routing step (below) is a no-op.
337
-
338
- ### Fee Routing
339
-
340
- After the project payment completes, the fee amount is pre-computed as `msg.value - payValue`:
341
-
342
- ```solidity
343
- payValue = msg.value - payValue; // reuse payValue for the pre-computed fee amount
344
-
345
- if (payValue != 0) {
346
- IJBTerminal feeTerminal = DIRECTORY.primaryTerminalOf(FEE_PROJECT_ID, NATIVE_TOKEN);
347
- try feeTerminal.pay{value: payValue}({
348
- projectId: FEE_PROJECT_ID,
349
- amount: payValue,
350
- token: NATIVE_TOKEN,
351
- beneficiary: feeBeneficiary,
352
- minReturnedTokens: 0,
353
- memo: "",
354
- metadata: feeMetadata
355
- }) {}
356
- catch {
357
- // Fallback: send fee to feeBeneficiary, then msg.sender if that fails too.
358
- (bool success,) = feeBeneficiary.call{value: payValue}("");
359
- if (!success) {
360
- (success,) = msg.sender.call{value: payValue}("");
361
- }
362
- }
363
- }
364
- ```
365
-
366
- ### Fee Project Token Distribution
367
-
368
- 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).
369
-
370
- ### Fee Accounting Details
371
-
372
- | Scenario | Fee Behavior |
373
- |----------|-------------|
374
- | Normal mint (1 post, 1 ETH price) | `fee = 1 ether / 20 = 0.05 ether`. Poster sends >= 1.05 ETH. |
375
- | Batch mint (3 posts, 1 ETH each) | `fee = 3 ether / 20 = 0.15 ether`. Poster sends >= 3.15 ETH. |
376
- | Existing tier reuse (1 ETH on-chain price) | Fee uses on-chain price, not `post.price`. `fee = 1 ether / 20`. |
377
- | Free tier (price = 0) | `fee = 0 / 20 = 0`. No fee payment occurs. |
378
- | `projectId == FEE_PROJECT_ID` | Fee deduction skipped entirely. Full `msg.value` goes to project. |
379
- | `msg.value` exceeds requirement | Excess goes to fee project. Poster overpays. |
380
- | Dust from integer division | Up to 19 wei lost per tx. Fee project receives slightly less. |
381
-
382
- ### Events
383
-
384
- - No events are emitted by the fee sub-flow itself. The `Mint` event (see Journey 2) is emitted before the fee payment. The fee terminal's `pay()` call emits its own events from the terminal contract.
385
-
386
- ### Edge Cases
387
-
388
- - **Fee project has no primary terminal:** `DIRECTORY.primaryTerminalOf()` returns `address(0)`. The `pay()` call to address(0) reverts inside the try-catch. The fee falls back to `feeBeneficiary.call{value}`, then `msg.sender.call{value}`. The main project payment is not affected.
389
- - **Fee terminal reverts:** The fee terminal payment is wrapped in try-catch. If the fee terminal reverts, the fee is sent to `feeBeneficiary` via low-level call. If that also fails, the fee is sent to `msg.sender`. The main project payment is never rolled back due to a fee terminal failure. The fee project loses revenue during the outage.
390
- - **Pre-computed fee is 0:** This happens when `fee == 0` (e.g., `totalPrice < FEE_DIVISOR` or `projectId == FEE_PROJECT_ID`). The fee payment is skipped entirely.
391
- - **Force-sent ETH (via `selfdestruct`):** The fee amount is now pre-computed as `msg.value - payValue` rather than using `address(this).balance`. Force-sent ETH does not affect fee calculation or routing. CTPublisher has no `receive()` or `fallback()`, so normal sends revert. Only `selfdestruct` (deprecated post-Dencun) can force-send ETH, and it remains in the contract.
392
-
393
- ---
394
-
395
- ## Journey 5: Deploy a Croptop Project via CTDeployer with Posting Criteria
396
-
397
- **Actor:** Project creator
398
- **Entry point:** `CTDeployer.deployProjectFor(address owner, CTProjectConfig calldata projectConfig, CTSuckerDeploymentConfig calldata suckerDeploymentConfiguration, IJBController controller) external returns (uint256 projectId, IJB721TiersHook hook)` with non-empty `projectConfig.allowedPosts`
399
- **Who can call:** Anyone. No access control on this function. Same as Journey 1.
400
- **Source:** `src/CTDeployer.sol` lines 244-348, internal `_configurePostingCriteriaFor()` at lines 382-411
401
-
402
- This is an extension of Journey 1 that details the posting criteria configuration during deployment.
403
-
404
- ### Posting Criteria Flow
405
-
406
- 1. CTDeployer receives `CTDeployerAllowedPost[]` (which omits the `hook` field, since the hook hasn't been deployed yet).
407
- 2. After the hook is deployed, `_configurePostingCriteriaFor()` converts each `CTDeployerAllowedPost` to a `CTAllowedPost` by injecting `hook: address(hook)` (lines 398-406).
408
- 3. Calls `PUBLISHER.configurePostingCriteriaFor(formattedAllowedPosts)` (line 410).
409
- 4. The publisher validates each entry (supply bounds, permissions) and stores the packed allowances and allowlists.
410
-
411
- ### Permission Flow
412
-
413
- The `PUBLISHER.configurePostingCriteriaFor()` call checks `ADJUST_721_TIERS` permission from `JBOwnable(hook).owner()`. At this point in the deployment:
414
-
415
- - The hook was deployed by `DEPLOYER.deployHookFor()` on behalf of CTDeployer
416
- - Hook ownership is set to CTDeployer (the deployer is the effective owner after deployment)
417
- - The permission check passes because CTDeployer is both the caller and the hook owner (or has wildcard permission)
418
-
419
- After the deployment completes and ownership is transferred to `owner`, only the new owner (or their delegate) can reconfigure posting criteria.
420
-
421
- ### State Changes
422
-
423
- 1. `CTPublisher._packedAllowanceFor[hook][category]` -- set for each allowed post category.
424
- 2. `CTPublisher._allowedAddresses[hook][category]` -- set for each allowed post category with allowlists.
425
-
426
- ### Events
427
-
428
- - `ConfigurePostingCriteria(address indexed hook, CTAllowedPost allowedPost, address caller)` -- emitted by CTPublisher once per `allowedPosts` entry. The `caller` is CTDeployer's address (since CTDeployer calls the publisher). The `hook` is the newly deployed hook address.
429
-
430
- ### Edge Cases
431
-
432
- - **Empty `allowedPosts`:** The `_configurePostingCriteriaFor()` call is skipped (line 309 condition). The project has no posting categories configured. Content cannot be posted until the owner configures criteria manually.
433
- - **Invalid criteria in deployment:** If any `CTDeployerAllowedPost` has `minimumTotalSupply == 0` or `minimumTotalSupply > maximumTotalSupply`, the publisher reverts, and the entire deployment fails.
434
-
435
- ---
436
-
437
- ## Journey 6: Lock Project Ownership (Burn-Lock)
438
-
439
- **Actor:** Project owner
440
- **Entry point:** `IERC721(PROJECTS).safeTransferFrom(address from, address to, uint256 tokenId)` where `to = address(ctProjectOwner)`
441
- **Who can call:** The current owner of the project NFT, or an approved operator. The `safeTransferFrom` is an ERC-721 function with standard ownership/approval checks. CTProjectOwner itself has no caller restrictions in `onERC721Received` beyond requiring `msg.sender == address(PROJECTS)`.
442
- **Source:** `src/CTProjectOwner.sol` lines 50-83
443
-
444
- ### Parameters
445
-
446
- - `from` (`address`): Current holder of the project NFT (not checked by CTProjectOwner, unlike CTDeployer).
447
- - `to` (`address`): Must be `address(ctProjectOwner)`.
448
- - `tokenId` (`uint256`): The project ID to lock.
449
-
450
- ### Execution Flow
451
-
452
- 1. The project owner calls `safeTransferFrom` on the JBProjects ERC-721 contract, transferring their project NFT to the CTProjectOwner contract.
453
- 2. The ERC-721 contract calls `CTProjectOwner.onERC721Received()`.
454
- 3. **Validation** (line 65): `msg.sender == address(PROJECTS)` -- only accepts tokens from the JBProjects contract. Reverts with empty revert on failure.
455
- 4. **Permission grant** (lines 68-80):
456
- ```
457
- PERMISSIONS.setPermissionsFor(
458
- account: address(this),
459
- permissionsData: JBPermissionsData({
460
- operator: address(PUBLISHER),
461
- projectId: uint64(tokenId),
462
- permissionIds: [ADJUST_721_TIERS]
463
- })
464
- )
465
- ```
466
- 5. Returns `IERC721Receiver.onERC721Received.selector`.
467
-
468
- ### State Changes
469
-
470
- 1. `JBProjects` (ERC-721) -- token transferred from owner to CTProjectOwner.
471
- 2. `JBPermissions` -- CTPublisher granted `ADJUST_721_TIERS` for this project from CTProjectOwner's account.
472
-
473
- ### Events
474
-
475
- - No events are emitted directly by CTProjectOwner. The ERC-721 `Transfer(from, to, tokenId)` event is emitted by JBProjects. The `JBPermissions.setPermissionsFor()` call emits its own event from the permissions contract.
476
-
477
- ### Consequences
478
-
479
- - The project NFT is now held by CTProjectOwner. Since CTProjectOwner has no transfer function, ownership is effectively burned.
480
- - CTPublisher can still adjust tiers (post content) because it has `ADJUST_721_TIERS` permission.
481
- - The project owner can no longer change rulesets, add terminals, or perform any owner-only operations.
482
- - This is irreversible. There is no recovery mechanism.
483
-
484
- ### Edge Cases
485
-
486
- - **`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.
487
- - **`tokenId` truncation:** The `uint64(tokenId)` cast truncates if `tokenId > type(uint64).max`. For realistic sequential project IDs, this is not a concern.
488
- - **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.
489
- - **Double transfer:** If two different project NFTs are transferred to CTProjectOwner, each gets its own scoped permission. The contract can hold multiple projects simultaneously.
490
- - **Accidental transfer:** There is no confirmation, cooling period, or undo. A user who accidentally sends their project NFT to CTProjectOwner loses ownership permanently.
491
-
492
- ---
493
-
494
- ## Journey 7: Claim Hook Collection Ownership
495
-
496
- **Actor:** Project owner
497
- **Entry point:** `CTDeployer.claimCollectionOwnershipOf(IJB721TiersHook hook) external`
498
- **Who can call:** Only the current owner of the project NFT (`PROJECTS.ownerOf(hook.PROJECT_ID())`). Checked via `PROJECTS.ownerOf(projectId) != _msgSender()` -- reverts with `CTDeployer_NotOwnerOfProject(projectId, address(hook), _msgSender())` on failure.
499
- **Source:** `src/CTDeployer.sol` lines 224-235
500
-
501
- ### Parameters
502
-
503
- - `hook` (`IJB721TiersHook`): The 721 hook to claim ownership of.
504
-
505
- ### Execution Flow
506
-
507
- 1. **Read project ID** (line 226): `projectId = hook.PROJECT_ID()`
508
- 2. **Owner check** (lines 229-231): `PROJECTS.ownerOf(projectId) == _msgSender()` or revert `CTDeployer_NotOwnerOfProject(projectId, address(hook), _msgSender())`
509
- 3. **Transfer ownership** (line 234):
510
- ```
511
- JBOwnable(address(hook)).transferOwnershipToProject(projectId)
512
- ```
513
-
514
- ### State Changes
515
-
516
- 1. Hook's `JBOwnable` storage -- owner changed from CTDeployer to the project (ownership tied to project NFT holder via `PROJECTS.ownerOf(projectId)`).
517
-
518
- ### Events
519
-
520
- - No events are emitted directly by CTDeployer. The `JBOwnable.transferOwnershipToProject()` call emits its own ownership transfer event from the hook contract.
521
-
522
- ### Consequences
523
-
524
- 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.
525
-
526
- **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.
527
-
528
- ### Edge Cases
529
-
530
- - **Already claimed:** If hook ownership has already been transferred, `transferOwnershipToProject` may revert (depending on JBOwnable implementation). CTDeployer is no longer the owner.
531
- - **Project transferred after deployment:** If the project was sold or transferred, the new owner can claim the hook. The original deployer cannot.
532
- - **Hook not deployed by CTDeployer:** If the `hook` was deployed independently, `JBOwnable(hook).transferOwnershipToProject()` will revert because CTDeployer is not the owner.
533
-
534
- ---
535
-
536
- ## Journey 8: Deploy Suckers for Existing Project
537
-
538
- **Actor:** Project owner (or permissioned delegate)
539
- **Entry point:** `CTDeployer.deploySuckersFor(uint256 projectId, CTSuckerDeploymentConfig calldata suckerDeploymentConfiguration) external returns (address[] memory suckers)`
540
- **Who can call:** The project owner (`PROJECTS.ownerOf(projectId)`) or any address that has been granted the `DEPLOY_SUCKERS` permission for that project. Checked via `_requirePermissionFrom(account: PROJECTS.ownerOf(projectId), projectId: projectId, permissionId: JBPermissionIds.DEPLOY_SUCKERS)`.
541
- **Source:** `src/CTDeployer.sol` lines 354-373
542
-
543
- ### Parameters
544
-
545
- - `projectId` (`uint256`): The project to deploy suckers for.
546
- - `suckerDeploymentConfiguration` (`CTSuckerDeploymentConfig`): Deployer configs + salt.
547
-
548
- ### Execution Flow
549
-
550
- 1. **Permission check** (lines 362-364):
551
- ```
552
- _requirePermissionFrom(
553
- account: PROJECTS.ownerOf(projectId),
554
- projectId: projectId,
555
- permissionId: JBPermissionIds.DEPLOY_SUCKERS
556
- )
557
- ```
558
-
559
- 2. **Sucker deployment** (lines 368-372):
560
- ```
561
- suckers = SUCKER_REGISTRY.deploySuckersFor(
562
- projectId,
563
- keccak256(abi.encode(suckerDeploymentConfiguration.salt, _msgSender())),
564
- suckerDeploymentConfiguration.deployerConfigurations
565
- )
566
- ```
567
-
568
- ### State Changes
569
-
570
- 1. Sucker Registry -- new suckers registered for the project.
571
- 2. Deployed sucker contracts -- new contracts deployed via Create2.
572
-
573
- ### Events
574
-
575
- - No events are emitted directly by CTDeployer. The `SUCKER_REGISTRY.deploySuckersFor()` call emits its own events from the registry contract.
576
-
577
- ### Edge Cases
578
-
579
- - **Permission not granted:** Reverts. The project owner must explicitly grant `DEPLOY_SUCKERS` to the caller, or the caller must be the project owner.
580
- - **Salt collision:** If the computed salt matches a previously deployed sucker, the Create2 deployment reverts.
581
- - **Empty `deployerConfigurations`:** The sucker registry call succeeds with zero suckers deployed.
582
-
583
- ---
584
-
585
- ## Journey 9: Data Hook Interception (Pay)
586
-
587
- **Actor:** Anyone paying a Croptop-deployed project
588
- **Entry point:** `CTDeployer.beforePayRecordedWith(JBBeforePayRecordedContext calldata context) external view returns (uint256 weight, JBPayHookSpecification[] memory hookSpecifications)`
589
- **Who can call:** Intended to be called by JBMultiTerminal during the `pay()` flow. No explicit access control -- any address can call this function, but it is only meaningful when called by the terminal as part of a payment.
590
- **Source:** `src/CTDeployer.sol` lines 160-169
591
-
592
- ### Parameters
593
-
594
- - `context` (`JBBeforePayRecordedContext`): Standard Juicebox payment context containing `projectId`, payer details, amount, and metadata.
595
-
596
- ### Execution Flow
597
-
598
- 1. JBMultiTerminal calls `CTDeployer.beforePayRecordedWith(context)` because CTDeployer is registered as the project's data hook.
599
- 2. CTDeployer forwards the call directly to `dataHookOf[context.projectId]` (line 168), which is the JB721TiersHook.
600
- 3. The hook returns `(weight, hookSpecifications)` which determine token issuance and pay hook routing.
601
-
602
- ### State Changes
603
-
604
- - None. This is a `view` function.
605
-
606
- ### Events
607
-
608
- - None. This is a `view` function.
609
-
610
- ### Edge Cases
611
-
612
- - **`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.
613
- - **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.
614
-
615
- ---
616
-
617
- ## Journey 10: Data Hook Interception (Cash Out)
618
-
619
- **Actor:** Token holder cashing out from a Croptop-deployed project
620
- **Entry point:** `CTDeployer.beforeCashOutRecordedWith(JBBeforeCashOutRecordedContext calldata context) external view returns (uint256 cashOutTaxRate, uint256 cashOutCount, uint256 totalSupply, JBCashOutHookSpecification[] memory hookSpecifications)`
621
- **Who can call:** Intended to be called by JBMultiTerminal during the `cashOut()` flow. No explicit access control -- any address can call this function, but it is only meaningful when called by the terminal as part of a cash-out.
622
- **Source:** `src/CTDeployer.sol` lines 132-151
623
-
624
- ### Parameters
625
-
626
- - `context` (`JBBeforeCashOutRecordedContext`): Standard Juicebox cash-out context containing `projectId`, `holder`, `cashOutCount`, `totalSupply`, and metadata.
627
-
628
- ### Execution Flow
629
-
630
- 1. JBMultiTerminal calls `CTDeployer.beforeCashOutRecordedWith(context)`.
631
-
632
- 2. **Sucker check** (line 144):
633
- ```
634
- if (SUCKER_REGISTRY.isSuckerOf(context.projectId, context.holder))
635
- return (0, context.cashOutCount, context.totalSupply, [])
636
- ```
637
- If the holder is a registered sucker: return zero tax rate (fee-free cash out). Skip the hook entirely.
638
-
639
- 3. **Normal path** (line 150): Forward to `dataHookOf[context.projectId].beforeCashOutRecordedWith(context)`.
640
-
641
- ### State Changes
642
-
643
- - None. This is a `view` function.
644
-
645
- ### Events
646
-
647
- - None. This is a `view` function.
648
-
649
- ### Edge Cases
650
-
651
- - **Sucker impersonation:** If an attacker can register as a sucker (via compromised registry), they get zero-tax cash outs from any Croptop project.
652
- - **Sucker registry reverts:** If `isSuckerOf()` reverts, the entire cash-out reverts. This could DoS cash-outs.
653
- - **Hook reverts (non-sucker path):** Same as Journey 9 -- permanent DoS for non-sucker cash-outs.
654
- - **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).
655
-
656
- ---
657
-
658
- ## Journey 11: Read Posting Allowance
659
-
660
- **Actor:** Anyone (view function)
661
- **Entry point:** `CTPublisher.allowanceFor(address hook, uint256 category) public view returns (uint256 minimumPrice, uint256 minimumTotalSupply, uint256 maximumTotalSupply, uint256 maximumSplitPercent, address[] memory allowedAddresses)`
662
- **Who can call:** Anyone. This is a public view function with no access control.
663
- **Source:** `src/CTPublisher.sol` lines 161-193
664
-
665
- ### Parameters
666
-
667
- - `hook` (`address`): The hook contract.
668
- - `category` (`uint256`): The posting category.
669
-
670
- ### Returns
671
-
672
- | Return | Type | Description |
673
- |--------|------|-------------|
674
- | `minimumPrice` | `uint256` | Extracted from bits 0-103 of packed storage |
675
- | `minimumTotalSupply` | `uint256` | Extracted from bits 104-135 |
676
- | `maximumTotalSupply` | `uint256` | Extracted from bits 136-167 |
677
- | `maximumSplitPercent` | `uint256` | Extracted from bits 168-199 |
678
- | `allowedAddresses` | `address[]` | Full copy of the allowlist array |
679
-
680
- ### State Changes
681
-
682
- - None. This is a `view` function.
683
-
684
- ### Events
685
-
686
- - None. This is a `view` function.
687
-
688
- ### Edge Cases
689
-
690
- - **Unconfigured category:** Returns all zeros and empty array. A `minimumTotalSupply` of 0 means posting is not allowed.
691
- - **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.
692
-
693
- ---
694
-
695
- ## Journey 12: Look Up Tiers by IPFS URI
696
-
697
- **Actor:** Anyone (view function)
698
- **Entry point:** `CTPublisher.tiersFor(address hook, bytes32[] memory encodedIPFSUris) external view returns (JB721Tier[] memory tiers)`
699
- **Who can call:** Anyone. This is an external view function with no access control.
700
- **Source:** `src/CTPublisher.sol` lines 118-145
701
-
702
- ### Parameters
703
-
704
- - `hook` (`address`): The hook contract.
705
- - `encodedIPFSUris` (`bytes32[]`): Array of encoded IPFS URIs to look up.
706
-
707
- ### Returns
708
-
709
- `JB721Tier[]` -- one tier per URI. Empty tier (all zeros) if the URI has no associated tier.
710
-
711
- ### Execution Flow
712
-
713
- For each URI:
714
- 1. Look up `tierIdForEncodedIPFSUriOf[hook][uri]`
715
- 2. If non-zero, call `hook.STORE().tierOf(hook, tierId, false)` (line 142)
716
- 3. If zero, return an empty `JB721Tier`
717
-
718
- ### State Changes
719
-
720
- - None. This is a `view` function.
721
-
722
- ### Events
723
-
724
- - None. This is a `view` function.
725
-
726
- ### Edge Cases
727
-
728
- - **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.
729
- - **Large array:** Each URI requires an external call to the store. Gas scales linearly with array length.
57
+ - Use [nana-721-hook-v6](../nana-721-hook-v6/USER_JOURNEYS.md) for the underlying tier issuance behavior Croptop wraps.
58
+ - Use [nana-core-v6](../nana-core-v6/USER_JOURNEYS.md) when the question is about base project accounting rather than post validation or fee routing.