@croptop/core-v6 0.0.37 → 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.37",
3
+ "version": "0.0.38",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
  }
@@ -187,17 +187,10 @@ contract CodexNemesisMetadataShadowTest is Test {
187
187
  datas[0] = abi.encode(true, forgedTierIds);
188
188
  bytes memory shadowingMetadata = JBMetadataResolver.createMetadata(ids, datas);
189
189
 
190
+ // H-26 FIX: metadata shadow attack now reverts instead of succeeding.
191
+ vm.expectRevert(CTPublisher.CTPublisher_DuplicatePayMetadata.selector);
190
192
  publisher.mintFrom{value: 105}(
191
193
  IJB721TiersHook(address(hook)), posts, address(this), address(this), shadowingMetadata, ""
192
194
  );
193
-
194
- assertEq(
195
- publisher.tierIdForEncodedIPFSUriOf(address(hook), posts[0].encodedIPFSUri),
196
- 1,
197
- "publisher validated and cached tier 1"
198
- );
199
- assertTrue(projectTerminal.found(), "terminal/parser should find pay metadata");
200
- assertEq(projectTerminal.tierCount(), 1, "forged metadata contains one tier");
201
- assertEq(projectTerminal.firstTierId(), 2, "parser used caller-supplied tier 2 instead of publisher tier 1");
202
195
  }
203
196
  }
