@croptop/core-v6 0.0.28 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ADMINISTRATION.md CHANGED
@@ -161,7 +161,7 @@ What admins CANNOT do:
161
161
 
162
162
  4. **Project owners cannot disable Croptop posting entirely for a category.** `configurePostingCriteriaFor()` requires `minimumTotalSupply > 0`. The workaround is to set an astronomically high `minimumPrice` with `minimumTotalSupply = maximumTotalSupply = 1`. See finding NM-006.
163
163
 
164
- 5. **Project owners cannot bypass posting criteria to mint directly through CTPublisher.** They must use `mintFrom()` like anyone else, which enforces all configured rules. However, owners can adjust tiers directly on the hook (bypassing CTPublisher) if they have `ADJUST_721_TIERS` permission.
164
+ 5. **Project owners cannot bypass posting criteria through `CTPublisher`, but they may still bypass the publisher surface entirely.** `mintFrom()` enforces all configured rules. Separately, the initial owner/operator can hold direct hook-management permissions from `CTDeployer`, which lets them adjust tiers or mint without going through `CTPublisher` until ownership is claimed away or permissions are narrowed.
165
165
 
166
166
  6. **CTPublisher cannot mint without paying.** `mintFrom()` requires `msg.value >= totalPrice + fee`. There is no free-mint path through CTPublisher.
167
167
 
@@ -169,6 +169,6 @@ What admins CANNOT do:
169
169
 
170
170
  8. **No admin can modify existing tier prices.** Once a tier is created via `_setupPosts()`, the price is set in the `JB721TiersHookStore`. CTPublisher uses the stored price for fee calculation on subsequent mints (not `post.price`). See H-19 fix.
171
171
 
172
- 9. **No admin can drain CTPublisher funds.** CTPublisher has no `withdraw()` function and no `receive()` / `fallback()`. The only ETH that enters the contract is during `mintFrom()` and it is fully routed to the project terminal and fee terminal (or fallback recipients) within the same transaction. The fee terminal payment is wrapped in try-catch with fallback to `feeBeneficiary` then `msg.sender`, so ETH is never stranded by a fee terminal failure.
172
+ 9. **No admin can drain CTPublisher funds.** CTPublisher has no `withdraw()` function and no `receive()` / `fallback()`. The only ETH that enters the contract is during `mintFrom()` and it is fully routed to the project terminal and fee terminal (or refunded to the caller if the fee terminal reverts) within the same transaction. If that refund also fails, the mint reverts rather than trapping ETH in the publisher.
173
173
 
174
174
  10. **Sucker registry trust is irrevocable.** The `MAP_SUCKER_TOKEN` permission is granted at CTDeployer construction with `projectId: 0` (wildcard). There is no function to revoke this permission from within CTDeployer.
package/ARCHITECTURE.md CHANGED
@@ -30,7 +30,7 @@ poster
30
30
  -> publisher validates each post against project-defined criteria
31
31
  -> publisher calls the 721 hook to create or reuse tiers
32
32
  -> project terminal receives the publish payment
33
- -> fee project receives the fixed fee slice
33
+ -> fee project receives the fixed fee slice, or `_msgSender()` is refunded that fee if the fee terminal rejects it
34
34
  -> first copy of each created tier is minted to the poster
35
35
  ```
36
36
 
@@ -46,6 +46,7 @@ creator
46
46
 
47
47
  - A post is valid only if it satisfies the configured category, price, supply, split, and allowlist constraints.
48
48
  - Fee routing must be computed from the payment value, not transient contract balance, so forced ETH cannot distort the fee.
49
+ - Fee routing must not strand ETH in `CTPublisher`. If the fee terminal rejects payment, the fee is refunded to `_msgSender()`; if that refund fails, the mint reverts.
49
50
  - `CTProjectOwner` only makes sense as a lock, not a flexible admin layer. Once a project is burn-locked, Croptop becomes the only intended tier-adjustment path.
50
51
  - Publishing should not bypass the 721 hook's own invariants around tier creation and minting.
51
52
 
@@ -53,7 +54,7 @@ creator
53
54
 
54
55
  - Post validation is spread across category rules, split limits, supply bounds, and optional allowlists.
55
56
  - `CTDeployer` is subtle because it is both a launch helper and, in some flows, a runtime hook proxy.
56
- - Fee routing has multiple fallback paths and needs to stay value-conserving under failure.
57
+ - Fee routing is intentionally liveness-first: it prefers refunding `_msgSender()` over blocking the mint when the fee terminal is down, but still reverts if the refund itself cannot be delivered.
57
58
 
58
59
  ## Dependencies
59
60
 
package/README.md CHANGED
@@ -14,7 +14,9 @@ Croptop is built around three ideas:
14
14
  - publishers call `mintFrom` to create or reuse 721 tiers that represent their post
15
15
  - a one-click deployer can create a full Juicebox project, its 721 hook configuration, and its posting rules in a single transaction
16
16
 
17
- Every mint collects a 5% Croptop fee unless the target project is itself the fee project.
17
+ Every mint collects a 5% Croptop fee unless the target project is itself the fee project. If the configured fee
18
+ terminal rejects that fee payment, Croptop refunds the fee portion to `_msgSender()` and still lets the publish
19
+ continue. If `_msgSender()` cannot receive ETH, the mint reverts.
18
20
 
19
21
  Use this repo when the product is "permissioned publishing on a Juicebox project." Do not use it when you only need plain 721 tier sales; that belongs in `nana-721-hook-v6`.
20
22
 
@@ -62,6 +64,9 @@ Useful scripts:
62
64
 
63
65
  Deployments are handled through Sphinx using the environments configured in the repo scripts. `CTDeployer` can also compose cross-chain sucker deployments when the target publishing project needs omnichain support.
64
66
 
67
+ The deploy script now expects an explicit nonzero `FEE_PROJECT_ID` for canonical deployments. It does not safely
68
+ autodiscover a fee project by scanning existing project IDs.
69
+
65
70
  ## Repository Layout
66
71
 
67
72
  ```text
