@croptop/core-v6 0.0.6 → 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 CHANGED
@@ -1,45 +1,137 @@
1
- # croptop-core-v5
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 a `IJBRulesetDataHook` proxy that forwards pay/cash-out calls to the underlying hook while granting fee-free cash outs to suckers. |
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 including hook address, category, price/supply bounds, and address allowlist. |
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 category. |
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-v5
72
+ npm install @croptop/core-v6
27
73
  ```
28
74
 
29
- ## Develop
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
- curl -L https://foundry.paradigm.xyz | sh
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 and write artifacts to `out`. |
41
- | `forge test` | Run the test suite. |
42
- | `forge fmt` | Lint Solidity files. |
43
- | `forge build --sizes` | Get contract sizes. |
44
- | `forge coverage` | Generate a test coverage report. |
45
- | `forge clean` | Remove build artifacts and cache. |
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
- # croptop-core-v5
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 a data hook proxy. |
13
- | `CTProjectOwner` | Receives project ownership and grants the publisher tier-adjustment permissions permanently. |
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
- | Function | Contract | What it does |
18
- |----------|----------|--------------|
19
- | `mintFrom` | `CTPublisher` | Publishes posts as new 721 tiers, mints first copies to `nftBeneficiary`, deducts a 5% fee (1/`FEE_DIVISOR`) routed to `FEE_PROJECT_ID`, and pays the remainder into the project terminal. |
20
- | `configurePostingCriteriaFor` | `CTPublisher` | Sets per-category posting rules (min price, min/max supply, address allowlist) for a given hook. Requires `ADJUST_721_TIERS` permission from the hook owner. |
21
- | `allowanceFor` | `CTPublisher` | Reads the packed allowance for a hook+category: minimum price (104 bits), min supply (32 bits), max supply (32 bits), plus the address allowlist. |
22
- | `tiersFor` | `CTPublisher` | Resolves an array of encoded IPFS URIs to their corresponding `JB721Tier` structs via the stored `tierIdForEncodedIPFSUriOf` mapping. |
23
- | `deployProjectFor` | `CTDeployer` | Deploys a new Juicebox project with a 721 tiers hook, configures posting criteria, optionally deploys suckers, and transfers project ownership to the specified owner. |
24
- | `claimCollectionOwnershipOf` | `CTDeployer` | Transfers hook ownership to the project (via `JBOwnable.transferOwnershipToProject`). Only callable by the project owner. |
25
- | `deploySuckersFor` | `CTDeployer` | Deploys new cross-chain suckers for an existing project. Requires `DEPLOY_SUCKERS` permission. |
26
- | `beforePayRecordedWith` | `CTDeployer` | Data hook proxy: forwards pay context to the stored `dataHookOf[projectId]`. |
27
- | `beforeCashOutRecordedWith` | `CTDeployer` | Data hook proxy: returns zero tax rate for sucker addresses (fee-free cross-chain cash outs), otherwise forwards to the stored data hook. |
28
- | `onERC721Received` | `CTProjectOwner` | On receiving the project NFT, grants `CTPublisher` the `ADJUST_721_TIERS` permission for that project. |
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/Enum | Key Fields | Used In |
44
- |-------------|------------|---------|
45
- | `CTAllowedPost` | `hook`, `category`, `minimumPrice` (uint104), `minimumTotalSupply` (uint32), `maximumTotalSupply` (uint32), `allowedAddresses` | `CTPublisher.configurePostingCriteriaFor` |
46
- | `CTPost` | `encodedIPFSUri` (bytes32), `totalSupply` (uint32), `price` (uint104), `category` (uint24) | `CTPublisher.mintFrom` |
47
- | `CTProjectConfig` | `terminalConfigurations`, `projectUri`, `allowedPosts`, `contractUri`, `name`, `symbol`, `salt` | `CTDeployer.deployProjectFor` |
48
- | `CTDeployerAllowedPost` | `category`, `minimumPrice`, `minimumTotalSupply`, `maximumTotalSupply`, `allowedAddresses` | `CTProjectConfig.allowedPosts` |
49
- | `CTSuckerDeploymentConfig` | `deployerConfigurations`, `salt` | `CTDeployer.deployProjectFor`, `CTDeployer.deploySuckersFor` |
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
- - The `FEE_DIVISOR` is 20 (5% fee), not a percentage. Fee = `totalPrice / 20`. The fee is skipped when `projectId == FEE_PROJECT_ID`.
54
- - Allowances are bit-packed into a single `uint256`: price in bits 0-103, min supply in 104-135, max supply in 136-167. Reading with wrong bit widths will silently return wrong values.
55
- - `CTDeployer` owns the project NFT temporarily during deployment (to configure permissions and hooks) then transfers it to the specified `owner`. If the transfer reverts, the entire deployment fails.
56
- - `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.
57
- - `CTProjectOwner.onERC721Received` only accepts tokens from `PROJECTS` and only from `address(0)` (mints). In `CTDeployer`, it accepts mints only. Sending a project NFT via transfer to `CTProjectOwner` works, but to `CTDeployer` it does not (reverts if `from != address(0)`).
58
- - `_setupPosts` resizes `tiersToAdd` via inline assembly if some posts reuse existing tiers. The `tierIdsToMint` array is NOT resized and may contain zeros for pre-existing tiers that were already minted in prior calls.
59
- - The `mintFrom` function sends `address(this).balance` as the fee payment after the main payment. If no ETH remains (e.g., exact payment), the fee transfer is skipped entirely.
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
- // Mint a post from a Croptop-enabled project
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.6",
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.7",
20
- "@bananapus/buyback-hook-v6": "^0.0.4",
21
- "@bananapus/core-v6": "^0.0.5",
22
- "@bananapus/ownable-v6": "^0.0.4",
23
- "@bananapus/permission-ids-v6": "^0.0.3",
24
- "@bananapus/suckers-v6": "^0.0.4",
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
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.3",
29
+ "@rev-net/core-v6": "^0.0.6",
30
30
  "@sphinx-labs/plugins": "^0.33.1"
31
31
  }
32
32
  }
@@ -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(
@@ -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 = hook.STORE().maxTierIdOf(address(hook)) + 1;
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) tierIdsToMint[i] = tierId;
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
- // Increment the total price.
530
- totalPrice += post.price;
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
+ }