@bannynet/core-v6 0.0.20 → 0.0.21

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/CHANGELOG.md CHANGED
@@ -23,6 +23,8 @@ This file describes the verified change from `banny-retail-v5` to the current `b
23
23
  - `pricingContext()` consumption changed with the v6 721 hook and now uses the two-value return shape.
24
24
  - The resolver adds explicit `Banny721TokenUriResolver_ArrayLengthMismatch()` and `Banny721TokenUriResolver_BannyBodyNotBodyCategory()` errors.
25
25
  - Outfit and background handling now includes logic intended to preserve attachment state when a previously equipped asset cannot be returned cleanly.
26
+ - (L-1) `_storeOutfitsWithRetained` now verifies that no two merged outfits share the same category after sorting. A retained outfit whose transfer failed could previously duplicate a category supplied in the new outfit set, leading to rendering artifacts. The new `Banny721TokenUriResolver_DuplicateCategory()` error prevents this.
27
+ - Gas optimizations: all `for` loops use `unchecked { ++i; }` increments, `_sortOutfitsByCategory` pre-computes categories to avoid repeated external calls during sort comparisons, `_msgSender()` is cached once per entry point to avoid repeated ERC-2771 context reads, and the mannequin SVG style string is inlined to remove redundant `string.concat` overhead.
26
28
 
27
29
  ## Migration notes
28
30
 
package/RISKS.md CHANGED
@@ -54,7 +54,7 @@ This file focuses on failure modes that can break NFT custody, let untrusted hoo
54
54
  - Every background held by this contract has a corresponding `_userOf[hook][backgroundId]` pointing to a valid banny body.
55
55
  - `outfitLockedUntil[hook][bannyBodyId]` is monotonically non-decreasing per banny body (lock can only be extended, never shortened).
56
56
  - After `decorateBannyWith`, all previously equipped outfits not in the new set are either transferred back to `_msgSender()` or retained in the attached list if the transfer failed.
57
- - `_attachedOutfitIdsOf[hook][bannyBodyId]` contains the outfitIds passed to the most recent `decorateBannyWith` call, plus any retained outfits whose return transfer failed. Category exclusivity is enforced on the merged set (retained + new outfits), not just the new outfit set alone.
57
+ - `_attachedOutfitIdsOf[hook][bannyBodyId]` contains the outfitIds passed to the most recent `decorateBannyWith` call, plus any retained outfits whose return transfer failed. Category exclusivity is enforced on the merged set (retained + new outfits), not just the new outfit set alone. Additionally, duplicate categories in the merged set are rejected with `Banny721TokenUriResolver_DuplicateCategory()` to prevent retained outfits from silently duplicating a category supplied in the new set.
58
58
  - SVG content integrity: `keccak256(_svgContentOf[upc]) == svgHashOf[upc]` for all populated entries.
59
59
  - NFT custody balance: the number of outfit NFTs held by this contract (`IERC721(hook).balanceOf(address(this))`) equals the total number of outfits currently equipped across all banny bodies for that hook. Violations indicate phantom outfits (equipped in state but NFT lost via try-catch silent failure) or orphaned NFTs (held by contract but not tracked in `_wearerOf`).
60
60
 
@@ -66,7 +66,7 @@ This file focuses on failure modes that can break NFT custody, let untrusted hoo
66
66
 
67
67
  - **Backgrounds**: If returning the old background fails, the entire background change is aborted (`return` in `_decorateBannyWithBackground`). The old background stays attached and the new one is not equipped.
68
68
  - **Background removal**: If returning the background fails during removal (backgroundId=0), `_attachedBackgroundIdOf` is not cleared. The background stays attached.
69
- - **Outfits**: Failed-to-return outfits remain non-zero in the `previousOutfitIds` array. `_storeOutfitsWithRetained` appends them to the new outfit list, preserving their attachment record.
69
+ - **Outfits**: Failed-to-return outfits remain non-zero in the `previousOutfitIds` array. `_storeOutfitsWithRetained` appends them to the new outfit list, preserving their attachment record. After merging, the resolver verifies no two outfits share the same category (reverts with `DuplicateCategory` if a retained outfit conflicts with a newly supplied one).
70
70
 
71
71
  This prevents NFT stranding — assets held by the resolver stay tracked and recoverable. Once the transfer issue is resolved (e.g., the owner contract implements `IERC721Receiver`), a subsequent `decorateBannyWith` call will successfully return the retained items.
72
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bannynet/core-v6",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,18 +20,18 @@
20
20
  "artifacts": "source ./.env && npx sphinx artifacts --org-id 'ea165b21-7cdc-4d7b-be59-ecdd4c26bee4' --project-name 'banny-core-v6'"
21
21
  },
22
22
  "dependencies": {
23
- "@bananapus/721-hook-v6": "^0.0.31",
24
- "@bananapus/core-v6": "^0.0.31",
23
+ "@bananapus/721-hook-v6": "^0.0.32",
24
+ "@bananapus/core-v6": "^0.0.32",
25
25
  "@bananapus/permission-ids-v6": "^0.0.15",
26
- "@bananapus/router-terminal-v6": "^0.0.25",
27
- "@bananapus/suckers-v6": "^0.0.21",
28
- "@croptop/core-v6": "^0.0.30",
26
+ "@bananapus/router-terminal-v6": "^0.0.26",
27
+ "@bananapus/suckers-v6": "^0.0.22",
28
+ "@croptop/core-v6": "^0.0.31",
29
29
  "@openzeppelin/contracts": "^5.6.1",
30
- "@rev-net/core-v6": "^0.0.28",
30
+ "@rev-net/core-v6": "^0.0.29",
31
31
  "keccak": "^3.0.4"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@bananapus/address-registry-v6": "^0.0.17",
35
- "@sphinx-labs/plugins": "^0.33.2"
35
+ "@sphinx-labs/plugins": "^0.33.3"
36
36
  }
37
37
  }
@@ -37,6 +37,7 @@ contract Banny721TokenUriResolver is
37
37
  error Banny721TokenUriResolver_CantAccelerateTheLock();
38
38
  error Banny721TokenUriResolver_ContentsAlreadyStored();
39
39
  error Banny721TokenUriResolver_ContentsMismatch();
40
+ error Banny721TokenUriResolver_DuplicateCategory();
40
41
  error Banny721TokenUriResolver_HashAlreadyStored();
41
42
  error Banny721TokenUriResolver_HashNotFound();
42
43
  error Banny721TokenUriResolver_HeadAlreadyAdded();
@@ -197,6 +198,7 @@ contract Banny721TokenUriResolver is
197
198
  //*********************************************************************//
198
199
 
199
200
  /// @notice Returns the SVG showing a dressed banny body in a background.
201
+ /// @param hook The hook storing the assets.
200
202
  /// @param tokenId The ID of the token to show. If the ID belongs to a banny body, it will be shown with its
201
203
  /// current outfits in its current background.
202
204
  /// @return tokenUri The URI representing the SVG.
@@ -250,18 +252,21 @@ contract Banny721TokenUriResolver is
250
252
 
251
253
  extraMetadata = '"outfitIds": [';
252
254
 
253
- for (uint256 i; i < outfitIds.length; i++) {
255
+ for (uint256 i; i < outfitIds.length;) {
254
256
  extraMetadata = string.concat(extraMetadata, outfitIds[i].toString());
255
257
 
256
258
  // Add a comma if it's not the last outfit.
257
259
  if (i < outfitIds.length - 1) {
258
260
  extraMetadata = string.concat(extraMetadata, ",");
259
261
  }
262
+ unchecked {
263
+ ++i;
264
+ }
260
265
  }
261
266
 
262
267
  extraMetadata = string.concat(extraMetadata, "],");
263
268
 
264
- for (uint256 i; i < outfitIds.length; i++) {
269
+ for (uint256 i; i < outfitIds.length;) {
265
270
  JB721Tier memory outfitProduct = _productOfTokenId({hook: hook, tokenId: outfitIds[i]});
266
271
 
267
272
  attributes = string.concat(
@@ -272,6 +277,9 @@ contract Banny721TokenUriResolver is
272
277
  _productNameOf(outfitProduct.id),
273
278
  '"},'
274
279
  );
280
+ unchecked {
281
+ ++i;
282
+ }
275
283
  }
276
284
 
277
285
  if (backgroundId != 0) {
@@ -352,7 +360,7 @@ contract Banny721TokenUriResolver is
352
360
  //*********************************************************************//
353
361
 
354
362
  /// @notice The assets currently attached to each banny body.
355
- /// @custom:param hook The hook address of the collection.
363
+ /// @param hook The hook address of the collection.
356
364
  /// @param bannyBodyId The ID of the banny body shown with the associated assets.
357
365
  /// @return backgroundId The background attached to the banny body.
358
366
  /// @return outfitIds The outfits attached to the banny body.
@@ -378,7 +386,7 @@ contract Banny721TokenUriResolver is
378
386
  uint256 storedOutfitId;
379
387
 
380
388
  // Return the outfit's that are still being worn by the banny body.
381
- for (uint256 i; i < storedOutfitIds.length; i++) {
389
+ for (uint256 i; i < storedOutfitIds.length;) {
382
390
  // Set the stored outfit ID being iterated on.
383
391
  storedOutfitId = storedOutfitIds[i];
384
392
 
@@ -386,6 +394,9 @@ contract Banny721TokenUriResolver is
386
394
  if (wearerOf({hook: hook, outfitId: storedOutfitId}) == bannyBodyId) {
387
395
  outfitIds[numberOfIncludedOutfits++] = storedOutfitId;
388
396
  }
397
+ unchecked {
398
+ ++i;
399
+ }
389
400
  }
390
401
 
391
402
  // Resize the array to the actual number of included outfits (remove trailing zeros).
@@ -519,9 +530,12 @@ contract Banny721TokenUriResolver is
519
530
  // Keep a reference to the outfit IDs currently attached to a banny body.
520
531
  uint256[] memory attachedOutfitIds = _attachedOutfitIdsOf[hook][bannyBodyId];
521
532
 
522
- for (uint256 i; i < attachedOutfitIds.length; i++) {
533
+ for (uint256 i; i < attachedOutfitIds.length;) {
523
534
  // If the outfit is still attached, return the banny body ID.
524
535
  if (attachedOutfitIds[i] == outfitId) return bannyBodyId;
536
+ unchecked {
537
+ ++i;
538
+ }
525
539
  }
526
540
 
527
541
  // If the outfit is no longer attached, return 0.
@@ -616,7 +630,9 @@ contract Banny721TokenUriResolver is
616
630
  if (IERC721(hook).ownerOf(upc) != _msgSender()) revert Banny721TokenUriResolver_UnauthorizedBannyBody();
617
631
  }
618
632
 
633
+ /// @notice The length of the context suffix appended by a trusted forwarder.
619
634
  /// @dev ERC-2771 specifies the context as being a single address (20 bytes).
635
+ /// @return The suffix length in bytes.
620
636
  function _contextSuffixLength() internal view virtual override(ERC2771Context, Context) returns (uint256) {
621
637
  return super._contextSuffixLength();
622
638
  }
@@ -625,6 +641,11 @@ contract Banny721TokenUriResolver is
625
641
  // Metadata strings (name, description, external_url) are set by the contract owner, not by users.
626
642
  // No JSON escaping is applied — the owner is trusted to provide valid values. On-chain JSON is consumed
627
643
  // by off-chain indexers and UIs, not rendered in a browser context where XSS would apply.
644
+ /// @param tokenId The ID of the token.
645
+ /// @param product The tier product for the token.
646
+ /// @param extraMetadata Additional JSON metadata fields to include.
647
+ /// @param imageContents The base64-encoded SVG image contents.
648
+ /// @return The fully encoded data URI string.
628
649
  function _encodeTokenUri(
629
650
  uint256 tokenId,
630
651
  JB721Tier memory product,
@@ -753,8 +774,11 @@ contract Banny721TokenUriResolver is
753
774
  /// @param array The array to search in.
754
775
  /// @return found True if the value was found.
755
776
  function _isInArray(uint256 value, uint256[] memory array) internal pure returns (bool found) {
756
- for (uint256 i; i < array.length; i++) {
777
+ for (uint256 i; i < array.length;) {
757
778
  if (array[i] == value) return true;
779
+ unchecked {
780
+ ++i;
781
+ }
758
782
  }
759
783
  }
760
784
 
@@ -772,23 +796,8 @@ contract Banny721TokenUriResolver is
772
796
  /// @notice The SVG contents for a mannequin banny.
773
797
  /// @return contents The SVG contents of the mannequin banny.
774
798
  function _mannequinBannySvg() internal view returns (string memory) {
775
- string memory fillNoneString = string.concat("{fill:none;}");
776
799
  return string.concat(
777
- "<style>.o{fill:#808080;}.b1",
778
- fillNoneString,
779
- ".b2",
780
- fillNoneString,
781
- ".b3",
782
- fillNoneString,
783
- ".b4",
784
- fillNoneString,
785
- ".a1",
786
- fillNoneString,
787
- ".a2",
788
- fillNoneString,
789
- ".a3",
790
- fillNoneString,
791
- "</style>",
800
+ "<style>.o{fill:#808080;}.b1{fill:none;}.b2{fill:none;}.b3{fill:none;}.b4{fill:none;}.a1{fill:none;}.a2{fill:none;}.a3{fill:none;}</style>",
792
801
  BANNY_BODY
793
802
  );
794
803
  }
@@ -834,7 +843,7 @@ contract Banny721TokenUriResolver is
834
843
 
835
844
  // For each outfit, add the SVG layer if it's owned by the same owner as the banny body being dressed.
836
845
  // Loop once more to make sure all default outfits are added.
837
- for (uint256 i; i < numberOfOutfits + 1; i++) {
846
+ for (uint256 i; i < numberOfOutfits + 1;) {
838
847
  // Keep a reference to the outfit ID being iterated on.
839
848
  uint256 outfitId;
840
849
 
@@ -903,6 +912,9 @@ contract Banny721TokenUriResolver is
903
912
  if (outfitId != 0 && category != _NECKLACE_CATEGORY) {
904
913
  contents = string.concat(contents, _svgOf({hook: hook, upc: upc}));
905
914
  }
915
+ unchecked {
916
+ ++i;
917
+ }
906
918
  }
907
919
  }
908
920
 
@@ -949,33 +961,41 @@ contract Banny721TokenUriResolver is
949
961
  /// @param hook The 721 hook whose tier categories determine sort order.
950
962
  /// @param outfitIds The outfit token IDs to sort in-place by ascending category.
951
963
  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.
964
+ // Cache array length and skip sorting when fewer than 2 elements.
965
+ uint256 length = outfitIds.length;
966
+ if (length < 2) return;
967
+
968
+ // Pre-compute all categories to avoid repeated external STATICCALL during sort comparisons.
969
+ uint256[] memory categories = new uint256[](length);
970
+ for (uint256 i; i < length;) {
971
+ categories[i] = _productOfTokenId({hook: hook, tokenId: outfitIds[i]}).category;
972
+ unchecked {
973
+ ++i;
974
+ }
975
+ }
976
+
977
+ // Insertion sort using cached categories.
978
+ for (uint256 i = 1; i < length;) {
979
+ // Cache the current element so it can be inserted into the correct sorted position.
955
980
  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.
981
+ uint256 category = categories[i];
959
982
  uint256 j = i;
960
983
 
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;
984
+ // Shift larger-category entries right until the insertion point is found.
985
+ while (j != 0 && categories[j - 1] > category) {
986
+ outfitIds[j] = outfitIds[j - 1];
987
+ categories[j] = categories[j - 1];
971
988
  unchecked {
972
- // Safe because the loop guard ensures `j` is non-zero before decrementing.
973
989
  --j;
974
990
  }
975
991
  }
976
992
 
977
- // Write the cached outfit into the insertion point that preserves ascending category order.
993
+ // Place the cached element at its sorted position.
978
994
  outfitIds[j] = outfitId;
995
+ categories[j] = category;
996
+ unchecked {
997
+ ++i;
998
+ }
979
999
  }
980
1000
  }
981
1001
 
@@ -989,6 +1009,7 @@ contract Banny721TokenUriResolver is
989
1009
  /// @notice The banny body and outfit SVG files.
990
1010
  /// @param hook The 721 contract that the product belongs to.
991
1011
  /// @param upc The universal product code of the product that the SVG contents represent.
1012
+ /// @return The SVG content string, either from storage or decoded from IPFS.
992
1013
  function _svgOf(address hook, uint256 upc) internal view returns (string memory) {
993
1014
  // Keep a reference to the stored svg contents.
994
1015
  string memory svgContents = _svgContentOf[upc];
@@ -1018,7 +1039,7 @@ contract Banny721TokenUriResolver is
1018
1039
  bool hasSuitPiece;
1019
1040
 
1020
1041
  // Scan every outfit and classify it into one of the two exclusive groups.
1021
- for (uint256 i; i < outfitIds.length; i++) {
1042
+ for (uint256 i; i < outfitIds.length;) {
1022
1043
  // Look up the tier category for this outfit token.
1023
1044
  uint256 category = _productOfTokenId({hook: hook, tokenId: outfitIds[i]}).category;
1024
1045
 
@@ -1038,6 +1059,9 @@ contract Banny721TokenUriResolver is
1038
1059
  // Individual top or bottom pieces that would conflict with a full SUIT.
1039
1060
  hasSuitPiece = true;
1040
1061
  }
1062
+ unchecked {
1063
+ ++i;
1064
+ }
1041
1065
  }
1042
1066
 
1043
1067
  // A full HEAD and individual head accessories cannot coexist — the head would hide the accessories.
@@ -1088,7 +1112,10 @@ contract Banny721TokenUriResolver is
1088
1112
  override
1089
1113
  nonReentrant
1090
1114
  {
1091
- _checkIfSenderIsOwner({hook: hook, upc: bannyBodyId});
1115
+ // Cache the sender once to avoid repeated ERC-2771 context reads throughout the call chain.
1116
+ address sender = _msgSender();
1117
+
1118
+ if (IERC721(hook).ownerOf(bannyBodyId) != sender) revert Banny721TokenUriResolver_UnauthorizedBannyBody();
1092
1119
 
1093
1120
  // Make sure the bannyBodyId belongs to a body-category tier.
1094
1121
  if (_productOfTokenId({hook: hook, tokenId: bannyBodyId}).category != _BODY_CATEGORY) {
@@ -1101,14 +1128,14 @@ contract Banny721TokenUriResolver is
1101
1128
  }
1102
1129
 
1103
1130
  emit DecorateBanny({
1104
- hook: hook, bannyBodyId: bannyBodyId, backgroundId: backgroundId, outfitIds: outfitIds, caller: _msgSender()
1131
+ hook: hook, bannyBodyId: bannyBodyId, backgroundId: backgroundId, outfitIds: outfitIds, caller: sender
1105
1132
  });
1106
1133
 
1107
1134
  // Add the background.
1108
- _decorateBannyWithBackground({hook: hook, bannyBodyId: bannyBodyId, backgroundId: backgroundId});
1135
+ _decorateBannyWithBackground({hook: hook, bannyBodyId: bannyBodyId, backgroundId: backgroundId, sender: sender});
1109
1136
 
1110
1137
  // Add the outfits.
1111
- _decorateBannyWithOutfits({hook: hook, bannyBodyId: bannyBodyId, outfitIds: outfitIds});
1138
+ _decorateBannyWithOutfits({hook: hook, bannyBodyId: bannyBodyId, outfitIds: outfitIds, sender: sender});
1112
1139
  }
1113
1140
 
1114
1141
  /// @notice Locks a banny body ID so that it can't change its outfit for a period of time.
@@ -1131,6 +1158,7 @@ contract Banny721TokenUriResolver is
1131
1158
  outfitLockedUntil[hook][bannyBodyId] = newLockUntil;
1132
1159
  }
1133
1160
 
1161
+ /// @notice Handles the receipt of an ERC-721 token, only accepting transfers initiated by this contract.
1134
1162
  /// @dev Make sure tokens can be received if the transaction was initiated by this contract.
1135
1163
  // NFTs sent via transferFrom (not safeTransferFrom) bypass onERC721Received and cannot be
1136
1164
  // tracked or recovered. This is an inherent ERC-721 limitation — the contract cannot prevent non-safe
@@ -1139,6 +1167,7 @@ contract Banny721TokenUriResolver is
1139
1167
  /// @param from The address that initiated the transfer.
1140
1168
  /// @param tokenId The ID of the token being transferred.
1141
1169
  /// @param data The data of the transfer.
1170
+ /// @return The ERC-721 receiver selector.
1142
1171
  function onERC721Received(
1143
1172
  address operator,
1144
1173
  address from,
@@ -1190,13 +1219,17 @@ contract Banny721TokenUriResolver is
1190
1219
  function setProductNames(uint256[] memory upcs, string[] memory names) external override onlyOwner {
1191
1220
  if (upcs.length != names.length) revert Banny721TokenUriResolver_ArrayLengthMismatch();
1192
1221
 
1193
- for (uint256 i; i < upcs.length; i++) {
1222
+ address sender = _msgSender();
1223
+ for (uint256 i; i < upcs.length;) {
1194
1224
  uint256 upc = upcs[i];
1195
1225
  string memory name = names[i];
1196
1226
 
1197
1227
  _customProductNameOf[upc] = name;
1198
1228
 
1199
- emit SetProductName({upc: upc, name: name, caller: _msgSender()});
1229
+ emit SetProductName({upc: upc, name: name, caller: sender});
1230
+ unchecked {
1231
+ ++i;
1232
+ }
1200
1233
  }
1201
1234
  }
1202
1235
 
@@ -1206,7 +1239,8 @@ contract Banny721TokenUriResolver is
1206
1239
  function setSvgContentsOf(uint256[] memory upcs, string[] calldata svgContents) external override {
1207
1240
  if (upcs.length != svgContents.length) revert Banny721TokenUriResolver_ArrayLengthMismatch();
1208
1241
 
1209
- for (uint256 i; i < upcs.length; i++) {
1242
+ address sender = _msgSender();
1243
+ for (uint256 i; i < upcs.length;) {
1210
1244
  uint256 upc = upcs[i];
1211
1245
  string memory svgContent = svgContents[i];
1212
1246
 
@@ -1225,7 +1259,10 @@ contract Banny721TokenUriResolver is
1225
1259
  // Store the svg contents.
1226
1260
  _svgContentOf[upc] = svgContent;
1227
1261
 
1228
- emit SetSvgContent({upc: upc, svgContent: svgContent, caller: _msgSender()});
1262
+ emit SetSvgContent({upc: upc, svgContent: svgContent, caller: sender});
1263
+ unchecked {
1264
+ ++i;
1265
+ }
1229
1266
  }
1230
1267
  }
1231
1268
 
@@ -1236,7 +1273,8 @@ contract Banny721TokenUriResolver is
1236
1273
  function setSvgHashesOf(uint256[] memory upcs, bytes32[] memory svgHashes) external override onlyOwner {
1237
1274
  if (upcs.length != svgHashes.length) revert Banny721TokenUriResolver_ArrayLengthMismatch();
1238
1275
 
1239
- for (uint256 i; i < upcs.length; i++) {
1276
+ address sender = _msgSender();
1277
+ for (uint256 i; i < upcs.length;) {
1240
1278
  uint256 upc = upcs[i];
1241
1279
  bytes32 svgHash = svgHashes[i];
1242
1280
 
@@ -1246,7 +1284,10 @@ contract Banny721TokenUriResolver is
1246
1284
  // Store the svg contents.
1247
1285
  svgHashOf[upc] = svgHash;
1248
1286
 
1249
- emit SetSvgHash({upc: upc, svgHash: svgHash, caller: _msgSender()});
1287
+ emit SetSvgHash({upc: upc, svgHash: svgHash, caller: sender});
1288
+ unchecked {
1289
+ ++i;
1290
+ }
1250
1291
  }
1251
1292
  }
1252
1293
 
@@ -1258,7 +1299,15 @@ contract Banny721TokenUriResolver is
1258
1299
  /// @param hook The hook storing the assets.
1259
1300
  /// @param bannyBodyId The ID of the banny body being dressed.
1260
1301
  /// @param backgroundId The ID of the background that'll be associated with the specified banny.
1261
- function _decorateBannyWithBackground(address hook, uint256 bannyBodyId, uint256 backgroundId) internal {
1302
+ /// @param sender The cached msg sender.
1303
+ function _decorateBannyWithBackground(
1304
+ address hook,
1305
+ uint256 bannyBodyId,
1306
+ uint256 backgroundId,
1307
+ address sender
1308
+ )
1309
+ internal
1310
+ {
1262
1311
  // Keep a reference to the previous background attached.
1263
1312
  uint256 previousBackgroundId = _attachedBackgroundIdOf[hook][bannyBodyId];
1264
1313
 
@@ -1273,7 +1322,7 @@ contract Banny721TokenUriResolver is
1273
1322
  address owner = IERC721(hook).ownerOf(backgroundId);
1274
1323
 
1275
1324
  // Check if the call is being made by the background's owner, or the owner of a banny body using it.
1276
- if (_msgSender() != owner) {
1325
+ if (sender != owner) {
1277
1326
  // Get the banny body currently using this background.
1278
1327
  uint256 userId = userOf({hook: hook, backgroundId: backgroundId});
1279
1328
 
@@ -1281,7 +1330,7 @@ contract Banny721TokenUriResolver is
1281
1330
  if (userId == 0) revert Banny721TokenUriResolver_UnauthorizedBackground();
1282
1331
 
1283
1332
  // If the background is used, the banny body's owner can also authorize its use.
1284
- if (_msgSender() != IERC721(hook).ownerOf(userId)) {
1333
+ if (sender != IERC721(hook).ownerOf(userId)) {
1285
1334
  revert Banny721TokenUriResolver_UnauthorizedBackground();
1286
1335
  }
1287
1336
 
@@ -1300,9 +1349,8 @@ contract Banny721TokenUriResolver is
1300
1349
  // Try to transfer the previous background back before updating state.
1301
1350
  // If the transfer fails, the old background stays attached to prevent NFT stranding.
1302
1351
  if (userOfPreviousBackground == bannyBodyId) {
1303
- if (!_tryTransferFrom({
1304
- hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId
1305
- })) {
1352
+ if (!_tryTransferFrom({hook: hook, from: address(this), to: sender, assetId: previousBackgroundId}))
1353
+ {
1306
1354
  // Transfer failed — skip the background change entirely so the old background
1307
1355
  // remains tracked and recoverable. The new background is not equipped.
1308
1356
  return;
@@ -1315,16 +1363,15 @@ contract Banny721TokenUriResolver is
1315
1363
 
1316
1364
  // Transfer the new background to this contract if it's not already owned by this contract.
1317
1365
  if (owner != address(this)) {
1318
- _transferFrom({hook: hook, from: _msgSender(), to: address(this), assetId: backgroundId});
1366
+ _transferFrom({hook: hook, from: sender, to: address(this), assetId: backgroundId});
1319
1367
  }
1320
1368
  } else {
1321
1369
  // Try to transfer the previous background back before clearing state.
1322
1370
  if (userOfPreviousBackground == bannyBodyId) {
1323
1371
  // Only clear attachment state if the transfer succeeded. If it fails (e.g. recipient rejects
1324
1372
  // ERC-721), the background stays attached so the owner can retry or recover.
1325
- if (_tryTransferFrom({
1326
- hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId
1327
- })) {
1373
+ if (_tryTransferFrom({hook: hook, from: address(this), to: sender, assetId: previousBackgroundId}))
1374
+ {
1328
1375
  _attachedBackgroundIdOf[hook][bannyBodyId] = 0;
1329
1376
  }
1330
1377
  } else {
@@ -1341,7 +1388,15 @@ contract Banny721TokenUriResolver is
1341
1388
  /// @param bannyBodyId The ID of the banny body being dressed.
1342
1389
  /// @param outfitIds The IDs of the outfits that'll be associated with the specified banny. Only one outfit per
1343
1390
  /// outfit category allowed at a time and they must be passed in order.
1344
- function _decorateBannyWithOutfits(address hook, uint256 bannyBodyId, uint256[] memory outfitIds) internal {
1391
+ /// @param sender The cached msg sender.
1392
+ function _decorateBannyWithOutfits(
1393
+ address hook,
1394
+ uint256 bannyBodyId,
1395
+ uint256[] memory outfitIds,
1396
+ address sender
1397
+ )
1398
+ internal
1399
+ {
1345
1400
  // Keep track of certain outfits being used along the way to prevent conflicting outfits.
1346
1401
  bool hasHead;
1347
1402
  bool hasSuit;
@@ -1369,14 +1424,14 @@ contract Banny721TokenUriResolver is
1369
1424
 
1370
1425
  // Iterate through each outfit, transfering them in and adding them to the banny if needed, while transfering
1371
1426
  // out and removing old outfits no longer being worn.
1372
- for (uint256 i; i < outfitIds.length; i++) {
1427
+ for (uint256 i; i < outfitIds.length;) {
1373
1428
  // Set the outfit ID being iterated on.
1374
1429
  uint256 outfitId = outfitIds[i];
1375
1430
 
1376
1431
  // Check if the call is being made either by the outfit's owner or the owner of the banny body currently
1377
1432
  // wearing it.
1378
1433
  // slither-disable-next-line calls-loop
1379
- if (_msgSender() != IERC721(hook).ownerOf(outfitId)) {
1434
+ if (sender != IERC721(hook).ownerOf(outfitId)) {
1380
1435
  // Get the banny body currently wearing this outfit.
1381
1436
  uint256 wearerId = wearerOf({hook: hook, outfitId: outfitId});
1382
1437
 
@@ -1385,7 +1440,7 @@ contract Banny721TokenUriResolver is
1385
1440
 
1386
1441
  // If the outfit is worn, the banny body's owner can also authorize its use.
1387
1442
  // slither-disable-next-line calls-loop
1388
- if (_msgSender() != IERC721(hook).ownerOf(wearerId)) {
1443
+ if (sender != IERC721(hook).ownerOf(wearerId)) {
1389
1444
  revert Banny721TokenUriResolver_UnauthorizedOutfit();
1390
1445
  }
1391
1446
 
@@ -1438,9 +1493,7 @@ contract Banny721TokenUriResolver is
1438
1493
  // Use try-transfer: the previous outfit may have been burned or its tier removed.
1439
1494
  // If transfer fails, zero is NOT written to previousOutfitIds — the entry is preserved
1440
1495
  // so it can be retained in the attached list (preventing NFT stranding).
1441
- if (_tryTransferFrom({
1442
- hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId
1443
- })) {
1496
+ if (_tryTransferFrom({hook: hook, from: address(this), to: sender, assetId: previousOutfitId})) {
1444
1497
  // Mark as successfully transferred so it won't be retained.
1445
1498
  previousOutfitIds[previousOutfitIndex] = 0;
1446
1499
  }
@@ -1468,12 +1521,15 @@ contract Banny721TokenUriResolver is
1468
1521
  // Transfer the outfit to this contract.
1469
1522
  // slither-disable-next-line reentrancy-no-eth,calls-loop
1470
1523
  if (IERC721(hook).ownerOf(outfitId) != address(this)) {
1471
- _transferFrom({hook: hook, from: _msgSender(), to: address(this), assetId: outfitId});
1524
+ _transferFrom({hook: hook, from: sender, to: address(this), assetId: outfitId});
1472
1525
  }
1473
1526
  }
1474
1527
 
1475
1528
  // Keep a reference to the last outfit's category.
1476
1529
  lastAssetCategory = outfitProductCategory;
1530
+ unchecked {
1531
+ ++i;
1532
+ }
1477
1533
  }
1478
1534
 
1479
1535
  // Remove and transfer out any remaining assets no longer being worn.
@@ -1484,13 +1540,13 @@ contract Banny721TokenUriResolver is
1484
1540
  // `_attachedOutfitIdsOf` hasnt been called yet, so the wearer should still be the banny body being
1485
1541
  // decorated.
1486
1542
  // Skip outfits that are being re-equipped in the new outfit set.
1487
- if (_isInArray(previousOutfitId, outfitIds)) {
1543
+ if (_isInArray({value: previousOutfitId, array: outfitIds})) {
1488
1544
  // This outfit is being re-equipped — mark as handled.
1489
1545
  previousOutfitIds[previousOutfitIndex] = 0;
1490
1546
  } else if (wearerOf({hook: hook, outfitId: previousOutfitId}) == bannyBodyId) {
1491
1547
  // Use try-transfer: the previous outfit may have been burned or its tier removed.
1492
1548
  // If transfer fails, the entry stays non-zero and is retained (preventing NFT stranding).
1493
- if (_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId})) {
1549
+ if (_tryTransferFrom({hook: hook, from: address(this), to: sender, assetId: previousOutfitId})) {
1494
1550
  previousOutfitIds[previousOutfitIndex] = 0;
1495
1551
  }
1496
1552
  } else {
@@ -1530,8 +1586,11 @@ contract Banny721TokenUriResolver is
1530
1586
  {
1531
1587
  // Count how many previous outfits failed to transfer (non-zero entries remain).
1532
1588
  uint256 retainedCount;
1533
- for (uint256 i; i < previousOutfitIds.length; i++) {
1589
+ for (uint256 i; i < previousOutfitIds.length;) {
1534
1590
  if (previousOutfitIds[i] != 0) retainedCount++;
1591
+ unchecked {
1592
+ ++i;
1593
+ }
1535
1594
  }
1536
1595
 
1537
1596
  if (retainedCount == 0) {
@@ -1539,14 +1598,20 @@ contract Banny721TokenUriResolver is
1539
1598
  } else {
1540
1599
  // Merge new outfits with retained outfits that couldn't be transferred back.
1541
1600
  uint256[] memory mergedOutfitIds = new uint256[](outfitIds.length + retainedCount);
1542
- for (uint256 i; i < outfitIds.length; i++) {
1601
+ for (uint256 i; i < outfitIds.length;) {
1543
1602
  mergedOutfitIds[i] = outfitIds[i];
1603
+ unchecked {
1604
+ ++i;
1605
+ }
1544
1606
  }
1545
1607
  uint256 mergeIndex = outfitIds.length;
1546
- for (uint256 i; i < previousOutfitIds.length; i++) {
1608
+ for (uint256 i; i < previousOutfitIds.length;) {
1547
1609
  if (previousOutfitIds[i] != 0) {
1548
1610
  mergedOutfitIds[mergeIndex++] = previousOutfitIds[i];
1549
1611
  }
1612
+ unchecked {
1613
+ ++i;
1614
+ }
1550
1615
  }
1551
1616
 
1552
1617
  // Revalidate category exclusivity on the merged set. Retained outfits may conflict with the new outfits
@@ -1556,6 +1621,20 @@ contract Banny721TokenUriResolver is
1556
1621
  // caller-supplied set.
1557
1622
  _sortOutfitsByCategory({hook: hook, outfitIds: mergedOutfitIds});
1558
1623
 
1624
+ // After sorting, verify no two outfits share the same category. A retained outfit whose transfer
1625
+ // failed could duplicate a category supplied in the new outfit set.
1626
+ for (uint256 i = 1; i < mergedOutfitIds.length;) {
1627
+ if (
1628
+ _productOfTokenId({hook: hook, tokenId: mergedOutfitIds[i]}).category
1629
+ == _productOfTokenId({hook: hook, tokenId: mergedOutfitIds[i - 1]}).category
1630
+ ) {
1631
+ revert Banny721TokenUriResolver_DuplicateCategory();
1632
+ }
1633
+ unchecked {
1634
+ ++i;
1635
+ }
1636
+ }
1637
+
1559
1638
  // Persist the merged-and-sorted attachment list so later reads and redecorations see a stable order.
1560
1639
  _attachedOutfitIdsOf[hook][bannyBodyId] = mergedOutfitIds;
1561
1640
  }
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.28;
3
+
4
+ import {Test} from "forge-std/Test.sol";
5
+ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
6
+ import {JB721TierFlags} from "@bananapus/721-hook-v6/src/structs/JB721TierFlags.sol";
7
+ import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
8
+
9
+ import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
10
+
11
+ contract DuplicateCategoryMockHook {
12
+ mapping(uint256 tokenId => address) public ownerOf;
13
+ mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
14
+ address public immutable MOCK_STORE;
15
+
16
+ constructor(address store) {
17
+ MOCK_STORE = store;
18
+ }
19
+
20
+ function STORE() external view returns (address) {
21
+ return MOCK_STORE;
22
+ }
23
+
24
+ function setOwner(uint256 tokenId, address owner) external {
25
+ ownerOf[tokenId] = owner;
26
+ }
27
+
28
+ function setApprovalForAll(address operator, bool approved) external {
29
+ isApprovedForAll[msg.sender][operator] = approved;
30
+ }
31
+
32
+ function safeTransferFrom(address from, address to, uint256 tokenId) external {
33
+ require(
34
+ msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
35
+ "MockHook: not authorized"
36
+ );
37
+ ownerOf[tokenId] = to;
38
+
39
+ if (to.code.length != 0) {
40
+ bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
41
+ require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
42
+ }
43
+ }
44
+
45
+ function pricingContext() external pure returns (uint256, uint256, uint256) {
46
+ return (1, 18, 0);
47
+ }
48
+
49
+ function baseURI() external pure returns (string memory) {
50
+ return "ipfs://";
51
+ }
52
+ }
53
+
54
+ contract DuplicateCategoryMockStore {
55
+ mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
56
+
57
+ function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
58
+ tiers[hook][tokenId] = tier;
59
+ }
60
+
61
+ function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
62
+ return tiers[hook][tokenId];
63
+ }
64
+
65
+ // forge-lint: disable-next-line(mixed-case-function)
66
+ function encodedTierIPFSUriOf(address, uint256) external pure returns (bytes32) {
67
+ return bytes32(0);
68
+ }
69
+
70
+ // forge-lint: disable-next-line(mixed-case-function)
71
+ function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
72
+ return bytes32(0);
73
+ }
74
+ }
75
+
76
+ contract ERC721RejectingOwner {
77
+ function approveResolver(DuplicateCategoryMockHook hook, address resolver) external {
78
+ hook.setApprovalForAll(resolver, true);
79
+ }
80
+
81
+ function decorate(
82
+ Banny721TokenUriResolver resolver,
83
+ address hook,
84
+ uint256 bodyId,
85
+ uint256 backgroundId,
86
+ uint256[] memory outfitIds
87
+ )
88
+ external
89
+ {
90
+ resolver.decorateBannyWith(hook, bodyId, backgroundId, outfitIds);
91
+ }
92
+ }
93
+
94
+ contract DuplicateCategoryRetentionTest is Test {
95
+ Banny721TokenUriResolver resolver;
96
+ DuplicateCategoryMockHook hook;
97
+ DuplicateCategoryMockStore store;
98
+ ERC721RejectingOwner rejector;
99
+
100
+ uint256 internal constant BODY_TOKEN = 4_000_000_001;
101
+ uint256 internal constant NECKLACE_ONE = 10_000_000_001;
102
+ uint256 internal constant NECKLACE_TWO = 11_000_000_001;
103
+
104
+ function setUp() public {
105
+ store = new DuplicateCategoryMockStore();
106
+ hook = new DuplicateCategoryMockHook(address(store));
107
+ rejector = new ERC721RejectingOwner();
108
+
109
+ resolver = new Banny721TokenUriResolver(
110
+ "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", address(this), address(0)
111
+ );
112
+
113
+ _setupTier(BODY_TOKEN, 4, 0);
114
+ _setupTier(NECKLACE_ONE, 10, 3);
115
+ _setupTier(NECKLACE_TWO, 11, 3);
116
+
117
+ hook.setOwner(BODY_TOKEN, address(rejector));
118
+ hook.setOwner(NECKLACE_ONE, address(rejector));
119
+ hook.setOwner(NECKLACE_TWO, address(rejector));
120
+ rejector.approveResolver(hook, address(resolver));
121
+ }
122
+
123
+ function test_retainedOutfitCanBypassOnePerCategoryInvariant() public {
124
+ uint256[] memory first = new uint256[](1);
125
+ first[0] = NECKLACE_ONE;
126
+ rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, first);
127
+
128
+ uint256[] memory replacement = new uint256[](1);
129
+ replacement[0] = NECKLACE_TWO;
130
+
131
+ // After the L-1 fix, the duplicate category is detected and the call reverts.
132
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_DuplicateCategory.selector);
133
+ rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, replacement);
134
+ }
135
+
136
+ function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
137
+ store.setTier(
138
+ address(hook),
139
+ tokenId,
140
+ JB721Tier({
141
+ id: tierId,
142
+ price: 0.01 ether,
143
+ remainingSupply: 100,
144
+ initialSupply: 100,
145
+ votingUnits: 0,
146
+ reserveFrequency: 0,
147
+ reserveBeneficiary: address(0),
148
+ encodedIPFSUri: bytes32(0),
149
+ category: category,
150
+ discountPercent: 0,
151
+ flags: JB721TierFlags({
152
+ allowOwnerMint: false,
153
+ transfersPausable: false,
154
+ cantBeRemoved: false,
155
+ cantIncreaseDiscountPercent: false,
156
+ cantBuyWithCredits: false
157
+ }),
158
+ splitPercent: 0,
159
+ resolvedUri: ""
160
+ })
161
+ );
162
+ }
163
+ }
@@ -78,14 +78,14 @@ contract MigrationHelperHarness {
78
78
  }
79
79
 
80
80
  contract MockHook {
81
- address internal immutable _store;
81
+ address internal immutable _STORE;
82
82
 
83
83
  constructor(address store) {
84
- _store = store;
84
+ _STORE = store;
85
85
  }
86
86
 
87
87
  function STORE() external view returns (IJB721TiersHookStore) {
88
- return IJB721TiersHookStore(_store);
88
+ return IJB721TiersHookStore(_STORE);
89
89
  }
90
90
  }
91
91