@@ -82,6 +87,7 @@ script/
82
87
  ## Risks And Notes
83
88
 
84
89
  - posting criteria are only as safe as the project owner configures them
85
- - fee routing depends on the designated fee project remaining correctly configured
90
+ - fee routing depends on the designated fee project remaining correctly configured; if its terminal rejects payments,
91
+ Croptop refunds the fee to `_msgSender()` instead of trapping ETH in `CTPublisher`
86
92
  - burn-lock ownership is intentionally irreversible and should only be used when immutability is desired
87
93
  - duplicate-content and stale-tier edge cases are guarded by tests, but integrations should still treat metadata reuse carefully
package/RISKS.md CHANGED
@@ -31,7 +31,7 @@ This file focuses on the publishing, fee-routing, and hook-composition risks tha
31
31
  - **Fee evasion via duplicate posts across hooks.** `tierIdForEncodedIPFSUriOf` is keyed per hook. The same `encodedIPFSUri` can be posted to different hooks without duplicate detection, potentially creating fee-arbitrage opportunities.
32
32
  - **Fee calculation rounding.** Fee is `totalPrice / FEE_DIVISOR` (FEE_DIVISOR=20, so 5% fee). Integer division truncates, losing up to 19 wei per post. Negligible individually but could compound across many micro-priced posts. Explicit validation: reverts `CTPublisher_InsufficientEthSent` if `msg.value < fee` (before subtraction) or if `msg.value - fee < totalPrice` (after subtraction).
33
33
  - **Pre-computed fee routing.** `CTPublisher.mintFrom` computes the fee as `msg.value - payValue` before the external payment call, so the fee amount is determined from `msg.value` alone. Force-sent ETH (via selfdestruct) does not affect fee calculation.
34
- - **Try-catch fee payment.** The fee terminal payment is wrapped in try-catch. If the fee terminal reverts, the fee is sent to `feeBeneficiary` via low-level call. If that also fails, the fee is sent to `msg.sender`. This means a broken fee terminal does not block mints, but the fee project may lose fee revenue during the outage.
34
+ - **Fee terminal fallback refunds the caller.** If the configured fee terminal cannot accept the fee payment, `mintFrom` refunds the fee portion to `_msgSender()`. This preserves mint liveness for normal callers, but relayers or contracts that cannot receive ETH will still cause the mint to revert.
35
35
  - **Split percent manipulation.** Posters can set `splitPercent` up to `maximumSplitPercent`. Splits route funds away from the project treasury to poster-specified addresses. If `maximumSplitPercent` is set high, posters can redirect most of the tier revenue.
36
36
 
37
37
  ## 3. Access Control
@@ -61,7 +61,7 @@ This file focuses on the publishing, fee-routing, and hook-composition risks tha
61
61
  - **No mechanism for hook migration.** `dataHookOf` is written once in `deployProjectFor` and never updated. If the data hook becomes compromised, there is no governance path to replace it without deploying a new project.
62
62
  - **Tier ID prediction.** `_setupPosts` predicts new tier IDs as `maxTierIdOf(hook) + 1 + i`. If another transaction adds tiers between `maxTierIdOf` read and `adjustTiers` execution, tier IDs shift and the wrong tiers are minted. This is a race condition in concurrent posting.
