@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.
@@ -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: msg.sender});
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
- if (bytes(description).length != 0) svgDescription = description;
1055
- if (bytes(url).length != 0) svgExternalUrl = url;
1056
- if (bytes(baseUri).length != 0) svgBaseUri = baseUri;
1075
+ svgDescription = description;
1076
+ svgExternalUrl = url;
1077
+ svgBaseUri = baseUri;
1057
1078
 
1058
- emit SetMetadata({description: description, externalUrl: url, baseUri: baseUri, caller: msg.sender});
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: msg.sender});
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: msg.sender});
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
- // slither-disable-next-line reentrancy-no-eth
1204
- _transferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId});
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
- // slither-disable-next-line reentrancy-no-eth
1243
- _transferFrom({hook: hook, from: address(this), to: _msgSender(), assetId: previousOutfitId});
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
- // Store the background for the banny.
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
- // Transfer the background to this contract if it's not already owned by this contract.
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
- // slither-disable-next-line reentrancy-no-eth
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 test_setMetadata_skipsEmptyStrings() public {
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 leave existing values unchanged.
197
+ // Passing empty strings should clear all fields (L-59 fix).
198
198
  resolver.setMetadata("", "", "");
199
- assertEq(resolver.svgDescription(), "Initial desc");
200
- assertEq(resolver.svgExternalUrl(), "https://initial.url");
201
- assertEq(resolver.svgBaseUri(), "https://initial.base/");
199
+ assertEq(resolver.svgDescription(), "");
200
+ assertEq(resolver.svgExternalUrl(), "");
201
+ assertEq(resolver.svgBaseUri(), "");
202
202
 
203
- // Passing one non-empty value should only update that field.
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(), "https://initial.url");
207
- assertEq(resolver.svgBaseUri(), "https://initial.base/");
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
+ }