@bannynet/core-v6 0.0.9 → 0.0.11

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.
@@ -127,6 +127,12 @@ contract Banny721TokenUriResolver is
127
127
  /// @dev Naked Banny's will only be shown with outfits currently owned by the owner of the banny body.
128
128
  /// @dev NOTE: Equipped outfits travel with the banny body NFT on transfer. When a body is transferred,
129
129
  /// the new owner inherits all equipped outfits and can unequip them to receive the outfit NFTs.
130
+ // The _attachedOutfitIdsOf array grows with each attachment. Gas cost for operations
131
+ // iterating this array increases linearly. In practice, Bannys have a small, bounded number of outfit slots
132
+ // (< 20), making gas cost manageable. No explicit cap is needed given the natural slot limit.
133
+ // This array may contain stale entries (e.g. outfits transferred away externally). Stale entries are
134
+ // filtered at read time via `outfitsOf` and `wearerOf`, which check current ownership/attachment status.
135
+ // This lazy reconciliation avoids extra storage writes on every transfer.
130
136
  /// @custom:param hook The hook address of the collection.
131
137
  /// @custom:param bannyBodyId The ID of the banny body of the outfits.
132
138
  mapping(address hook => mapping(uint256 bannyBodyId => uint256[])) internal _attachedOutfitIdsOf;
@@ -616,6 +622,9 @@ contract Banny721TokenUriResolver is
616
622
  }
617
623
 
618
624
  /// @notice Encode the token URI JSON with base64.
625
+ // Metadata strings (name, description, external_url) are set by the contract owner, not by users.
626
+ // No JSON escaping is applied — the owner is trusted to provide valid values. On-chain JSON is consumed
627
+ // by off-chain indexers and UIs, not rendered in a browser context where XSS would apply.
619
628
  function _encodeTokenUri(
620
629
  uint256 tokenId,
621
630
  JB721Tier memory product,
@@ -1025,6 +1034,9 @@ contract Banny721TokenUriResolver is
1025
1034
  }
1026
1035
 
1027
1036
  /// @dev Make sure tokens can be received if the transaction was initiated by this contract.
1037
+ // NFTs sent via transferFrom (not safeTransferFrom) bypass onERC721Received and cannot be
1038
+ // tracked or recovered. This is an inherent ERC-721 limitation — the contract cannot prevent non-safe
1039
+ // transfers. Users and UIs should always use safeTransferFrom.
1028
1040
  /// @param operator The address that initiated the transaction.
1029
1041
  /// @param from The address that initiated the transfer.
1030
1042
  /// @param tokenId The ID of the token being transferred.
@@ -1073,6 +1085,8 @@ contract Banny721TokenUriResolver is
1073
1085
  }
1074
1086
 
1075
1087
  /// @notice Allows the owner to set the product's name.
1088
+ /// @dev Product names are mutable — the owner can update them at any time. This is intentional to allow
1089
+ /// corrections and localization. Names are only used in metadata and do not affect on-chain logic.
1076
1090
  /// @param upcs The universal product codes of the products having their name stored.
1077
1091
  /// @param names The names of the products.
1078
1092
  function setProductNames(uint256[] memory upcs, string[] memory names) external override onlyOwner {
@@ -1142,6 +1156,16 @@ contract Banny721TokenUriResolver is
1142
1156
  // ---------------------- internal transactions ---------------------- //
1143
1157
  //*********************************************************************//
1144
1158
 
1159
+ /// @notice Revert if an equipped asset is being reassigned away from a locked source body.
1160
+ /// @param hook The hook storing the assets.
1161
+ /// @param bannyBodyId The body currently using the asset.
1162
+ /// @param exemptBodyId The destination body currently being decorated.
1163
+ function _revertIfBodyLocked(address hook, uint256 bannyBodyId, uint256 exemptBodyId) internal view {
1164
+ if (bannyBodyId != 0 && bannyBodyId != exemptBodyId && outfitLockedUntil[hook][bannyBodyId] > block.timestamp) {
1165
+ revert Banny721TokenUriResolver_OutfitChangesLocked();
1166
+ }
1167
+ }
1168
+
1145
1169
  /// @notice Add a background to a banny body.
1146
1170
  /// @param hook The hook storing the assets.
1147
1171
  /// @param bannyBodyId The ID of the banny body being dressed.
@@ -1172,6 +1196,9 @@ contract Banny721TokenUriResolver is
1172
1196
  if (_msgSender() != IERC721(hook).ownerOf(userId)) {
1173
1197
  revert Banny721TokenUriResolver_UnauthorizedBackground();
1174
1198
  }
1199
+
1200
+ // A locked source body keeps its equipped background until the lock expires.
1201
+ _revertIfBodyLocked({hook: hook, bannyBodyId: userId, exemptBodyId: bannyBodyId});
1175
1202
  }
