@croptop/core-v6 0.0.15 → 0.0.17
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/AUDIT_INSTRUCTIONS.md +458 -0
- package/CHANGE_LOG.md +253 -0
- package/RISKS.md +37 -226
- package/SKILLS.md +1 -1
- package/USER_JOURNEYS.md +598 -0
- package/package.json +9 -9
- package/script/ConfigureFeeProject.s.sol +6 -7
- package/src/CTDeployer.sol +9 -0
- package/src/CTProjectOwner.sol +4 -1
- package/src/CTPublisher.sol +19 -3
- package/src/interfaces/ICTDeployer.sol +2 -0
- package/test/CTDeployer.t.sol +608 -0
- package/test/CTProjectOwner.t.sol +185 -0
- package/test/CTPublisher.t.sol +134 -0
- package/test/ClaimCollectionOwnership.t.sol +315 -0
- package/test/Fork.t.sol +3 -4
- package/test/TestAuditGaps.sol +689 -0
package/USER_JOURNEYS.md
ADDED
|
@@ -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.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,17 +16,17 @@
|
|
|
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.
|
|
20
|
-
"@bananapus/buyback-hook-v6": "^0.0.
|
|
21
|
-
"@bananapus/core-v6": "^0.0.
|
|
22
|
-
"@bananapus/ownable-v6": "^0.0.
|
|
23
|
-
"@bananapus/permission-ids-v6": "^0.0.
|
|
24
|
-
"@bananapus/router-terminal-v6": "^0.0.
|
|
25
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
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": {
|
|
29
|
-
"@rev-net/core-v6": "^0.0.
|
|
29
|
+
"@rev-net/core-v6": "^0.0.12",
|
|
30
30
|
"@sphinx-labs/plugins": "^0.33.1"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -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
|
-
|
|
241
|
+
remoteToken: bytes32(uint256(uint160(JBConstants.NATIVE_TOKEN)))
|
|
243
242
|
});
|
|
244
243
|
|
|
245
244
|
REVSuckerDeploymentConfig memory suckerDeploymentConfiguration;
|
|
@@ -342,10 +341,10 @@ contract ConfigureFeeProjectScript is Script, Sphinx {
|
|
|
342
341
|
})
|
|
343
342
|
}),
|
|
344
343
|
salt: HOOK_SALT,
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
344
|
+
preventSplitOperatorAdjustingTiers: false,
|
|
345
|
+
preventSplitOperatorUpdatingMetadata: false,
|
|
346
|
+
preventSplitOperatorMinting: false,
|
|
347
|
+
preventSplitOperatorIncreasingDiscountPercent: false
|
|
349
348
|
}),
|
|
350
349
|
allowedPosts: allowedPosts
|
|
351
350
|
});
|
|
@@ -359,7 +358,7 @@ contract ConfigureFeeProjectScript is Script, Sphinx {
|
|
|
359
358
|
|
|
360
359
|
// Deploy the NANA fee project.
|
|
361
360
|
revnet.basic_deployer
|
|
362
|
-
.
|
|
361
|
+
.deployFor({
|
|
363
362
|
revnetId: FEE_PROJECT_ID,
|
|
364
363
|
configuration: feeProjectConfig.configuration,
|
|
365
364
|
terminalConfigurations: feeProjectConfig.terminalConfigurations,
|