@@ -0,0 +1,388 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "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 {JBMetadataResolver} from "@bananapus/core-v6/src/libraries/JBMetadataResolver.sol";
15
+ import {JBPermissions} from "@bananapus/core-v6/src/JBPermissions.sol";
16
+ import {JBSplit} from "@bananapus/core-v6/src/structs/JBSplit.sol";
17
+
18
+ import {CTPublisher} from "../../src/CTPublisher.sol";
19
+ import {CTAllowedPost} from "../../src/structs/CTAllowedPost.sol";
20
+ import {CTPost} from "../../src/structs/CTPost.sol";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Minimal mock contracts (reusable across both tests)
24
+ // ---------------------------------------------------------------------------
25
+
26
+ contract P12MockTerminal {
27
+ function pay(
28
+ uint256,
29
+ address,
30
+ uint256,
31
+ address,
32
+ uint256,
33
+ string calldata,
34
+ bytes calldata
35
+ )
36
+ external
37
+ payable
38
+ returns (uint256)
39
+ {
40
+ return 0;
41
+ }
42
+ }
43
+
44
+ contract P12MockDirectory {
45
+ // forge-lint: disable-next-line(screaming-snake-case-immutable)
46
+ IJBTerminal internal immutable _terminal;
47
+
48
+ constructor(IJBTerminal terminal_) {
49
+ _terminal = terminal_;
50
+ }
51
+
52
+ function primaryTerminalOf(uint256, address) external view returns (IJBTerminal) {
53
+ return _terminal;
54
+ }
55
+ }
56
+
57
+ contract P12MockStore {
58
+ struct TierData {
59
+ bytes32 uri;
60
+ uint104 price;
61
+ uint24 category;
62
+ uint32 supply;
63
+ bool removed;
64
+ }
65
+
66
+ uint256 internal _maxTierId;
67
+ mapping(uint256 tierId => TierData) internal _tiers;
68
+
69
+ function maxTierIdOf(address) external view returns (uint256) {
70
+ return _maxTierId;
71
+ }
72
+
73
+ function isTierRemoved(address, uint256 tierId) external view returns (bool) {
74
+ return _tiers[tierId].removed;
75
+ }
76
+
77
+ function tierOf(address, uint256 tierId, bool) external view returns (JB721Tier memory) {
78
+ TierData memory tier = _tiers[tierId];
79
+ return JB721Tier({
80
+ // forge-lint: disable-next-line(unsafe-typecast)
81
+ id: uint32(tierId),
82
+ price: tier.price,
83
+ remainingSupply: tier.supply,
84
+ initialSupply: tier.supply,
85
+ votingUnits: 0,
86
+ reserveFrequency: 0,
87
+ reserveBeneficiary: address(0),
88
+ encodedIPFSUri: tier.uri,
89
+ category: tier.category,
90
+ discountPercent: 0,
91
+ flags: JB721TierFlags({
92
+ allowOwnerMint: false,
93
+ transfersPausable: false,
94
+ cantBeRemoved: false,
95
+ cantIncreaseDiscountPercent: false,
96
+ cantBuyWithCredits: false
97
+ }),
98
+ splitPercent: 0,
99
+ resolvedUri: ""
100
+ });
101
+ }
102
+
103
+ function addTier(JB721TierConfig calldata config) external returns (uint256 tierId) {
104
+ tierId = ++_maxTierId;
105
+ _tiers[tierId] = TierData({
106
+ uri: config.encodedIPFSUri,
107
+ price: config.price,
108
+ category: config.category,
109
+ supply: config.initialSupply,
110
+ removed: false
111
+ });
112
+ }
113
+
114
+ // forge-lint: disable-next-line(mixed-case-function)
115
+ function setEncodedIPFSUriOf(uint256 tierId, bytes32 uri) external {
116
+ _tiers[tierId].uri = uri;
117
+ }
118
+
119
+ function tierUri(uint256 tierId) external view returns (bytes32) {
120
+ return _tiers[tierId].uri;
121
+ }
122
+
123
+ function maxTierId() external view returns (uint256) {
124
+ return _maxTierId;
125
+ }
126
+ }
127
+
128
+ contract P12MockHook {
129
+ address internal _owner;
130
+ uint256 public immutable PROJECT_ID;
131
+ // forge-lint: disable-next-line(screaming-snake-case-immutable)
132
+ P12MockStore internal immutable _store;
133
+
134
+ constructor(address owner_, uint256 projectId_, P12MockStore store_) {
135
+ _owner = owner_;
136
+ PROJECT_ID = projectId_;
137
+ _store = store_;
138
+ }
139
+
140
+ function owner() external view returns (address) {
141
+ return _owner;
142
+ }
143
+
144
+ // forge-lint: disable-next-line(mixed-case-function)
145
+ function STORE() external view returns (IJB721TiersHookStore) {
146
+ return IJB721TiersHookStore(address(_store));
147
+ }
148
+
149
+ // forge-lint: disable-next-line(mixed-case-function)
150
+ function METADATA_ID_TARGET() external view returns (address) {
151
+ return address(this);
152
+ }
153
+
154
+ function adjustTiers(JB721TierConfig[] calldata tiersToAdd, uint256[] calldata) external {
155
+ for (uint256 i; i < tiersToAdd.length; i++) {
156
+ _store.addTier(tiersToAdd[i]);
157
+ }
158
+ }
159
+
160
+ function setMetadata(
161
+ string calldata,
162
+ string calldata,
163
+ string calldata,
164
+ string calldata,
165
+ address,
166
+ uint256 encodedIPFSUriTierId,
167
+ bytes32 encodedIPFSUri
168
+ )
169
+ external
170
+ {
171
+ require(msg.sender == _owner, "not owner");
172
+ _store.setEncodedIPFSUriOf(encodedIPFSUriTierId, encodedIPFSUri);
173
+ }
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Test contract
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /// @notice Regression tests for Pass 12 audit fixes:
181
+ /// H-26: Metadata shadow — additionalPayMetadata with duplicate pay ID
182
+ /// M-42: URI cache desync — tier URI changed via setMetadata
183
+ contract Pass12FixesTest is Test {
184
+ bytes32 internal constant URI_A = keccak256("uri-a");
185
+ bytes32 internal constant URI_B = keccak256("uri-b");
186
+
187
+ JBPermissions internal permissions;
188
+ P12MockTerminal internal terminal;
189
+ P12MockDirectory internal directory;
190
+ P12MockStore internal store;
191
+ P12MockHook internal hook;
192
+ CTPublisher internal publisher;
193
+
194
+ address internal hookOwner = makeAddr("hookOwner");
195
+ address internal poster = makeAddr("poster");
196
+ uint256 internal constant FEE_PROJECT_ID = 1;
197
+ uint256 internal constant PROJECT_ID = 42;
198
+
199
+ function setUp() public {
200
+ permissions = new JBPermissions(address(0));
201
+ terminal = new P12MockTerminal();
202
+ directory = new P12MockDirectory(IJBTerminal(address(terminal)));
203
+ store = new P12MockStore();
204
+ hook = new P12MockHook(hookOwner, PROJECT_ID, store);
205
+ publisher = new CTPublisher(
206
+ IJBDirectory(address(directory)), IJBPermissions(address(permissions)), FEE_PROJECT_ID, address(0)
207
+ );
208
+
209
+ vm.deal(poster, 100 ether);
210
+
211
+ // Configure a category that allows posting.
212
+ CTAllowedPost[] memory allowedPosts = new CTAllowedPost[](1);
213
+ allowedPosts[0] = CTAllowedPost({
214
+ hook: address(hook),
215
+ category: 7,
216
+ minimumPrice: 1 ether,
217
+ minimumTotalSupply: 1,
218
+ maximumTotalSupply: 100,
219
+ maximumSplitPercent: 0,
220
+ allowedAddresses: new address[](0)
221
+ });
222
+
223
+ vm.prank(hookOwner);
224
+ publisher.configurePostingCriteriaFor(allowedPosts);
225
+ }
226
+
227
+ // -----------------------------------------------------------------------
228
+ // H-26: Metadata shadow — duplicate pay ID in additionalPayMetadata
229
+ // -----------------------------------------------------------------------
230
+
231
+ /// @notice When additionalPayMetadata already contains an entry for the pay ID,
232
+ /// the fix should revert with CTPublisher_DuplicatePayMetadata.
233
+ function test_H26_fix_reverts_duplicate_metadata() public {
234
+ CTPost[] memory posts = new CTPost[](1);
235
+ posts[0] = CTPost({
236
+ encodedIPFSUri: URI_A,
237
+ totalSupply: 10,
238
+ price: 1 ether,
239
+ category: 7,
240
+ splitPercent: 0,
241
+ splits: new JBSplit[](0)
242
+ });
243
+
244
+ // Build metadata that already contains the pay ID for this hook.
245
+ address metadataIdTarget = address(hook); // hook.METADATA_ID_TARGET() returns address(hook)
246
+ uint16[] memory forgedTierIds = new uint16[](1);
247
+ forgedTierIds[0] = 999; // Attacker's desired tier
248
+ bytes4[] memory ids = new bytes4[](1);
249
+ bytes[] memory datas = new bytes[](1);
250
+ ids[0] = JBMetadataResolver.getId({purpose: "pay", target: metadataIdTarget});
251
+ datas[0] = abi.encode(true, forgedTierIds);
252
+ bytes memory shadowingMetadata = JBMetadataResolver.createMetadata(ids, datas);
253
+
254
+ vm.prank(poster);
255
+ vm.expectRevert(CTPublisher.CTPublisher_DuplicatePayMetadata.selector);
256
+ publisher.mintFrom{value: 1.05 ether}(
257
+ IJB721TiersHook(address(hook)), posts, poster, poster, shadowingMetadata, ""
258
+ );
259
+ }
260
+
261
+ /// @notice Empty additionalPayMetadata should NOT revert.
262
+ function test_H26_fix_allows_empty_metadata() public {
263
+ CTPost[] memory posts = new CTPost[](1);
264
+ posts[0] = CTPost({
265
+ encodedIPFSUri: URI_A,
266
+ totalSupply: 10,
267
+ price: 1 ether,
268
+ category: 7,
269
+ splitPercent: 0,
270
+ splits: new JBSplit[](0)
271
+ });
272
+
273
+ // Empty metadata — should succeed.
274
+ vm.prank(poster);
275
+ publisher.mintFrom{value: 1.05 ether}(IJB721TiersHook(address(hook)), posts, poster, poster, "", "");
276
+
277
+ assertEq(
278
+ publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "tier should be created with empty metadata"
279
+ );
280
+ }
281
+
282
+ /// @notice additionalPayMetadata with a DIFFERENT ID (not the pay ID) should NOT revert.
283
+ function test_H26_fix_allows_unrelated_metadata() public {
284
+ CTPost[] memory posts = new CTPost[](1);
285
+ posts[0] = CTPost({
286
+ encodedIPFSUri: URI_A,
287
+ totalSupply: 10,
288
+ price: 1 ether,
289
+ category: 7,
290
+ splitPercent: 0,
291
+ splits: new JBSplit[](0)
292
+ });
293
+
294
+ // Build metadata with a different purpose — should NOT trigger the check.
295
+ bytes4[] memory ids = new bytes4[](1);
296
+ bytes[] memory datas = new bytes[](1);
297
+ ids[0] = JBMetadataResolver.getId({purpose: "unrelated", target: address(hook)});
298
+ datas[0] = abi.encode(uint256(42));
299
+ bytes memory unrelatedMetadata = JBMetadataResolver.createMetadata(ids, datas);
300
+
301
+ vm.prank(poster);
302
+ publisher.mintFrom{value: 1.05 ether}(
303
+ IJB721TiersHook(address(hook)), posts, poster, poster, unrelatedMetadata, ""
304
+ );
305
+
306
+ assertEq(
307
+ publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A),
308
+ 1,
309
+ "tier should be created with unrelated metadata"
310
+ );
311
+ }
312
+
313
+ // -----------------------------------------------------------------------
314
+ // M-42: URI cache desync — tier URI changed via setMetadata
315
+ // -----------------------------------------------------------------------
316
+
317
+ /// @notice When a tier's URI is changed via setMetadata, the cache entry
318
+ /// (old URI -> tier ID) becomes stale. The fix should detect
319
+ /// the mismatch and clear the cache, creating a new tier.
320
+ function test_M42_fix_clears_stale_cache() public {
321
+ // Step 1: Publish URI_A — creates tier 1.
322
+ _publish(URI_A);
323
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "URI_A cached as tier 1");
324
+
325
+ // Step 2: Owner changes tier 1's URI from URI_A to URI_B via setMetadata.
326
+ vm.prank(hookOwner);
327
+ hook.setMetadata("", "", "", "", address(this), 1, URI_B);
328
+
329
+ // The publisher cache still maps URI_A -> tier 1, but tier 1 now has URI_B.
330
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "stale cache still maps URI_A -> tier 1");
331
+
332
+ // Step 3: Try to publish URI_A again. The fix should detect the mismatch
333
+ // (tier 1's actual URI is URI_B, not URI_A), clear the stale cache, and
334
+ // create a new tier 2 for URI_A.
335
+ _publish(URI_A);
336
+
337
+ assertEq(store.maxTierId(), 2, "new tier should be created for URI_A after cache invalidation");
338
+ assertEq(
339
+ publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 2, "URI_A should now map to tier 2 (fresh tier)"
340
+ );
341
+ }
342
+
343
+ /// @notice When the tier was removed, the cache should still be cleared (existing behavior preserved).
344
+ function test_M42_fix_still_handles_removed_tiers() public {
345
+ // Use vm.mockCall to simulate isTierRemoved returning true for tier 1.
346
+ _publish(URI_A);
347
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "URI_A cached as tier 1");
348
+
349
+ // Mock isTierRemoved to return true for tier 1.
350
+ vm.mockCall(
351
+ address(store),
352
+ abi.encodeWithSelector(IJB721TiersHookStore.isTierRemoved.selector, address(hook), uint256(1)),
353
+ abi.encode(true)
354
+ );
355
+
356
+ // Publish URI_A again — should clear stale mapping and create tier 2.
357
+ _publish(URI_A);
358
+
359
+ assertEq(store.maxTierId(), 2, "new tier should be created after tier removal");
360
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 2, "URI_A should map to new tier 2");
361
+ }
362
+
363
+ /// @notice When a cached tier's URI still matches, it should be reused (no regression).
364
+ function test_M42_fix_reuses_valid_cache() public {
365
+ // Publish URI_A — creates tier 1.
366
+ _publish(URI_A);
367
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "URI_A cached as tier 1");
368
+
369
+ // Publish URI_A again — URI still matches, should reuse tier 1 (no new tier created).
370
+ _publish(URI_A);
371
+ assertEq(store.maxTierId(), 1, "no new tier should be created for matching URI");
372
+ assertEq(publisher.tierIdForEncodedIPFSUriOf(address(hook), URI_A), 1, "URI_A still maps to tier 1");
373
+ }
374
+
375
+ // -----------------------------------------------------------------------
376
+ // Helper
377
+ // -----------------------------------------------------------------------
378
+
379
+ function _publish(bytes32 uri) internal {
380
+ CTPost[] memory posts = new CTPost[](1);
381
+ posts[0] = CTPost({
382
+ encodedIPFSUri: uri, totalSupply: 10, price: 1 ether, category: 7, splitPercent: 0, splits: new JBSplit[](0)
383
+ });
384
+
385
+ vm.prank(poster);
386
+ publisher.mintFrom{value: 1.05 ether}(IJB721TiersHook(address(hook)), posts, poster, poster, "", "");
387
+ }
388
+ }
@@ -129,8 +129,8 @@ contract PublishForkTest is Test, DeployPermit2 {
129
129
  // ─────────────────────────────────────
130
130
 
131
131
  function setUp() public {
132
- // Fork ETH mainnet.
133
- vm.createSelectFork("ethereum");
132
+ // Fork ETH mainnet at a pinned block to avoid RPC tip-of-chain flakiness.
133
+ vm.createSelectFork("ethereum", 24_960_000);
134
134
 
135
135
  // Deploy all JB core contracts fresh within the fork.
136
136
  _deployJBCore();
@@ -129,6 +129,16 @@ contract L52_StaleTierIdMapping is Test {
129
129
  abi.encode(true)
130
130
  );
131
131
 
132
+ // Mock tierOf for the removed tier — the M-42 fix calls tierOf before checking isTierRemoved.
133
+ JB721Tier memory removedTier;
134
+ removedTier.id = 1;
135
+ removedTier.encodedIPFSUri = TEST_URI;
136
+ vm.mockCall(
137
+ hookStoreAddr,
138
+ abi.encodeWithSelector(IJB721TiersHookStore.tierOf.selector, hookAddr, 1, false),
139
+ abi.encode(removedTier)
140
+ );
141
+
132
142
  // Update maxTierId to 1 so new tier gets ID 2.
133
143
  _setupMintMocks(1);
134
144