63
63
  - **CTProjectOwner accepts any project NFT.** `onERC721Received` grants `ADJUST_721_TIERS` to `PUBLISHER` for whatever tokenId is received. If a non-Croptop project is accidentally transferred to `CTProjectOwner`, the publisher gains tier adjustment permission for it.
64
- - **Fee payment destination.** Fees are routed to `FEE_PROJECT_ID` via its primary terminal. If the fee project changes its terminal or token acceptance, fee payments will fail. However, the fee terminal payment is wrapped in try-catch: on failure, the fee is sent to `feeBeneficiary` via low-level call, then to `msg.sender` if that also fails. Minting is never blocked by a broken fee terminal, but the fee project loses revenue during the outage.
64
+ - **Fee payment destination.** Fees are routed to `FEE_PROJECT_ID` via its primary terminal. If the fee project changes its terminal or token acceptance incompatibly, `mintFrom` attempts to refund the fee to `_msgSender()`. If the caller cannot receive ETH, the mint reverts.
65
65
 
66
66
  ## 7. Accepted Behaviors
67
67
 
@@ -73,6 +73,13 @@ This file focuses on the publishing, fee-routing, and hook-composition risks tha
73
73
 
74
74
  `_setupPosts` predicts new tier IDs as `maxTierIdOf(hook) + 1 + i`. A concurrent `adjustTiers` call between the `maxTierIdOf` read and the `adjustTiers` execution shifts all predicted IDs, causing the wrong tiers to be minted. This is a known race condition. Mitigation is at the application layer: frontends should use nonce-based transaction ordering or warn users about concurrent posting. The hook-level `adjustTiers` is atomic (all-or-nothing), so a failed prediction reverts the entire batch cleanly.
75
75
 
76
+ ### 7.3 Project owners can bypass the publisher surface while they retain direct hook permissions
77
+
78
+ `CTDeployer.deployProjectFor` intentionally grants the initial owner/operator enough hook permissions to manage the
79
+ collection directly. That means the owner can bypass `CTPublisher`'s policy and fee path until ownership is moved into
80
+ another authority surface or those permissions are narrowed. This is an accepted product tradeoff and should be treated
81
+ as part of the trust model, not as a hidden invariant enforced by `CTPublisher`.
82
+
76
83
  ## 8. Invariants to Verify
77
84
 
78
85
  - `tierIdForEncodedIPFSUriOf[hook][encodedIPFSUri]` is set exactly once per (hook, encodedIPFSUri) pair and points to a valid, non-removed tier.
package/USER_JOURNEYS.md CHANGED
@@ -26,9 +26,9 @@
26
26
  **Flow**
27
27
  1. The publisher calls `mintFrom(...)` or the equivalent publishing surface with the content URI and pricing data.
28
28
  2. `CTPublisher` checks the post against category rules and fee policy.
29
- 3. It creates or reuses the underlying 721 tier, mints the first copy, and routes both project revenue and the Croptop fee.
29
+ 3. It creates or reuses the underlying 721 tier, mints the first copy, and routes both project revenue and the Croptop fee. If the fee terminal is unavailable, the fee is refunded to `_msgSender()` instead.
30
30
 
31
- **Failure cases that matter:** duplicate URIs, split configurations that evade fees, stale tier mappings, and publisher inputs that satisfy the 721 hook but violate Croptop's stricter publishing rules.
31
+ **Failure cases that matter:** duplicate URIs, split configurations that evade fees, stale tier mappings, publisher inputs that satisfy the 721 hook but violate Croptop's stricter publishing rules, and callers that cannot receive ETH when a fee refund fallback is needed.
32
32
 
33
33
  ## Journey 3: Launch A New Croptop Project End To End
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@croptop/core-v6",
3
- "version": "0.0.28",
3
+ "version": "0.0.29",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,12 +17,11 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@bananapus/721-hook-v6": "^0.0.30",
20
- "@bananapus/address-registry-v6": "^0.0.16",
21
- "@bananapus/buyback-hook-v6": "^0.0.24",
22
- "@bananapus/core-v6": "^0.0.30",
20
+ "@bananapus/buyback-hook-v6": "^0.0.25",
21
+ "@bananapus/core-v6": "^0.0.31",
23
22
  "@bananapus/ownable-v6": "^0.0.16",
24
23
  "@bananapus/permission-ids-v6": "^0.0.15",
25
- "@bananapus/router-terminal-v6": "^0.0.24",
24
+ "@bananapus/router-terminal-v6": "^0.0.25",
26
25
  "@bananapus/suckers-v6": "^0.0.20",
27
26
  "@openzeppelin/contracts": "^5.6.1"
28
27
  },
@@ -64,29 +64,10 @@ contract DeployScript is Script, Sphinx {
64
64
  }
65
65
 
