@bannynet/core-v6 0.0.18 → 0.0.19

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": "@bannynet/core-v6",
3
- "version": "0.0.18",
3
+ "version": "0.0.19",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,13 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@bananapus/721-hook-v6": "^0.0.30",
24
- "@bananapus/core-v6": "^0.0.28",
25
- "@bananapus/permission-ids-v6": "^0.0.14",
26
- "@bananapus/router-terminal-v6": "^0.0.21",
27
- "@bananapus/suckers-v6": "^0.0.18",
28
- "@croptop/core-v6": "^0.0.28",
24
+ "@bananapus/core-v6": "^0.0.31",
25
+ "@bananapus/permission-ids-v6": "^0.0.15",
26
+ "@bananapus/router-terminal-v6": "^0.0.25",
27
+ "@bananapus/suckers-v6": "^0.0.20",
28
+ "@croptop/core-v6": "^0.0.29",
29
29
  "@openzeppelin/contracts": "^5.6.1",
30
- "@rev-net/core-v6": "^0.0.24",
30
+ "@rev-net/core-v6": "^0.0.26",
31
31
  "keccak": "^3.0.4"
32
32
  },
33
33
  "devDependencies": {
@@ -748,6 +748,16 @@ contract Banny721TokenUriResolver is
748
748
  name = string.concat(name, "UPC #", uint256(product.id).toString());
749
749
  }
750
750
 
751
+ /// @notice Check if a value is present in an array.
752
+ /// @param value The value to search for.
753
+ /// @param array The array to search in.
754
+ /// @return found True if the value was found.
755
+ function _isInArray(uint256 value, uint256[] memory array) internal pure returns (bool found) {
756
+ for (uint256 i; i < array.length; i++) {
757
+ if (array[i] == value) return true;
758
+ }
759
+ }
760
+
751
761
  /// @notice Returns the standard dimension SVG containing dynamic contents and SVG metadata.
752
762
  /// @param contents The contents of the SVG
753
763
  /// @return svg The SVG contents.
@@ -923,6 +933,52 @@ contract Banny721TokenUriResolver is
923
933
  return _storeOf(hook).tierOfTokenId({hook: hook, tokenId: tokenId, includeResolvedUri: false});
924
934
  }
925
935
 
936
+ /// @notice Revert if an equipped asset is being reassigned away from a locked source body.
937
+ /// @param hook The hook storing the assets.
938
+ /// @param bannyBodyId The body currently using the asset.
939
+ /// @param exemptBodyId The destination body currently being decorated.
940
+ function _revertIfBodyLocked(address hook, uint256 bannyBodyId, uint256 exemptBodyId) internal view {
941
+ if (bannyBodyId != 0 && bannyBodyId != exemptBodyId && outfitLockedUntil[hook][bannyBodyId] > block.timestamp) {
942
+ revert Banny721TokenUriResolver_OutfitChangesLocked();
943
+ }
944
+ }
945
+
946
+ /// @notice Sort outfit IDs in ascending category order.
947
+ /// @dev Retained outfits are merged back in after failed transfers, so the merged list can become unsorted even
948
+ /// when the caller provided the new outfits in canonical order. Rendering logic depends on category progression.
949
+ /// @param hook The 721 hook whose tier categories determine sort order.
950
+ /// @param outfitIds The outfit token IDs to sort in-place by ascending category.
951
+ function _sortOutfitsByCategory(address hook, uint256[] memory outfitIds) internal view {
952
+ // Treat each element after index 0 as the next value to insert into the already-sorted prefix.
953
+ for (uint256 i = 1; i < outfitIds.length; i++) {
954
+ // Cache the current outfit ID so we can shift larger-category entries to the right around it.
955
+ uint256 outfitId = outfitIds[i];
956
+ // Look up the current outfit's category once and compare against earlier entries while inserting it.
957
+ uint256 category = _productOfTokenId({hook: hook, tokenId: outfitId}).category;
958
+ // Walk backward through the sorted prefix until the insertion point is found.
959
+ uint256 j = i;
960
+
961
+ while (j != 0) {
962
+ // Load the previous outfit that may need to move one slot to the right.
963
+ uint256 previousId = outfitIds[j - 1];
964
+ // Compare by category because canonical outfit ordering is category order, not token ID order.
965
+ uint256 previousCategory = _productOfTokenId({hook: hook, tokenId: previousId}).category;
966
+ // Stop once the previous category is already ordered before or equal to the current one.
967
+ if (previousCategory <= category) break;
968
+
969
+ // Shift the larger-category outfit right to make room for the current outfit.
970
+ outfitIds[j] = previousId;
971
+ unchecked {
972
+ // Safe because the loop guard ensures `j` is non-zero before decrementing.
973
+ --j;
974
+ }
975
+ }
976
+
977
+ // Write the cached outfit into the insertion point that preserves ascending category order.
978
+ outfitIds[j] = outfitId;
979
+ }
980
+ }
981
+
926
982
  /// @notice The store of the hook.
