@croptop/core-v6 0.0.5 → 0.0.7
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/README.md +111 -19
- package/SKILLS.md +139 -39
- package/package.json +9 -9
- package/script/ConfigureFeeProject.s.sol +9 -8
- package/src/CTDeployer.sol +1 -0
- package/src/CTPublisher.sol +23 -5
- package/test/regression/H19_FeeEvasion.t.sol +280 -0
- package/test/regression/L52_StaleTierIdMapping.t.sol +214 -0
package/README.md
CHANGED
|
@@ -1,45 +1,137 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Croptop Core
|
|
2
2
|
|
|
3
|
-
Permissioned NFT publishing for Juicebox projects -- anyone can post content as NFT tiers to a project's 721 hook, provided the posts meet criteria set by the project owner.
|
|
3
|
+
Permissioned NFT publishing for Juicebox projects -- anyone can post content as NFT tiers to a project's 721 hook, provided the posts meet criteria set by the project owner. A 5% fee is routed to a designated fee project on each mint.
|
|
4
|
+
|
|
5
|
+
[Docs](https://docs.juicebox.money) | [Discord](https://discord.gg/juicebox) | [Croptop](https://croptop.eth.limo)
|
|
6
|
+
|
|
7
|
+
## Conceptual Overview
|
|
8
|
+
|
|
9
|
+
Croptop turns any Juicebox project with a 721 tiers hook into a permissioned content marketplace. Project owners define posting criteria -- minimum price, supply bounds, address allowlists -- and anyone who meets those criteria can publish new NFT tiers on the project. The poster's content becomes a mintable NFT tier, and the first copy is minted to them automatically.
|
|
10
|
+
|
|
11
|
+
### How It Works
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
1. Project owner configures posting criteria per category
|
|
15
|
+
→ configurePostingCriteriaFor(allowedPosts)
|
|
16
|
+
→ Sets min price, supply bounds, max split %, address allowlist
|
|
17
|
+
|
|
|
18
|
+
2. Anyone posts content that meets the criteria
|
|
19
|
+
→ mintFrom(hook, posts, nftBeneficiary, feeBeneficiary, ...)
|
|
20
|
+
→ Validates each post against category rules
|
|
21
|
+
→ Creates new 721 tiers on the hook (or reuses existing ones)
|
|
22
|
+
→ Mints first copy of each tier to the poster
|
|
23
|
+
|
|
|
24
|
+
3. Payment routing
|
|
25
|
+
→ 5% fee (totalPrice / FEE_DIVISOR) sent to fee project
|
|
26
|
+
→ Remainder paid into the project's primary terminal
|
|
27
|
+
|
|
|
28
|
+
4. Anyone can mint additional copies
|
|
29
|
+
→ Standard 721 tier minting via the project's hook
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### One-Click Deployment
|
|
33
|
+
|
|
34
|
+
`CTDeployer` creates a complete Juicebox project + 721 hook + posting criteria in a single transaction. It also:
|
|
35
|
+
- Acts as a data hook proxy, forwarding pay/cash-out calls to the underlying 721 hook
|
|
36
|
+
- Grants fee-free cash outs to cross-chain suckers
|
|
37
|
+
- Optionally deploys suckers for omnichain support
|
|
38
|
+
|
|
39
|
+
### Burn-Lock Ownership
|
|
40
|
+
|
|
41
|
+
`CTProjectOwner` provides an ownership burn-lock pattern. Transferring a project's NFT to this contract permanently locks ownership while granting `CTPublisher` tier-adjustment permissions -- making the project's configuration immutable except through Croptop posts.
|
|
4
42
|
|
|
5
43
|
## Architecture
|
|
6
44
|
|
|
7
45
|
| Contract | Description |
|
|
8
46
|
|----------|-------------|
|
|
9
|
-
| `CTPublisher` | Core publishing engine. Validates posts against owner-configured allowances (min price, supply bounds, address allowlists), creates new 721 tiers on the hook, mints the first copy to the poster, and routes a 5% fee to a designated fee project. |
|
|
10
|
-
| `CTDeployer` | One-click project factory. Deploys a Juicebox project with a 721 tiers hook pre-wired, configures posting criteria via `CTPublisher`, optionally deploys cross-chain suckers, and acts as
|
|
47
|
+
| `CTPublisher` | Core publishing engine. Validates posts against owner-configured allowances (min price, supply bounds, address allowlists, max split percent), creates new 721 tiers on the hook, mints the first copy to the poster, and routes a 5% fee to a designated fee project. Inherits `JBPermissioned` and `ERC2771Context`. |
|
|
48
|
+
| `CTDeployer` | One-click project factory. Deploys a Juicebox project with a 721 tiers hook pre-wired, configures posting criteria via `CTPublisher`, optionally deploys cross-chain suckers, and acts as an `IJBRulesetDataHook` proxy that forwards pay/cash-out calls to the underlying hook while granting fee-free cash outs to suckers. |
|
|
11
49
|
| `CTProjectOwner` | Burn-lock ownership helper. Receives a project's ERC-721 ownership token and automatically grants `CTPublisher` the `ADJUST_721_TIERS` permission, effectively making the project's tier configuration immutable except through Croptop posts. |
|
|
12
50
|
|
|
13
51
|
### Structs
|
|
14
52
|
|
|
15
53
|
| Struct | Purpose |
|
|
16
54
|
|--------|---------|
|
|
17
|
-
| `CTAllowedPost` | Full posting criteria
|
|
55
|
+
| `CTAllowedPost` | Full posting criteria: hook address, category, price/supply bounds, max split percent, and address allowlist. |
|
|
18
56
|
| `CTDeployerAllowedPost` | Same as `CTAllowedPost` but without the hook address (inferred during deployment). |
|
|
19
|
-
| `CTPost` | A post to publish: encoded IPFS URI, total supply, price, and
|
|
20
|
-
| `CTProjectConfig` | Project deployment configuration: terminals, metadata URIs, allowed posts, collection name/symbol, and salt. |
|
|
57
|
+
| `CTPost` | A post to publish: encoded IPFS URI, total supply, price, category, split percent, and splits. |
|
|
58
|
+
| `CTProjectConfig` | Project deployment configuration: terminals, metadata URIs, allowed posts, collection name/symbol, and deterministic salt. |
|
|
21
59
|
| `CTSuckerDeploymentConfig` | Cross-chain sucker deployment: deployer configurations and deterministic salt. |
|
|
22
60
|
|
|
61
|
+
### Interfaces
|
|
62
|
+
|
|
63
|
+
| Interface | Description |
|
|
64
|
+
|-----------|-------------|
|
|
65
|
+
| `ICTPublisher` | Publishing engine: `mintFrom`, `configurePostingCriteriaFor`, `allowanceFor`, `tiersFor`, plus events. |
|
|
66
|
+
| `ICTDeployer` | Factory: `deployProjectFor`, `claimCollectionOwnershipOf`, `deploySuckersFor`. |
|
|
67
|
+
| `ICTProjectOwner` | Burn-lock: `onERC721Received` (IERC721Receiver). |
|
|
68
|
+
|
|
23
69
|
## Install
|
|
24
70
|
|
|
25
71
|
```bash
|
|
26
|
-
npm install @croptop/core-
|
|
72
|
+
npm install @croptop/core-v6
|
|
27
73
|
```
|
|
28
74
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
`croptop-core-v5` uses [npm](https://www.npmjs.com/) for package management and [Foundry](https://github.com/foundry-rs/foundry) for builds, tests, and deployments.
|
|
75
|
+
If using Forge directly:
|
|
32
76
|
|
|
33
77
|
```bash
|
|
34
|
-
|
|
35
|
-
npm install && forge install
|
|
78
|
+
forge install
|
|
36
79
|
```
|
|
37
80
|
|
|
81
|
+
## Develop
|
|
82
|
+
|
|
38
83
|
| Command | Description |
|
|
39
84
|
|---------|-------------|
|
|
40
|
-
| `forge build` | Compile contracts
|
|
41
|
-
| `forge test` | Run
|
|
42
|
-
| `forge
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
85
|
+
| `forge build` | Compile contracts |
|
|
86
|
+
| `forge test` | Run all tests (4 test files covering publishing, attacks, fork integration, metadata) |
|
|
87
|
+
| `forge test -vvv` | Run tests with full trace |
|
|
88
|
+
|
|
89
|
+
## Repository Layout
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
src/
|
|
93
|
+
CTPublisher.sol # Core publishing engine (~540 lines)
|
|
94
|
+
CTDeployer.sol # Project factory + data hook proxy (~425 lines)
|
|
95
|
+
CTProjectOwner.sol # Burn-lock ownership helper (~79 lines)
|
|
96
|
+
interfaces/
|
|
97
|
+
ICTPublisher.sol # Publisher interface + events
|
|
98
|
+
ICTDeployer.sol # Factory interface
|
|
99
|
+
ICTProjectOwner.sol # Burn-lock interface
|
|
100
|
+
structs/
|
|
101
|
+
CTAllowedPost.sol # Posting criteria with hook address
|
|
102
|
+
CTDeployerAllowedPost.sol # Posting criteria without hook (for deployment)
|
|
103
|
+
CTPost.sol # Post data: IPFS URI, supply, price, category
|
|
104
|
+
CTProjectConfig.sol # Full project deployment config
|
|
105
|
+
CTSuckerDeploymentConfig.sol # Cross-chain sucker config
|
|
106
|
+
test/
|
|
107
|
+
CTPublisher.t.sol # Unit tests (~672 lines, ~22 cases)
|
|
108
|
+
CroptopAttacks.t.sol # Security/adversarial tests (~440 lines, ~12 cases)
|
|
109
|
+
Fork.t.sol # Mainnet fork integration tests
|
|
110
|
+
Test_MetadataGeneration.t.sol # JBMetadataResolver roundtrip tests
|
|
111
|
+
script/
|
|
112
|
+
Deploy.s.sol # Sphinx multi-chain deployment
|
|
113
|
+
ConfigureFeeProject.s.sol # Fee project configuration
|
|
114
|
+
helpers/
|
|
115
|
+
CroptopDeploymentLib.sol # Deployment artifact reader
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Permissions
|
|
119
|
+
|
|
120
|
+
| Permission | Required For |
|
|
121
|
+
|------------|-------------|
|
|
122
|
+
| `ADJUST_721_TIERS` | `configurePostingCriteriaFor` -- set posting criteria on a hook (from hook owner) |
|
|
123
|
+
| `DEPLOY_SUCKERS` | `deploySuckersFor` -- deploy cross-chain suckers for an existing project |
|
|
124
|
+
|
|
125
|
+
`CTDeployer` grants the following to the project owner during deployment:
|
|
126
|
+
- `ADJUST_721_TIERS` -- modify tiers directly
|
|
127
|
+
- `SET_721_METADATA` -- update collection metadata
|
|
128
|
+
- `MINT_721` -- mint NFTs directly
|
|
129
|
+
- `SET_721_DISCOUNT_PERCENT` -- adjust discount rates
|
|
130
|
+
|
|
131
|
+
## Risks
|
|
132
|
+
|
|
133
|
+
- **Sucker registry compromise:** `CTDeployer.beforeCashOutRecordedWith` checks `SUCKER_REGISTRY.isSuckerOf` to grant fee-free cash outs. If the sucker registry is compromised, any address could cash out without tax.
|
|
134
|
+
- **Fee skipping:** When `projectId == FEE_PROJECT_ID`, no fee is collected. This is intentional but means the fee project itself never pays Croptop fees.
|
|
135
|
+
- **Allowlist scaling:** `_isAllowed()` uses linear scan over the address allowlist. Large allowlists (100+ addresses) increase gas costs proportionally.
|
|
136
|
+
- **Tier reuse via IPFS URI:** If the same encoded IPFS URI has already been minted, the existing tier is reused rather than creating a new one. This prevents duplicate content but means a poster cannot create a second tier with the same content.
|
|
137
|
+
- **Exact payment edge case:** `mintFrom` sends the remaining contract balance as the fee payment after the main payment. If exactly the right amount is sent (no remainder), the fee transfer is skipped.
|
package/SKILLS.md
CHANGED
|
@@ -1,62 +1,134 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Croptop Core
|
|
2
2
|
|
|
3
3
|
## Purpose
|
|
4
4
|
|
|
5
|
-
Permissioned NFT publishing system that lets anyone post content as 721 tiers to a Juicebox project, subject to owner-defined criteria for price, supply, and poster identity.
|
|
5
|
+
Permissioned NFT publishing system that lets anyone post content as 721 tiers to a Juicebox project, subject to owner-defined criteria for price, supply, split percentages, and poster identity. Routes a 5% fee on each mint to a designated fee project.
|
|
6
6
|
|
|
7
7
|
## Contracts
|
|
8
8
|
|
|
9
9
|
| Contract | Role |
|
|
10
10
|
|----------|------|
|
|
11
|
-
| `CTPublisher` | Validates posts against allowances, creates 721 tiers, mints first copies, and routes fees. |
|
|
12
|
-
| `CTDeployer` | Factory that deploys a Juicebox project + 721 hook + posting criteria in one transaction. Also acts as
|
|
13
|
-
| `CTProjectOwner` | Receives project ownership and grants the
|
|
11
|
+
| `CTPublisher` | Core publishing engine. Validates posts against bit-packed allowances, creates 721 tiers on hooks, mints first copies to posters, and routes fees. Inherits `JBPermissioned`, `ERC2771Context`. |
|
|
12
|
+
| `CTDeployer` | Factory that deploys a Juicebox project + 721 hook + posting criteria in one transaction. Also acts as `IJBRulesetDataHook` proxy that forwards pay/cash-out calls to the underlying hook while granting fee-free cash outs to suckers. |
|
|
13
|
+
| `CTProjectOwner` | Receives project ownership NFT and grants `CTPublisher` the `ADJUST_721_TIERS` permission permanently. Locks ownership while keeping posting enabled. |
|
|
14
14
|
|
|
15
15
|
## Key Functions
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
|
20
|
-
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
|
27
|
-
|
|
28
|
-
| `
|
|
17
|
+
### Publishing
|
|
18
|
+
|
|
19
|
+
| Function | What it does |
|
|
20
|
+
|----------|-------------|
|
|
21
|
+
| `CTPublisher.mintFrom(hook, posts, nftBeneficiary, feeBeneficiary, additionalPayMetadata, feeMetadata)` | Publishes posts as new 721 tiers, mints first copies to `nftBeneficiary`, deducts a 5% fee (`totalPrice / FEE_DIVISOR`) routed to `FEE_PROJECT_ID`, and pays the remainder into the project's primary terminal. Reuses existing tiers if the IPFS URI was already minted. |
|
|
22
|
+
| `CTPublisher.configurePostingCriteriaFor(allowedPosts)` | Sets per-category posting rules (min price, min/max supply, max split percent, address allowlist) for a given hook. Requires `ADJUST_721_TIERS` permission from the hook owner. |
|
|
23
|
+
|
|
24
|
+
### Views
|
|
25
|
+
|
|
26
|
+
| Function | What it does |
|
|
27
|
+
|----------|-------------|
|
|
28
|
+
| `CTPublisher.allowanceFor(hook, category)` | Returns the posting criteria for a hook/category: minimum price (uint104), min supply (uint32), max supply (uint32), max split percent (uint32), plus the address allowlist. Reads from bit-packed storage. |
|
|
29
|
+
| `CTPublisher.tiersFor(hook, encodedIPFSUris)` | Resolves an array of encoded IPFS URIs to their corresponding `JB721Tier` structs via the stored `tierIdForEncodedIPFSUriOf` mapping. |
|
|
30
|
+
|
|
31
|
+
### Project Deployment
|
|
32
|
+
|
|
33
|
+
| Function | What it does |
|
|
34
|
+
|----------|-------------|
|
|
35
|
+
| `CTDeployer.deployProjectFor(owner, projectConfig, suckerDeploymentConfiguration, controller)` | Deploys a new Juicebox project with a 721 tiers hook, configures posting criteria, optionally deploys suckers, and transfers project ownership to the specified owner. Uses `CTDeployer` as data hook proxy. Returns `(projectId, hook)`. |
|
|
36
|
+
| `CTDeployer.claimCollectionOwnershipOf(hook)` | Transfers hook ownership to the project via `JBOwnable.transferOwnershipToProject`. Only callable by the project owner. |
|
|
37
|
+
| `CTDeployer.deploySuckersFor(projectId, suckerDeploymentConfiguration)` | Deploys new cross-chain suckers for an existing project. Requires `DEPLOY_SUCKERS` permission. |
|
|
38
|
+
|
|
39
|
+
### Data Hook Proxy
|
|
40
|
+
|
|
41
|
+
| Function | What it does |
|
|
42
|
+
|----------|-------------|
|
|
43
|
+
| `CTDeployer.beforePayRecordedWith(context)` | Forwards pay context to the stored `dataHookOf[projectId]` (typically the 721 tiers hook). |
|
|
44
|
+
| `CTDeployer.beforeCashOutRecordedWith(context)` | Returns zero tax rate for sucker addresses (fee-free cross-chain cash outs). Otherwise forwards to the stored data hook. |
|
|
45
|
+
| `CTDeployer.hasMintPermissionFor(projectId, ruleset, addr)` | Returns `true` if `addr` is a sucker for the project. |
|
|
46
|
+
|
|
47
|
+
### Burn-Lock Ownership
|
|
48
|
+
|
|
49
|
+
| Function | What it does |
|
|
50
|
+
|----------|-------------|
|
|
51
|
+
| `CTProjectOwner.onERC721Received(operator, from, tokenId, data)` | On receiving the project NFT, grants `CTPublisher` the `ADJUST_721_TIERS` permission for that project. Only accepts mints from `PROJECTS` (rejects direct transfers). |
|
|
29
52
|
|
|
30
53
|
## Integration Points
|
|
31
54
|
|
|
32
55
|
| Dependency | Import | Used For |
|
|
33
56
|
|------------|--------|----------|
|
|
34
|
-
| `@bananapus/core-v6` | `IJBDirectory`, `IJBPermissions`, `IJBTerminal`, `IJBProjects`, `IJBController` | Project lookup, permission enforcement, payment routing, project creation
|
|
35
|
-
| `@bananapus/721-hook-v6` | `IJB721TiersHook`, `IJB721TiersHookDeployer`, `JB721TierConfig`, `JB721Tier` | Tier creation/adjustment, hook deployment, tier data resolution
|
|
36
|
-
| `@bananapus/ownable-v6` | `JBOwnable` | Ownership checks and transfers for hooks
|
|
37
|
-
| `@bananapus/suckers-v6` | `IJBSuckerRegistry`, `JBSuckerDeployerConfig` | Cross-chain sucker deployment and fee-free cash-out detection
|
|
38
|
-
| `@bananapus/permission-ids-v6` | `JBPermissionIds` | Permission ID constants (`ADJUST_721_TIERS`, `DEPLOY_SUCKERS`, `MAP_SUCKER_TOKEN`, etc.)
|
|
39
|
-
| `@openzeppelin/contracts` | `ERC2771Context`, `IERC721Receiver` | Meta-transaction support, safe project NFT receipt
|
|
57
|
+
| `@bananapus/core-v6` | `IJBDirectory`, `IJBPermissions`, `IJBTerminal`, `IJBProjects`, `IJBController`, `JBConstants`, `JBMetadataResolver` | Project lookup, permission enforcement, payment routing, project creation, metadata encoding |
|
|
58
|
+
| `@bananapus/721-hook-v6` | `IJB721TiersHook`, `IJB721TiersHookDeployer`, `JB721TierConfig`, `JB721Tier` | Tier creation/adjustment, hook deployment, tier data resolution |
|
|
59
|
+
| `@bananapus/ownable-v6` | `JBOwnable` | Ownership checks and transfers for hooks |
|
|
60
|
+
| `@bananapus/suckers-v6` | `IJBSuckerRegistry`, `JBSuckerDeployerConfig` | Cross-chain sucker deployment and fee-free cash-out detection |
|
|
61
|
+
| `@bananapus/permission-ids-v6` | `JBPermissionIds` | Permission ID constants (`ADJUST_721_TIERS`, `DEPLOY_SUCKERS`, `MAP_SUCKER_TOKEN`, etc.) |
|
|
62
|
+
| `@openzeppelin/contracts` | `ERC2771Context`, `IERC721Receiver` | Meta-transaction support, safe project NFT receipt |
|
|
40
63
|
|
|
41
64
|
## Key Types
|
|
42
65
|
|
|
43
|
-
| Struct
|
|
44
|
-
|
|
45
|
-
| `CTAllowedPost` | `hook`, `category
|
|
46
|
-
| `CTPost` | `encodedIPFSUri` (bytes32), `totalSupply` (uint32), `price` (uint104), `category` (uint24) | `
|
|
47
|
-
| `CTProjectConfig` | `terminalConfigurations`, `projectUri`, `allowedPosts
|
|
48
|
-
| `CTDeployerAllowedPost` |
|
|
49
|
-
| `CTSuckerDeploymentConfig` | `deployerConfigurations
|
|
66
|
+
| Struct | Key Fields | Used In |
|
|
67
|
+
|--------|------------|---------|
|
|
68
|
+
| `CTAllowedPost` | `hook`, `category` (uint24), `minimumPrice` (uint104), `minimumTotalSupply` (uint32), `maximumTotalSupply` (uint32), `maximumSplitPercent` (uint32), `allowedAddresses[]` | `configurePostingCriteriaFor` |
|
|
69
|
+
| `CTPost` | `encodedIPFSUri` (bytes32), `totalSupply` (uint32), `price` (uint104), `category` (uint24), `splitPercent` (uint32), `splits[]` (JBSplit[]) | `mintFrom` |
|
|
70
|
+
| `CTProjectConfig` | `terminalConfigurations`, `projectUri`, `allowedPosts` (CTDeployerAllowedPost[]), `contractUri`, `name`, `symbol`, `salt` | `deployProjectFor` |
|
|
71
|
+
| `CTDeployerAllowedPost` | Same as `CTAllowedPost` minus `hook` (inferred during deployment) | `CTProjectConfig.allowedPosts` |
|
|
72
|
+
| `CTSuckerDeploymentConfig` | `deployerConfigurations` (JBSuckerDeployerConfig[]), `salt` | `deployProjectFor`, `deploySuckersFor` |
|
|
73
|
+
|
|
74
|
+
## Events
|
|
75
|
+
|
|
76
|
+
| Event | When |
|
|
77
|
+
|-------|------|
|
|
78
|
+
| `ConfigurePostingCriteria(hook, allowedPost, caller)` | Posting criteria set or updated for a hook/category |
|
|
79
|
+
| `Mint(projectId, hook, nftBeneficiary, feeBeneficiary, posts, postValue, txValue, caller)` | Posts published and first copies minted |
|
|
80
|
+
|
|
81
|
+
## Errors
|
|
82
|
+
|
|
83
|
+
| Error | When |
|
|
84
|
+
|-------|------|
|
|
85
|
+
| `CTPublisher_EmptyEncodedIPFSUri` | Post has `encodedIPFSUri == bytes32(0)` |
|
|
86
|
+
| `CTPublisher_InsufficientEthSent` | `totalPrice + fee > msg.value` |
|
|
87
|
+
| `CTPublisher_MaxTotalSupplyLessThanMin` | `minimumTotalSupply > maximumTotalSupply` in config |
|
|
88
|
+
| `CTPublisher_NotInAllowList` | Caller not in allowlist (when allowlist is non-empty) |
|
|
89
|
+
| `CTPublisher_PriceTooSmall` | Post price below `minimumPrice` |
|
|
90
|
+
| `CTPublisher_SplitPercentExceedsMaximum` | Post `splitPercent > maximumSplitPercent` |
|
|
91
|
+
| `CTPublisher_TotalSupplyTooSmall` | Post `totalSupply < minimumTotalSupply` |
|
|
92
|
+
| `CTPublisher_TotalSupplyTooBig` | Post `totalSupply > maximumTotalSupply` (when max > 0) |
|
|
93
|
+
| `CTPublisher_UnauthorizedToPostInCategory` | Category unconfigured (`minSupply == 0`) |
|
|
94
|
+
| `CTPublisher_ZeroTotalSupply` | `configurePostingCriteriaFor` with `minimumTotalSupply == 0` |
|
|
95
|
+
| `CTDeployer_NotOwnerOfProject` | `claimCollectionOwnershipOf` called by non-owner |
|
|
96
|
+
|
|
97
|
+
## Constants
|
|
98
|
+
|
|
99
|
+
| Constant | Value | Purpose |
|
|
100
|
+
|----------|-------|---------|
|
|
101
|
+
| `FEE_DIVISOR` | 20 | 5% fee: `totalPrice / 20` |
|
|
102
|
+
| `FEE_PROJECT_ID` | immutable | Fees routed to this project. Fee skipped when `projectId == FEE_PROJECT_ID` |
|
|
103
|
+
|
|
104
|
+
## Storage
|
|
105
|
+
|
|
106
|
+
| Mapping | Type | Purpose |
|
|
107
|
+
|---------|------|---------|
|
|
108
|
+
| `tierIdForEncodedIPFSUriOf` | `hook => encodedIPFSUri => uint256` | Maps IPFS URI to existing tier ID (prevents duplicates) |
|
|
109
|
+
| `_packedAllowanceFor` | `hook => category => uint256` | Bit-packed allowance: price (0-103), minSupply (104-135), maxSupply (136-167), maxSplitPercent (168-199) |
|
|
110
|
+
| `_allowedAddresses` | `hook => category => address[]` | Per-category address allowlist |
|
|
111
|
+
| `dataHookOf` | `projectId => IJBRulesetDataHook` | Stores original data hook (CTDeployer proxy pattern) |
|
|
50
112
|
|
|
51
113
|
## Gotchas
|
|
52
114
|
|
|
53
|
-
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
115
|
+
1. **Bit-packed allowances.** Allowances are packed into a single `uint256`: price in bits 0-103, min supply in 104-135, max supply in 136-167, max split percent in 168-199. Reading with wrong bit widths silently returns wrong values.
|
|
116
|
+
2. **Fee is 1/20, not a percentage.** `FEE_DIVISOR = 20` means fee = `totalPrice / 20` = 5%. Integer division truncates (rounding down favors payer).
|
|
117
|
+
3. **Fee skipped for fee project.** When `projectId == FEE_PROJECT_ID`, no fee is deducted. This prevents self-referential fee loops.
|
|
118
|
+
4. **Fee payment uses contract balance.** After the main payment, `mintFrom` sends `address(this).balance` as the fee. If the main payment uses exact funds (no remainder), the fee transfer is skipped entirely.
|
|
119
|
+
5. **Tier reuse by IPFS URI.** If an encoded IPFS URI was already minted on the hook, the existing tier ID is reused instead of creating a new tier. The poster still gets a mint of the existing tier. The fee is calculated from the actual tier price stored on-chain (not from `post.price`), preventing fee evasion (H-19 fix).
|
|
120
|
+
6. **Stale tier mapping cleanup.** If a tier was removed externally via `adjustTiers()`, the `tierIdForEncodedIPFSUriOf` mapping is automatically cleared when the same IPFS URI is posted again, allowing a new tier to be created (L-52 fix).
|
|
121
|
+
7. **Array resizing via assembly.** `_setupPosts` resizes `tiersToAdd` via inline assembly when some posts reuse existing tiers. The `tierIdsToMint` array is NOT resized and may contain zeros for pre-existing tiers.
|
|
122
|
+
8. **CTProjectOwner only accepts mints.** `onERC721Received` reverts if `from != address(0)` -- it only accepts tokens minted by `PROJECTS`, not transferred directly. But external project NFT transfers (where `from` is the previous owner) DO work since the hook is on `CTProjectOwner`, not `CTDeployer`.
|
|
123
|
+
9. **CTDeployer rejects direct transfers.** `CTDeployer.onERC721Received` reverts if `from != address(0)`. It only accepts mints from `PROJECTS`.
|
|
124
|
+
10. **Temporary ownership during deployment.** `CTDeployer` owns the project NFT temporarily during `deployProjectFor` (to configure permissions and hooks), then transfers it to the specified `owner`. If the transfer reverts, the entire deployment fails.
|
|
125
|
+
11. **Data hook proxy pattern.** `CTDeployer` wraps itself as the data hook, forwarding to `dataHookOf[projectId]`. This is needed to intercept cash-out calls and grant fee-free cash outs to suckers. Both `useDataHookForPay` and `useDataHookForCashOut` are enabled (M-37 fix).
|
|
126
|
+
12. **Sucker registry trust.** `CTDeployer.beforeCashOutRecordedWith` trusts `SUCKER_REGISTRY.isSuckerOf` to determine fee exemption. If the registry is compromised, any address could cash out without tax.
|
|
127
|
+
13. **Allowlist uses linear scan.** `_isAllowed()` iterates the full allowlist array. Acceptable for <100 addresses; gas cost scales linearly with list size.
|
|
128
|
+
14. **Referral ID in metadata.** `FEE_PROJECT_ID` is stored in the first 32 bytes of mint metadata (via assembly `mstore`), allowing the fee terminal to track referrals.
|
|
129
|
+
15. **Deterministic deployment.** Hook salt is `keccak256(abi.encode(projectConfig.salt, msg.sender))` and sucker salt is `keccak256(abi.encode(suckerConfig.salt, msg.sender))`. Different callers with the same salt get different addresses.
|
|
130
|
+
16. **Default project weight.** `CTDeployer` deploys projects with `weight = 1_000_000 * 10^18`, ETH currency, and `maxCashOutTaxRate`. These defaults are hardcoded.
|
|
131
|
+
17. **ERC2771 meta-transaction support.** Both `CTPublisher` and `CTDeployer` support meta-transactions via `ERC2771Context` with a configurable trusted forwarder, allowing relayers to submit transactions on behalf of users.
|
|
60
132
|
|
|
61
133
|
## Example Integration
|
|
62
134
|
|
|
@@ -65,13 +137,16 @@ import {ICTPublisher} from "@croptop/core-v6/src/interfaces/ICTPublisher.sol";
|
|
|
65
137
|
import {CTPost} from "@croptop/core-v6/src/structs/CTPost.sol";
|
|
66
138
|
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
67
139
|
|
|
68
|
-
//
|
|
140
|
+
// --- Post content to a Croptop-enabled project ---
|
|
141
|
+
|
|
69
142
|
CTPost[] memory posts = new CTPost[](1);
|
|
70
143
|
posts[0] = CTPost({
|
|
71
144
|
encodedIPFSUri: 0x1234..., // encoded IPFS CID
|
|
72
145
|
totalSupply: 100,
|
|
73
146
|
price: 0.01 ether,
|
|
74
|
-
category: 1
|
|
147
|
+
category: 1,
|
|
148
|
+
splitPercent: 0,
|
|
149
|
+
splits: new JBSplit[](0)
|
|
75
150
|
});
|
|
76
151
|
|
|
77
152
|
// Price + 5% fee
|
|
@@ -85,4 +160,29 @@ publisher.mintFrom{value: totalCost}(
|
|
|
85
160
|
"", // additional pay metadata
|
|
86
161
|
"" // fee metadata
|
|
87
162
|
);
|
|
163
|
+
|
|
164
|
+
// --- Deploy a new Croptop project ---
|
|
165
|
+
|
|
166
|
+
(uint256 projectId, IJB721TiersHook hook) = deployer.deployProjectFor({
|
|
167
|
+
owner: msg.sender,
|
|
168
|
+
projectConfig: CTProjectConfig({
|
|
169
|
+
terminalConfigurations: terminals,
|
|
170
|
+
projectUri: "ipfs://...",
|
|
171
|
+
allowedPosts: allowedPosts,
|
|
172
|
+
contractUri: "ipfs://...",
|
|
173
|
+
name: "My Collection",
|
|
174
|
+
symbol: "MYC",
|
|
175
|
+
salt: bytes32("my-project")
|
|
176
|
+
}),
|
|
177
|
+
suckerDeploymentConfiguration: CTSuckerDeploymentConfig({
|
|
178
|
+
deployerConfigurations: new JBSuckerDeployerConfig[](0),
|
|
179
|
+
salt: bytes32(0)
|
|
180
|
+
}),
|
|
181
|
+
controller: IJBController(controllerAddress)
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// --- Lock ownership via CTProjectOwner ---
|
|
185
|
+
// Transfer the project NFT to CTProjectOwner to burn-lock ownership
|
|
186
|
+
// while keeping Croptop posting enabled.
|
|
187
|
+
IERC721(projects).safeTransferFrom(msg.sender, address(projectOwner), projectId);
|
|
88
188
|
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@croptop/core-v6",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
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/suckers-v6": "^0.0.
|
|
25
|
-
"@bananapus/router-terminal-v6": "^0.0.
|
|
19
|
+
"@bananapus/721-hook-v6": "^0.0.9",
|
|
20
|
+
"@bananapus/buyback-hook-v6": "^0.0.7",
|
|
21
|
+
"@bananapus/core-v6": "^0.0.9",
|
|
22
|
+
"@bananapus/ownable-v6": "^0.0.5",
|
|
23
|
+
"@bananapus/permission-ids-v6": "^0.0.4",
|
|
24
|
+
"@bananapus/suckers-v6": "^0.0.6",
|
|
25
|
+
"@bananapus/router-terminal-v6": "^0.0.6",
|
|
26
26
|
"@openzeppelin/contracts": "^5.2.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@rev-net/core-v6": "^0.0.
|
|
29
|
+
"@rev-net/core-v6": "^0.0.6",
|
|
30
30
|
"@sphinx-labs/plugins": "^0.33.1"
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -326,14 +326,15 @@ contract ConfigureFeeProjectScript is Script, Sphinx {
|
|
|
326
326
|
core.projects.approve(address(revnet.basic_deployer), FEE_PROJECT_ID);
|
|
327
327
|
|
|
328
328
|
// Deploy the NANA fee project.
|
|
329
|
-
revnet.basic_deployer
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
329
|
+
revnet.basic_deployer
|
|
330
|
+
.deployWith721sFor({
|
|
331
|
+
revnetId: FEE_PROJECT_ID,
|
|
332
|
+
configuration: feeProjectConfig.configuration,
|
|
333
|
+
terminalConfigurations: feeProjectConfig.terminalConfigurations,
|
|
334
|
+
suckerDeploymentConfiguration: feeProjectConfig.suckerDeploymentConfiguration,
|
|
335
|
+
tiered721HookConfiguration: feeProjectConfig.hookConfiguration,
|
|
336
|
+
allowedPosts: feeProjectConfig.allowedPosts
|
|
337
|
+
});
|
|
337
338
|
}
|
|
338
339
|
|
|
339
340
|
function _isDeployed(
|
package/src/CTDeployer.sol
CHANGED
|
@@ -275,6 +275,7 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
|
|
|
275
275
|
rulesetConfigurations[0].metadata.cashOutTaxRate = JBConstants.MAX_CASH_OUT_TAX_RATE;
|
|
276
276
|
rulesetConfigurations[0].metadata.dataHook = address(this);
|
|
277
277
|
rulesetConfigurations[0].metadata.useDataHookForPay = true;
|
|
278
|
+
rulesetConfigurations[0].metadata.useDataHookForCashOut = true;
|
|
278
279
|
|
|
279
280
|
// Launch the project, and sanity check the project ID.
|
|
280
281
|
assert(
|
package/src/CTPublisher.sol
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
pragma solidity 0.8.26;
|
|
3
3
|
|
|
4
4
|
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
5
|
+
import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
|
|
5
6
|
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
6
7
|
import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
|
|
7
8
|
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
@@ -427,9 +428,12 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
|
|
|
427
428
|
// Set the size of the tier IDs of the posts that should be minted once published.
|
|
428
429
|
tierIdsToMint = new uint256[](posts.length);
|
|
429
430
|
|
|
431
|
+
// Keep a reference to the hook's store for tier lookups.
|
|
432
|
+
IJB721TiersHookStore store = hook.STORE();
|
|
433
|
+
|
|
430
434
|
// The tier ID that will be created, and the first one that should be minted from, is one more than the current
|
|
431
435
|
// max.
|
|
432
|
-
uint256 startingTierId =
|
|
436
|
+
uint256 startingTierId = store.maxTierIdOf(address(hook)) + 1;
|
|
433
437
|
|
|
434
438
|
// Keep a reference to the total number of tiers being added.
|
|
435
439
|
uint256 numberOfTiersBeingAdded;
|
|
@@ -450,7 +454,21 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
|
|
|
450
454
|
// Check if there's an ID of a tier already minted for this encodedIPFSUri.
|
|
451
455
|
uint256 tierId = tierIdForEncodedIPFSUriOf[address(hook)][post.encodedIPFSUri];
|
|
452
456
|
|
|
453
|
-
if (tierId != 0)
|
|
457
|
+
if (tierId != 0) {
|
|
458
|
+
// If the tier was removed externally (via adjustTiers), clear the stale mapping
|
|
459
|
+
// so the code falls through to create a new tier.
|
|
460
|
+
// slither-disable-next-line calls-loop
|
|
461
|
+
if (hook.STORE().isTierRemoved(address(hook), tierId)) {
|
|
462
|
+
delete tierIdForEncodedIPFSUriOf[address(hook)][post.encodedIPFSUri];
|
|
463
|
+
} else {
|
|
464
|
+
tierIdsToMint[i] = tierId;
|
|
465
|
+
|
|
466
|
+
// For existing tiers, use the actual tier price (not the user-supplied post.price)
|
|
467
|
+
// to prevent fee evasion by passing price=0 for an existing tier.
|
|
468
|
+
// slither-disable-next-line calls-loop
|
|
469
|
+
totalPrice += store.tierOf(address(hook), tierId, false).price;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
454
472
|
}
|
|
455
473
|
|
|
456
474
|
// If no tier already exists, post the tier.
|
|
@@ -524,10 +542,10 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
|
|
|
524
542
|
|
|
525
543
|
// Save the encodedIPFSUri as minted.
|
|
526
544
|
tierIdForEncodedIPFSUriOf[address(hook)][post.encodedIPFSUri] = tierIdsToMint[i];
|
|
527
|
-
}
|
|
528
545
|
|
|
529
|
-
|
|
530
|
-
|
|
546
|
+
// For new tiers, use the post's price for totalPrice accumulation.
|
|
547
|
+
totalPrice += post.price;
|
|
548
|
+
}
|
|
531
549
|
}
|
|
532
550
|
|
|
533
551
|
// Resize the array if there's a mismatch in length.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
9
|
+
import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
|
|
10
|
+
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
11
|
+
import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
|
|
12
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
13
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
14
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
15
|
+
import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
|
|
16
|
+
|
|
17
|
+
import {CTPublisher} from "../../src/CTPublisher.sol";
|
|
18
|
+
import {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
|
|
19
|
+
import {CTPost} from "../../src/structs/CTPost.sol";
|
|
20
|
+
|
|
21
|
+
/// @title H19_FeeEvasion
|
|
22
|
+
/// @notice Regression test for H-19: fee evasion for existing tier mints.
|
|
23
|
+
/// Before the fix, a user could set post.price = 0 for an existing tier
|
|
24
|
+
/// to evade the 5% Croptop fee entirely. The fix reads the actual tier price
|
|
25
|
+
/// from the store for existing tiers.
|
|
26
|
+
contract H19_FeeEvasion is Test {
|
|
27
|
+
CTPublisher publisher;
|
|
28
|
+
|
|
29
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
30
|
+
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
31
|
+
|
|
32
|
+
address hookOwner = makeAddr("hookOwner");
|
|
33
|
+
address hookAddr = makeAddr("hook");
|
|
34
|
+
address hookStoreAddr = makeAddr("hookStore");
|
|
35
|
+
address terminalAddr = makeAddr("terminal");
|
|
36
|
+
address feeTerminalAddr = makeAddr("feeTerminal");
|
|
37
|
+
address poster = makeAddr("poster");
|
|
38
|
+
|
|
39
|
+
uint256 feeProjectId = 1;
|
|
40
|
+
uint256 hookProjectId = 42;
|
|
41
|
+
|
|
42
|
+
bytes32 constant TEST_URI = keccak256("existing-tier-content");
|
|
43
|
+
uint104 constant TIER_PRICE = 1 ether;
|
|
44
|
+
|
|
45
|
+
function setUp() public {
|
|
46
|
+
publisher = new CTPublisher(directory, permissions, feeProjectId, address(0));
|
|
47
|
+
|
|
48
|
+
// Mock hook.owner().
|
|
49
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
|
|
50
|
+
// Mock hook.PROJECT_ID().
|
|
51
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.PROJECT_ID.selector), abi.encode(hookProjectId));
|
|
52
|
+
// Mock hook.STORE().
|
|
53
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(hookStoreAddr));
|
|
54
|
+
|
|
55
|
+
// Mock isTierRemoved to return false by default (tier exists).
|
|
56
|
+
vm.mockCall(
|
|
57
|
+
hookStoreAddr, abi.encodeWithSelector(IJB721TiersHookStore.isTierRemoved.selector), abi.encode(false)
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Mock permissions to return true by default.
|
|
61
|
+
vm.mockCall(
|
|
62
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Fund poster.
|
|
66
|
+
vm.deal(poster, 100 ether);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _configureCategory() internal {
|
|
70
|
+
CTAllowedPost[] memory posts = new CTAllowedPost[](1);
|
|
71
|
+
posts[0] = CTAllowedPost({
|
|
72
|
+
hook: hookAddr,
|
|
73
|
+
category: 5,
|
|
74
|
+
minimumPrice: 0,
|
|
75
|
+
minimumTotalSupply: 1,
|
|
76
|
+
maximumTotalSupply: 100,
|
|
77
|
+
maximumSplitPercent: 0,
|
|
78
|
+
allowedAddresses: new address[](0)
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
vm.prank(hookOwner);
|
|
82
|
+
publisher.configurePostingCriteriaFor(posts);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _setupMintMocks(uint256 maxTierId) internal {
|
|
86
|
+
vm.mockCall(
|
|
87
|
+
hookStoreAddr, abi.encodeWithSelector(IJB721TiersHookStore.maxTierIdOf.selector), abi.encode(maxTierId)
|
|
88
|
+
);
|
|
89
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.adjustTiers.selector), abi.encode());
|
|
90
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(bytes4(keccak256("METADATA_ID_TARGET()"))), abi.encode(address(0)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/// @notice Test that fee is still charged when post.price = 0 for an existing tier.
|
|
94
|
+
/// Before the fix, the attacker could set post.price = 0 and pay exactly 0 ETH
|
|
95
|
+
/// for the fee. After the fix, the actual tier price is read from the store.
|
|
96
|
+
function test_feeChargedForExistingTierEvenWithZeroPostPrice() public {
|
|
97
|
+
_configureCategory();
|
|
98
|
+
|
|
99
|
+
// First mint: create tier 1 with TIER_PRICE.
|
|
100
|
+
_setupMintMocks(0);
|
|
101
|
+
|
|
102
|
+
// Mock tierOf for tier 1 to return a tier with TIER_PRICE.
|
|
103
|
+
JB721Tier memory tier = JB721Tier({
|
|
104
|
+
id: 1,
|
|
105
|
+
price: TIER_PRICE,
|
|
106
|
+
remainingSupply: 9,
|
|
107
|
+
initialSupply: 10,
|
|
108
|
+
votingUnits: 0,
|
|
109
|
+
reserveFrequency: 0,
|
|
110
|
+
reserveBeneficiary: address(0),
|
|
111
|
+
encodedIPFSUri: TEST_URI,
|
|
112
|
+
category: 5,
|
|
113
|
+
discountPercent: 0,
|
|
114
|
+
allowOwnerMint: false,
|
|
115
|
+
transfersPausable: false,
|
|
116
|
+
cannotBeRemoved: false,
|
|
117
|
+
cannotIncreaseDiscountPercent: false,
|
|
118
|
+
splitPercent: 0,
|
|
119
|
+
resolvedUri: ""
|
|
120
|
+
});
|
|
121
|
+
vm.mockCall(
|
|
122
|
+
hookStoreAddr,
|
|
123
|
+
abi.encodeWithSelector(IJB721TiersHookStore.tierOf.selector, hookAddr, 1, false),
|
|
124
|
+
abi.encode(tier)
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Mock terminals.
|
|
128
|
+
vm.mockCall(
|
|
129
|
+
address(directory),
|
|
130
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, hookProjectId),
|
|
131
|
+
abi.encode(terminalAddr)
|
|
132
|
+
);
|
|
133
|
+
vm.mockCall(
|
|
134
|
+
address(directory),
|
|
135
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, feeProjectId),
|
|
136
|
+
abi.encode(feeTerminalAddr)
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Mock terminal.pay() to succeed and record the value sent.
|
|
140
|
+
vm.mockCall(terminalAddr, "", abi.encode(uint256(0)));
|
|
141
|
+
vm.mockCall(feeTerminalAddr, "", abi.encode(uint256(0)));
|
|
142
|
+
|
|
143
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
144
|
+
posts[0] = CTPost({
|
|
145
|
+
encodedIPFSUri: TEST_URI,
|
|
146
|
+
totalSupply: 10,
|
|
147
|
+
price: TIER_PRICE,
|
|
148
|
+
category: 5,
|
|
149
|
+
splitPercent: 0,
|
|
150
|
+
splits: new JBSplit[](0)
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// First mint to create the tier and populate the mapping.
|
|
154
|
+
vm.prank(poster);
|
|
155
|
+
publisher.mintFrom{value: 2 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
156
|
+
|
|
157
|
+
// Verify the mapping was set.
|
|
158
|
+
assertEq(publisher.tierIdForEncodedIPFSUriOf(hookAddr, TEST_URI), 1, "tier ID should be stored");
|
|
159
|
+
|
|
160
|
+
// Now the attack: existing tier, but attacker sets post.price = 0.
|
|
161
|
+
// Update mocks for the second mint (maxTierId is now 1).
|
|
162
|
+
_setupMintMocks(1);
|
|
163
|
+
|
|
164
|
+
CTPost[] memory attackPosts = new CTPost[](1);
|
|
165
|
+
attackPosts[0] = CTPost({
|
|
166
|
+
encodedIPFSUri: TEST_URI,
|
|
167
|
+
totalSupply: 10,
|
|
168
|
+
price: 0, // Attacker tries to evade fee by setting price = 0.
|
|
169
|
+
category: 5,
|
|
170
|
+
splitPercent: 0,
|
|
171
|
+
splits: new JBSplit[](0)
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// The fee is TIER_PRICE / FEE_DIVISOR = 1 ether / 20 = 0.05 ether.
|
|
175
|
+
// The project payment is TIER_PRICE - fee = 1 ether - 0.05 ether = 0.95 ether.
|
|
176
|
+
// Total required: TIER_PRICE = 1 ether (project gets 0.95 ether, fee is 0.05 ether).
|
|
177
|
+
// With the fix, the actual tier price (1 ether) is used, so the full msg.value is needed.
|
|
178
|
+
|
|
179
|
+
// Sending 0 ETH should revert because totalPrice is now the actual tier price (1 ether),
|
|
180
|
+
// not the attacker's 0.
|
|
181
|
+
vm.prank(poster);
|
|
182
|
+
vm.expectRevert();
|
|
183
|
+
publisher.mintFrom{value: 0}(IJB721TiersHook(hookAddr), attackPosts, poster, poster, "", "");
|
|
184
|
+
|
|
185
|
+
// Sending the correct amount should succeed.
|
|
186
|
+
vm.prank(poster);
|
|
187
|
+
publisher.mintFrom{value: 2 ether}(IJB721TiersHook(hookAddr), attackPosts, poster, poster, "", "");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/// @notice Test that the correct fee amount is deducted for existing tier mints.
|
|
191
|
+
/// The fee should be based on the actual tier price, not post.price.
|
|
192
|
+
function test_correctFeeDeductedForExistingTier() public {
|
|
193
|
+
_configureCategory();
|
|
194
|
+
|
|
195
|
+
// Create tier 1 with TIER_PRICE.
|
|
196
|
+
_setupMintMocks(0);
|
|
197
|
+
|
|
198
|
+
// Mock tierOf for tier 1.
|
|
199
|
+
JB721Tier memory tier = JB721Tier({
|
|
200
|
+
id: 1,
|
|
201
|
+
price: TIER_PRICE,
|
|
202
|
+
remainingSupply: 9,
|
|
203
|
+
initialSupply: 10,
|
|
204
|
+
votingUnits: 0,
|
|
205
|
+
reserveFrequency: 0,
|
|
206
|
+
reserveBeneficiary: address(0),
|
|
207
|
+
encodedIPFSUri: TEST_URI,
|
|
208
|
+
category: 5,
|
|
209
|
+
discountPercent: 0,
|
|
210
|
+
allowOwnerMint: false,
|
|
211
|
+
transfersPausable: false,
|
|
212
|
+
cannotBeRemoved: false,
|
|
213
|
+
cannotIncreaseDiscountPercent: false,
|
|
214
|
+
splitPercent: 0,
|
|
215
|
+
resolvedUri: ""
|
|
216
|
+
});
|
|
217
|
+
vm.mockCall(
|
|
218
|
+
hookStoreAddr,
|
|
219
|
+
abi.encodeWithSelector(IJB721TiersHookStore.tierOf.selector, hookAddr, 1, false),
|
|
220
|
+
abi.encode(tier)
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
// Mock terminals.
|
|
224
|
+
vm.mockCall(
|
|
225
|
+
address(directory),
|
|
226
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, hookProjectId),
|
|
227
|
+
abi.encode(terminalAddr)
|
|
228
|
+
);
|
|
229
|
+
vm.mockCall(
|
|
230
|
+
address(directory),
|
|
231
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, feeProjectId),
|
|
232
|
+
abi.encode(feeTerminalAddr)
|
|
233
|
+
);
|
|
234
|
+
vm.mockCall(terminalAddr, "", abi.encode(uint256(0)));
|
|
235
|
+
vm.mockCall(feeTerminalAddr, "", abi.encode(uint256(0)));
|
|
236
|
+
|
|
237
|
+
// First mint to create the tier.
|
|
238
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
239
|
+
posts[0] = CTPost({
|
|
240
|
+
encodedIPFSUri: TEST_URI,
|
|
241
|
+
totalSupply: 10,
|
|
242
|
+
price: TIER_PRICE,
|
|
243
|
+
category: 5,
|
|
244
|
+
splitPercent: 0,
|
|
245
|
+
splits: new JBSplit[](0)
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
vm.prank(poster);
|
|
249
|
+
publisher.mintFrom{value: 2 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
250
|
+
|
|
251
|
+
// Second mint with the existing tier. Even with post.price = 0, the fee
|
|
252
|
+
// should be based on the actual price (1 ether).
|
|
253
|
+
_setupMintMocks(1);
|
|
254
|
+
|
|
255
|
+
CTPost[] memory existingPosts = new CTPost[](1);
|
|
256
|
+
existingPosts[0] = CTPost({
|
|
257
|
+
encodedIPFSUri: TEST_URI,
|
|
258
|
+
totalSupply: 10,
|
|
259
|
+
price: 0, // Attacker sets price to 0.
|
|
260
|
+
category: 5,
|
|
261
|
+
splitPercent: 0,
|
|
262
|
+
splits: new JBSplit[](0)
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// Fee = 1 ether / 20 = 0.05 ether
|
|
266
|
+
// payValue = msg.value - fee = msg.value - 0.05 ether
|
|
267
|
+
// totalPrice = 1 ether (from the store, not post.price)
|
|
268
|
+
// Need: totalPrice <= payValue, i.e., 1 ether <= msg.value - 0.05 ether
|
|
269
|
+
// So msg.value >= 1.05 ether
|
|
270
|
+
|
|
271
|
+
// Sending exactly 1.05 ether should succeed.
|
|
272
|
+
vm.prank(poster);
|
|
273
|
+
publisher.mintFrom{value: 1.05 ether}(IJB721TiersHook(hookAddr), existingPosts, poster, poster, "", "");
|
|
274
|
+
|
|
275
|
+
// Sending 1.04 ether should fail (1.04 - 0.05 = 0.99 < 1 ether totalPrice).
|
|
276
|
+
vm.prank(poster);
|
|
277
|
+
vm.expectRevert();
|
|
278
|
+
publisher.mintFrom{value: 1.04 ether}(IJB721TiersHook(hookAddr), existingPosts, poster, poster, "", "");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
|
|
6
|
+
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
7
|
+
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
|
+
import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
|
|
9
|
+
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
10
|
+
import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
|
|
11
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
12
|
+
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
13
|
+
import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
|
|
14
|
+
import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
|
|
15
|
+
|
|
16
|
+
import {CTPublisher} from "../../src/CTPublisher.sol";
|
|
17
|
+
import {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
|
|
18
|
+
import {CTPost} from "../../src/structs/CTPost.sol";
|
|
19
|
+
|
|
20
|
+
/// @title L52_StaleTierIdMapping
|
|
21
|
+
/// @notice Regression test for L-52: stale tierIdForEncodedIPFSUriOf mapping after external tier removal.
|
|
22
|
+
/// When a tier is removed externally via adjustTiers(), the publisher's mapping still pointed
|
|
23
|
+
/// to the removed tier ID, blocking re-creation. The fix clears the stale mapping and allows
|
|
24
|
+
/// the post to fall through to new-tier creation.
|
|
25
|
+
contract L52_StaleTierIdMapping is Test {
|
|
26
|
+
CTPublisher publisher;
|
|
27
|
+
|
|
28
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
29
|
+
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
30
|
+
|
|
31
|
+
address hookOwner = makeAddr("hookOwner");
|
|
32
|
+
address hookAddr = makeAddr("hook");
|
|
33
|
+
address hookStoreAddr = makeAddr("hookStore");
|
|
34
|
+
address terminalAddr = makeAddr("terminal");
|
|
35
|
+
address poster = makeAddr("poster");
|
|
36
|
+
|
|
37
|
+
uint256 feeProjectId = 1;
|
|
38
|
+
uint256 hookProjectId = 42;
|
|
39
|
+
|
|
40
|
+
bytes32 constant TEST_URI = keccak256("removable-content");
|
|
41
|
+
|
|
42
|
+
function setUp() public {
|
|
43
|
+
publisher = new CTPublisher(directory, permissions, feeProjectId, address(0));
|
|
44
|
+
|
|
45
|
+
// Mock hook.owner().
|
|
46
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
|
|
47
|
+
// Mock hook.PROJECT_ID().
|
|
48
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.PROJECT_ID.selector), abi.encode(hookProjectId));
|
|
49
|
+
// Mock hook.STORE().
|
|
50
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(hookStoreAddr));
|
|
51
|
+
|
|
52
|
+
// Mock permissions to return true by default.
|
|
53
|
+
vm.mockCall(
|
|
54
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Fund poster.
|
|
58
|
+
vm.deal(poster, 100 ether);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function _configureCategory() internal {
|
|
62
|
+
CTAllowedPost[] memory posts = new CTAllowedPost[](1);
|
|
63
|
+
posts[0] = CTAllowedPost({
|
|
64
|
+
hook: hookAddr,
|
|
65
|
+
category: 5,
|
|
66
|
+
minimumPrice: 0,
|
|
67
|
+
minimumTotalSupply: 1,
|
|
68
|
+
maximumTotalSupply: 100,
|
|
69
|
+
maximumSplitPercent: 0,
|
|
70
|
+
allowedAddresses: new address[](0)
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
vm.prank(hookOwner);
|
|
74
|
+
publisher.configurePostingCriteriaFor(posts);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function _setupMintMocks(uint256 maxTierId) internal {
|
|
78
|
+
vm.mockCall(
|
|
79
|
+
hookStoreAddr, abi.encodeWithSelector(IJB721TiersHookStore.maxTierIdOf.selector), abi.encode(maxTierId)
|
|
80
|
+
);
|
|
81
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.adjustTiers.selector), abi.encode());
|
|
82
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(bytes4(keccak256("METADATA_ID_TARGET()"))), abi.encode(address(0)));
|
|
83
|
+
vm.mockCall(
|
|
84
|
+
address(directory),
|
|
85
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector),
|
|
86
|
+
abi.encode(terminalAddr)
|
|
87
|
+
);
|
|
88
|
+
vm.mockCall(terminalAddr, "", abi.encode(uint256(0)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// @notice After a tier is removed externally, the stale mapping should be cleared
|
|
92
|
+
/// so that the same encodedIPFSUri can be re-posted as a new tier.
|
|
93
|
+
function test_staleMappingClearedWhenTierRemoved() public {
|
|
94
|
+
_configureCategory();
|
|
95
|
+
|
|
96
|
+
// First mint: create tier 1 for TEST_URI.
|
|
97
|
+
_setupMintMocks(0);
|
|
98
|
+
|
|
99
|
+
// Mock isTierRemoved to return false (tier exists).
|
|
100
|
+
vm.mockCall(
|
|
101
|
+
hookStoreAddr,
|
|
102
|
+
abi.encodeWithSelector(IJB721TiersHookStore.isTierRemoved.selector, hookAddr, 1),
|
|
103
|
+
abi.encode(false)
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
107
|
+
posts[0] = CTPost({
|
|
108
|
+
encodedIPFSUri: TEST_URI,
|
|
109
|
+
totalSupply: 10,
|
|
110
|
+
price: 0.1 ether,
|
|
111
|
+
category: 5,
|
|
112
|
+
splitPercent: 0,
|
|
113
|
+
splits: new JBSplit[](0)
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
vm.prank(poster);
|
|
117
|
+
publisher.mintFrom{value: 0.2 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
118
|
+
|
|
119
|
+
// Verify tier ID 1 was stored in the mapping.
|
|
120
|
+
assertEq(
|
|
121
|
+
publisher.tierIdForEncodedIPFSUriOf(hookAddr, TEST_URI), 1, "tier ID should be stored after first mint"
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Now simulate external tier removal: isTierRemoved returns true for tier 1.
|
|
125
|
+
vm.mockCall(
|
|
126
|
+
hookStoreAddr,
|
|
127
|
+
abi.encodeWithSelector(IJB721TiersHookStore.isTierRemoved.selector, hookAddr, 1),
|
|
128
|
+
abi.encode(true)
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// Update maxTierId to 1 so new tier gets ID 2.
|
|
132
|
+
_setupMintMocks(1);
|
|
133
|
+
|
|
134
|
+
// Second mint with the same URI should succeed (creating a new tier),
|
|
135
|
+
// because the fix detects the stale mapping and clears it.
|
|
136
|
+
vm.prank(poster);
|
|
137
|
+
publisher.mintFrom{value: 0.2 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
138
|
+
|
|
139
|
+
// Verify the mapping now points to the new tier ID (2).
|
|
140
|
+
assertEq(
|
|
141
|
+
publisher.tierIdForEncodedIPFSUriOf(hookAddr, TEST_URI),
|
|
142
|
+
2,
|
|
143
|
+
"tier ID should be updated to new tier after re-post"
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/// @notice When a tier is NOT removed, the mapping should be used as-is (no re-creation).
|
|
148
|
+
function test_existingTierNotRemovedUsesMapping() public {
|
|
149
|
+
_configureCategory();
|
|
150
|
+
|
|
151
|
+
// First mint: create tier 1 for TEST_URI.
|
|
152
|
+
_setupMintMocks(0);
|
|
153
|
+
|
|
154
|
+
// Mock isTierRemoved to return false (tier exists).
|
|
155
|
+
vm.mockCall(
|
|
156
|
+
hookStoreAddr,
|
|
157
|
+
abi.encodeWithSelector(IJB721TiersHookStore.isTierRemoved.selector, hookAddr, 1),
|
|
158
|
+
abi.encode(false)
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
// Mock tierOf for tier 1 so the H-19 price lookup succeeds.
|
|
162
|
+
JB721Tier memory tier = JB721Tier({
|
|
163
|
+
id: 1,
|
|
164
|
+
price: 0.1 ether,
|
|
165
|
+
remainingSupply: 9,
|
|
166
|
+
initialSupply: 10,
|
|
167
|
+
votingUnits: 0,
|
|
168
|
+
reserveFrequency: 0,
|
|
169
|
+
reserveBeneficiary: address(0),
|
|
170
|
+
encodedIPFSUri: TEST_URI,
|
|
171
|
+
category: 5,
|
|
172
|
+
discountPercent: 0,
|
|
173
|
+
allowOwnerMint: false,
|
|
174
|
+
transfersPausable: false,
|
|
175
|
+
cannotBeRemoved: false,
|
|
176
|
+
cannotIncreaseDiscountPercent: false,
|
|
177
|
+
splitPercent: 0,
|
|
178
|
+
resolvedUri: ""
|
|
179
|
+
});
|
|
180
|
+
vm.mockCall(
|
|
181
|
+
hookStoreAddr,
|
|
182
|
+
abi.encodeWithSelector(IJB721TiersHookStore.tierOf.selector, hookAddr, 1, false),
|
|
183
|
+
abi.encode(tier)
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
187
|
+
posts[0] = CTPost({
|
|
188
|
+
encodedIPFSUri: TEST_URI,
|
|
189
|
+
totalSupply: 10,
|
|
190
|
+
price: 0.1 ether,
|
|
191
|
+
category: 5,
|
|
192
|
+
splitPercent: 0,
|
|
193
|
+
splits: new JBSplit[](0)
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
vm.prank(poster);
|
|
197
|
+
publisher.mintFrom{value: 0.2 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
198
|
+
|
|
199
|
+
assertEq(publisher.tierIdForEncodedIPFSUriOf(hookAddr, TEST_URI), 1);
|
|
200
|
+
|
|
201
|
+
// Second mint with existing tier (not removed) — should reuse tier ID 1.
|
|
202
|
+
_setupMintMocks(1);
|
|
203
|
+
|
|
204
|
+
vm.prank(poster);
|
|
205
|
+
publisher.mintFrom{value: 0.2 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
206
|
+
|
|
207
|
+
// Mapping should still point to tier 1.
|
|
208
|
+
assertEq(
|
|
209
|
+
publisher.tierIdForEncodedIPFSUriOf(hookAddr, TEST_URI),
|
|
210
|
+
1,
|
|
211
|
+
"tier ID should remain unchanged when tier is not removed"
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|