@croptop/core-v6 0.0.28 → 0.0.30
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 +2 -2
- package/ARCHITECTURE.md +3 -2
- package/README.md +8 -2
- package/RISKS.md +9 -2
- package/USER_JOURNEYS.md +2 -2
- package/package.json +9 -9
- package/script/Deploy.s.sol +4 -23
- package/src/CTDeployer.sol +5 -1
- package/src/CTPublisher.sol +6 -8
- package/test/audit/DeployerPermissionBypass.t.sol +213 -0
- package/test/audit/FeeFallbackBlackhole.t.sol +263 -0
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
|
|
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
|
|
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
|
|
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
|
-
- **
|
|
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
|
|
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,
|
|
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.
|
|
3
|
+
"version": "0.0.30",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -16,18 +16,18 @@
|
|
|
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/
|
|
21
|
-
"@bananapus/
|
|
22
|
-
"@bananapus/
|
|
23
|
-
"@bananapus/ownable-v6": "^0.0.16",
|
|
19
|
+
"@bananapus/721-hook-v6": "^0.0.31",
|
|
20
|
+
"@bananapus/buyback-hook-v6": "^0.0.25",
|
|
21
|
+
"@bananapus/core-v6": "^0.0.31",
|
|
22
|
+
"@bananapus/ownable-v6": "^0.0.17",
|
|
24
23
|
"@bananapus/permission-ids-v6": "^0.0.15",
|
|
25
|
-
"@bananapus/router-terminal-v6": "^0.0.
|
|
26
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
24
|
+
"@bananapus/router-terminal-v6": "^0.0.25",
|
|
25
|
+
"@bananapus/suckers-v6": "^0.0.21",
|
|
27
26
|
"@openzeppelin/contracts": "^5.6.1"
|
|
28
27
|
},
|
|
29
28
|
"devDependencies": {
|
|
29
|
+
"@bananapus/address-registry-v6": "^0.0.17",
|
|
30
30
|
"@rev-net/core-v6": "^0.0.24",
|
|
31
31
|
"@sphinx-labs/plugins": "^0.33.1"
|
|
32
32
|
}
|
|
33
|
-
}
|
|
33
|
+
}
|
package/script/Deploy.s.sol
CHANGED
|
@@ -64,29 +64,10 @@ contract DeployScript is Script, Sphinx {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
function deploy() public sphinx {
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
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
|
{
|
package/src/CTDeployer.sol
CHANGED
|
@@ -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;
|
package/src/CTPublisher.sol
CHANGED
|
@@ -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.
|
|
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
|
-
//
|
|
433
|
-
(bool success,) =
|
|
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
|
+
}
|