927
983
  /// @param hook The hook to get the store of.
928
984
  /// @return store The store of the hook.
@@ -948,6 +1004,48 @@ contract Banny721TokenUriResolver is
948
1004
  );
949
1005
  }
950
1006
 
1007
+ /// @notice Validate that an array of outfit IDs does not violate category exclusivity rules.
1008
+ /// @dev HEAD is exclusive with EYES, GLASSES, MOUTH, and HEADTOP. SUIT is exclusive with SUIT_TOP and
1009
+ /// SUIT_BOTTOM. The array does not need to be sorted.
1010
+ /// @param hook The hook storing the assets.
1011
+ /// @param outfitIds The outfit IDs to validate.
1012
+ function _validateCategoryExclusivity(address hook, uint256[] memory outfitIds) internal view {
1013
+ // Track whether a full-coverage item has been seen for each exclusive group.
1014
+ bool hasHead;
1015
+ bool hasSuit;
1016
+ // Track whether a partial/accessory item has been seen for each exclusive group.
1017
+ bool hasHeadAccessory;
1018
+ bool hasSuitPiece;
1019
+
1020
+ // Scan every outfit and classify it into one of the two exclusive groups.
1021
+ for (uint256 i; i < outfitIds.length; i++) {
1022
+ // Look up the tier category for this outfit token.
1023
+ uint256 category = _productOfTokenId({hook: hook, tokenId: outfitIds[i]}).category;
1024
+
1025
+ if (category == _HEAD_CATEGORY) {
1026
+ // A full HEAD covers the entire head area (eyes, glasses, mouth, headtop).
1027
+ hasHead = true;
1028
+ } else if (
1029
+ category == _EYES_CATEGORY || category == _GLASSES_CATEGORY || category == _MOUTH_CATEGORY
1030
+ || category == _HEADTOP_CATEGORY
1031
+ ) {
1032
+ // Individual face/head accessories that would be hidden by a full HEAD.
1033
+ hasHeadAccessory = true;
1034
+ } else if (category == _SUIT_CATEGORY) {
1035
+ // A full SUIT covers both the top and bottom body areas.
1036
+ hasSuit = true;
1037
+ } else if (category == _SUIT_TOP_CATEGORY || category == _SUIT_BOTTOM_CATEGORY) {
1038
+ // Individual top or bottom pieces that would conflict with a full SUIT.
1039
+ hasSuitPiece = true;
1040
+ }
1041
+ }
1042
+
1043
+ // A full HEAD and individual head accessories cannot coexist — the head would hide the accessories.
1044
+ if (hasHead && hasHeadAccessory) revert Banny721TokenUriResolver_HeadAlreadyAdded();
1045
+ // A full SUIT and individual suit pieces cannot coexist — the suit would hide the pieces.
1046
+ if (hasSuit && hasSuitPiece) revert Banny721TokenUriResolver_SuitAlreadyAdded();
1047
+ }
1048
+
951
1049
  //*********************************************************************//
952
1050
  // ---------------------- external transactions ---------------------- //
953
1051
  //*********************************************************************//
