@bannynet/core-v6 0.0.2 → 0.0.4
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/README.md +114 -32
- package/SKILLS.md +169 -48
- package/package.json +8 -8
- package/script/Add.Denver.s.sol +15 -3
- package/script/Drop1.s.sol +7 -26
- package/script/helpers/BannyverseDeploymentLib.sol +11 -4
- package/src/Banny721TokenUriResolver.sol +63 -25
- package/test/Banny721TokenUriResolver.t.sol +9 -8
- package/test/regression/I25_CEIReorder.t.sol +204 -0
- package/test/regression/L56_MsgSenderEvents.t.sol +152 -0
- package/test/regression/L57_BodyCategoryValidation.t.sol +142 -0
- package/test/regression/L58_ArrayLengthValidation.t.sol +58 -0
- package/test/regression/L59_ClearMetadata.t.sol +52 -0
- package/test/regression/L62_BurnedTokenCheck.t.sol +181 -0
|
@@ -28,6 +28,8 @@ contract Banny721TokenUriResolver is
|
|
|
28
28
|
{
|
|
29
29
|
using Strings for uint256;
|
|
30
30
|
|
|
31
|
+
error Banny721TokenUriResolver_ArrayLengthMismatch();
|
|
32
|
+
error Banny721TokenUriResolver_BannyBodyNotBodyCategory();
|
|
31
33
|
error Banny721TokenUriResolver_CantAccelerateTheLock();
|
|
32
34
|
error Banny721TokenUriResolver_ContentsAlreadyStored();
|
|
33
35
|
error Banny721TokenUriResolver_ContentsMismatch();
|
|
@@ -114,12 +116,15 @@ contract Banny721TokenUriResolver is
|
|
|
114
116
|
|
|
115
117
|
/// @notice The outfits currently attached to each banny body.
|
|
116
118
|
/// @dev Naked Banny's will only be shown with outfits currently owned by the owner of the banny body.
|
|
119
|
+
/// @dev NOTE: Equipped outfits travel with the banny body NFT on transfer. When a body is transferred,
|
|
120
|
+
/// the new owner inherits all equipped outfits and can unequip them to receive the outfit NFTs.
|
|
117
121
|
/// @custom:param hook The hook address of the collection.
|
|
118
122
|
/// @custom:param bannyBodyId The ID of the banny body of the outfits.
|
|
119
123
|
mapping(address hook => mapping(uint256 bannyBodyId => uint256[])) internal _attachedOutfitIdsOf;
|
|
120
124
|
|
|
121
125
|
/// @notice The background currently attached to each banny body.
|
|
122
126
|
/// @dev Naked Banny's will only be shown with a background currently owned by the owner of the banny body.
|
|
127
|
+
/// @dev NOTE: Equipped backgrounds travel with the banny body NFT on transfer, same as outfits.
|
|
123
128
|
/// @custom:param hook The hook address of the collection.
|
|
124
129
|
/// @custom:param bannyBodyId The ID of the banny body of the background.
|
|
125
130
|
mapping(address hook => mapping(uint256 bannyBodyId => uint256)) internal _attachedBackgroundIdOf;
|
|
@@ -608,6 +613,7 @@ contract Banny721TokenUriResolver is
|
|
|
608
613
|
view
|
|
609
614
|
returns (string memory)
|
|
610
615
|
{
|
|
616
|
+
// slither-disable-next-line encode-packed-collision
|
|
611
617
|
return string.concat(
|
|
612
618
|
"data:application/json;base64,",
|
|
613
619
|
Base64.encode(
|
|
@@ -945,6 +951,12 @@ contract Banny721TokenUriResolver is
|
|
|
945
951
|
/// 6. Conflicting categories are rejected (e.g., a full head blocks individual face pieces;
|
|
946
952
|
/// a full suit blocks separate top/bottom).
|
|
947
953
|
///
|
|
954
|
+
/// @dev WARNING: Equipped outfits and backgrounds are held by this contract on behalf of the banny body. When the
|
|
955
|
+
/// banny body NFT is transferred to a new owner, all equipped assets remain associated with that body. The new
|
|
956
|
+
/// owner of the body effectively gains control of all equipped items — they can unequip them (receiving the
|
|
957
|
+
/// outfit
|
|
958
|
+
/// NFTs) or re-equip different items. Sellers should unequip valuable outfits before transferring a banny body.
|
|
959
|
+
///
|
|
948
960
|
/// @param hook The hook storing the assets.
|
|
949
961
|
/// @param bannyBodyId The ID of the banny body being dressed.
|
|
950
962
|
/// @param backgroundId The ID of the background that'll be associated with the specified banny.
|
|
@@ -962,6 +974,11 @@ contract Banny721TokenUriResolver is
|
|
|
962
974
|
{
|
|
963
975
|
_checkIfSenderIsOwner({hook: hook, upc: bannyBodyId});
|
|
964
976
|
|
|
977
|
+
// Make sure the bannyBodyId belongs to a body-category tier.
|
|
978
|
+
if (_productOfTokenId({hook: hook, tokenId: bannyBodyId}).category != _BODY_CATEGORY) {
|
|
979
|
+
revert Banny721TokenUriResolver_BannyBodyNotBodyCategory();
|
|
980
|
+
}
|
|
981
|
+
|
|
965
982
|
// Can't decorate a banny that's locked.
|
|
966
983
|
if (outfitLockedUntil[hook][bannyBodyId] > block.timestamp) {
|
|
967
984
|
revert Banny721TokenUriResolver_OutfitChangesLocked();
|
|
@@ -1028,17 +1045,21 @@ contract Banny721TokenUriResolver is
|
|
|
1028
1045
|
/// @param upcs The universal product codes of the products having their name stored.
|
|
1029
1046
|
/// @param names The names of the products.
|
|
1030
1047
|
function setProductNames(uint256[] memory upcs, string[] memory names) external override onlyOwner {
|
|
1048
|
+
if (upcs.length != names.length) revert Banny721TokenUriResolver_ArrayLengthMismatch();
|
|
1049
|
+
|
|
1031
1050
|
for (uint256 i; i < upcs.length; i++) {
|
|
1032
1051
|
uint256 upc = upcs[i];
|
|
1033
1052
|
string memory name = names[i];
|
|
1034
1053
|
|
|
1035
1054
|
_customProductNameOf[upc] = name;
|
|
1036
1055
|
|
|
1037
|
-
emit SetProductName({upc: upc, name: name, caller:
|
|
1056
|
+
emit SetProductName({upc: upc, name: name, caller: _msgSender()});
|
|
1038
1057
|
}
|
|
1039
1058
|
}
|
|
1040
1059
|
|
|
1041
1060
|
/// @notice Allows the owner of this contract to set the token metadata description, external URL, and SVG base URI.
|
|
1061
|
+
/// @dev All fields are always written. Pass the current value for any field you do not want to change,
|
|
1062
|
+
/// or pass an empty string to clear a field.
|
|
1042
1063
|
/// @param description The description to use in token metadata.
|
|
1043
1064
|
/// @param url The external URL to use in token metadata.
|
|
1044
1065
|
/// @param baseUri The base URI of the SVG files.
|
|
@@ -1051,17 +1072,19 @@ contract Banny721TokenUriResolver is
|
|
|
1051
1072
|
override
|
|
1052
1073
|
onlyOwner
|
|
1053
1074
|
{
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1075
|
+
svgDescription = description;
|
|
1076
|
+
svgExternalUrl = url;
|
|
1077
|
+
svgBaseUri = baseUri;
|
|
1057
1078
|
|
|
1058
|
-
emit SetMetadata({description: description, externalUrl: url, baseUri: baseUri, caller:
|
|
1079
|
+
emit SetMetadata({description: description, externalUrl: url, baseUri: baseUri, caller: _msgSender()});
|
|
1059
1080
|
}
|
|
1060
1081
|
|
|
1061
1082
|
/// @notice The owner of this contract can store SVG files for product IDs.
|
|
1062
1083
|
/// @param upcs The universal product codes of the products having SVGs stored.
|
|
1063
1084
|
/// @param svgContents The svg contents being stored, not including the parent <svg></svg> element.
|
|
1064
1085
|
function setSvgContentsOf(uint256[] memory upcs, string[] calldata svgContents) external override {
|
|
1086
|
+
if (upcs.length != svgContents.length) revert Banny721TokenUriResolver_ArrayLengthMismatch();
|
|
1087
|
+
|
|
1065
1088
|
for (uint256 i; i < upcs.length; i++) {
|
|
1066
1089
|
uint256 upc = upcs[i];
|
|
1067
1090
|
string memory svgContent = svgContents[i];
|
|
@@ -1081,7 +1104,7 @@ contract Banny721TokenUriResolver is
|
|
|
1081
1104
|
// Store the svg contents.
|
|
1082
1105
|
_svgContentOf[upc] = svgContent;
|
|
1083
1106
|
|
|
1084
|
-
emit SetSvgContent({upc: upc, svgContent: svgContent, caller:
|
|
1107
|
+
emit SetSvgContent({upc: upc, svgContent: svgContent, caller: _msgSender()});
|
|
1085
1108
|
}
|
|
1086
1109
|
}
|
|
1087
1110
|
|
|
@@ -1090,6 +1113,8 @@ contract Banny721TokenUriResolver is
|
|
|
1090
1113
|
/// @param upcs The universal product codes of the products having SVG hashes stored.
|
|
1091
1114
|
/// @param svgHashes The svg hashes being stored, not including the parent <svg></svg> element.
|
|
1092
1115
|
function setSvgHashesOf(uint256[] memory upcs, bytes32[] memory svgHashes) external override onlyOwner {
|
|
1116
|
+
if (upcs.length != svgHashes.length) revert Banny721TokenUriResolver_ArrayLengthMismatch();
|
|
1117
|
+
|
|
1093
1118
|
for (uint256 i; i < upcs.length; i++) {
|
|
1094
1119
|
uint256 upc = upcs[i];
|
|
1095
1120
|
bytes32 svgHash = svgHashes[i];
|
|
@@ -1100,7 +1125,7 @@ contract Banny721TokenUriResolver is
|
|
|
1100
1125
|
// Store the svg contents.
|
|
1101
1126
|
svgHashOf[upc] = svgHash;
|
|
1102
1127
|
|
|
1103
|
-
emit SetSvgHash({upc: upc, svgHash: svgHash, caller:
|
|
1128
|
+
emit SetSvgHash({upc: upc, svgHash: svgHash, caller: _msgSender()});
|
|
1104
1129
|
}
|
|
1105
1130
|
}
|
|
1106
1131
|
|
|
@@ -1148,6 +1173,7 @@ contract Banny721TokenUriResolver is
|
|
|
1148
1173
|
|
|
1149
1174
|
// Check if the call is being made either by the outfit's owner or the owner of the banny body currently
|
|
1150
1175
|
// wearing it.
|
|
1176
|
+
// slither-disable-next-line calls-loop
|
|
1151
1177
|
if (_msgSender() != IERC721(hook).ownerOf(outfitId)) {
|
|
1152
1178
|
// Get the banny body currently wearing this outfit.
|
|
1153
1179
|
uint256 wearerId = wearerOf({hook: hook, outfitId: outfitId});
|
|
@@ -1156,6 +1182,7 @@ contract Banny721TokenUriResolver is
|
|
|
1156
1182
|
if (wearerId == 0) revert Banny721TokenUriResolver_UnauthorizedOutfit();
|
|
1157
1183
|
|
|
1158
1184
|
// If the outfit is worn, the banny body's owner can also authorize its use.
|
|
1185
|
+
// slither-disable-next-line calls-loop
|
|
1159
1186
|
if (_msgSender() != IERC721(hook).ownerOf(wearerId)) {
|
|
1160
1187
|
revert Banny721TokenUriResolver_UnauthorizedOutfit();
|
|
1161
1188
|
}
|
|
@@ -1200,8 +1227,8 @@ contract Banny721TokenUriResolver is
|
|
|
1200
1227
|
// `_attachedOutfitIdsOf` hasnt been called yet, so the wearer should still be the banny body being
|
|
1201
1228
|
// decorated.
|
|
1202
1229
|
if (previousOutfitId != outfitId && wearerOf({hook: hook, outfitId: previousOutfitId}) == bannyBodyId) {
|
|
1203
|
-
//
|
|
1204
|
-
|
|
1230
|
+
// Use try-transfer: the previous outfit may have been burned or its tier removed.
|
|
1231
|
+
_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId});
|
|
1205
1232
|
}
|
|
1206
1233
|
|
|
1207
1234
|
if (++previousOutfitIndex < previousOutfitIds.length) {
|
|
@@ -1221,7 +1248,7 @@ contract Banny721TokenUriResolver is
|
|
|
1221
1248
|
_wearerOf[hook][outfitId] = bannyBodyId;
|
|
1222
1249
|
|
|
1223
1250
|
// Transfer the outfit to this contract.
|
|
1224
|
-
// slither-disable-next-line reentrancy-no-eth
|
|
1251
|
+
// slither-disable-next-line reentrancy-no-eth,calls-loop
|
|
1225
1252
|
if (IERC721(hook).ownerOf(outfitId) != address(this)) {
|
|
1226
1253
|
_transferFrom({hook: hook, from: _msgSender(), to: address(this), assetId: outfitId});
|
|
1227
1254
|
}
|
|
@@ -1239,8 +1266,8 @@ contract Banny721TokenUriResolver is
|
|
|
1239
1266
|
// `_attachedOutfitIdsOf` hasnt been called yet, so the wearer should still be the banny body being
|
|
1240
1267
|
// decorated.
|
|
1241
1268
|
if (wearerOf({hook: hook, outfitId: previousOutfitId}) == bannyBodyId) {
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1269
|
+
// Use try-transfer: the previous outfit may have been burned or its tier removed.
|
|
1270
|
+
_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId});
|
|
1244
1271
|
}
|
|
1245
1272
|
|
|
1246
1273
|
if (++previousOutfitIndex < previousOutfitIds.length) {
|
|
@@ -1268,12 +1295,6 @@ contract Banny721TokenUriResolver is
|
|
|
1268
1295
|
|
|
1269
1296
|
// If the background is changing, add the latest background and transfer the old one back to the owner.
|
|
1270
1297
|
if (backgroundId != previousBackgroundId || userOfPreviousBackground != bannyBodyId) {
|
|
1271
|
-
// If there's a previous background worn by this banny, transfer it back to the owner.
|
|
1272
|
-
if (userOfPreviousBackground == bannyBodyId) {
|
|
1273
|
-
// Transfer the previous background to the owner of the banny.
|
|
1274
|
-
_transferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId});
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
1298
|
// Add the background if needed.
|
|
1278
1299
|
if (backgroundId != 0) {
|
|
1279
1300
|
// Keep a reference to the background's owner.
|
|
@@ -1301,21 +1322,27 @@ contract Banny721TokenUriResolver is
|
|
|
1301
1322
|
revert Banny721TokenUriResolver_UnrecognizedBackground();
|
|
1302
1323
|
}
|
|
1303
1324
|
|
|
1304
|
-
//
|
|
1305
|
-
// slither-disable-next-line reentrancy-no-eth
|
|
1325
|
+
// Effects: update all state before any external transfers (CEI pattern).
|
|
1306
1326
|
_attachedBackgroundIdOf[hook][bannyBodyId] = backgroundId;
|
|
1307
|
-
|
|
1308
|
-
// Store the banny that's in the background.
|
|
1309
|
-
// slither-disable-next-line reentrancy-no-eth
|
|
1310
1327
|
_userOf[hook][backgroundId] = bannyBodyId;
|
|
1311
1328
|
|
|
1312
|
-
//
|
|
1329
|
+
// Interactions: try-transfer the previous background back (may have been burned).
|
|
1330
|
+
if (userOfPreviousBackground == bannyBodyId) {
|
|
1331
|
+
_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
// Transfer the new background to this contract if it's not already owned by this contract.
|
|
1313
1335
|
if (owner != address(this)) {
|
|
1314
1336
|
_transferFrom({hook: hook, from: _msgSender(), to: address(this), assetId: backgroundId});
|
|
1315
1337
|
}
|
|
1316
1338
|
} else {
|
|
1317
|
-
//
|
|
1339
|
+
// Effects: clear the background state before any external transfer.
|
|
1318
1340
|
_attachedBackgroundIdOf[hook][bannyBodyId] = 0;
|
|
1341
|
+
|
|
1342
|
+
// Interactions: try-transfer the previous background back (may have been burned).
|
|
1343
|
+
if (userOfPreviousBackground == bannyBodyId) {
|
|
1344
|
+
_tryTransferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousBackgroundId});
|
|
1345
|
+
}
|
|
1319
1346
|
}
|
|
1320
1347
|
}
|
|
1321
1348
|
}
|
|
@@ -1328,4 +1355,15 @@ contract Banny721TokenUriResolver is
|
|
|
1328
1355
|
function _transferFrom(address hook, address from, address to, uint256 assetId) internal {
|
|
1329
1356
|
IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId});
|
|
1330
1357
|
}
|
|
1358
|
+
|
|
1359
|
+
/// @notice Try to transfer a token, silently succeeding if the transfer fails (e.g. token was burned).
|
|
1360
|
+
/// @dev Used when returning previously equipped items that may no longer exist.
|
|
1361
|
+
/// @param hook The 721 contract of the token being transferred.
|
|
1362
|
+
/// @param from The address to transfer the token from.
|
|
1363
|
+
/// @param to The address to transfer the token to.
|
|
1364
|
+
/// @param assetId The ID of the token to transfer.
|
|
1365
|
+
function _tryTransferFrom(address hook, address from, address to, uint256 assetId) internal {
|
|
1366
|
+
// slither-disable-next-line reentrancy-no-eth
|
|
1367
|
+
try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {} catch {}
|
|
1368
|
+
}
|
|
1331
1369
|
}
|
|
@@ -190,21 +190,22 @@ contract TestBanny721TokenUriResolver is Test {
|
|
|
190
190
|
assertEq(resolver.svgBaseUri(), "https://svg.example.com/");
|
|
191
191
|
}
|
|
192
192
|
|
|
193
|
-
function
|
|
193
|
+
function test_setMetadata_clearsWithEmptyStrings() public {
|
|
194
194
|
vm.startPrank(deployer);
|
|
195
195
|
resolver.setMetadata("Initial desc", "https://initial.url", "https://initial.base/");
|
|
196
196
|
|
|
197
|
-
// Passing empty strings should
|
|
197
|
+
// Passing empty strings should clear all fields (L-59 fix).
|
|
198
198
|
resolver.setMetadata("", "", "");
|
|
199
|
-
assertEq(resolver.svgDescription(), "
|
|
200
|
-
assertEq(resolver.svgExternalUrl(), "
|
|
201
|
-
assertEq(resolver.svgBaseUri(), "
|
|
199
|
+
assertEq(resolver.svgDescription(), "");
|
|
200
|
+
assertEq(resolver.svgExternalUrl(), "");
|
|
201
|
+
assertEq(resolver.svgBaseUri(), "");
|
|
202
202
|
|
|
203
|
-
// Passing one non-empty value should
|
|
203
|
+
// Passing one non-empty value should update that field and clear others.
|
|
204
|
+
resolver.setMetadata("Initial desc", "https://initial.url", "https://initial.base/");
|
|
204
205
|
resolver.setMetadata("Updated desc", "", "");
|
|
205
206
|
assertEq(resolver.svgDescription(), "Updated desc");
|
|
206
|
-
assertEq(resolver.svgExternalUrl(), "
|
|
207
|
-
assertEq(resolver.svgBaseUri(), "
|
|
207
|
+
assertEq(resolver.svgExternalUrl(), "");
|
|
208
|
+
assertEq(resolver.svgBaseUri(), "");
|
|
208
209
|
vm.stopPrank();
|
|
209
210
|
}
|
|
210
211
|
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
6
|
+
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
|
|
7
|
+
|
|
8
|
+
import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
|
|
9
|
+
|
|
10
|
+
/// @notice Mock hook that records transfer ordering so we can verify CEI.
|
|
11
|
+
contract MockHookI25 {
|
|
12
|
+
mapping(uint256 => address) public ownerOf;
|
|
13
|
+
address public immutable MOCK_STORE;
|
|
14
|
+
mapping(address => mapping(address => bool)) public isApprovedForAll;
|
|
15
|
+
|
|
16
|
+
/// @notice Tracks order of transfers for CEI verification.
|
|
17
|
+
uint256[] public transferLog;
|
|
18
|
+
|
|
19
|
+
constructor(address store) {
|
|
20
|
+
MOCK_STORE = store;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function STORE() external view returns (address) {
|
|
24
|
+
return MOCK_STORE;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
28
|
+
ownerOf[tokenId] = owner;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setApprovalForAll(address operator, bool approved) external {
|
|
32
|
+
isApprovedForAll[msg.sender][operator] = approved;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function safeTransferFrom(address from, address to, uint256 tokenId) external {
|
|
36
|
+
require(
|
|
37
|
+
msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
|
|
38
|
+
"MockHook: not authorized"
|
|
39
|
+
);
|
|
40
|
+
ownerOf[tokenId] = to;
|
|
41
|
+
transferLog.push(tokenId);
|
|
42
|
+
if (to.code.length > 0) {
|
|
43
|
+
bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
|
|
44
|
+
require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function transferLogLength() external view returns (uint256) {
|
|
49
|
+
return transferLog.length;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function pricingContext() external pure returns (uint256, uint256, uint256) {
|
|
53
|
+
return (1, 18, 0);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function baseURI() external pure returns (string memory) {
|
|
57
|
+
return "ipfs://";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/// @notice Minimal mock store.
|
|
62
|
+
contract MockStoreI25 {
|
|
63
|
+
mapping(address => mapping(uint256 => JB721Tier)) public tiers;
|
|
64
|
+
|
|
65
|
+
function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
|
|
66
|
+
tiers[hook][tokenId] = tier;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
70
|
+
return tiers[hook][tokenId];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
|
|
74
|
+
return bytes32(0);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/// @notice Regression test for I-25: _decorateBannyWithBackground follows CEI pattern.
|
|
79
|
+
/// @dev The fix reordered state writes (effects) before external transfers (interactions)
|
|
80
|
+
/// in _decorateBannyWithBackground. This test verifies that after a background replacement,
|
|
81
|
+
/// state is consistent and both the old background return and new background custody work.
|
|
82
|
+
contract I25_CEIReorderTest is Test {
|
|
83
|
+
Banny721TokenUriResolver resolver;
|
|
84
|
+
MockHookI25 hook;
|
|
85
|
+
MockStoreI25 store;
|
|
86
|
+
|
|
87
|
+
address deployer = makeAddr("deployer");
|
|
88
|
+
address alice = makeAddr("alice");
|
|
89
|
+
|
|
90
|
+
uint256 constant BODY_TOKEN = 4_000_000_001;
|
|
91
|
+
uint256 constant BG_TOKEN_A = 5_000_000_001; // background A
|
|
92
|
+
uint256 constant BG_TOKEN_B = 5_000_000_002; // background B
|
|
93
|
+
|
|
94
|
+
function setUp() public {
|
|
95
|
+
store = new MockStoreI25();
|
|
96
|
+
hook = new MockHookI25(address(store));
|
|
97
|
+
|
|
98
|
+
vm.prank(deployer);
|
|
99
|
+
resolver = new Banny721TokenUriResolver(
|
|
100
|
+
"<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Set up body (category 0).
|
|
104
|
+
_setupTier(BODY_TOKEN, 4, 0);
|
|
105
|
+
// Set up two backgrounds (category 1).
|
|
106
|
+
_setupTier(BG_TOKEN_A, 5, 1);
|
|
107
|
+
_setupTier(BG_TOKEN_B, 6, 1);
|
|
108
|
+
|
|
109
|
+
hook.setOwner(BODY_TOKEN, alice);
|
|
110
|
+
hook.setOwner(BG_TOKEN_A, alice);
|
|
111
|
+
hook.setOwner(BG_TOKEN_B, alice);
|
|
112
|
+
|
|
113
|
+
vm.prank(alice);
|
|
114
|
+
hook.setApprovalForAll(address(resolver), true);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// @notice Replacing background A with B should update state correctly and return A to caller.
|
|
118
|
+
function test_replaceBackground_stateConsistentAfterCEIReorder() public {
|
|
119
|
+
uint256[] memory emptyOutfits = new uint256[](0);
|
|
120
|
+
|
|
121
|
+
// Attach background A.
|
|
122
|
+
vm.prank(alice);
|
|
123
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BG_TOKEN_A, emptyOutfits);
|
|
124
|
+
|
|
125
|
+
// Verify background A is attached.
|
|
126
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_A), BODY_TOKEN, "BG_A should be used by body");
|
|
127
|
+
assertEq(hook.ownerOf(BG_TOKEN_A), address(resolver), "BG_A should be held by resolver");
|
|
128
|
+
|
|
129
|
+
// Replace background A with B.
|
|
130
|
+
vm.prank(alice);
|
|
131
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BG_TOKEN_B, emptyOutfits);
|
|
132
|
+
|
|
133
|
+
// Verify state is consistent after replacement.
|
|
134
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_B), BODY_TOKEN, "BG_B should now be used by body");
|
|
135
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_A), 0, "BG_A should no longer be used");
|
|
136
|
+
assertEq(hook.ownerOf(BG_TOKEN_B), address(resolver), "BG_B should be held by resolver");
|
|
137
|
+
assertEq(hook.ownerOf(BG_TOKEN_A), alice, "BG_A should be returned to alice");
|
|
138
|
+
|
|
139
|
+
// Verify assetIdsOf reflects the new background.
|
|
140
|
+
(uint256 backgroundId,) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
141
|
+
assertEq(backgroundId, BG_TOKEN_B, "assetIdsOf should show BG_B");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/// @notice Clearing a background (setting to 0) should update state before transfer.
|
|
145
|
+
function test_clearBackground_stateConsistentAfterCEIReorder() public {
|
|
146
|
+
uint256[] memory emptyOutfits = new uint256[](0);
|
|
147
|
+
|
|
148
|
+
// Attach background A.
|
|
149
|
+
vm.prank(alice);
|
|
150
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BG_TOKEN_A, emptyOutfits);
|
|
151
|
+
|
|
152
|
+
// Clear background.
|
|
153
|
+
vm.prank(alice);
|
|
154
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, emptyOutfits);
|
|
155
|
+
|
|
156
|
+
// State should be cleared and token returned.
|
|
157
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_A), 0, "BG_A should no longer be used");
|
|
158
|
+
assertEq(hook.ownerOf(BG_TOKEN_A), alice, "BG_A should be returned to alice");
|
|
159
|
+
|
|
160
|
+
(uint256 backgroundId,) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
161
|
+
assertEq(backgroundId, 0, "assetIdsOf should show no background");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// @notice Setting the same background again should be a no-op (no redundant transfers).
|
|
165
|
+
function test_sameBackground_noRedundantTransfer() public {
|
|
166
|
+
uint256[] memory emptyOutfits = new uint256[](0);
|
|
167
|
+
|
|
168
|
+
// Attach background A.
|
|
169
|
+
vm.prank(alice);
|
|
170
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BG_TOKEN_A, emptyOutfits);
|
|
171
|
+
|
|
172
|
+
uint256 logBefore = hook.transferLogLength();
|
|
173
|
+
|
|
174
|
+
// Re-attach same background.
|
|
175
|
+
vm.prank(alice);
|
|
176
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BG_TOKEN_A, emptyOutfits);
|
|
177
|
+
|
|
178
|
+
// No new transfers should have occurred for the background.
|
|
179
|
+
uint256 logAfter = hook.transferLogLength();
|
|
180
|
+
assertEq(logAfter, logBefore, "No transfers should occur when re-attaching same background");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
|
|
184
|
+
JB721Tier memory tier = JB721Tier({
|
|
185
|
+
id: tierId,
|
|
186
|
+
price: 0.01 ether,
|
|
187
|
+
remainingSupply: 100,
|
|
188
|
+
initialSupply: 100,
|
|
189
|
+
votingUnits: 0,
|
|
190
|
+
reserveFrequency: 0,
|
|
191
|
+
reserveBeneficiary: address(0),
|
|
192
|
+
encodedIPFSUri: bytes32(0),
|
|
193
|
+
category: category,
|
|
194
|
+
discountPercent: 0,
|
|
195
|
+
allowOwnerMint: false,
|
|
196
|
+
transfersPausable: false,
|
|
197
|
+
cannotBeRemoved: false,
|
|
198
|
+
cannotIncreaseDiscountPercent: false,
|
|
199
|
+
splitPercent: 0,
|
|
200
|
+
resolvedUri: ""
|
|
201
|
+
});
|
|
202
|
+
store.setTier(address(hook), tokenId, tier);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.26;
|
|
3
|
+
|
|
4
|
+
import "forge-std/Test.sol";
|
|
5
|
+
import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
|
|
6
|
+
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
|
|
7
|
+
|
|
8
|
+
import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
|
|
9
|
+
import {IBanny721TokenUriResolver} from "../../src/interfaces/IBanny721TokenUriResolver.sol";
|
|
10
|
+
|
|
11
|
+
/// @notice Minimal mock hook.
|
|
12
|
+
contract MockHook56 {
|
|
13
|
+
mapping(uint256 => address) public ownerOf;
|
|
14
|
+
address public immutable MOCK_STORE;
|
|
15
|
+
mapping(address => mapping(address => bool)) public isApprovedForAll;
|
|
16
|
+
|
|
17
|
+
constructor(address store) {
|
|
18
|
+
MOCK_STORE = store;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function STORE() external view returns (address) {
|
|
22
|
+
return MOCK_STORE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
26
|
+
ownerOf[tokenId] = owner;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setApprovalForAll(address operator, bool approved) external {
|
|
30
|
+
isApprovedForAll[msg.sender][operator] = approved;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeTransferFrom(address from, address to, uint256 tokenId) external {
|
|
34
|
+
require(
|
|
35
|
+
msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
|
|
36
|
+
"MockHook: not authorized"
|
|
37
|
+
);
|
|
38
|
+
ownerOf[tokenId] = to;
|
|
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
|
+
/// @notice Minimal mock store.
|
|
55
|
+
contract MockStore56 {
|
|
56
|
+
mapping(address => mapping(uint256 => JB721Tier)) public tiers;
|
|
57
|
+
|
|
58
|
+
function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
|
|
59
|
+
tiers[hook][tokenId] = tier;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
63
|
+
return tiers[hook][tokenId];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
|
|
67
|
+
return bytes32(0);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// @notice Regression test: L-56 -- events should emit _msgSender(), not msg.sender.
|
|
72
|
+
contract L56_MsgSenderEventsTest is Test {
|
|
73
|
+
Banny721TokenUriResolver resolver;
|
|
74
|
+
MockHook56 hook;
|
|
75
|
+
MockStore56 store;
|
|
76
|
+
|
|
77
|
+
address deployer = makeAddr("deployer");
|
|
78
|
+
|
|
79
|
+
function setUp() public {
|
|
80
|
+
store = new MockStore56();
|
|
81
|
+
hook = new MockHook56(address(store));
|
|
82
|
+
|
|
83
|
+
vm.prank(deployer);
|
|
84
|
+
resolver = new Banny721TokenUriResolver(
|
|
85
|
+
"<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// @notice Verify SetProductName event emits _msgSender() (== deployer) not msg.sender.
|
|
90
|
+
function test_setProductNames_emitsCorrectCaller() public {
|
|
91
|
+
uint256[] memory upcs = new uint256[](1);
|
|
92
|
+
upcs[0] = 100;
|
|
93
|
+
string[] memory names = new string[](1);
|
|
94
|
+
names[0] = "Test";
|
|
95
|
+
|
|
96
|
+
vm.expectEmit(true, true, true, true);
|
|
97
|
+
emit IBanny721TokenUriResolver.SetProductName({upc: 100, name: "Test", caller: deployer});
|
|
98
|
+
|
|
99
|
+
vm.prank(deployer);
|
|
100
|
+
resolver.setProductNames(upcs, names);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// @notice Verify SetMetadata event emits _msgSender() (== deployer) not msg.sender.
|
|
104
|
+
function test_setMetadata_emitsCorrectCaller() public {
|
|
105
|
+
vm.expectEmit(true, true, true, true);
|
|
106
|
+
emit IBanny721TokenUriResolver.SetMetadata({
|
|
107
|
+
description: "desc", externalUrl: "url", baseUri: "base", caller: deployer
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
vm.prank(deployer);
|
|
111
|
+
resolver.setMetadata("desc", "url", "base");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// @notice Verify SetSvgHash event emits _msgSender() (== deployer) not msg.sender.
|
|
115
|
+
function test_setSvgHashesOf_emitsCorrectCaller() public {
|
|
116
|
+
uint256[] memory upcs = new uint256[](1);
|
|
117
|
+
upcs[0] = 100;
|
|
118
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
119
|
+
hashes[0] = keccak256("test");
|
|
120
|
+
|
|
121
|
+
vm.expectEmit(true, true, true, true);
|
|
122
|
+
emit IBanny721TokenUriResolver.SetSvgHash({upc: 100, svgHash: keccak256("test"), caller: deployer});
|
|
123
|
+
|
|
124
|
+
vm.prank(deployer);
|
|
125
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// @notice Verify SetSvgContent event emits _msgSender() not msg.sender.
|
|
129
|
+
function test_setSvgContentsOf_emitsCorrectCaller() public {
|
|
130
|
+
string memory content = "test-svg-content";
|
|
131
|
+
|
|
132
|
+
// Store hash first.
|
|
133
|
+
uint256[] memory upcs = new uint256[](1);
|
|
134
|
+
upcs[0] = 100;
|
|
135
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
136
|
+
hashes[0] = keccak256(abi.encodePacked(content));
|
|
137
|
+
|
|
138
|
+
vm.prank(deployer);
|
|
139
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
140
|
+
|
|
141
|
+
// Now store content -- anyone can call this.
|
|
142
|
+
string[] memory contents = new string[](1);
|
|
143
|
+
contents[0] = content;
|
|
144
|
+
|
|
145
|
+
address alice = makeAddr("alice");
|
|
146
|
+
vm.expectEmit(true, true, true, true);
|
|
147
|
+
emit IBanny721TokenUriResolver.SetSvgContent({upc: 100, svgContent: content, caller: alice});
|
|
148
|
+
|
|
149
|
+
vm.prank(alice);
|
|
150
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
151
|
+
}
|
|
152
|
+
}
|