66
66
  function deploy() public sphinx {
67
- // If the fee project id is 0, then we want to deploy a new fee project but only if the publisher
68
- // singleton doesn't already exist. Re-running with FEE_PROJECT_ID=0 would create a second fee project
69
- // with different CREATE2 addresses, stranding the previous suite.
70
- if (FEE_PROJECT_ID == 0) {
71
- // Check if the publisher already exists by scanning existing project IDs.
72
- uint256 _existingCount = core.projects.count();
73
- bool _found;
74
- for (uint256 _candidateId = 1; _candidateId <= _existingCount; _candidateId++) {
75
- (, bool _exists) = _isDeployed({
76
- salt: PUBLISHER_SALT,
77
- creationCode: type(CTPublisher).creationCode,
78
- arguments: abi.encode(core.directory, core.permissions, _candidateId, TRUSTED_FORWARDER)
79
- });
80
- if (_exists) {
81
- FEE_PROJECT_ID = _candidateId;
82
- _found = true;
83
- break;
84
- }
85
- }
86
- if (!_found) {
87
- FEE_PROJECT_ID = core.projects.createFor(safeAddress());
88
- }
89
- }
67
+ // Canonical Croptop deployments must bind fees to an explicit fee project. Autodiscovering the first
68
+ // matching publisher by scanning project IDs is unsafe because a preexisting publisher can pin fees to
69
+ // the wrong project forever.
70
+ require(FEE_PROJECT_ID != 0, "explicit fee project id required");
90
71
 
91
72
  CTPublisher publisher;
92
73
  {
@@ -248,6 +248,9 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
248
248
  }
249
249
 
250
250
  /// @notice Deploy a simple project meant to receive posts from Croptop templates.
251
+ /// @dev The initial project owner is intentionally granted direct hook-management permissions from
252
+ /// `CTDeployer`. This means the owner/operator can bypass the Croptop publisher path and interact
253
+ /// with the hook directly if they choose to. That is an explicit product tradeoff.
251
254
  /// @param owner The address that'll own the project.
252
255
  /// @param projectConfig The configuration for the project.
253
256
  /// @param suckerDeploymentConfiguration The configuration for the suckers to deploy.
@@ -342,7 +345,8 @@ contract CTDeployer is ERC2771Context, JBPermissioned, IJBRulesetDataHook, IERC7
342
345
  // These permissions are granted from CTDeployer (address(this)) to the initial owner.
343
346
  // The hook checks permissions against hook.owner(), which after claimCollectionOwnershipOf() resolves
344
347
  // dynamically via PROJECTS.ownerOf(projectId). Before claiming, CTDeployer is the static hook owner,
345
- // so these permissions allow the project owner to manage tiers through CTDeployer.
348
+ // so these permissions allow the project owner to manage tiers through CTDeployer. As a tradeoff,
349
+ // the owner can also bypass the Croptop publisher surface until ownership is claimed away.
346
350
  uint8[] memory permissionIds = new uint8[](4);
347
351
  permissionIds[0] = JBPermissionIds.ADJUST_721_TIERS;
348
352
  permissionIds[1] = JBPermissionIds.SET_721_METADATA;
@@ -34,6 +34,7 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
34
34
  error CTPublisher_MaxTotalSupplyLessThanMin(uint256 min, uint256 max);
35
35
  error CTPublisher_NotInAllowList(address addr, address[] allowedAddresses);
36
36
  error CTPublisher_PriceTooSmall(uint256 price, uint256 minimumPrice);
37
+ error CTPublisher_FeePaymentFailed(uint256 feeAmount);
37
38
  error CTPublisher_SplitPercentExceedsMaximum(uint256 splitPercent, uint256 maximumSplitPercent);
38
39
  error CTPublisher_TotalSupplyTooBig(uint256 totalSupply, uint256 maximumTotalSupply);
39
40
  error CTPublisher_TotalSupplyTooSmall(uint256 totalSupply, uint256 minimumTotalSupply);
@@ -417,7 +418,8 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
417
418
  IJBTerminal feeTerminal =
418
419
  DIRECTORY.primaryTerminalOf({projectId: FEE_PROJECT_ID, token: JBConstants.NATIVE_TOKEN});
419
420
 
420
- // Make the fee payment. Wrapped in try-catch so a reverting fee terminal doesn't block mints.
421
+ // Make the fee payment. If the fee sink is unavailable, refund the fee to the caller
422
+ // rather than trapping or silently redirecting protocol funds.
421
423
  // slither-disable-next-line unused-return
422
424
  try feeTerminal.pay{value: payValue}({
423
425
  projectId: FEE_PROJECT_ID,
@@ -429,13 +431,9 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
429
431
  metadata: feeMetadata
430
432
  }) {}
431
433
  catch {
432
- // If the fee payment fails, send the fee to the beneficiary instead.
433
- (bool success,) = feeBeneficiary.call{value: payValue}("");
434
- if (!success) {
435
- // If that also fails, send to the msg.sender.
436
- // slither-disable-next-line low-level-calls
437
- (success,) = msg.sender.call{value: payValue}("");
438
- }
434
+ // slither-disable-next-line low-level-calls
435
+ (bool success,) = _msgSender().call{value: payValue}("");
436
+ if (!success) revert CTPublisher_FeePaymentFailed(payValue);
439
437
  }
440
438
  }