1176
1203
 
1177
1204
  // Get the background's product info.
@@ -1260,6 +1287,9 @@ contract Banny721TokenUriResolver is
1260
1287
  if (_msgSender() != IERC721(hook).ownerOf(wearerId)) {
1261
1288
  revert Banny721TokenUriResolver_UnauthorizedOutfit();
1262
1289
  }
1290
+
1291
+ // A locked source body keeps its equipped outfits until the lock expires.
1292
+ _revertIfBodyLocked({hook: hook, bannyBodyId: wearerId, exemptBodyId: bannyBodyId});
1263
1293
  }
1264
1294
 
1265
1295
  // Get the outfit's product info.
@@ -1362,6 +1392,16 @@ contract Banny721TokenUriResolver is
1362
1392
  _attachedOutfitIdsOf[hook][bannyBodyId] = outfitIds;
1363
1393
  }
1364
1394
 
1395
+ /// @notice Check if a value is present in an array.
1396
+ /// @param value The value to search for.
1397
+ /// @param array The array to search in.
1398
+ /// @return found True if the value was found.
1399
+ function _isInArray(uint256 value, uint256[] memory array) internal pure returns (bool found) {
1400
+ for (uint256 i; i < array.length; i++) {
1401
+ if (array[i] == value) return true;
1402
+ }
1403
+ }
1404
+
1365
1405
  /// @notice Transfer a token from one address to another.
1366
1406
  /// @param hook The 721 contract of the token being transferred.
1367
1407
  /// @param from The address to transfer the token from.
@@ -1373,6 +1413,10 @@ contract Banny721TokenUriResolver is
1373
1413
 
1374
1414
  /// @notice Try to transfer a token, silently succeeding if the transfer fails (e.g. token was burned).
1375
1415
  /// @dev Used when returning previously equipped items that may no longer exist.
1416
+ // `_tryTransferFrom` may silently fail to transfer outfit NFTs, leaving them attached to the
1417
+ // Banny but owned by a different address. This is by design — the try-catch pattern prevents a single failing
1418
+ // outfit transfer from blocking the entire Banny transfer. Orphaned outfits can be recovered by the original
1419
+ // owner.
1376
1420
  /// @param hook The 721 contract of the token being transferred.
1377
1421
  /// @param from The address to transfer the token from.
1378
1422
  /// @param to The address to transfer the token to.
@@ -1381,14 +1425,4 @@ contract Banny721TokenUriResolver is
1381
1425
  // slither-disable-next-line reentrancy-no-eth
1382
1426
  try IERC721(hook).safeTransferFrom({from: from, to: to, tokenId: assetId}) {} catch {}
1383
1427
  }
1384
-
1385
- /// @notice Check if a value is present in an array.
1386
- /// @param value The value to search for.
1387
- /// @param array The array to search in.
1388
- /// @return found True if the value was found.
1389
- function _isInArray(uint256 value, uint256[] memory array) internal pure returns (bool found) {
1390
- for (uint256 i; i < array.length; i++) {
1391
- if (array[i] == value) return true;
1392
- }
1393
- }
1394
1428
  }
@@ -797,6 +797,37 @@ contract DecorateFlowTests is Test {
797
797
  assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace returned to bob");
798
798
  }
799
799
 