@@ -1156,16 +1254,6 @@ contract Banny721TokenUriResolver is
1156
1254
  // ---------------------- internal transactions ---------------------- //
1157
1255
  //*********************************************************************//
1158
1256
 
1159
- /// @notice Revert if an equipped asset is being reassigned away from a locked source body.
1160
- /// @param hook The hook storing the assets.
1161
- /// @param bannyBodyId The body currently using the asset.
1162
- /// @param exemptBodyId The destination body currently being decorated.
1163
- function _revertIfBodyLocked(address hook, uint256 bannyBodyId, uint256 exemptBodyId) internal view {
1164
- if (bannyBodyId != 0 && bannyBodyId != exemptBodyId && outfitLockedUntil[hook][bannyBodyId] > block.timestamp) {
1165
- revert Banny721TokenUriResolver_OutfitChangesLocked();
1166
- }
1167
- }
1168
-
1169
1257
  /// @notice Add a background to a banny body.
1170
1258
  /// @param hook The hook storing the assets.
1171
1259
  /// @param bannyBodyId The ID of the banny body being dressed.
@@ -1464,53 +1552,15 @@ contract Banny721TokenUriResolver is
1464
1552
  // Revalidate category exclusivity on the merged set. Retained outfits may conflict with the new outfits
1465
1553
  // (e.g., a retained HEAD outfit combined with new EYES/GLASSES/MOUTH/HEADTOP outfits).
1466
1554
  _validateCategoryExclusivity({hook: hook, outfitIds: mergedOutfitIds});
1555
+ // Restore canonical category ordering before persisting, since retained outfits are appended after the
1556
+ // caller-supplied set.
1557
+ _sortOutfitsByCategory({hook: hook, outfitIds: mergedOutfitIds});
1467
1558
 
1559
+ // Persist the merged-and-sorted attachment list so later reads and redecorations see a stable order.
1468
1560
  _attachedOutfitIdsOf[hook][bannyBodyId] = mergedOutfitIds;
1469
1561
  }
1470
1562
  }
1471
1563
 
1472
- /// @notice Validate that an array of outfit IDs does not violate category exclusivity rules.
1473
- /// @dev HEAD is exclusive with EYES, GLASSES, MOUTH, and HEADTOP. SUIT is exclusive with SUIT_TOP and
1474
- /// SUIT_BOTTOM. The array does not need to be sorted.
1475
- /// @param hook The hook storing the assets.
1476
- /// @param outfitIds The outfit IDs to validate.
1477
- function _validateCategoryExclusivity(address hook, uint256[] memory outfitIds) internal view {
1478
- bool hasHead;
1479
- bool hasSuit;
1480
- bool hasHeadAccessory;
1481
- bool hasSuitPiece;
1482
-
1483
- for (uint256 i; i < outfitIds.length; i++) {
1484
- uint256 category = _productOfTokenId({hook: hook, tokenId: outfitIds[i]}).category;
1485
-
1486
- if (category == _HEAD_CATEGORY) {
1487
- hasHead = true;
1488
- } else if (
1489
- category == _EYES_CATEGORY || category == _GLASSES_CATEGORY || category == _MOUTH_CATEGORY
1490
- || category == _HEADTOP_CATEGORY
1491
- ) {
1492
- hasHeadAccessory = true;
1493
- } else if (category == _SUIT_CATEGORY) {
1494
- hasSuit = true;
1495
- } else if (category == _SUIT_TOP_CATEGORY || category == _SUIT_BOTTOM_CATEGORY) {
1496
- hasSuitPiece = true;
1497
- }
1498
- }
1499
-
1500
- if (hasHead && hasHeadAccessory) revert Banny721TokenUriResolver_HeadAlreadyAdded();
1501
- if (hasSuit && hasSuitPiece) revert Banny721TokenUriResolver_SuitAlreadyAdded();
1502
- }
1503
-
1504
- /// @notice Check if a value is present in an array.
1505
- /// @param value The value to search for.
1506
- /// @param array The array to search in.
1507
- /// @return found True if the value was found.
1508
- function _isInArray(uint256 value, uint256[] memory array) internal pure returns (bool found) {
1509
- for (uint256 i; i < array.length; i++) {
1510
- if (array[i] == value) return true;
1511
- }
1512
- }
1513
-
1514
1564
  /// @notice Transfer a token from one address to another.
