@croptop/core-v6 0.0.36 → 0.0.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@croptop/core-v6",
3
- "version": "0.0.36",
3
+ "version": "0.0.38",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,13 +16,13 @@
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.35",
20
- "@bananapus/buyback-hook-v6": "^0.0.27",
21
- "@bananapus/core-v6": "^0.0.34",
22
- "@bananapus/ownable-v6": "^0.0.17",
23
- "@bananapus/permission-ids-v6": "^0.0.17",
24
- "@bananapus/router-terminal-v6": "^0.0.26",
25
- "@bananapus/suckers-v6": "^0.0.25",
19
+ "@bananapus/721-hook-v6": "^0.0.38",
20
+ "@bananapus/buyback-hook-v6": "^0.0.30",
21
+ "@bananapus/core-v6": "^0.0.36",
22
+ "@bananapus/ownable-v6": "^0.0.20",
23
+ "@bananapus/permission-ids-v6": "^0.0.19",
24
+ "@bananapus/router-terminal-v6": "^0.0.30",
25
+ "@bananapus/suckers-v6": "^0.0.28",
26
26
  "@openzeppelin/contracts": "^5.6.1"
27
27
  },
28
28
  "devDependencies": {
@@ -33,6 +33,7 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
33
33
  error CTPublisher_MaxTotalSupplyLessThanMin(uint256 min, uint256 max);
34
34
  error CTPublisher_NotInAllowList(address addr, address[] allowedAddresses);
35
35
  error CTPublisher_PriceTooSmall(uint256 price, uint256 minimumPrice);
36
+ error CTPublisher_DuplicatePayMetadata();
36
37
  error CTPublisher_FeePaymentFailed(uint256 feeAmount);
37
38
  error CTPublisher_SplitPercentExceedsMaximum(uint256 splitPercent, uint256 maximumSplitPercent);
38
39
  error CTPublisher_TotalSupplyTooBig(uint256 totalSupply, uint256 maximumTotalSupply);
@@ -236,6 +237,15 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
236
237
  // Keep a reference to the metadata ID target.
237
238
  address metadataIdTarget = hook.METADATA_ID_TARGET();
238
239
 
240
+ // Revert if the caller's additional metadata already contains a pay ID — this would shadow Croptop's
241
+ // tier selection, allowing the caller to mint arbitrary tiers.
242
+ {
243
+ bytes4 payId = JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget});
244
+ // slither-disable-next-line unused-return
245
+ (bool exists,) = JBMetadataResolver.getDataFor({id: payId, metadata: additionalPayMetadata});
246
+ if (exists) revert CTPublisher_DuplicatePayMetadata();
247
+ }
248
+
239
249
  // Create the metadata for the payment to specify the tier IDs that should be minted. We create manually the
240
250
  // original metadata, following
241
251
  // the specifications from the JBMetadataResolver library.
