@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/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 "@bananapus/721-hook-v6/script/helpers/Hook721DeploymentLib.sol";
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
- import "./../src/CTDeployer.sol";
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 {CTProjectOwner} from "./../src/CTProjectOwner.sol";
13
- import {CTPublisher} from "./../src/CTPublisher.sol";
34
+ import {IJBSuckerDeployer} from "@bananapus/suckers-v6/src/interfaces/IJBSuckerDeployer.sol";
14
35
 
15
- import "forge-std/Test.sol";
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
- /// @notice tracks the deployment of the core contracts for the chain we are deploying to.
19
- CoreDeployment core;
20
- /// @notice tracks the deployment of the 721 hook contracts for the chain we are deploying to.
21
- Hook721Deployment hook;
22
- /// @notice tracks the deployment of the sucker contracts for the chain we are deploying to.
23
- SuckerDeployment suckers;
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(vm.rpcUrl("ethereum"), 22_432_742);
91
+ vm.createSelectFork(rpcUrl);
33
92
 
34
- // Get the deployment addresses for the nana CORE for this chain.
35
- // We want to do this outside of the `sphinx` modifier.
36
- core = CoreDeploymentLib.getDeployment(
37
- vm.envOr("NANA_CORE_DEPLOYMENT_PATH", string("node_modules/@bananapus/core-v6/deployments/"))
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
- // We use the same trusted forwarder as the core deployment.
49
- TRUSTED_FORWARDER = core.controller.trustedForwarder();
99
+ // Deploy the sucker infrastructure.
100
+ _deploySuckers();
50
101
 
51
102
  // Deploy the croptop contracts.
52
- publisher = new CTPublisher(core.directory, core.permissions, 1, TRUSTED_FORWARDER);
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, core.controller);
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] = JBSuckerDeployerConfig({deployer: suckers.optimismDeployer, mappings: tokens});
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, core.controller);
159
+ (uint256 projectId,) = deployer.deployProjectFor(owner, config, suckerConfig, jbController);
110
160
 
111
161
  // Check that the projectId has a sucker.
112
- assertEq(suckers.registry.suckersOf(projectId).length, deployerConfigurations.length);
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 Regression test for H-19: fee evasion for existing tier mints.
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(IJB721TiersHook.PROJECT_ID.selector), abi.encode(hookProjectId));
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 Regression test for L-52: stale tierIdForEncodedIPFSUriOf mapping after external tier removal.
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(IJB721TiersHook.PROJECT_ID.selector), abi.encode(hookProjectId));
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 H-19 price lookup succeeds.
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
+ }