@croptop/core-v6 0.0.7 → 0.0.10
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 +133 -0
- package/ARCHITECTURE.md +66 -0
- package/RISKS.md +238 -0
- package/STYLE_GUIDE.md +470 -0
- package/foundry.toml +2 -2
- package/package.json +9 -9
- package/remappings.txt +1 -0
- package/script/ConfigureFeeProject.s.sol +13 -5
- package/src/CTDeployer.sol +56 -56
- package/src/CTProjectOwner.sol +1 -1
- package/src/CTPublisher.sol +11 -3
- package/src/interfaces/ICTDeployer.sol +11 -11
- package/src/interfaces/ICTProjectOwner.sol +0 -1
- package/src/interfaces/ICTPublisher.sol +33 -19
- package/test/CTPublisher.t.sol +2 -2
- package/test/CroptopAttacks.t.sol +2 -2
- package/test/Fork.t.sol +152 -39
- package/test/regression/H19_FeeEvasion.t.sol +3 -2
- package/test/regression/L52_StaleTierIdMapping.t.sol +4 -3
- package/test/regression/M6_DuplicateUriFeeEvasion.t.sol +312 -0
package/test/Fork.t.sol
CHANGED
|
@@ -1,58 +1,107 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MIT
|
|
2
2
|
pragma solidity ^0.8.17;
|
|
3
3
|
|
|
4
|
-
import "
|
|
5
|
-
import "@bananapus/core-v6/script/helpers/CoreDeploymentLib.sol";
|
|
6
|
-
import "@bananapus/suckers-v6/script/helpers/SuckerDeploymentLib.sol";
|
|
4
|
+
import "forge-std/Test.sol";
|
|
7
5
|
|
|
8
|
-
|
|
6
|
+
// JB core — deploy fresh within fork.
|
|
7
|
+
import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
|
|
8
|
+
import {JBProjects} from "@bananapus/core-v6/src/JBProjects.sol";
|
|
9
|
+
import {JBDirectory} from "@bananapus/core-v6/src/JBDirectory.sol";
|
|
10
|
+
import {JBRulesets} from "@bananapus/core-v6/src/JBRulesets.sol";
|
|
11
|
+
import {JBTokens} from "@bananapus/core-v6/src/JBTokens.sol";
|
|
12
|
+
import {JBERC20} from "@bananapus/core-v6/src/JBERC20.sol";
|
|
13
|
+
import {JBSplits} from "@bananapus/core-v6/src/JBSplits.sol";
|
|
14
|
+
import {JBPrices} from "@bananapus/core-v6/src/JBPrices.sol";
|
|
15
|
+
import {JBController} from "@bananapus/core-v6/src/JBController.sol";
|
|
16
|
+
import {JBFundAccessLimits} from "@bananapus/core-v6/src/JBFundAccessLimits.sol";
|
|
9
17
|
import {JBConstants} from "@bananapus/core-v6/src/libraries/JBConstants.sol";
|
|
18
|
+
|
|
19
|
+
// 721 hook — deploy fresh within fork.
|
|
20
|
+
import {JB721TiersHookStore} from "@bananapus/721-hook-v6/src/JB721TiersHookStore.sol";
|
|
21
|
+
import {JB721TiersHook} from "@bananapus/721-hook-v6/src/JB721TiersHook.sol";
|
|
22
|
+
import {JB721TiersHookDeployer} from "@bananapus/721-hook-v6/src/JB721TiersHookDeployer.sol";
|
|
23
|
+
import {JBAddressRegistry} from "@bananapus/address-registry-v6/src/JBAddressRegistry.sol";
|
|
24
|
+
|
|
25
|
+
// Suckers — deploy fresh within fork.
|
|
26
|
+
import {JBSuckerRegistry} from "@bananapus/suckers-v6/src/JBSuckerRegistry.sol";
|
|
27
|
+
import {JBOptimismSuckerDeployer} from "@bananapus/suckers-v6/src/deployers/JBOptimismSuckerDeployer.sol";
|
|
28
|
+
import {JBOptimismSucker} from "@bananapus/suckers-v6/src/JBOptimismSucker.sol";
|
|
29
|
+
import {JBAddToBalanceMode} from "@bananapus/suckers-v6/src/enums/JBAddToBalanceMode.sol";
|
|
30
|
+
import {IOPMessenger} from "@bananapus/suckers-v6/src/interfaces/IOPMessenger.sol";
|
|
31
|
+
import {IOPStandardBridge} from "@bananapus/suckers-v6/src/interfaces/IOPStandardBridge.sol";
|
|
10
32
|
import {JBSuckerDeployerConfig} from "@bananapus/suckers-v6/src/structs/JBSuckerDeployerConfig.sol";
|
|
11
33
|
import {JBTokenMapping} from "@bananapus/suckers-v6/src/structs/JBTokenMapping.sol";
|
|
12
|
-
import {
|
|
13
|
-
import {CTPublisher} from "./../src/CTPublisher.sol";
|
|
34
|
+
import {IJBSuckerDeployer} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerDeployer.sol";
|
|
14
35
|
|
|
15
|
-
import
|
|
36
|
+
// Croptop — wildcard import pulls in all structs (CTProjectConfig, CTDeployerAllowedPost, etc.).
|
|
37
|
+
import "./../src/CTDeployer.sol";
|
|
38
|
+
import {CTPublisher} from "./../src/CTPublisher.sol";
|
|
16
39
|
|
|
40
|
+
/// @notice Fork tests for Croptop. Deploys all JB infrastructure fresh within a mainnet fork.
|
|
17
41
|
contract ForkTest is Test {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
// ───────────────────────── Mainnet addresses
|
|
43
|
+
// ──────────────────────────
|
|
44
|
+
|
|
45
|
+
// OP L1 bridge contracts (exist on Ethereum mainnet).
|
|
46
|
+
IOPMessenger constant OP_L1_MESSENGER = IOPMessenger(0x25ace71c97B33Cc4729CF772ae268934F7ab5fA1);
|
|
47
|
+
IOPStandardBridge constant OP_L1_BRIDGE = IOPStandardBridge(0x99C9fc46f92E8a1c0deC1b1747d010903E884bE1);
|
|
48
|
+
|
|
49
|
+
// ───────────────────────── JB core (deployed fresh)
|
|
50
|
+
// ───────────────────
|
|
51
|
+
|
|
52
|
+
address multisig = address(0xBEEF);
|
|
53
|
+
address trustedForwarder = address(0);
|
|
54
|
+
|
|
55
|
+
JBPermissions jbPermissions;
|
|
56
|
+
JBProjects jbProjects;
|
|
57
|
+
JBDirectory jbDirectory;
|
|
58
|
+
JBRulesets jbRulesets;
|
|
59
|
+
JBTokens jbTokens;
|
|
60
|
+
JBSplits jbSplits;
|
|
61
|
+
JBPrices jbPrices;
|
|
62
|
+
JBFundAccessLimits jbFundAccessLimits;
|
|
63
|
+
JBController jbController;
|
|
64
|
+
|
|
65
|
+
// ───────────────────────── 721 hook (deployed fresh)
|
|
66
|
+
// ──────────────────
|
|
67
|
+
|
|
68
|
+
JB721TiersHookDeployer hookDeployer;
|
|
69
|
+
|
|
70
|
+
// ───────────────────────── Suckers (deployed fresh)
|
|
71
|
+
// ───────────────────
|
|
72
|
+
|
|
73
|
+
JBSuckerRegistry suckerRegistry;
|
|
74
|
+
JBOptimismSuckerDeployer opSuckerDeployer;
|
|
75
|
+
|
|
76
|
+
// ───────────────────────── Croptop
|
|
77
|
+
// ────────────────────────────────────
|
|
24
78
|
|
|
25
79
|
CTPublisher publisher;
|
|
26
80
|
CTDeployer deployer;
|
|
27
81
|
|
|
28
|
-
address TRUSTED_FORWARDER;
|
|
29
|
-
|
|
30
82
|
function setUp() public {
|
|
83
|
+
// Skip fork tests when the RPC URL is not available (e.g. in CI).
|
|
84
|
+
string memory rpcUrl = vm.envOr("RPC_ETHEREUM_MAINNET", string(""));
|
|
85
|
+
if (bytes(rpcUrl).length == 0) {
|
|
86
|
+
vm.skip(true);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
31
90
|
// Fork ETH mainnet.
|
|
32
|
-
vm.createSelectFork(
|
|
91
|
+
vm.createSelectFork(rpcUrl);
|
|
33
92
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
);
|
|
39
|
-
// Get the deployment addresses for the 721 hook contracts for this chain.
|
|
40
|
-
hook = Hook721DeploymentLib.getDeployment(
|
|
41
|
-
vm.envOr("NANA_721_DEPLOYMENT_PATH", string("node_modules/@bananapus/721-hook-v6/deployments/"))
|
|
42
|
-
);
|
|
43
|
-
// Get the deployment addresses for the suckers contracts for this chain.
|
|
44
|
-
suckers = SuckerDeploymentLib.getDeployment(
|
|
45
|
-
vm.envOr("NANA_SUCKERS_DEPLOYMENT_PATH", string("node_modules/@bananapus/suckers-v6/deployments/"))
|
|
46
|
-
);
|
|
93
|
+
// Deploy all JB core contracts fresh within the fork.
|
|
94
|
+
_deployJBCore();
|
|
95
|
+
|
|
96
|
+
// Deploy the 721 hook infrastructure.
|
|
97
|
+
_deploy721Hook();
|
|
47
98
|
|
|
48
|
-
//
|
|
49
|
-
|
|
99
|
+
// Deploy the sucker infrastructure.
|
|
100
|
+
_deploySuckers();
|
|
50
101
|
|
|
51
102
|
// Deploy the croptop contracts.
|
|
52
|
-
publisher = new CTPublisher(
|
|
53
|
-
deployer = new CTDeployer(
|
|
54
|
-
core.permissions, core.projects, hook.hook_deployer, publisher, suckers.registry, TRUSTED_FORWARDER
|
|
55
|
-
);
|
|
103
|
+
publisher = new CTPublisher(jbDirectory, jbPermissions, 1, trustedForwarder);
|
|
104
|
+
deployer = new CTDeployer(jbPermissions, jbProjects, hookDeployer, publisher, suckerRegistry, trustedForwarder);
|
|
56
105
|
}
|
|
57
106
|
|
|
58
107
|
function testDeployProject(address owner) public {
|
|
@@ -72,7 +121,7 @@ contract ForkTest is Test {
|
|
|
72
121
|
CTSuckerDeploymentConfig memory suckerConfig =
|
|
73
122
|
CTSuckerDeploymentConfig({deployerConfigurations: new JBSuckerDeployerConfig[](0), salt: bytes32(0)});
|
|
74
123
|
|
|
75
|
-
deployer.deployProjectFor(owner, config, suckerConfig,
|
|
124
|
+
deployer.deployProjectFor(owner, config, suckerConfig, jbController);
|
|
76
125
|
}
|
|
77
126
|
|
|
78
127
|
function testDeployProjectWithSuckers(address owner, bytes32 salt, bytes32 suckerSalt) public {
|
|
@@ -100,15 +149,79 @@ contract ForkTest is Test {
|
|
|
100
149
|
});
|
|
101
150
|
|
|
102
151
|
JBSuckerDeployerConfig[] memory deployerConfigurations = new JBSuckerDeployerConfig[](1);
|
|
103
|
-
deployerConfigurations[0] =
|
|
152
|
+
deployerConfigurations[0] =
|
|
153
|
+
JBSuckerDeployerConfig({deployer: IJBSuckerDeployer(address(opSuckerDeployer)), mappings: tokens});
|
|
104
154
|
|
|
105
155
|
CTSuckerDeploymentConfig memory suckerConfig =
|
|
106
156
|
CTSuckerDeploymentConfig({deployerConfigurations: deployerConfigurations, salt: suckerSalt});
|
|
107
157
|
|
|
108
158
|
// Deploy the project.
|
|
109
|
-
(uint256 projectId,) = deployer.deployProjectFor(owner, config, suckerConfig,
|
|
159
|
+
(uint256 projectId,) = deployer.deployProjectFor(owner, config, suckerConfig, jbController);
|
|
110
160
|
|
|
111
161
|
// Check that the projectId has a sucker.
|
|
112
|
-
assertEq(
|
|
162
|
+
assertEq(suckerRegistry.suckersOf(projectId).length, deployerConfigurations.length);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ───────────────────────── Internal deployment helpers
|
|
166
|
+
// ────────────────
|
|
167
|
+
|
|
168
|
+
function _deployJBCore() internal {
|
|
169
|
+
jbPermissions = new JBPermissions(trustedForwarder);
|
|
170
|
+
jbProjects = new JBProjects(multisig, address(0), trustedForwarder);
|
|
171
|
+
jbDirectory = new JBDirectory(jbPermissions, jbProjects, multisig);
|
|
172
|
+
JBERC20 jbErc20 = new JBERC20();
|
|
173
|
+
jbTokens = new JBTokens(jbDirectory, jbErc20);
|
|
174
|
+
jbRulesets = new JBRulesets(jbDirectory);
|
|
175
|
+
jbPrices = new JBPrices(jbDirectory, jbPermissions, jbProjects, multisig, trustedForwarder);
|
|
176
|
+
jbSplits = new JBSplits(jbDirectory);
|
|
177
|
+
jbFundAccessLimits = new JBFundAccessLimits(jbDirectory);
|
|
178
|
+
|
|
179
|
+
jbController = new JBController(
|
|
180
|
+
jbDirectory,
|
|
181
|
+
jbFundAccessLimits,
|
|
182
|
+
jbPermissions,
|
|
183
|
+
jbPrices,
|
|
184
|
+
jbProjects,
|
|
185
|
+
jbRulesets,
|
|
186
|
+
jbSplits,
|
|
187
|
+
jbTokens,
|
|
188
|
+
address(0), // omnichainRulesetOperator
|
|
189
|
+
trustedForwarder
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
vm.prank(multisig);
|
|
193
|
+
jbDirectory.setIsAllowedToSetFirstController(address(jbController), true);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function _deploy721Hook() internal {
|
|
197
|
+
JB721TiersHookStore store = new JB721TiersHookStore();
|
|
198
|
+
JBAddressRegistry addressRegistry = new JBAddressRegistry();
|
|
199
|
+
|
|
200
|
+
JB721TiersHook hookImpl =
|
|
201
|
+
new JB721TiersHook(jbDirectory, jbPermissions, jbRulesets, store, jbSplits, trustedForwarder);
|
|
202
|
+
|
|
203
|
+
hookDeployer = new JB721TiersHookDeployer(hookImpl, store, addressRegistry, trustedForwarder);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function _deploySuckers() internal {
|
|
207
|
+
suckerRegistry = new JBSuckerRegistry(jbDirectory, jbPermissions, multisig, trustedForwarder);
|
|
208
|
+
|
|
209
|
+
// Deploy the OP sucker deployer with `multisig` as the configurator.
|
|
210
|
+
opSuckerDeployer =
|
|
211
|
+
new JBOptimismSuckerDeployer(jbDirectory, jbPermissions, jbTokens, multisig, trustedForwarder);
|
|
212
|
+
|
|
213
|
+
// Configure the OP sucker deployer with L1 bridge addresses.
|
|
214
|
+
vm.startPrank(multisig);
|
|
215
|
+
opSuckerDeployer.setChainSpecificConstants(OP_L1_MESSENGER, OP_L1_BRIDGE);
|
|
216
|
+
|
|
217
|
+
// Deploy and configure the singleton.
|
|
218
|
+
JBOptimismSucker singleton = new JBOptimismSucker(
|
|
219
|
+
opSuckerDeployer, jbDirectory, jbPermissions, jbTokens, JBAddToBalanceMode.ON_CLAIM, trustedForwarder
|
|
220
|
+
);
|
|
221
|
+
opSuckerDeployer.configureSingleton(singleton);
|
|
222
|
+
|
|
223
|
+
// Allow the deployer in the registry.
|
|
224
|
+
suckerRegistry.allowSuckerDeployer(address(opSuckerDeployer));
|
|
225
|
+
vm.stopPrank();
|
|
113
226
|
}
|
|
114
227
|
}
|
|
@@ -7,6 +7,7 @@ import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.s
|
|
|
7
7
|
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
8
|
import {IJBTerminal} from "@bananapus/core-v6/src/interfaces/IJBTerminal.sol";
|
|
9
9
|
import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
|
|
10
|
+
import {IJB721Hook} from "@bananapus/721-hook-v6/src/interfaces/IJB721Hook.sol";
|
|
10
11
|
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
11
12
|
import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
|
|
12
13
|
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
@@ -19,7 +20,7 @@ import {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
|
|
|
19
20
|
import {CTPost} from "../../src/structs/CTPost.sol";
|
|
20
21
|
|
|
21
22
|
/// @title H19_FeeEvasion
|
|
22
|
-
/// @notice
|
|
23
|
+
/// @notice Fee evasion for existing tier mints.
|
|
23
24
|
/// Before the fix, a user could set post.price = 0 for an existing tier
|
|
24
25
|
/// to evade the 5% Croptop fee entirely. The fix reads the actual tier price
|
|
25
26
|
/// from the store for existing tiers.
|
|
@@ -48,7 +49,7 @@ contract H19_FeeEvasion is Test {
|
|
|
48
49
|
// Mock hook.owner().
|
|
49
50
|
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
|
|
50
51
|
// Mock hook.PROJECT_ID().
|
|
51
|
-
vm.mockCall(hookAddr, abi.encodeWithSelector(
|
|
52
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(hookProjectId));
|
|
52
53
|
// Mock hook.STORE().
|
|
53
54
|
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(hookStoreAddr));
|
|
54
55
|
|
|
@@ -6,6 +6,7 @@ import "forge-std/Test.sol";
|
|
|
6
6
|
import {IJBPermissions} from "@bananapus/core-v6/src/interfaces/IJBPermissions.sol";
|
|
7
7
|
import {IJBDirectory} from "@bananapus/core-v6/src/interfaces/IJBDirectory.sol";
|
|
8
8
|
import {IJBOwnable} from "@bananapus/ownable-v6/src/interfaces/IJBOwnable.sol";
|
|
9
|
+
import {IJB721Hook} from "@bananapus/721-hook-v6/src/interfaces/IJB721Hook.sol";
|
|
9
10
|
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
10
11
|
import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
|
|
11
12
|
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
@@ -18,7 +19,7 @@ import {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
|
|
|
18
19
|
import {CTPost} from "../../src/structs/CTPost.sol";
|
|
19
20
|
|
|
20
21
|
/// @title L52_StaleTierIdMapping
|
|
21
|
-
/// @notice
|
|
22
|
+
/// @notice Stale tierIdForEncodedIPFSUriOf mapping after external tier removal.
|
|
22
23
|
/// When a tier is removed externally via adjustTiers(), the publisher's mapping still pointed
|
|
23
24
|
/// to the removed tier ID, blocking re-creation. The fix clears the stale mapping and allows
|
|
24
25
|
/// the post to fall through to new-tier creation.
|
|
@@ -45,7 +46,7 @@ contract L52_StaleTierIdMapping is Test {
|
|
|
45
46
|
// Mock hook.owner().
|
|
46
47
|
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
|
|
47
48
|
// Mock hook.PROJECT_ID().
|
|
48
|
-
vm.mockCall(hookAddr, abi.encodeWithSelector(
|
|
49
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(hookProjectId));
|
|
49
50
|
// Mock hook.STORE().
|
|
50
51
|
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(hookStoreAddr));
|
|
51
52
|
|
|
@@ -158,7 +159,7 @@ contract L52_StaleTierIdMapping is Test {
|
|
|
158
159
|
abi.encode(false)
|
|
159
160
|
);
|
|
160
161
|
|
|
161
|
-
// Mock tierOf for tier 1 so the
|
|
162
|
+
// Mock tierOf for tier 1 so the existing-tier price lookup succeeds.
|
|
162
163
|
JB721Tier memory tier = JB721Tier({
|
|
163
164
|
id: 1,
|
|
164
165
|
price: 0.1 ether,
|
|
@@ -0,0 +1,312 @@
|
|
|
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 {IJB721Hook} from "@bananapus/721-hook-v6/src/interfaces/IJB721Hook.sol";
|
|
11
|
+
import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
|
|
12
|
+
import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.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 M6_DuplicateUriFeeEvasion
|
|
22
|
+
/// @notice Duplicate encodedIPFSUri in a single mintFrom batch
|
|
23
|
+
/// enables fee evasion. Before the fix, a second post with the same URI would read
|
|
24
|
+
/// a stale tierIdForEncodedIPFSUriOf mapping (written by _setupPosts for the first
|
|
25
|
+
/// post but not yet committed to the store), causing store.tierOf() to return price=0,
|
|
26
|
+
/// so the fee was computed on 1x the price instead of 2x.
|
|
27
|
+
/// The fix reverts with CTPublisher_DuplicatePost when duplicate URIs appear in a batch.
|
|
28
|
+
contract M6_DuplicateUriFeeEvasion is Test {
|
|
29
|
+
CTPublisher publisher;
|
|
30
|
+
|
|
31
|
+
IJBPermissions permissions = IJBPermissions(makeAddr("permissions"));
|
|
32
|
+
IJBDirectory directory = IJBDirectory(makeAddr("directory"));
|
|
33
|
+
|
|
34
|
+
address hookOwner = makeAddr("hookOwner");
|
|
35
|
+
address hookAddr = makeAddr("hook");
|
|
36
|
+
address hookStoreAddr = makeAddr("hookStore");
|
|
37
|
+
address terminalAddr = makeAddr("terminal");
|
|
38
|
+
address feeTerminalAddr = makeAddr("feeTerminal");
|
|
39
|
+
address poster = makeAddr("poster");
|
|
40
|
+
|
|
41
|
+
uint256 feeProjectId = 1;
|
|
42
|
+
uint256 hookProjectId = 42;
|
|
43
|
+
|
|
44
|
+
function setUp() public {
|
|
45
|
+
publisher = new CTPublisher(directory, permissions, feeProjectId, address(0));
|
|
46
|
+
|
|
47
|
+
// Mock hook.owner().
|
|
48
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJBOwnable.owner.selector), abi.encode(hookOwner));
|
|
49
|
+
// Mock hook.PROJECT_ID().
|
|
50
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721Hook.PROJECT_ID.selector), abi.encode(hookProjectId));
|
|
51
|
+
// Mock hook.STORE().
|
|
52
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.STORE.selector), abi.encode(hookStoreAddr));
|
|
53
|
+
|
|
54
|
+
// Mock permissions to return true by default.
|
|
55
|
+
vm.mockCall(
|
|
56
|
+
address(permissions), abi.encodeWithSelector(IJBPermissions.hasPermission.selector), abi.encode(true)
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Fund poster.
|
|
60
|
+
vm.deal(poster, 100 ether);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _configureCategory() internal {
|
|
64
|
+
CTAllowedPost[] memory posts = new CTAllowedPost[](1);
|
|
65
|
+
posts[0] = CTAllowedPost({
|
|
66
|
+
hook: hookAddr,
|
|
67
|
+
category: 5,
|
|
68
|
+
minimumPrice: 0.01 ether,
|
|
69
|
+
minimumTotalSupply: 1,
|
|
70
|
+
maximumTotalSupply: 1000,
|
|
71
|
+
maximumSplitPercent: 0,
|
|
72
|
+
allowedAddresses: new address[](0)
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
vm.prank(hookOwner);
|
|
76
|
+
publisher.configurePostingCriteriaFor(posts);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _setupMintMocks() internal {
|
|
80
|
+
vm.mockCall(
|
|
81
|
+
hookStoreAddr, abi.encodeWithSelector(IJB721TiersHookStore.maxTierIdOf.selector), abi.encode(uint256(0))
|
|
82
|
+
);
|
|
83
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(IJB721TiersHook.adjustTiers.selector), abi.encode());
|
|
84
|
+
vm.mockCall(hookAddr, abi.encodeWithSelector(bytes4(keccak256("METADATA_ID_TARGET()"))), abi.encode(address(0)));
|
|
85
|
+
vm.mockCall(
|
|
86
|
+
address(directory),
|
|
87
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, hookProjectId),
|
|
88
|
+
abi.encode(terminalAddr)
|
|
89
|
+
);
|
|
90
|
+
vm.mockCall(
|
|
91
|
+
address(directory),
|
|
92
|
+
abi.encodeWithSelector(IJBDirectory.primaryTerminalOf.selector, feeProjectId),
|
|
93
|
+
abi.encode(feeTerminalAddr)
|
|
94
|
+
);
|
|
95
|
+
vm.mockCall(terminalAddr, "", abi.encode(uint256(0)));
|
|
96
|
+
vm.mockCall(feeTerminalAddr, "", abi.encode(uint256(0)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// =========================================================================
|
|
100
|
+
// Test 1: Duplicate URI in batch reverts with CTPublisher_DuplicatePost
|
|
101
|
+
// =========================================================================
|
|
102
|
+
/// @notice Sending two posts with the same encodedIPFSUri in a single mintFrom batch
|
|
103
|
+
/// must revert with CTPublisher_DuplicatePost.
|
|
104
|
+
function test_duplicateUriInBatch_reverts() public {
|
|
105
|
+
_configureCategory();
|
|
106
|
+
_setupMintMocks();
|
|
107
|
+
|
|
108
|
+
bytes32 duplicateUri = keccak256("same-content");
|
|
109
|
+
|
|
110
|
+
CTPost[] memory posts = new CTPost[](2);
|
|
111
|
+
posts[0] = CTPost({
|
|
112
|
+
encodedIPFSUri: duplicateUri,
|
|
113
|
+
totalSupply: 10,
|
|
114
|
+
price: 0.1 ether,
|
|
115
|
+
category: 5,
|
|
116
|
+
splitPercent: 0,
|
|
117
|
+
splits: new JBSplit[](0)
|
|
118
|
+
});
|
|
119
|
+
posts[1] = CTPost({
|
|
120
|
+
encodedIPFSUri: duplicateUri, // Same URI as posts[0].
|
|
121
|
+
totalSupply: 10,
|
|
122
|
+
price: 0.1 ether,
|
|
123
|
+
category: 5,
|
|
124
|
+
splitPercent: 0,
|
|
125
|
+
splits: new JBSplit[](0)
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
vm.prank(poster);
|
|
129
|
+
vm.expectRevert(abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, duplicateUri));
|
|
130
|
+
publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// =========================================================================
|
|
134
|
+
// Test 2: Three posts, first and third duplicate — reverts
|
|
135
|
+
// =========================================================================
|
|
136
|
+
/// @notice Duplicates do not need to be adjacent to be caught.
|
|
137
|
+
function test_duplicateUriNonAdjacent_reverts() public {
|
|
138
|
+
_configureCategory();
|
|
139
|
+
_setupMintMocks();
|
|
140
|
+
|
|
141
|
+
bytes32 duplicateUri = keccak256("content-A");
|
|
142
|
+
bytes32 uniqueUri = keccak256("content-B");
|
|
143
|
+
|
|
144
|
+
CTPost[] memory posts = new CTPost[](3);
|
|
145
|
+
posts[0] = CTPost({
|
|
146
|
+
encodedIPFSUri: duplicateUri,
|
|
147
|
+
totalSupply: 10,
|
|
148
|
+
price: 0.1 ether,
|
|
149
|
+
category: 5,
|
|
150
|
+
splitPercent: 0,
|
|
151
|
+
splits: new JBSplit[](0)
|
|
152
|
+
});
|
|
153
|
+
posts[1] = CTPost({
|
|
154
|
+
encodedIPFSUri: uniqueUri, // Different URI.
|
|
155
|
+
totalSupply: 10,
|
|
156
|
+
price: 0.1 ether,
|
|
157
|
+
category: 5,
|
|
158
|
+
splitPercent: 0,
|
|
159
|
+
splits: new JBSplit[](0)
|
|
160
|
+
});
|
|
161
|
+
posts[2] = CTPost({
|
|
162
|
+
encodedIPFSUri: duplicateUri, // Same as posts[0].
|
|
163
|
+
totalSupply: 10,
|
|
164
|
+
price: 0.1 ether,
|
|
165
|
+
category: 5,
|
|
166
|
+
splitPercent: 0,
|
|
167
|
+
splits: new JBSplit[](0)
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
vm.prank(poster);
|
|
171
|
+
vm.expectRevert(abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, duplicateUri));
|
|
172
|
+
publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// =========================================================================
|
|
176
|
+
// Test 3: Two posts with different URIs succeed
|
|
177
|
+
// =========================================================================
|
|
178
|
+
/// @notice Two posts with distinct encodedIPFSUri values should not revert
|
|
179
|
+
/// (at least not with the duplicate error).
|
|
180
|
+
function test_distinctUrisInBatch_succeeds() public {
|
|
181
|
+
_configureCategory();
|
|
182
|
+
_setupMintMocks();
|
|
183
|
+
|
|
184
|
+
CTPost[] memory posts = new CTPost[](2);
|
|
185
|
+
posts[0] = CTPost({
|
|
186
|
+
encodedIPFSUri: keccak256("content-1"),
|
|
187
|
+
totalSupply: 10,
|
|
188
|
+
price: 0.1 ether,
|
|
189
|
+
category: 5,
|
|
190
|
+
splitPercent: 0,
|
|
191
|
+
splits: new JBSplit[](0)
|
|
192
|
+
});
|
|
193
|
+
posts[1] = CTPost({
|
|
194
|
+
encodedIPFSUri: keccak256("content-2"),
|
|
195
|
+
totalSupply: 10,
|
|
196
|
+
price: 0.1 ether,
|
|
197
|
+
category: 5,
|
|
198
|
+
splitPercent: 0,
|
|
199
|
+
splits: new JBSplit[](0)
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Should not revert with CTPublisher_DuplicatePost.
|
|
203
|
+
// May succeed fully or revert downstream in mocks, but never with the duplicate error.
|
|
204
|
+
vm.prank(poster);
|
|
205
|
+
try publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "") {}
|
|
206
|
+
catch (bytes memory reason) {
|
|
207
|
+
// Ensure it did NOT revert with CTPublisher_DuplicatePost.
|
|
208
|
+
assertTrue(
|
|
209
|
+
keccak256(reason)
|
|
210
|
+
!= keccak256(
|
|
211
|
+
abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, keccak256("content-1"))
|
|
212
|
+
),
|
|
213
|
+
"should not revert with duplicate post error for content-1"
|
|
214
|
+
);
|
|
215
|
+
assertTrue(
|
|
216
|
+
keccak256(reason)
|
|
217
|
+
!= keccak256(
|
|
218
|
+
abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, keccak256("content-2"))
|
|
219
|
+
),
|
|
220
|
+
"should not revert with duplicate post error for content-2"
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// =========================================================================
|
|
226
|
+
// Test 4: Single post (no duplicates possible) succeeds
|
|
227
|
+
// =========================================================================
|
|
228
|
+
/// @notice A single post should never trigger the duplicate check.
|
|
229
|
+
function test_singlePost_noDuplicateError() public {
|
|
230
|
+
_configureCategory();
|
|
231
|
+
_setupMintMocks();
|
|
232
|
+
|
|
233
|
+
CTPost[] memory posts = new CTPost[](1);
|
|
234
|
+
posts[0] = CTPost({
|
|
235
|
+
encodedIPFSUri: keccak256("sole-content"),
|
|
236
|
+
totalSupply: 10,
|
|
237
|
+
price: 0.1 ether,
|
|
238
|
+
category: 5,
|
|
239
|
+
splitPercent: 0,
|
|
240
|
+
splits: new JBSplit[](0)
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
vm.prank(poster);
|
|
244
|
+
try publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "") {}
|
|
245
|
+
catch (bytes memory reason) {
|
|
246
|
+
assertTrue(
|
|
247
|
+
keccak256(reason)
|
|
248
|
+
!= keccak256(
|
|
249
|
+
abi.encodeWithSelector(
|
|
250
|
+
CTPublisher.CTPublisher_DuplicatePost.selector, keccak256("sole-content")
|
|
251
|
+
)
|
|
252
|
+
),
|
|
253
|
+
"should not revert with duplicate post error"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// =========================================================================
|
|
259
|
+
// Test 5: Fuzz — batch of 2 posts, duplicate iff URIs match
|
|
260
|
+
// =========================================================================
|
|
261
|
+
/// @notice Fuzz test: when two URIs are equal the call must revert with
|
|
262
|
+
/// CTPublisher_DuplicatePost; when they differ it must not.
|
|
263
|
+
function testFuzz_duplicateDetection(bytes32 uri1, bytes32 uri2) public {
|
|
264
|
+
vm.assume(uri1 != bytes32(""));
|
|
265
|
+
vm.assume(uri2 != bytes32(""));
|
|
266
|
+
|
|
267
|
+
_configureCategory();
|
|
268
|
+
_setupMintMocks();
|
|
269
|
+
|
|
270
|
+
CTPost[] memory posts = new CTPost[](2);
|
|
271
|
+
posts[0] = CTPost({
|
|
272
|
+
encodedIPFSUri: uri1,
|
|
273
|
+
totalSupply: 10,
|
|
274
|
+
price: 0.1 ether,
|
|
275
|
+
category: 5,
|
|
276
|
+
splitPercent: 0,
|
|
277
|
+
splits: new JBSplit[](0)
|
|
278
|
+
});
|
|
279
|
+
posts[1] = CTPost({
|
|
280
|
+
encodedIPFSUri: uri2,
|
|
281
|
+
totalSupply: 10,
|
|
282
|
+
price: 0.1 ether,
|
|
283
|
+
category: 5,
|
|
284
|
+
splitPercent: 0,
|
|
285
|
+
splits: new JBSplit[](0)
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (uri1 == uri2) {
|
|
289
|
+
// Must revert with duplicate error.
|
|
290
|
+
vm.prank(poster);
|
|
291
|
+
vm.expectRevert(abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, uri1));
|
|
292
|
+
publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "");
|
|
293
|
+
} else {
|
|
294
|
+
// Must NOT revert with duplicate error. May still revert for other reasons
|
|
295
|
+
// (e.g. mocked terminal behavior), but not CTPublisher_DuplicatePost.
|
|
296
|
+
vm.prank(poster);
|
|
297
|
+
try publisher.mintFrom{value: 1 ether}(IJB721TiersHook(hookAddr), posts, poster, poster, "", "") {}
|
|
298
|
+
catch (bytes memory reason) {
|
|
299
|
+
assertTrue(
|
|
300
|
+
keccak256(reason)
|
|
301
|
+
!= keccak256(abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, uri1)),
|
|
302
|
+
"should not revert with duplicate post error for uri1"
|
|
303
|
+
);
|
|
304
|
+
assertTrue(
|
|
305
|
+
keccak256(reason)
|
|
306
|
+
!= keccak256(abi.encodeWithSelector(CTPublisher.CTPublisher_DuplicatePost.selector, uri2)),
|
|
307
|
+
"should not revert with duplicate post error for uri2"
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|