800
+ /// @notice A locked body keeps its equipped background until the lock expires, even if the owner also controls an
801
+ /// unlocked destination body.
802
+ function test_lock_preventsMovingBackgroundFromLockedBody() public {
803
+ vm.prank(alice);
804
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, new uint256[](0));
805
+
806
+ vm.prank(alice);
807
+ resolver.lockOutfitChangesFor(address(hook), BODY_A);
808
+
809
+ vm.prank(alice);
810
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_OutfitChangesLocked.selector);
811
+ resolver.decorateBannyWith(address(hook), BODY_B, BACKGROUND_1, new uint256[](0));
812
+ }
813
+
814
+ /// @notice A locked body keeps its equipped outfits until the lock expires, even if the owner also controls an
815
+ /// unlocked destination body.
816
+ function test_lock_preventsMovingOutfitFromLockedBody() public {
817
+ uint256[] memory outfits = new uint256[](1);
818
+ outfits[0] = NECKLACE_1;
819
+
820
+ vm.prank(alice);
821
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
822
+
823
+ vm.prank(alice);
824
+ resolver.lockOutfitChangesFor(address(hook), BODY_A);
825
+
826
+ vm.prank(alice);
827
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_OutfitChangesLocked.selector);
828
+ resolver.decorateBannyWith(address(hook), BODY_B, 0, outfits);
829
+ }
830
+
800
831
  // =========================================================================
801
832
  // SECTION 8: Complex Multi-Step Scenarios
802
833
  // =========================================================================