441
439
  }
@@ -0,0 +1,213 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+
7
+ import {IJB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookDeployer.sol";
8
+ import {JBDeploy721TiersHookConfig} from "@bananapus/721-hook-v6/src/structs/JBDeploy721TiersHookConfig.sol";
9
+ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
10
+ import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
11
+ import {JBPermissioned} from "@bananapus/core-v6/src/abstract/JBPermissioned.sol";
12
+ import {IJBController} from "@bananapus/core-v6/src/interfaces/IJBController.sol";
13
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
14
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
15
+ import {IJBProjects} from "@bananapus/core-v6/src/interfaces/IJBProjects.sol";
16
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
17
+ import {JBRulesetConfig} from "@bananapus/core-v6/src/structs/JBRulesetConfig.sol";
18
+ import {JBTerminalConfig} from "@bananapus/core-v6/src/structs/JBTerminalConfig.sol";
19
+ import {JBPermissionIds} from "@bananapus/permission-ids-v6/src/JBPermissionIds.sol";
20
+ import {IJBSuckerRegistry} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerRegistry.sol";
21
+ import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
22
+
23
+ import {CTDeployer} from "../../src/CTDeployer.sol";
24
+ import {CTPublisher} from "../../src/CTPublisher.sol";
25
+ import {ICTPublisher} from "../../src/interfaces/ICTPublisher.sol";
26
+ import {CTDeployerAllowedPost} from "../../src/structs/CTDeployerAllowedPost.sol";
27
+ import {CTProjectConfig} from "../../src/structs/CTProjectConfig.sol";
28
+ import {CTSuckerDeploymentConfig} from "../../src/structs/CTSuckerDeploymentConfig.sol";
29
+
30
+ contract MockProjects {
31
+ uint256 public countValue;
32
+ address public ownerOfProject;
33
+
34
+ function setCount(uint256 count_) external {
35
+ countValue = count_;
36
+ }
37
+
38
+ function setOwner(address owner_) external {
39
+ ownerOfProject = owner_;
40
+ }
41
+
42
+ function count() external view returns (uint256) {
43
+ return countValue;
44
+ }
45
+
46
+ function ownerOf(uint256) external view returns (address) {
47
+ return ownerOfProject;
48
+ }
49
+
50
+ function transferFrom(address, address to, uint256) external {
51
+ ownerOfProject = to;
52
+ }
53
+ }
54
+
55
+ contract MockController {
56
+ MockProjects public immutable PROJECTS;
57
+ // forge-lint: disable-next-line(screaming-snake-case-immutable)
58
+ uint256 public immutable nextProjectId;
59
+
60
+ constructor(MockProjects projects_, uint256 nextProjectId_) {
61
+ PROJECTS = projects_;
62
+ nextProjectId = nextProjectId_;
63
+ }
64
+
65
+ function launchProjectFor(
66
+ address,
67
+ string calldata,
68
+ JBRulesetConfig[] calldata,
69
+ JBTerminalConfig[] calldata,
70
+ string calldata
71
+ )
72
+ external
73
+ view
74
+ returns (uint256)
75
+ {
76
+ return nextProjectId;
77
+ }
78
+ }
79
+
80
+ contract MockSuckerRegistry {
81
+ function isSuckerOf(uint256, address) external pure returns (bool) {
82
+ return false;
83
+ }
84
+
85
+ function deploySuckersFor(
86
+ uint256,
87
+ bytes32,
88
+ JBSuckerDeployerConfig[] calldata
89
+ )
90
+ external
91
+ pure
92
+ returns (address[] memory suckers)
93
+ {
94
+ return suckers;
95
+ }
96
+ }
97
+
98
+ contract PermissionedHook is JBPermissioned {
99
+ // forge-lint: disable-next-line(screaming-snake-case-immutable)
100
+ address public immutable ownerAccount;
101
+ // forge-lint: disable-next-line(screaming-snake-case-immutable)
102
+ uint256 public immutable projectId;
103
+ bool public adjusted;
104
+
105
+ constructor(IJBPermissions permissions, address ownerAccount_, uint256 projectId_) JBPermissioned(permissions) {
106
+ ownerAccount = ownerAccount_;
107
+ projectId = projectId_;
108
+ }
109
+
110
+ // forge-lint: disable-next-line(mixed-case-function)
111
+ function PROJECT_ID() external view returns (uint256) {
112
+ return projectId;
113
+ }
114
+
115
+ function owner() external view returns (address) {
116
+ return ownerAccount;
117
+ }
118
+
119
+ function adjustTiers(JB721TierConfig[] calldata, uint256[] calldata) external {
120
+ _requirePermissionFrom(ownerAccount, projectId, JBPermissionIds.ADJUST_721_TIERS);
121
+ adjusted = true;
122
+ }
123
+ }
124
+
125
+ contract MockHookDeployer {
126
+ IJB721TiersHook public hook;
127
+
128
+ function setHook(IJB721TiersHook hook_) external {
129
+ hook = hook_;
130
+ }
131
+
132
+ function deployHookFor(
133
+ uint256,
134
+ JBDeploy721TiersHookConfig calldata,
135
+ bytes32
136
+ )
137
+ external
138
+ view
139
+ returns (IJB721TiersHook)
140
+ {
141
+ return hook;
142
+ }
143
+ }
144
+
145
+ contract DeployerPermissionBypassTest is Test {
146
+ JBPermissions permissions;
147
+ MockProjects projects;
148
+ MockHookDeployer hookDeployer;
149
+ MockSuckerRegistry suckerRegistry;
150
+ MockController controller;
151
+ CTPublisher publisher;
152
+ CTDeployer deployer;
153
+ PermissionedHook hook;
154
+
155
+ address owner = makeAddr("owner");
156
+
157
+ function setUp() public {
158
+ permissions = new JBPermissions(address(0));
159
+ projects = new MockProjects();
160
+ projects.setCount(5);
161
+ hookDeployer = new MockHookDeployer();
162
+ suckerRegistry = new MockSuckerRegistry();
163
+ publisher = new CTPublisher(IJBDirectory(makeAddr("directory")), permissions, 1, address(0));
164
+ deployer = new CTDeployer(
165
+ permissions,
166
+ IJBProjects(address(projects)),
167
+ IJB721TiersHookDeployer(address(hookDeployer)),
168
+ ICTPublisher(address(publisher)),
169
+ IJBSuckerRegistry(address(suckerRegistry)),
170
+ address(0)
171
+ );
172
+ hook = new PermissionedHook(permissions, address(deployer), 6);
173
+ hookDeployer.setHook(IJB721TiersHook(address(hook)));
174
+ controller = new MockController(projects, 6);
175
+ }
176
+
177
+ function test_projectOwnerCanBypassCroptopAndCallHookDirectlyAfterDeployment() public {
178
+ CTDeployerAllowedPost[] memory allowedPosts = new CTDeployerAllowedPost[](1);
179
+ allowedPosts[0] = CTDeployerAllowedPost({
180
+ category: 1,
181
+ minimumPrice: 1 ether,
182
+ minimumTotalSupply: 10,
183
+ maximumTotalSupply: 10,
184
+ maximumSplitPercent: 0,
185
+ allowedAddresses: new address[](0)
186
+ });
187
+
188
+ CTProjectConfig memory config = CTProjectConfig({
189
+ terminalConfigurations: new JBTerminalConfig[](0),
190
+ projectUri: "ipfs://project",
191
+ allowedPosts: allowedPosts,
192
+ contractUri: "ipfs://contract",
193
+ name: "Croptop",
194
+ symbol: "CT",
195
+ salt: bytes32(0)
196
+ });
197
+
198
+ CTSuckerDeploymentConfig memory suckerConfig =
199
+ CTSuckerDeploymentConfig({deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: bytes32(0)});
200
+
201
+ deployer.deployProjectFor(owner, config, suckerConfig, IJBController(address(controller)));
202
+
203
+ assertEq(projects.ownerOf(6), owner, "deployment should hand the project NFT to the owner");
204
+
205
+ JB721TierConfig[] memory arbitraryTiers = new JB721TierConfig[](0);
206
+ uint256[] memory removals = new uint256[](0);
207
+
208
+ vm.prank(owner);
209
+ PermissionedHook(address(hook)).adjustTiers(arbitraryTiers, removals);
210
+
211
+ assertTrue(hook.adjusted(), "project owner can mutate the hook without going through Croptop");
212
+ }
213
+ }
@@ -0,0 +1,263 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ // forge-lint: disable-next-line(unaliased-plain-import)
5
+ import "forge-std/Test.sol";
6
+
7
+ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
8
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
9
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
10
+ import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
11
+ import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
12
+ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
13
+ import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
14
+ import {JBPermissionsData} from "@bananapus/core-v6/src/structs/JBPermissionsData.sol";
15
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.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
+ contract BlackholeMockPermissions is IJBPermissions {
22
+ // forge-lint: disable-next-line(mixed-case-function)
23
+ function WILDCARD_PROJECT_ID() external pure returns (uint256) {
24
+ return 0;
25
+ }
26
+
27
+ function permissionsOf(address, address, uint256) external pure returns (uint256) {
28
+ return 0;
29
+ }
30
+
31
+ function hasPermission(address, address, uint256, uint256, bool, bool) external pure returns (bool) {
32
+ return true;
33
+ }
34
+
35
+ function hasPermissions(address, address, uint256, uint256[] calldata, bool, bool) external pure returns (bool) {
36
+ return true;
37
+ }
38
+
39
+ function setPermissionsFor(address, JBPermissionsData calldata) external {}
40
+ }
41
+
42
+ contract BlackholeMockStore {
43
+ function maxTierIdOf(address) external pure returns (uint256) {
44
+ return 0;
45
+ }
46
+
47
+ function isTierRemoved(address, uint256) external pure returns (bool) {
48
+ return false;
49
+ }
50
+
51
+ function tierOf(address, uint256, bool) external pure returns (JB721Tier memory tier) {
52
+ return tier;
53
+ }
54
+ }
55
+
56
+ contract BlackholeMockHook {
57
+ uint256 public immutable PROJECT_ID;
58
+ IJB721TiersHookStore public immutable STORE;
59
+ address public immutable OWNER;
60
+
61
+ constructor(uint256 projectId, IJB721TiersHookStore store_, address owner_) {
62
+ PROJECT_ID = projectId;
63
+ STORE = store_;
64
+ OWNER = owner_;
65
+ }
66
+
67
+ function adjustTiers(JB721TierConfig[] calldata, uint256[] calldata) external {}
68
+
69
+ function METADATA_ID_TARGET() external view returns (address) {
70
+ return address(this);
71
+ }
72
+
73
+ function owner() external view returns (address) {
74
+ return OWNER;
75
+ }
76
+ }
77
+
78
+ contract AcceptingProjectTerminal {
79
+ uint256 public totalReceived;
80
+
81
+ function pay(
82
+ uint256,
83
+ address,
84
+ uint256,
85
+ address,
86
+ uint256,
87
+ string calldata,
88
+ bytes calldata
89
+ )
90
+ external
91
+ payable
92
+ returns (uint256)
93
+ {
94
+ totalReceived += msg.value;
95
+ return 0;
96
+ }
97
+ }
98
+
99
+ contract RevertingFeeTerminal {
100
+ error FeeTerminalDown();
101
+
102
+ function pay(
103
+ uint256,
104
+ address,
105
+ uint256,
106
+ address,
107
+ uint256,
108
+ string calldata,
109
+ bytes calldata
110
+ )
111
+ external
112
+ payable
113
+ returns (uint256)
114
+ {
115
+ revert FeeTerminalDown();
116
+ }
117
+ }
118
+
119
+ contract BlackholeDirectory {
120
+ address public projectTerminal;
121
+ address public feeTerminal;
122
+
123
+ function setTerminals(address projectTerminal_, address feeTerminal_) external {
124
+ projectTerminal = projectTerminal_;
125
+ feeTerminal = feeTerminal_;
126
+ }
127
+
128
+ function primaryTerminalOf(uint256 projectId, address) external view returns (IJBTerminal) {
129
+ return IJBTerminal(projectId == 1 ? feeTerminal : projectTerminal);
130
+ }
131
+ }
132
+
133
+ contract RejectingFeeBeneficiary {
134
+ receive() external payable {
135
+ revert("no fee");
136
+ }
137
+ }
138
+
139
+ contract RejectingMintCaller {
140
+ function execute(
141
+ CTPublisher publisher,
142
+ IJB721TiersHook hook,
143
+ CTPost[] memory posts,
144
+ address nftBeneficiary,
145
+ address feeBeneficiary
146
+ )
147
+ external
148
+ payable
149
+ {
150
+ publisher.mintFrom{value: msg.value}(hook, posts, nftBeneficiary, feeBeneficiary, bytes(""), bytes(""));
151
+ }
152
+
153
+ receive() external payable {
154
+ revert("no refund");
155
+ }
156
+ }
157
+
158
+ contract AcceptingMintCaller {
159
+ function execute(
160
+ CTPublisher publisher,
161
+ IJB721TiersHook hook,
162
+ CTPost[] memory posts,
163
+ address nftBeneficiary,
164
+ address feeBeneficiary
165
+ )
166
+ external
167
+ payable
168
+ {
169
+ publisher.mintFrom{value: msg.value}(hook, posts, nftBeneficiary, feeBeneficiary, bytes(""), bytes(""));
170
+ }
171
+
172
+ receive() external payable {}
173
+ }
174
+
175
+ contract FeeFallbackBlackholeTest is Test {
176
+ BlackholeMockPermissions permissions;
177
+ BlackholeDirectory directory;
178
+ BlackholeMockStore store;
179
+ BlackholeMockHook hook;
180
+ AcceptingProjectTerminal projectTerminal;
181
+ RevertingFeeTerminal feeTerminal;
182
+ RejectingFeeBeneficiary feeBeneficiary;
183
+ RejectingMintCaller caller;
184
+ AcceptingMintCaller acceptingCaller;
185
+ CTPublisher publisher;
186
+
187
+ function setUp() public {
188
+ permissions = new BlackholeMockPermissions();
189
+ directory = new BlackholeDirectory();
190
+ store = new BlackholeMockStore();
191
+ hook = new BlackholeMockHook(2, IJB721TiersHookStore(address(store)), address(this));
192
+ projectTerminal = new AcceptingProjectTerminal();
193
+ feeTerminal = new RevertingFeeTerminal();
194
+ feeBeneficiary = new RejectingFeeBeneficiary();
195
+ caller = new RejectingMintCaller();
196
+ acceptingCaller = new AcceptingMintCaller();
197
+ publisher = new CTPublisher(IJBDirectory(address(directory)), permissions, 1, address(0));
198
+
199
+ directory.setTerminals(address(projectTerminal), address(feeTerminal));
200
+
201
+ CTAllowedPost[] memory allowedPosts = new CTAllowedPost[](1);
202
+ allowedPosts[0] = CTAllowedPost({
203
+ hook: address(hook),
204
+ category: 1,
205
+ minimumPrice: 1,
206
+ minimumTotalSupply: 1,
207
+ maximumTotalSupply: type(uint32).max,
208
+ maximumSplitPercent: 0,
209
+ allowedAddresses: new address[](0)
210
+ });
211
+ publisher.configurePostingCriteriaFor(allowedPosts);
212
+
213
+ vm.deal(address(caller), 105);
214
+ vm.deal(address(acceptingCaller), 105);
215
+ }
216
+
217
+ function test_feePaymentFailure_refundsMsgSenderAndPreservesMint() public {
218
+ CTPost[] memory posts = new CTPost[](1);
219
+ posts[0] = CTPost({
220
+ encodedIPFSUri: keccak256("post"),
221
+ totalSupply: 1,
222
+ price: 100,
223
+ category: 1,
224
+ splitPercent: 0,
225
+ splits: new JBSplit[](0)
226
+ });
227
+
228
+ vm.prank(address(acceptingCaller));
229
+ acceptingCaller.execute{value: 105}(
230
+ publisher, IJB721TiersHook(address(hook)), posts, address(this), address(feeBeneficiary)
231
+ );
232
+
233
+ assertEq(projectTerminal.totalReceived(), 100, "main project payment should still succeed");
234
+ assertEq(address(feeTerminal).balance, 0, "fee terminal should receive nothing after reverting");
235
+ assertEq(address(feeBeneficiary).balance, 0, "fee beneficiary should receive nothing");
236
+ assertEq(address(acceptingCaller).balance, 5, "caller should receive the refunded fee");
237
+ assertEq(address(publisher).balance, 0, "publisher should not retain trapped fees");
238
+ }
239
+
240
+ function test_feePaymentFailure_revertsIfMsgSenderRejectsRefund() public {
241
+ CTPost[] memory posts = new CTPost[](1);
242
+ posts[0] = CTPost({
243
+ encodedIPFSUri: keccak256("post"),
244
+ totalSupply: 1,
245
+ price: 100,
246
+ category: 1,
247
+ splitPercent: 0,
248
+ splits: new JBSplit[](0)
249
+ });
250
+
251
+ vm.prank(address(caller));
252
+ vm.expectRevert(abi.encodeWithSelector(CTPublisher.CTPublisher_FeePaymentFailed.selector, 5));
253
+ caller.execute{value: 105}(
254
+ publisher, IJB721TiersHook(address(hook)), posts, address(this), address(feeBeneficiary)
255
+ );
256
+
257
+ assertEq(projectTerminal.totalReceived(), 0, "main project payment should roll back with the fee failure");
258
+ assertEq(address(feeTerminal).balance, 0, "fee terminal should receive nothing after reverting");
259
+ assertEq(address(feeBeneficiary).balance, 0, "fee beneficiary should receive nothing");
260
+ assertEq(address(caller).balance, 105, "caller should retain funds when the mint reverts");
261
+ assertEq(address(publisher).balance, 0, "publisher should not retain trapped fees");
262
+ }
263
+ }