@@ -464,18 +474,23 @@ contract CTPublisher is JBPermissioned, ERC2771Context, ICTPublisher {
464
474
  uint256 tierId = tierIdForEncodedIPFSUriOf[address(hook)][post.encodedIPFSUri];
465
475
 
466
476
  if (tierId != 0) {
467
- // If the tier was removed externally (via adjustTiers), clear the stale mapping
468
- // so the code falls through to create a new tier.
477
+ // Validate the cached tier still exists and its URI still matches.
478
+ // The cache can become stale if the tier was removed (via adjustTiers) or
479
+ // its URI was changed (via setMetadata). In either case, clear the stale
480
+ // mapping and fall through to create a new tier.
481
+ // slither-disable-next-line calls-loop
482
+ JB721Tier memory cachedTier =
483
+ store.tierOf({hook: address(hook), id: tierId, includeResolvedUri: false});
469
484
  // slither-disable-next-line calls-loop
470
- if (store.isTierRemoved(address(hook), tierId)) {
485
+ if (store.isTierRemoved(address(hook), tierId) || cachedTier.encodedIPFSUri != post.encodedIPFSUri)
486
+ {
471
487
  delete tierIdForEncodedIPFSUriOf[address(hook)][post.encodedIPFSUri];
472
488
  } else {
473
489
  tierIdsToMint[i] = tierId;
474
490
 
475
491
  // For existing tiers, use the actual tier price (not the user-supplied post.price)
476
492
  // to prevent fee evasion by passing price=0 for an existing tier.
477
- // slither-disable-next-line calls-loop
478
- totalPrice += store.tierOf({hook: address(hook), id: tierId, includeResolvedUri: false}).price;
493
+ totalPrice += cachedTier.price;
479
494
  }
480
495
  }
481
496
  }
@@ -0,0 +1,329 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import "forge-std/Test.sol";
5
+
6
+ import {IJB721TiersHook} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHook.sol";
7
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
8
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
9
+ import {JB721TierConfig} from "@bananapus/721-hook-v6/src/structs/JB721TierConfig.sol";
10
+ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.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 {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
18
+ import {CTPost} from "../../src/structs/CTPost.sol";
19
+ import {CTPublisher} from "../../src/CTPublisher.sol";
20
+
21
+ contract NemesisMockPermissions is IJBPermissions {
22
+ function WILDCARD_PROJECT_ID() external pure returns (uint256) {
23
+ return 0;
24
+ }
25
+
26
+ function hasPermission(address, address, uint256, uint256, bool, bool) external pure returns (bool) {
27
+ return true;
28
+ }
29
+
30
+ function hasPermissions(address, address, uint256, uint256[] calldata, bool, bool) external pure returns (bool) {
31
+ return true;
32
+ }
33
+
34
+ function permissionsOf(address, address, uint256) external pure returns (uint256) {
35
+ return 0;
36
+ }
37
+
38
+ function setPermissionsFor(address, JBPermissionsData calldata) external {}
39
+ }
40
+
41
+ contract NemesisMockTerminal {
42
+ mapping(uint256 projectId => uint256 amount) public paidToProject;
43
+
44
+ function pay(
45
+ uint256 projectId,
46
+ address,
47
+ uint256,
48
+ address,
49
+ uint256,
50
+ string calldata,
51
+ bytes calldata
52
+ )
53
+ external
54
+ payable
55
+ returns (uint256)
56
+ {
57
+ paidToProject[projectId] += msg.value;
58
+ return 0;
59
+ }
60
+ }
61
+
62
+ contract NemesisMockDirectory {
63
+ mapping(uint256 projectId => address terminal) public terminalOf;
64
+
65
+ function setTerminal(uint256 projectId, address terminal) external {
66
+ terminalOf[projectId] = terminal;
67
+ }
68
+
69
+ function primaryTerminalOf(uint256 projectId, address) external view returns (IJBTerminal) {
70
+ return IJBTerminal(terminalOf[projectId]);
71
+ }
72
+ }
73
+
74
+ contract NemesisMockStore {
75
+ struct StoredTier {
76
+ uint104 price;
77
+ uint32 initialSupply;
78
+ uint32 remainingSupply;
79
+ bytes32 encodedIPFSUri;
80
+ bool removed;
81
+ }
82
+
83
+ uint256 public maxTierId;
84
+ mapping(uint256 tierId => StoredTier) public tierData;
85
+
86
+ function encodedUriOf(uint256 tierId) external view returns (bytes32) {
87
+ return tierData[tierId].encodedIPFSUri;
88
+ }
89
+
90
+ function addTier(JB721TierConfig memory config) external returns (uint256 tierId) {
91
+ tierId = ++maxTierId;
92
+ tierData[tierId] = StoredTier({
93
+ price: config.price,
94
+ initialSupply: config.initialSupply,
95
+ remainingSupply: config.initialSupply,
96
+ encodedIPFSUri: config.encodedIPFSUri,
97
+ removed: false
98
+ });
99
+ }
100
+
101
+ function setEncodedUri(uint256 tierId, bytes32 encodedIPFSUri) external {
102
+ tierData[tierId].encodedIPFSUri = encodedIPFSUri;
103
+ }
104
+
105
+ function maxTierIdOf(address) external view returns (uint256) {
106
+ return maxTierId;
107
+ }
108
+
109
+ function isTierRemoved(address, uint256 tierId) external view returns (bool) {
110
+ return tierData[tierId].removed;
111
+ }
112
+
113
+ function tierOf(address, uint256 tierId, bool) external view returns (JB721Tier memory tier) {
114
+ StoredTier memory stored = tierData[tierId];
115
+ tier = JB721Tier({
116
+ id: uint32(tierId),
117
+ price: stored.price,
118
+ remainingSupply: stored.remainingSupply,
119
+ initialSupply: stored.initialSupply,
120
+ votingUnits: 0,
121
+ reserveFrequency: 0,
122
+ reserveBeneficiary: address(0),
123
+ encodedIPFSUri: stored.encodedIPFSUri,
124
+ category: 0,
125
+ discountPercent: 0,
126
+ flags: JB721TierFlags({
127
+ allowOwnerMint: false,
128
+ transfersPausable: false,
129
+ cantBeRemoved: false,
130
+ cantIncreaseDiscountPercent: false,
131
+ cantBuyWithCredits: false
132
+ }),
133
+ splitPercent: 0,
134
+ resolvedUri: ""
135
+ });
136
+ }
137
+ }
138
+
139
+ contract NemesisMutableHook {
140
+ uint256 public immutable PROJECT_ID;
141
+ IJB721TiersHookStore public immutable STORE;
142
+ address public ownerAddress;
143
+
144
+ constructor(uint256 projectId, IJB721TiersHookStore store_, address owner_) {
145
+ PROJECT_ID = projectId;
146
+ STORE = store_;
147
+ ownerAddress = owner_;
148
+ }
149
+
150
+ function owner() external view returns (address) {
151
+ return ownerAddress;
152
+ }
153
+
154
+ function METADATA_ID_TARGET() external view returns (address) {
155
+ return address(this);
156
+ }
157
+
158
+ function adjustTiers(JB721TierConfig[] calldata tiersToAdd, uint256[] calldata) external {
159
+ for (uint256 i; i < tiersToAdd.length; i++) {
160
+ NemesisMockStore(address(STORE)).addTier(tiersToAdd[i]);
161
+ }
162
+ }
163
+
164
+ function setMetadata(
165
+ string calldata,
166
+ string calldata,
167
+ string calldata,
168
+ string calldata,
169
+ address,
170
+ uint256 encodedIPFSUriTierId,
171
+ bytes32 encodedIPFSUri
172
+ )
173
+ external
174
+ {
175
+ NemesisMockStore(address(STORE)).setEncodedUri(encodedIPFSUriTierId, encodedIPFSUri);
176
+ }
177
+ }
178
+
179
+ contract CodexNemesisCroptopPublisherBoundaryTest is Test {
180
+ uint256 internal constant FEE_PROJECT_ID = 1;
181
+ uint256 internal constant PROJECT_ID = 2;
182
+
183
+ bytes32 internal constant URI_A = keccak256("uri-a");
184
+ bytes32 internal constant URI_B = keccak256("uri-b");
185
+
186
+ address internal hookOwner = makeAddr("hookOwner");
187
+ address internal unrestrictedPoster = makeAddr("unrestrictedPoster");
188
+ address internal restrictedPoster = makeAddr("restrictedPoster");
189
+ address internal outsider = makeAddr("outsider");
190
+
191
+ NemesisMockPermissions internal permissions;
192
+ NemesisMockDirectory internal directory;
193
+ NemesisMockStore internal store;
194
+ NemesisMutableHook internal hook;
195
+ NemesisMockTerminal internal projectTerminal;
196
+ NemesisMockTerminal internal feeTerminal;
197
+ CTPublisher internal publisher;
198
+
199
+ function setUp() public {
200
+ permissions = new NemesisMockPermissions();
201
+ directory = new NemesisMockDirectory();
202
+ store = new NemesisMockStore();
203
+ hook = new NemesisMutableHook(PROJECT_ID, IJB721TiersHookStore(address(store)), hookOwner);
204
+ projectTerminal = new NemesisMockTerminal();
205
+ feeTerminal = new NemesisMockTerminal();
206
+ publisher = new CTPublisher(IJBDirectory(address(directory)), permissions, FEE_PROJECT_ID, address(0));
207
+
208
+ directory.setTerminal(PROJECT_ID, address(projectTerminal));
209
+ directory.setTerminal(FEE_PROJECT_ID, address(feeTerminal));
210
+
211
+ vm.deal(unrestrictedPoster, 100 ether);
212
+ vm.deal(restrictedPoster, 100 ether);
213
+ vm.deal(outsider, 100 ether);
214
+ }
215
+
216
+ function test_existingTierReuseBypassesUpdatedAllowlistAndPriceFloor() external {
217
+ _configureCategory(1, 1 ether, _singletonArray(unrestrictedPoster));
218
+
219
+ vm.prank(unrestrictedPoster);
220
+ publisher.mintFrom{value: 2 ether}(
221
+ IJB721TiersHook(address(hook)),
222
+ _singlePost({uri: URI_A, price: 1 ether, category: 1}),
223
+ unrestrictedPoster,
224
+ unrestrictedPoster,
225
+ "",
226
+ ""
227
+ );
228
+
229
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "initial publish should cache tier 1");
230
+
231
+ // Tighten the policy so only `restrictedPoster` can publish, and only at >= 5 ether.
232
+ _configureCategory(1, 5 ether, _singletonArray(restrictedPoster));
233
+
234
+ vm.prank(outsider);
235
+ publisher.mintFrom{value: 2 ether}(
236
+ IJB721TiersHook(address(hook)), _singlePost({uri: URI_A, price: 0, category: 1}), outsider, outsider, "", ""
237
+ );
238
+
239
+ // The outsider's second call succeeds because existing-tier reuse skips the allowlist and price checks.
240
+ assertEq(store.maxTierId(), 1, "reuse path should mint from the old tier instead of creating a new one");
241
+ assertEq(
242
+ projectTerminal.paidToProject(PROJECT_ID),
243
+ 3.9 ether,
244
+ "both mints should settle against the stale reused tier price"
245
+ );
246
+ assertEq(
247
+ feeTerminal.paidToProject(FEE_PROJECT_ID),
248
+ 0.1 ether,
249
+ "fee routing still uses the stale reused tier price instead of the new stricter floor"
250
+ );
251
+ }
252
+
253
+ function test_hookMetadataMutationDesyncsPublisherCacheAndAllowsDuplicateTier() external {
254
+ _configureCategory(1, 1 ether, new address[](0));
255
+
256
+ vm.prank(unrestrictedPoster);
257
+ publisher.mintFrom{value: 2 ether}(
258
+ IJB721TiersHook(address(hook)),
259
+ _singlePost({uri: URI_A, price: 1 ether, category: 1}),
260
+ unrestrictedPoster,
261
+ unrestrictedPoster,
262
+ "",
263
+ ""
264
+ );
265
+
266
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "publisher cache should point at tier 1");
267
+ assertEq(store.encodedUriOf(1), URI_A, "canonical hook metadata should start at uri A");
268
+
269
+ // The hook owner changes the canonical tier URI through the underlying 721 hook.
270
+ vm.prank(hookOwner);
271
+ hook.setMetadata("", "", "", "", address(0), 1, URI_B);
272
+
273
+ assertEq(store.encodedUriOf(1), URI_B, "hook metadata now says tier 1 is uri B");
274
+ assertEq(
275
+ publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A),
276
+ 1,
277
+ "publisher cache is stale and still thinks uri A owns tier 1"
278
+ );
279
+
280
+ vm.prank(unrestrictedPoster);
281
+ publisher.mintFrom{value: 2 ether}(
282
+ IJB721TiersHook(address(hook)),
283
+ _singlePost({uri: URI_B, price: 1 ether, category: 1}),
284
+ unrestrictedPoster,
285
+ unrestrictedPoster,
286
+ "",
287
+ ""
288
+ );
289
+
290
+ // Croptop creates a second tier for the same canonical URI because it never re-syncs against hook metadata.
291
+ assertEq(store.maxTierId(), 2, "publisher should have created a duplicate tier after the metadata drift");
292
+ assertEq(store.encodedUriOf(1), URI_B, "tier 1 still resolves to uri B");
293
+ assertEq(store.encodedUriOf(2), URI_B, "tier 2 now also resolves to uri B");
294
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_B), 2, "cache now points uri B at tier 2");
295
+ }
296
+
297
+ function _configureCategory(uint24 category, uint104 minimumPrice, address[] memory allowedAddresses) internal {
298
+ CTAllowedPost[] memory allowedPosts = new CTAllowedPost[](1);
299
+ allowedPosts[0] = CTAllowedPost({
300
+ hook: address(hook),
301
+ category: category,
302
+ minimumPrice: minimumPrice,
303
+ minimumTotalSupply: 1,
304
+ maximumTotalSupply: 100,
305
+ maximumSplitPercent: 0,
306
+ allowedAddresses: allowedAddresses
307
+ });
308
+
309
+ vm.prank(hookOwner);
310
+ publisher.configurePostingCriteriaFor(allowedPosts);
311
+ }
312
+
313
+ function _singlePost(bytes32 uri, uint104 price, uint24 category) internal pure returns (CTPost[] memory posts) {
314
+ posts = new CTPost[](1);
315
+ posts[0] = CTPost({
316
+ encodedIPFSUri: uri,
317
+ totalSupply: 10,
318
+ price: price,
319
+ category: category,
320
+ splitPercent: 0,
321
+ splits: new JBSplit[](0)
322
+ });
323
+ }
324
+
325
+ function _singletonArray(address account) internal pure returns (address[] memory addrs) {
326
+ addrs = new address[](1);
327
+ addrs[0] = account;
328
+ }
329
+ }