1515
1565
  /// @param hook The 721 contract of the token being transferred.
1516
1566
  /// @param from The address to transfer the token from.
@@ -265,6 +265,31 @@ contract AntiStrandingRetentionTest is Test {
265
265
  assertEq(resolver.wearerOf(address(hook), HEAD_TOKEN), BODY_TOKEN, "head still worn");
266
266
  }
267
267
 
268
+ // -----------------------------------------------------------------------
269
+ // Test 4b: Retained outfits are re-sorted before being stored
270
+ // -----------------------------------------------------------------------
271
+ function test_retainedOutfitsAreResortedBeforeStorage() public {
272
+ // Give the rejecting contract ownership so returns to the owner will fail and force retention.
273
+ _setOwnerForAll(address(nonReceiver));
274
+ nonReceiver.approveResolver(hook, address(resolver));
275
+
276
+ // Equip a lower-category outfit first so it becomes the retained item in the next decoration.
277
+ uint256[] memory necklaceOnly = new uint256[](1);
278
+ necklaceOnly[0] = NECKLACE_TOKEN;
279
+ nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, necklaceOnly);
280
+
281
+ // Add a higher-category outfit while the necklace return fails, forcing the stored list to be merged.
282
+ uint256[] memory headOnly = new uint256[](1);
283
+ headOnly[0] = HEAD_TOKEN;
284
+ nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, headOnly);
285
+
286
+ // The merged list should be stored in ascending category order, not append order.
287
+ (, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
288
+ assertEq(currentOutfits.length, 2, "both outfits should remain attached");
289
+ assertEq(currentOutfits[0], NECKLACE_TOKEN, "lower-category retained outfit should stay first");
290
+ assertEq(currentOutfits[1], HEAD_TOKEN, "higher-category new outfit should stay second");
291
+ }
292
+
268
293
  // -----------------------------------------------------------------------
269
294
  // Test 5: Recovery path --make contract receivable, retry decoration
270
295
  // -----------------------------------------------------------------------
@@ -72,6 +72,7 @@ contract MockBurnableStore {
72
72
  return tiers[hook][tokenId];
73
73
  }
74
74
 
75
+ // forge-lint: disable-next-line(mixed-case-function)
75
76
  function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
76
77
  return bytes32(0);
77
78
  }
