@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.
|
|
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.
|
|
24
|
-
"@bananapus/core-v6": "^0.0.
|
|
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.
|
|
27
|
-
"@bananapus/suckers-v6": "^0.0.
|
|
28
|
-
"@croptop/core-v6": "^0.0.
|
|
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.
|
|
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.
|
|
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;
|
|
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;
|
|
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
|
-
/// @
|
|
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;
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
-
//
|
|
953
|
-
|
|
954
|
-
|
|
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
|
-
|
|
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
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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 (
|
|
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 (
|
|
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:
|
|
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:
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
|
81
|
+
address internal immutable _STORE;
|
|
82
82
|
|
|
83
83
|
constructor(address store) {
|
|
84
|
-
|
|
84
|
+
_STORE = store;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function STORE() external view returns (IJB721TiersHookStore) {
|
|
88
|
-
return IJB721TiersHookStore(
|
|
88
|
+
return IJB721TiersHookStore(_STORE);
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|