@@ -0,0 +1,391 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.26;
3
+
4
+ import {Test} from "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 with transfer tracking for outfit-on-transfer lifecycle tests.
11
+ contract TransferMockHook {
12
+ mapping(uint256 tokenId => address) public ownerOf;
13
+ mapping(uint256 tokenId => uint32) public tierIdOf;
14
+ mapping(uint256 tokenId => uint24) public categoryOf;
15
+ address public immutable MOCK_STORE;
16
+ mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
17
+
18
+ constructor(address store) {
19
+ MOCK_STORE = store;
20
+ }
21
+
22
+ function STORE() external view returns (address) {
23
+ return MOCK_STORE;
24
+ }
25
+
26
+ function setOwner(uint256 tokenId, address _owner) external {
27
+ ownerOf[tokenId] = _owner;
28
+ }
29
+
30
+ function setTier(uint256 tokenId, uint32 tierId, uint24 category) external {
31
+ tierIdOf[tokenId] = tierId;
32
+ categoryOf[tokenId] = category;
33
+ }
34
+
35
+ function setApprovalForAll(address operator, bool approved) external {
36
+ isApprovedForAll[msg.sender][operator] = approved;
37
+ }
38
+
39
+ function safeTransferFrom(address from, address to, uint256 tokenId) external {
40
+ require(
41
+ msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
42
+ "MockHook: not authorized"
43
+ );
44
+ ownerOf[tokenId] = to;
45
+
46
+ if (to.code.length > 0) {
47
+ bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
48
+ require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
49
+ }
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 Mock store for outfit transfer lifecycle tests.
62
+ contract TransferMockStore {
63
+ mapping(address hook => mapping(uint256 tokenId => 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
+ // forge-lint: disable-next-line(mixed-case-function)
74
+ function encodedTierIPFSUriOf(address, uint256) external pure returns (bytes32) {
75
+ return bytes32(0);
76
+ }
77
+
78
+ // forge-lint: disable-next-line(mixed-case-function)
79
+ function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
80
+ return bytes32(0);
81
+ }
82
+ }
83
+
84
+ /// @title OutfitTransferLifecycleTest
85
+ /// @notice Tests verifying that when a Banny body NFT is "transferred" (ownership changes),
86
+ /// equipped outfits remain associated with the body. The new body owner gains control
87
+ /// over the equipped items -- they can unequip them, or re-equip different ones.
88
+ ///
89
+ /// IMPORTANT: Outfits do NOT automatically follow the body during an ERC721 transfer --
90
+ /// instead, they are held by the resolver contract. The "travel" is conceptual: the
91
+ /// outfit association (wearerOf, assetIdsOf) remains tied to the body ID, so whoever
92
+ /// owns the body controls the outfits.
93
+ contract OutfitTransferLifecycleTest is Test {
94
+ Banny721TokenUriResolver resolver;
95
+ TransferMockHook hook;
96
+ TransferMockStore store;
97
+
98
+ address deployer = makeAddr("deployer");
99
+ address alice = makeAddr("alice");
100
+ address bob = makeAddr("bob");
101
+ address charlie = makeAddr("charlie");
102
+
103
+ // Token IDs: tierId * 1_000_000_000 + sequence.
104
+ // Categories: 0=Body, 1=Background, 2=Backside, 3=Necklace, 4=Head, 5=Eyes
105
+ uint256 constant BODY_A = 4_000_000_001;
106
+ uint256 constant BODY_B = 4_000_000_002;
107
+ uint256 constant BACKGROUND_1 = 5_000_000_001;
108
+ uint256 constant NECKLACE_1 = 10_000_000_001;
109
+ uint256 constant EYES_1 = 30_000_000_001;
110
+ uint256 constant MOUTH_1 = 40_000_000_001;
111
+
112
+ function setUp() public {
113
+ store = new TransferMockStore();
114
+ hook = new TransferMockHook(address(store));
115
+
116
+ vm.prank(deployer);
117
+ resolver = new Banny721TokenUriResolver(
118
+ "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
119
+ );
120
+
121
+ // Set up tier data.
122
+ _setupTier(BODY_A, 4, 0);
123
+ _setupTier(BODY_B, 4, 0);
124
+ _setupTier(BACKGROUND_1, 5, 1);
125
+ _setupTier(NECKLACE_1, 10, 3);
126
+ _setupTier(EYES_1, 30, 5);
127
+ _setupTier(MOUTH_1, 40, 7);
128
+
129
+ // Alice owns everything initially.
130
+ hook.setOwner(BODY_A, alice);
131
+ hook.setOwner(BODY_B, alice);
132
+ hook.setOwner(BACKGROUND_1, alice);
133
+ hook.setOwner(NECKLACE_1, alice);
134
+ hook.setOwner(EYES_1, alice);
135
+ hook.setOwner(MOUTH_1, alice);
136
+
137
+ // Approve resolver for all.
138
+ vm.prank(alice);
139
+ hook.setApprovalForAll(address(resolver), true);
140
+ vm.prank(bob);
141
+ hook.setApprovalForAll(address(resolver), true);
142
+ vm.prank(charlie);
143
+ hook.setApprovalForAll(address(resolver), true);
144
+ }
145
+
146
+ // =========================================================================
147
+ // TEST 1: Outfits persist on body after ownership transfer.
148
+ // The new owner can query assetIdsOf and see the same outfits.
149
+ // =========================================================================
150
+ function test_outfitsPersistAfterBodyTransfer() public {
151
+ // Alice dresses body A with necklace, eyes, mouth, and background.
152
+ uint256[] memory outfits = new uint256[](3);
153
+ outfits[0] = NECKLACE_1; // cat 3
154
+ outfits[1] = EYES_1; // cat 5
155
+ outfits[2] = MOUTH_1; // cat 7
156
+
157
+ vm.prank(alice);
158
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfits);
159
+
160
+ // Verify: all assets are equipped.
161
+ (uint256 bgId, uint256[] memory equipped) = resolver.assetIdsOf(address(hook), BODY_A);
162
+ assertEq(bgId, BACKGROUND_1, "background equipped");
163
+ assertEq(equipped.length, 3, "3 outfits equipped");
164
+ assertEq(equipped[0], NECKLACE_1, "necklace equipped");
165
+ assertEq(equipped[1], EYES_1, "eyes equipped");
166
+ assertEq(equipped[2], MOUTH_1, "mouth equipped");
167
+
168
+ // Transfer body A to bob (simulated ownership change).
169
+ hook.setOwner(BODY_A, bob);
170
+
171
+ // Outfits should STILL be associated with body A.
172
+ (uint256 bgId2, uint256[] memory equipped2) = resolver.assetIdsOf(address(hook), BODY_A);
173
+ assertEq(bgId2, BACKGROUND_1, "background still equipped after transfer");
174
+ assertEq(equipped2.length, 3, "3 outfits still equipped after transfer");
175
+ assertEq(equipped2[0], NECKLACE_1, "necklace still equipped");
176
+ assertEq(equipped2[1], EYES_1, "eyes still equipped");
177
+ assertEq(equipped2[2], MOUTH_1, "mouth still equipped");
178
+
179
+ // wearerOf still points to body A.
180
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A, "necklace wearer unchanged");
181
+ assertEq(resolver.wearerOf(address(hook), EYES_1), BODY_A, "eyes wearer unchanged");
182
+ assertEq(resolver.wearerOf(address(hook), MOUTH_1), BODY_A, "mouth wearer unchanged");
183
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A, "background user unchanged");
184
+ }
185
+
186
+ // =========================================================================
187
+ // TEST 2: New body owner can unequip outfits (by decorating with empty).
188
+ // The outfits are returned to the new owner (msg.sender).
189
+ // =========================================================================
190
+ function test_newOwnerCanUnequipOutfits() public {
191
+ // Alice dresses body A.
192
+ uint256[] memory outfits = new uint256[](1);
193
+ outfits[0] = NECKLACE_1;
194
+ vm.prank(alice);
195
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfits);
196
+
197
+ // Transfer body A to bob.
198
+ hook.setOwner(BODY_A, bob);
199
+
200
+ // Bob undresses body A.
201
+ uint256[] memory empty = new uint256[](0);
202
+ vm.prank(bob);
203
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
204
+
205
+ // The necklace and background should be returned to bob (the msg.sender).
206
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace returned to new body owner");
207
+ assertEq(hook.ownerOf(BACKGROUND_1), bob, "background returned to new body owner");
208
+
209
+ // Body A has no equipped items.
210
+ (uint256 bgId, uint256[] memory equipped) = resolver.assetIdsOf(address(hook), BODY_A);
211
+ assertEq(bgId, 0, "no background");
212
+ assertEq(equipped.length, 0, "no outfits");
213
+ }
214
+
215
+ // =========================================================================
216
+ // TEST 3: New body owner can re-equip different outfits.
217
+ // =========================================================================
218
+ function test_newOwnerCanReEquipDifferentOutfits() public {
219
+ // Alice dresses body A with necklace.
220
+ uint256[] memory outfits = new uint256[](1);
221
+ outfits[0] = NECKLACE_1;
222
+ vm.prank(alice);
223
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
224
+
225
+ // Transfer body A and eyes to bob.
226
+ hook.setOwner(BODY_A, bob);
227
+ hook.setOwner(EYES_1, bob);
228
+
229
+ // Bob redresses body A with eyes instead of necklace.
230
+ uint256[] memory newOutfits = new uint256[](1);
231
+ newOutfits[0] = EYES_1;
232
+ vm.prank(bob);
233
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, newOutfits);
234
+
235
+ // Necklace returned to bob, eyes now equipped.
236
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "old necklace returned to bob");
237
+ assertEq(resolver.wearerOf(address(hook), EYES_1), BODY_A, "eyes now worn by body A");
238
+
239
+ (uint256 bgId, uint256[] memory equipped) = resolver.assetIdsOf(address(hook), BODY_A);
240
+ assertEq(bgId, 0, "no background");
241
+ assertEq(equipped.length, 1, "1 outfit");
242
+ assertEq(equipped[0], EYES_1, "eyes equipped");
243
+ }
244
+
245
+ // =========================================================================
246
+ // TEST 4: Old owner (alice) cannot interact with the body's outfits
247
+ // after transferring the body to bob.
248
+ // =========================================================================
249
+ function test_oldOwnerCannotModifyOutfitsAfterTransfer() public {
250
+ // Alice dresses body A.
251
+ uint256[] memory outfits = new uint256[](1);
252
+ outfits[0] = NECKLACE_1;
253
+ vm.prank(alice);
254
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
255
+
256
+ // Transfer body A to bob.
257
+ hook.setOwner(BODY_A, bob);
258
+
259
+ // Alice tries to undress body A. Should revert because alice no longer owns the body.
260
+ uint256[] memory empty = new uint256[](0);
261
+ vm.prank(alice);
262
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedBannyBody.selector);
263
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
264
+ }
265
+
266
+ // =========================================================================
267
+ // TEST 5: Background also "travels" with the body transfer.
268
+ // New owner can switch backgrounds.
269
+ // =========================================================================
270
+ function test_backgroundTravelsWithBody() public {
271
+ // Alice puts background on body A.
272
+ uint256[] memory empty = new uint256[](0);
273
+ vm.prank(alice);
274
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
275
+
276
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A, "background used by body A");
277
+
278
+ // Transfer body to bob.
279
+ hook.setOwner(BODY_A, bob);
280
+
281
+ // Background is still associated.
282
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A, "background still used after transfer");
283
+
284
+ // Bob removes the background.
285
+ vm.prank(bob);
286
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
287
+
288
+ // Background returned to bob.
289
+ assertEq(hook.ownerOf(BACKGROUND_1), bob, "background returned to new owner");
290
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), 0, "background no longer in use");
291
+ }
292
+
293
+ // =========================================================================
294
+ // TEST 6: Multi-outfit full lifecycle: dress -> transfer -> redress -> transfer again.
295
+ // =========================================================================
296
+ function test_fullLifecycle_dress_transfer_redress_transfer() public {
297
+ // Step 1: Alice dresses body A with necklace + eyes.
298
+ uint256[] memory outfitsA = new uint256[](2);
299
+ outfitsA[0] = NECKLACE_1; // cat 3
300
+ outfitsA[1] = EYES_1; // cat 5
301
+ vm.prank(alice);
302
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfitsA);
303
+
304
+ // Step 2: Transfer body A to bob.
305
+ hook.setOwner(BODY_A, bob);
306
+
307
+ // Step 3: Bob redresses with just mouth (removing necklace and eyes).
308
+ hook.setOwner(MOUTH_1, bob);
309
+ uint256[] memory outfitsB = new uint256[](1);
310
+ outfitsB[0] = MOUTH_1; // cat 7
311
+ vm.prank(bob);
312
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfitsB);
313
+
314
+ // Verify: necklace and eyes returned to bob, background returned to bob.
315
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace returned to bob");
316
+ assertEq(hook.ownerOf(EYES_1), bob, "eyes returned to bob");
317
+ assertEq(hook.ownerOf(BACKGROUND_1), bob, "background returned to bob");
318
+ assertEq(resolver.wearerOf(address(hook), MOUTH_1), BODY_A, "mouth now on body A");
319
+
320
+ // Step 4: Transfer body A to charlie.
321
+ hook.setOwner(BODY_A, charlie);
322
+
323
+ // Mouth is still on body A.
324
+ (uint256 bgId, uint256[] memory equipped) = resolver.assetIdsOf(address(hook), BODY_A);
325
+ assertEq(bgId, 0, "no background");
326
+ assertEq(equipped.length, 1, "1 outfit");
327
+ assertEq(equipped[0], MOUTH_1, "mouth still equipped");
328
+
329
+ // Step 5: Charlie unequips everything.
330
+ uint256[] memory empty = new uint256[](0);
331
+ vm.prank(charlie);
332
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
333
+
334
+ // Mouth returned to charlie.
335
+ assertEq(hook.ownerOf(MOUTH_1), charlie, "mouth returned to charlie");
336
+ }
337
+
338
+ // =========================================================================
339
+ // TEST 7: WARNING verification -- sellers should unequip before selling.
340
+ // If they don't, the buyer gets control of all equipped items.
341
+ // =========================================================================
342
+ function test_sellerWarning_buyerGetsEquippedItems() public {
343
+ // Alice dresses body A with expensive necklace.
344
+ uint256[] memory outfits = new uint256[](1);
345
+ outfits[0] = NECKLACE_1;
346
+ vm.prank(alice);
347
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
348
+
349
+ // Alice "sells" (transfers) body A to bob WITHOUT unequipping.
350
+ // This is the documented WARNING behavior.
351
+ hook.setOwner(BODY_A, bob);
352
+
353
+ // Bob can now unequip the necklace and keep it.
354
+ uint256[] memory empty = new uint256[](0);
355
+ vm.prank(bob);
356
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
357
+
358
+ // Bob now owns the necklace!
359
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "buyer gets the equipped outfit");
360
+ }
361
+
362
+ // =========================================================================
363
+ // HELPER
364
+ // =========================================================================
365
+
366
+ function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
367
+ hook.setTier(tokenId, tierId, category);
368
+ store.setTier(
369
+ address(hook),
370
+ tokenId,
371
+ JB721Tier({
372
+ id: tierId,
373
+ price: 0.01 ether,
374
+ remainingSupply: 100,
375
+ initialSupply: 100,
376
+ votingUnits: 0,
377
+ reserveFrequency: 0,
378
+ reserveBeneficiary: address(0),
379
+ encodedIPFSUri: bytes32(0),
380
+ category: category,
381
+ discountPercent: 0,
382
+ allowOwnerMint: false,
383
+ transfersPausable: false,
384
+ cannotBeRemoved: false,
385
+ cannotIncreaseDiscountPercent: false,
386
+ splitPercent: 0,
387
+ resolvedUri: ""
388
+ })
389
+ );
390
+ }
391
+ }