@@ -0,0 +1,102 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+
6
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v6/src/interfaces/IJB721TiersHookStore.sol";
7
+
8
+ import {MigrationHelper} from "../../script/helpers/MigrationHelper.sol";
9
+
10
+ contract MigrationHelperVerificationBypassTest is Test {
11
+ address internal constant ALICE = address(0xA11CE);
12
+ address internal constant FALLBACK_RESOLVER = address(0xFA11BAC);
13
+
14
+ MockStore internal v4Store;
15
+ MockStore internal v5Store;
16
+ MockHook internal v4Hook;
17
+ MockHook internal v5Hook;
18
+ MigrationHelperHarness internal harness;
19
+
20
+ function setUp() public {
21
+ v4Store = new MockStore();
22
+ v5Store = new MockStore();
23
+ v4Hook = new MockHook(address(v4Store));
24
+ v5Hook = new MockHook(address(v5Store));
25
+ harness = new MigrationHelperHarness();
26
+ }
27
+
28
+ function test_verifyTierBalances_skipsAllOwnersForTierWhenFallbackResolverOwnsAnyOfTier() public {
29
+ address[] memory owners = new address[](1);
30
+ owners[0] = ALICE;
31
+
32
+ uint256[] memory tierIds = new uint256[](1);
33
+ tierIds[0] = 7;
34
+
35
+ // Alice is over-allocated in V5 versus V4 for tier 7.
36
+ v4Store.setTierBalance(address(v4Hook), ALICE, 7, 1);
37
+ v5Store.setTierBalance(address(v5Hook), ALICE, 7, 2);
38
+
39
+ // One unrelated V4 token of the same tier sits in the fallback resolver.
40
+ v4Store.setTierBalance(address(v4Hook), FALLBACK_RESOLVER, 7, 1);
41
+
42
+ // Intended behavior would reject Alice's inflation, but the helper skips the tier entirely.
43
+ harness.verifyTierBalances(address(v5Hook), address(v4Hook), FALLBACK_RESOLVER, owners, tierIds);
44
+ }
45
+
46
+ function test_verifyTierBalances_revertsWhenFallbackResolverDoesNotOwnTier() public {
47
+ address[] memory owners = new address[](1);
48
+ owners[0] = ALICE;
49
+
50
+ uint256[] memory tierIds = new uint256[](1);
51
+ tierIds[0] = 7;
52
+
53
+ v4Store.setTierBalance(address(v4Hook), ALICE, 7, 1);
54
+ v5Store.setTierBalance(address(v5Hook), ALICE, 7, 2);
55
+
56
+ vm.expectRevert(
57
+ bytes(
58
+ "V5 tier balance exceeds V4: owner=0x00000000000000000000000000000000000a11ce tier=7 v4Balance=1 v5Balance=2"
59
+ )
60
+ );
61
+ harness.verifyTierBalances(address(v5Hook), address(v4Hook), FALLBACK_RESOLVER, owners, tierIds);
62
+ }
63
+ }
64
+
65
+ contract MigrationHelperHarness {
66
+ function verifyTierBalances(
67
+ address hookAddress,
68
+ address v4HookAddress,
69
+ address v4FallbackResolverAddress,
70
+ address[] memory owners,
71
+ uint256[] memory tierIds
72
+ )
73
+ external
74
+ view
75
+ {
76
+ MigrationHelper.verifyTierBalances(hookAddress, v4HookAddress, v4FallbackResolverAddress, owners, tierIds);
77
+ }
78
+ }
79
+
80
+ contract MockHook {
81
+ address internal immutable _store;
82
+
83
+ constructor(address store) {
84
+ _store = store;
85
+ }
86
+
87
+ function STORE() external view returns (IJB721TiersHookStore) {
88
+ return IJB721TiersHookStore(_store);
89
+ }
90
+ }
91
+
92
+ contract MockStore {
93
+ mapping(address hook => mapping(address owner => mapping(uint256 tierId => uint256))) internal _tierBalanceOf;
94
+
95
+ function setTierBalance(address hook, address owner, uint256 tierId, uint256 balance) external {
96
+ _tierBalanceOf[hook][owner][tierId] = balance;
97
+ }
98
+
99
+ function tierBalanceOf(address hook, address owner, uint256 tierId) external view returns (uint256) {
100
+ return _tierBalanceOf[hook][owner][tierId];
101
+ }
102
+ }
@@ -1,34 +0,0 @@
1
- # 🔐 Security Review — banny-retail-v6
2
-
3
- ---
4
-
5
- ## Scope
6
-
7
- | | |
8
- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
9
- | **Mode** | ALL / default |
10
- | **Files reviewed** | `Add.Denver.s.sol` · `Deploy.s.sol` · `Drop1.s.sol`<br>`BannyverseDeploymentLib.sol` · `MigrationHelper.sol` · `Banny721TokenUriResolver.sol` |
11
- | **Confidence threshold (1-100)** | 75 |
12
-
13
- ---
14
-
15
- ## Findings
16
-
17
- _No confirmed findings._
18
-
19
- ---
20
-
21
- Findings List
22
-
23
- | # | Confidence | Title |
24
- |---|---|---|
25
-
26
- ---
27
-
28
- ## Leads
29
-
30
- _None._
31
-
32
- ---
33
-
34
- > ⚠️ This review was performed by an AI assistant. AI analysis can never verify the complete absence of vulnerabilities and no guarantee of security is given. Team security reviews, bug bounty programs, and on-chain monitoring are strongly recommended. For a consultation regarding your projects' security, visit [https://www.pashov.com](https://www.pashov.com)