@bannynet/core-v6 0.0.1

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.
Files changed (48) hide show
  1. package/README.md +53 -0
  2. package/SKILLS.md +94 -0
  3. package/deployments/banny-core-v5/arbitrum/Banny721TokenUriResolver.json +1809 -0
  4. package/deployments/banny-core-v5/arbitrum_sepolia/Banny721TokenUriResolver.json +1795 -0
  5. package/deployments/banny-core-v5/base/Banny721TokenUriResolver.json +1810 -0
  6. package/deployments/banny-core-v5/base_sepolia/Banny721TokenUriResolver.json +1796 -0
  7. package/deployments/banny-core-v5/ethereum/Banny721TokenUriResolver.json +1795 -0
  8. package/deployments/banny-core-v5/optimism/Banny721TokenUriResolver.json +1810 -0
  9. package/deployments/banny-core-v5/optimism_sepolia/Banny721TokenUriResolver.json +1796 -0
  10. package/deployments/banny-core-v5/sepolia/Banny721TokenUriResolver.json +1795 -0
  11. package/foundry.toml +22 -0
  12. package/package.json +53 -0
  13. package/remappings.txt +1 -0
  14. package/script/1.Fix.s.sol +290 -0
  15. package/script/Add.Denver.s.sol +75 -0
  16. package/script/AirdropOutfits.s.sol +2302 -0
  17. package/script/Deploy.s.sol +440 -0
  18. package/script/Drop1.s.sol +979 -0
  19. package/script/MigrationContractArbitrum.sol +494 -0
  20. package/script/MigrationContractArbitrum1.sol +170 -0
  21. package/script/MigrationContractArbitrum2.sol +204 -0
  22. package/script/MigrationContractArbitrum3.sol +174 -0
  23. package/script/MigrationContractArbitrum4.sol +478 -0
  24. package/script/MigrationContractBase1.sol +444 -0
  25. package/script/MigrationContractBase2.sol +175 -0
  26. package/script/MigrationContractBase3.sol +309 -0
  27. package/script/MigrationContractBase4.sol +350 -0
  28. package/script/MigrationContractBase5.sol +259 -0
  29. package/script/MigrationContractEthereum1.sol +468 -0
  30. package/script/MigrationContractEthereum2.sol +306 -0
  31. package/script/MigrationContractEthereum3.sol +349 -0
  32. package/script/MigrationContractEthereum4.sol +352 -0
  33. package/script/MigrationContractEthereum5.sol +354 -0
  34. package/script/MigrationContractEthereum6.sol +270 -0
  35. package/script/MigrationContractEthereum7.sol +439 -0
  36. package/script/MigrationContractEthereum8.sol +385 -0
  37. package/script/MigrationContractOptimism.sol +196 -0
  38. package/script/helpers/BannyverseDeploymentLib.sol +73 -0
  39. package/script/helpers/MigrationHelper.sol +155 -0
  40. package/script/outfit_drop/generate-migration.js +3441 -0
  41. package/script/outfit_drop/raw.json +43276 -0
  42. package/slither-ci.config.json +10 -0
  43. package/sphinx.lock +521 -0
  44. package/src/Banny721TokenUriResolver.sol +1288 -0
  45. package/src/interfaces/IBanny721TokenUriResolver.sol +137 -0
  46. package/test/Banny721TokenUriResolver.t.sol +669 -0
  47. package/test/BannyAttacks.t.sol +322 -0
  48. package/test/DecorateFlow.t.sol +1056 -0
@@ -0,0 +1,1056 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity 0.8.23;
3
+
4
+ import "forge-std/Test.sol";
5
+ import {IERC721} from "@bananapus/721-hook-v5/src/abstract/ERC721.sol";
6
+ import {IJB721TiersHook} from "@bananapus/721-hook-v5/src/interfaces/IJB721TiersHook.sol";
7
+ import {IJB721TiersHookStore} from "@bananapus/721-hook-v5/src/interfaces/IJB721TiersHookStore.sol";
8
+ import {JB721Tier} from "@bananapus/721-hook-v5/src/structs/JB721Tier.sol";
9
+ import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
10
+
11
+ import {Banny721TokenUriResolver} from "../src/Banny721TokenUriResolver.sol";
12
+
13
+ /// @notice Mock hook that allows setting ownerOf for any token including token 0.
14
+ contract DecorateFlowMockHook {
15
+ mapping(uint256 tokenId => address) public ownerOf;
16
+ mapping(uint256 tokenId => uint32) public tierIdOf;
17
+ mapping(uint256 tokenId => uint24) public categoryOf;
18
+ address public immutable MOCK_STORE;
19
+ mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
20
+
21
+ constructor(address store) {
22
+ MOCK_STORE = store;
23
+ }
24
+
25
+ function STORE() external view returns (address) {
26
+ return MOCK_STORE;
27
+ }
28
+
29
+ function setOwner(uint256 tokenId, address _owner) external {
30
+ ownerOf[tokenId] = _owner;
31
+ }
32
+
33
+ function setTier(uint256 tokenId, uint32 tierId, uint24 category) external {
34
+ tierIdOf[tokenId] = tierId;
35
+ categoryOf[tokenId] = category;
36
+ }
37
+
38
+ function setApprovalForAll(address operator, bool approved) external {
39
+ isApprovedForAll[msg.sender][operator] = approved;
40
+ }
41
+
42
+ function safeTransferFrom(address from, address to, uint256 tokenId) external {
43
+ require(
44
+ msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
45
+ "MockHook: not authorized"
46
+ );
47
+ ownerOf[tokenId] = to;
48
+
49
+ if (to.code.length > 0) {
50
+ bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
51
+ require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
52
+ }
53
+ }
54
+
55
+ function pricingContext() external pure returns (uint256, uint256, uint256) {
56
+ return (1, 18, 0);
57
+ }
58
+
59
+ function baseURI() external pure returns (string memory) {
60
+ return "ipfs://";
61
+ }
62
+ }
63
+
64
+ /// @notice Mock store for decoration flow tests.
65
+ contract DecorateFlowMockStore {
66
+ mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
67
+
68
+ function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
69
+ tiers[hook][tokenId] = tier;
70
+ }
71
+
72
+ function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
73
+ return tiers[hook][tokenId];
74
+ }
75
+
76
+ function encodedTierIPFSUriOf(address, uint256) external pure returns (bytes32) {
77
+ return bytes32(0);
78
+ }
79
+
80
+ function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
81
+ return bytes32(0);
82
+ }
83
+ }
84
+
85
+ /// @title DecorateFlowTests
86
+ /// @notice Comprehensive tests for the Banny decoration (dress/undress) flow.
87
+ /// Includes tests proving why the L18 outfit authorization fix is needed.
88
+ contract DecorateFlowTests is Test {
89
+ Banny721TokenUriResolver resolver;
90
+ DecorateFlowMockHook hook;
91
+ DecorateFlowMockStore store;
92
+
93
+ address deployer = makeAddr("deployer");
94
+ address alice = makeAddr("alice");
95
+ address bob = makeAddr("bob");
96
+ address charlie = makeAddr("charlie");
97
+
98
+ // Token IDs: tierId * 1_000_000_000 + sequence.
99
+ // Categories: 0=Body, 1=Background, 2=Backside, 3=Necklace, 4=Head, 5=Eyes,
100
+ // 6=Glasses, 7=Mouth, 8=Legs, 9=Suit, 10=SuitBottom, 11=SuitTop, 12=HeadTop
101
+ uint256 constant BODY_A = 4_000_000_001;
102
+ uint256 constant BODY_B = 4_000_000_002;
103
+ uint256 constant BODY_C = 4_000_000_003;
104
+ uint256 constant BACKGROUND_1 = 5_000_000_001;
105
+ uint256 constant BACKGROUND_2 = 5_000_000_002;
106
+ uint256 constant BACKSIDE = 6_000_000_001;
107
+ uint256 constant NECKLACE_1 = 10_000_000_001;
108
+ uint256 constant NECKLACE_2 = 10_000_000_002;
109
+ uint256 constant HEAD = 20_000_000_001;
110
+ uint256 constant EYES = 30_000_000_001;
111
+ uint256 constant GLASSES = 31_000_000_001;
112
+ uint256 constant MOUTH = 40_000_000_001;
113
+ uint256 constant LEGS = 41_000_000_001;
114
+ uint256 constant SUIT = 50_000_000_001;
115
+ uint256 constant SUIT_BOTTOM = 51_000_000_001;
116
+ uint256 constant SUIT_TOP = 52_000_000_001;
117
+ uint256 constant HEADTOP = 53_000_000_001;
118
+
119
+ function setUp() public {
120
+ store = new DecorateFlowMockStore();
121
+ hook = new DecorateFlowMockHook(address(store));
122
+
123
+ vm.prank(deployer);
124
+ resolver = new Banny721TokenUriResolver(
125
+ "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
126
+ );
127
+
128
+ // Set up tier data for all tokens.
129
+ _setupTier(BODY_A, 4, 0);
130
+ _setupTier(BODY_B, 4, 0);
131
+ _setupTier(BODY_C, 4, 0);
132
+ _setupTier(BACKGROUND_1, 5, 1);
133
+ _setupTier(BACKGROUND_2, 5, 1);
134
+ _setupTier(BACKSIDE, 6, 2);
135
+ _setupTier(NECKLACE_1, 10, 3);
136
+ _setupTier(NECKLACE_2, 10, 3);
137
+ _setupTier(HEAD, 20, 4);
138
+ _setupTier(EYES, 30, 5);
139
+ _setupTier(GLASSES, 31, 6);
140
+ _setupTier(MOUTH, 40, 7);
141
+ _setupTier(LEGS, 41, 8);
142
+ _setupTier(SUIT, 50, 9);
143
+ _setupTier(SUIT_BOTTOM, 51, 10);
144
+ _setupTier(SUIT_TOP, 52, 11);
145
+ _setupTier(HEADTOP, 53, 12);
146
+
147
+ // Give alice all tokens by default.
148
+ hook.setOwner(BODY_A, alice);
149
+ hook.setOwner(BODY_B, alice);
150
+ hook.setOwner(BODY_C, alice);
151
+ hook.setOwner(BACKGROUND_1, alice);
152
+ hook.setOwner(BACKGROUND_2, alice);
153
+ hook.setOwner(BACKSIDE, alice);
154
+ hook.setOwner(NECKLACE_1, alice);
155
+ hook.setOwner(NECKLACE_2, alice);
156
+ hook.setOwner(HEAD, alice);
157
+ hook.setOwner(EYES, alice);
158
+ hook.setOwner(GLASSES, alice);
159
+ hook.setOwner(MOUTH, alice);
160
+ hook.setOwner(LEGS, alice);
161
+ hook.setOwner(SUIT, alice);
162
+ hook.setOwner(SUIT_BOTTOM, alice);
163
+ hook.setOwner(SUIT_TOP, alice);
164
+ hook.setOwner(HEADTOP, alice);
165
+
166
+ // Approve resolver for alice and bob.
167
+ vm.prank(alice);
168
+ hook.setApprovalForAll(address(resolver), true);
169
+ vm.prank(bob);
170
+ hook.setApprovalForAll(address(resolver), true);
171
+ vm.prank(charlie);
172
+ hook.setApprovalForAll(address(resolver), true);
173
+ }
174
+
175
+ // =========================================================================
176
+ // SECTION 1: L18 VULNERABILITY PROOF — Why the diff is needed
177
+ // =========================================================================
178
+
179
+ /// @notice CRITICAL TEST: Proves the L18 outfit authorization vulnerability.
180
+ ///
181
+ /// The OLD code was:
182
+ /// if (_msgSender() != owner && _msgSender() != IERC721(hook).ownerOf(wearerOf(hook, outfitId)))
183
+ ///
184
+ /// When an outfit is UNWORN, wearerOf() returns 0. The old code then calls ownerOf(0)
185
+ /// on the hook contract. If an attacker happens to own token 0 (or a hook returns
186
+ /// their address for ownerOf(0)), they pass the authorization check and can steal
187
+ /// any unworn outfit — dressing their body with someone else's NFT.
188
+ ///
189
+ /// The FIX checks wearerOf == 0 first and immediately reverts, so only the outfit's
190
+ /// direct owner can use an unworn outfit.
191
+ function test_l18_nonOwnerCannotUseUnwornOutfitViaTokenZero() public {
192
+ // Setup: Bob owns a body and token 0 on the hook. Alice owns a necklace (unworn).
193
+ hook.setOwner(BODY_A, bob);
194
+ hook.setOwner(0, bob); // Bob owns token 0 — this is the attack vector.
195
+
196
+ // The necklace is owned by alice and NOT currently worn by any body.
197
+ assertEq(hook.ownerOf(NECKLACE_1), alice, "alice owns the necklace");
198
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0, "necklace is unworn");
199
+
200
+ // Bob tries to decorate his body with alice's unworn necklace.
201
+ // OLD CODE BUG: wearerOf(hook, NECKLACE_1) = 0, ownerOf(0) = bob, so bob passes the check.
202
+ // FIXED CODE: wearerOf = 0 → immediate revert with UnauthorizedOutfit.
203
+ uint256[] memory outfits = new uint256[](1);
204
+ outfits[0] = NECKLACE_1;
205
+
206
+ vm.prank(bob);
207
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedOutfit.selector);
208
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
209
+
210
+ // Verify: necklace was NOT stolen.
211
+ assertEq(hook.ownerOf(NECKLACE_1), alice, "necklace must still belong to alice");
212
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0, "necklace must remain unworn");
213
+ }
214
+
215
+ /// @notice Variant: Even without token 0 trickery, a non-owner cannot use an unworn outfit.
216
+ function test_l18_nonOwnerCannotUseUnwornOutfit_noTokenZero() public {
217
+ // Bob owns a body. Alice owns the necklace. Token 0 has no owner (address(0)).
218
+ hook.setOwner(BODY_A, bob);
219
+
220
+ uint256[] memory outfits = new uint256[](1);
221
+ outfits[0] = NECKLACE_1;
222
+
223
+ vm.prank(bob);
224
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedOutfit.selector);
225
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
226
+ }
227
+
228
+ /// @notice The fix preserves the legitimate case: outfit owner CAN use their own unworn outfit.
229
+ function test_l18_outfitOwnerCanUseOwnUnwornOutfit() public {
230
+ // Alice owns both body and necklace. Necklace is unworn.
231
+ uint256[] memory outfits = new uint256[](1);
232
+ outfits[0] = NECKLACE_1;
233
+
234
+ vm.prank(alice);
235
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
236
+
237
+ // Necklace is now worn by body A.
238
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
239
+ assertEq(hook.ownerOf(NECKLACE_1), address(resolver), "resolver holds the necklace");
240
+ }
241
+
242
+ /// @notice The fix preserves the legitimate case: owner of a body wearing an outfit
243
+ /// can reassign it (e.g., move to another body or replace).
244
+ function test_l18_wearerOwnerCanReassignWornOutfit() public {
245
+ // Alice dresses body A with necklace.
246
+ uint256[] memory outfits = new uint256[](1);
247
+ outfits[0] = NECKLACE_1;
248
+ vm.prank(alice);
249
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
250
+
251
+ // Necklace is now worn by body A. Alice owns body A.
252
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
253
+
254
+ // Alice moves necklace from body A to body B (she owns both).
255
+ uint256[] memory outfitsB = new uint256[](1);
256
+ outfitsB[0] = NECKLACE_1;
257
+ vm.prank(alice);
258
+ resolver.decorateBannyWith(address(hook), BODY_B, 0, outfitsB);
259
+
260
+ // Necklace moved to body B. Body A no longer has it according to wearerOf.
261
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_B);
262
+ // Note: _attachedOutfitIdsOf for body A retains a stale entry (by design),
263
+ // but wearerOf correctly reports the necklace is NOT on body A.
264
+ }
265
+
266
+ /// @notice After alice's body is sold to bob (with outfit on it), bob as the new body
267
+ /// owner can re-decorate the body and the old outfit is returned to bob.
268
+ function test_l18_newBodyOwnerCanRedecorate() public {
269
+ // Alice dresses body A with necklace.
270
+ uint256[] memory outfits = new uint256[](1);
271
+ outfits[0] = NECKLACE_1;
272
+ vm.prank(alice);
273
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
274
+
275
+ // Simulate: alice sells body A to bob (mock transfer).
276
+ hook.setOwner(BODY_A, bob);
277
+
278
+ // Bob now owns body A. The necklace is worn by body A (held by resolver).
279
+ // Bob should be able to redecorate and get the necklace returned to him.
280
+ uint256[] memory empty = new uint256[](0);
281
+ vm.prank(bob);
282
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
283
+
284
+ // Necklace returned to bob (the new body owner / msg.sender).
285
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace should be returned to new body owner");
286
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0, "necklace no longer worn");
287
+ }
288
+
289
+ /// @notice Third party (charlie) cannot use an outfit worn by alice's body,
290
+ /// even if charlie owns token 0.
291
+ function test_l18_thirdPartyCannotStealWornOutfit() public {
292
+ // Alice dresses body A with necklace.
293
+ uint256[] memory outfits = new uint256[](1);
294
+ outfits[0] = NECKLACE_1;
295
+ vm.prank(alice);
296
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
297
+
298
+ // Charlie owns a different body and token 0.
299
+ hook.setOwner(BODY_C, charlie);
300
+ hook.setOwner(0, charlie);
301
+
302
+ // Charlie tries to use alice's worn necklace on body C.
303
+ // Charlie is NOT the necklace owner (resolver holds it) and NOT body A's owner.
304
+ uint256[] memory charlieOutfits = new uint256[](1);
305
+ charlieOutfits[0] = NECKLACE_1;
306
+
307
+ vm.prank(charlie);
308
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedOutfit.selector);
309
+ resolver.decorateBannyWith(address(hook), BODY_C, 0, charlieOutfits);
310
+
311
+ // Necklace still on body A.
312
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
313
+ }
314
+
315
+ /// @notice CRITICAL TEST: Same vulnerability exists for backgrounds.
316
+ ///
317
+ /// Line 1214 (OLD):
318
+ /// if (_msgSender() != owner && _msgSender() != IERC721(hook).ownerOf(userOf(hook, backgroundId)))
319
+ ///
320
+ /// When a background is NOT in use, userOf() returns 0. The old code then calls
321
+ /// ownerOf(0). If the attacker owns token 0, they bypass authorization and can
322
+ /// steal any unused background.
323
+ function test_l18_nonOwnerCannotUseUnusedBackgroundViaTokenZero() public {
324
+ // Bob owns a body and token 0. Alice owns the background (unused).
325
+ hook.setOwner(BODY_A, bob);
326
+ hook.setOwner(0, bob);
327
+
328
+ assertEq(hook.ownerOf(BACKGROUND_1), alice, "alice owns the background");
329
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), 0, "background is unused");
330
+
331
+ // Bob tries to decorate his body with alice's unused background.
332
+ uint256[] memory empty = new uint256[](0);
333
+
334
+ vm.prank(bob);
335
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedBackground.selector);
336
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
337
+
338
+ // Background must NOT be stolen.
339
+ assertEq(hook.ownerOf(BACKGROUND_1), alice, "background must still belong to alice");
340
+ }
341
+
342
+ /// @notice Variant: non-owner cannot use unused background even without token 0 trickery.
343
+ function test_l18_nonOwnerCannotUseUnusedBackground_noTokenZero() public {
344
+ hook.setOwner(BODY_A, bob);
345
+
346
+ uint256[] memory empty = new uint256[](0);
347
+
348
+ vm.prank(bob);
349
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedBackground.selector);
350
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
351
+ }
352
+
353
+ /// @notice Background owner CAN use their own unused background (fix preserves this).
354
+ function test_l18_backgroundOwnerCanUseOwnUnusedBackground() public {
355
+ uint256[] memory empty = new uint256[](0);
356
+
357
+ vm.prank(alice);
358
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
359
+
360
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A);
361
+ assertEq(hook.ownerOf(BACKGROUND_1), address(resolver));
362
+ }
363
+
364
+ // =========================================================================
365
+ // SECTION 2: Basic Dress Flow
366
+ // =========================================================================
367
+
368
+ /// @notice Dress a banny with a single outfit. Verify all state transitions.
369
+ function test_dress_singleOutfit_fullStateCheck() public {
370
+ uint256[] memory outfits = new uint256[](1);
371
+ outfits[0] = NECKLACE_1;
372
+
373
+ vm.prank(alice);
374
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
375
+
376
+ // Token transferred to resolver.
377
+ assertEq(hook.ownerOf(NECKLACE_1), address(resolver));
378
+ // Wearer set.
379
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
380
+ // assetIdsOf returns the outfit.
381
+ (uint256 bgId, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
382
+ assertEq(bgId, 0);
383
+ assertEq(outfitIds.length, 1);
384
+ assertEq(outfitIds[0], NECKLACE_1);
385
+ }
386
+
387
+ /// @notice Dress with multiple outfits across different categories.
388
+ function test_dress_multipleOutfits_ordered() public {
389
+ // backside(2), necklace(3), eyes(5), mouth(7), legs(8), suit_bottom(10), suit_top(11)
390
+ uint256[] memory outfits = new uint256[](7);
391
+ outfits[0] = BACKSIDE; // cat 2
392
+ outfits[1] = NECKLACE_1; // cat 3
393
+ outfits[2] = EYES; // cat 5
394
+ outfits[3] = MOUTH; // cat 7
395
+ outfits[4] = LEGS; // cat 8
396
+ outfits[5] = SUIT_BOTTOM; // cat 10
397
+ outfits[6] = SUIT_TOP; // cat 11
398
+
399
+ vm.prank(alice);
400
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
401
+
402
+ // Verify all outfits worn.
403
+ assertEq(resolver.wearerOf(address(hook), BACKSIDE), BODY_A);
404
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
405
+ assertEq(resolver.wearerOf(address(hook), EYES), BODY_A);
406
+ assertEq(resolver.wearerOf(address(hook), MOUTH), BODY_A);
407
+ assertEq(resolver.wearerOf(address(hook), LEGS), BODY_A);
408
+ assertEq(resolver.wearerOf(address(hook), SUIT_BOTTOM), BODY_A);
409
+ assertEq(resolver.wearerOf(address(hook), SUIT_TOP), BODY_A);
410
+
411
+ // All held by resolver.
412
+ assertEq(hook.ownerOf(BACKSIDE), address(resolver));
413
+ assertEq(hook.ownerOf(NECKLACE_1), address(resolver));
414
+ assertEq(hook.ownerOf(EYES), address(resolver));
415
+ assertEq(hook.ownerOf(MOUTH), address(resolver));
416
+ assertEq(hook.ownerOf(LEGS), address(resolver));
417
+ assertEq(hook.ownerOf(SUIT_BOTTOM), address(resolver));
418
+ assertEq(hook.ownerOf(SUIT_TOP), address(resolver));
419
+
420
+ (, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
421
+ assertEq(outfitIds.length, 7);
422
+ }
423
+
424
+ /// @notice Dress with background only (no outfits).
425
+ function test_dress_backgroundOnly() public {
426
+ uint256[] memory empty = new uint256[](0);
427
+
428
+ vm.prank(alice);
429
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
430
+
431
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A);
432
+ assertEq(hook.ownerOf(BACKGROUND_1), address(resolver));
433
+
434
+ (uint256 bgId,) = resolver.assetIdsOf(address(hook), BODY_A);
435
+ assertEq(bgId, BACKGROUND_1);
436
+ }
437
+
438
+ /// @notice Dress with both background and outfits in one call.
439
+ function test_dress_backgroundAndOutfits() public {
440
+ uint256[] memory outfits = new uint256[](2);
441
+ outfits[0] = NECKLACE_1; // cat 3
442
+ outfits[1] = EYES; // cat 5
443
+
444
+ vm.prank(alice);
445
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfits);
446
+
447
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A);
448
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
449
+ assertEq(resolver.wearerOf(address(hook), EYES), BODY_A);
450
+
451
+ (uint256 bgId, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
452
+ assertEq(bgId, BACKGROUND_1);
453
+ assertEq(outfitIds.length, 2);
454
+ }
455
+
456
+ // =========================================================================
457
+ // SECTION 3: Undress Flow
458
+ // =========================================================================
459
+
460
+ /// @notice Remove all outfits by passing empty array. Old outfits returned to caller.
461
+ function test_undress_removeAllOutfits() public {
462
+ // First: dress with necklace + eyes.
463
+ uint256[] memory outfits = new uint256[](2);
464
+ outfits[0] = NECKLACE_1;
465
+ outfits[1] = EYES;
466
+ vm.prank(alice);
467
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
468
+
469
+ // Verify dressed.
470
+ assertEq(hook.ownerOf(NECKLACE_1), address(resolver));
471
+ assertEq(hook.ownerOf(EYES), address(resolver));
472
+
473
+ // Undress: pass empty outfits.
474
+ uint256[] memory empty = new uint256[](0);
475
+ vm.prank(alice);
476
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
477
+
478
+ // Outfits returned to alice.
479
+ assertEq(hook.ownerOf(NECKLACE_1), alice, "necklace returned");
480
+ assertEq(hook.ownerOf(EYES), alice, "eyes returned");
481
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0, "necklace unworn");
482
+ assertEq(resolver.wearerOf(address(hook), EYES), 0, "eyes unworn");
483
+
484
+ (, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
485
+ assertEq(outfitIds.length, 0, "no outfits on body");
486
+ }
487
+
488
+ /// @notice Remove background by passing 0. Old background returned to caller.
489
+ function test_undress_removeBackground() public {
490
+ // Dress with background.
491
+ uint256[] memory empty = new uint256[](0);
492
+ vm.prank(alice);
493
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
494
+ assertEq(hook.ownerOf(BACKGROUND_1), address(resolver));
495
+
496
+ // Remove background.
497
+ vm.prank(alice);
498
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
499
+
500
+ assertEq(hook.ownerOf(BACKGROUND_1), alice, "background returned");
501
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), 0, "background unused");
502
+
503
+ (uint256 bgId,) = resolver.assetIdsOf(address(hook), BODY_A);
504
+ assertEq(bgId, 0, "no background on body");
505
+ }
506
+
507
+ /// @notice Strip everything (background + outfits) in one call.
508
+ function test_undress_removeEverything() public {
509
+ // Fully dress.
510
+ uint256[] memory outfits = new uint256[](2);
511
+ outfits[0] = NECKLACE_1;
512
+ outfits[1] = MOUTH;
513
+ vm.prank(alice);
514
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfits);
515
+
516
+ // Strip everything.
517
+ uint256[] memory empty = new uint256[](0);
518
+ vm.prank(alice);
519
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
520
+
521
+ // All returned.
522
+ assertEq(hook.ownerOf(BACKGROUND_1), alice);
523
+ assertEq(hook.ownerOf(NECKLACE_1), alice);
524
+ assertEq(hook.ownerOf(MOUTH), alice);
525
+
526
+ (uint256 bgId, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
527
+ assertEq(bgId, 0);
528
+ assertEq(outfitIds.length, 0);
529
+ }
530
+
531
+ /// @notice Partial undress: keep some outfits, remove others.
532
+ function test_undress_partial_keepSomeRemoveOthers() public {
533
+ // Dress with necklace(3) + eyes(5) + mouth(7).
534
+ uint256[] memory outfits = new uint256[](3);
535
+ outfits[0] = NECKLACE_1;
536
+ outfits[1] = EYES;
537
+ outfits[2] = MOUTH;
538
+ vm.prank(alice);
539
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
540
+
541
+ // Re-dress: keep only necklace and mouth (eyes removed).
542
+ uint256[] memory keepOutfits = new uint256[](2);
543
+ keepOutfits[0] = NECKLACE_1; // cat 3 — keep
544
+ keepOutfits[1] = MOUTH; // cat 7 — keep
545
+ vm.prank(alice);
546
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, keepOutfits);
547
+
548
+ // Eyes returned, necklace + mouth still worn.
549
+ assertEq(hook.ownerOf(EYES), alice, "eyes returned");
550
+ assertEq(resolver.wearerOf(address(hook), EYES), 0, "eyes unworn");
551
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A, "necklace still worn");
552
+ assertEq(resolver.wearerOf(address(hook), MOUTH), BODY_A, "mouth still worn");
553
+ }
554
+
555
+ // =========================================================================
556
+ // SECTION 4: Outfit Replacement
557
+ // =========================================================================
558
+
559
+ /// @notice Replace one necklace with another necklace (same category). Old one returned.
560
+ function test_replace_sameCategoryOutfit() public {
561
+ // Dress with necklace 1.
562
+ uint256[] memory outfits1 = new uint256[](1);
563
+ outfits1[0] = NECKLACE_1;
564
+ vm.prank(alice);
565
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits1);
566
+
567
+ // Replace with necklace 2.
568
+ uint256[] memory outfits2 = new uint256[](1);
569
+ outfits2[0] = NECKLACE_2;
570
+ vm.prank(alice);
571
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits2);
572
+
573
+ // Old returned, new worn.
574
+ assertEq(hook.ownerOf(NECKLACE_1), alice, "old necklace returned");
575
+ assertEq(hook.ownerOf(NECKLACE_2), address(resolver), "new necklace held by resolver");
576
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0);
577
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_2), BODY_A);
578
+ }
579
+
580
+ /// @notice Replace background with a different background.
581
+ function test_replace_background() public {
582
+ uint256[] memory empty = new uint256[](0);
583
+
584
+ // Attach background 1.
585
+ vm.prank(alice);
586
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
587
+
588
+ // Replace with background 2.
589
+ vm.prank(alice);
590
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_2, empty);
591
+
592
+ assertEq(hook.ownerOf(BACKGROUND_1), alice, "old bg returned");
593
+ assertEq(hook.ownerOf(BACKGROUND_2), address(resolver), "new bg held");
594
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), 0);
595
+ assertEq(resolver.userOf(address(hook), BACKGROUND_2), BODY_A);
596
+ }
597
+
598
+ /// @notice Re-dressing with the same outfit doesn't trigger unnecessary transfers.
599
+ function test_redress_sameOutfit_noUnnecessaryTransfer() public {
600
+ // Dress with necklace.
601
+ uint256[] memory outfits = new uint256[](1);
602
+ outfits[0] = NECKLACE_1;
603
+ vm.prank(alice);
604
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
605
+
606
+ // Re-dress with same necklace. Should not revert or change ownership.
607
+ vm.prank(alice);
608
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
609
+
610
+ // Still worn by body A, held by resolver.
611
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
612
+ assertEq(hook.ownerOf(NECKLACE_1), address(resolver));
613
+ }
614
+
615
+ // =========================================================================
616
+ // SECTION 5: Multi-Body Outfit Transfer
617
+ // =========================================================================
618
+
619
+ /// @notice Move outfit from body A to body B (same owner).
620
+ function test_moveOutfit_betweenOwnedBodies() public {
621
+ // Dress body A with necklace.
622
+ uint256[] memory outfits = new uint256[](1);
623
+ outfits[0] = NECKLACE_1;
624
+ vm.prank(alice);
625
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
626
+
627
+ // Move necklace to body B. Alice owns body A (the current wearer).
628
+ vm.prank(alice);
629
+ resolver.decorateBannyWith(address(hook), BODY_B, 0, outfits);
630
+
631
+ // Necklace now on body B per wearerOf.
632
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_B);
633
+ // Body A's wearerOf for the necklace is no longer body A.
634
+ // (Note: _attachedOutfitIdsOf for body A keeps a stale entry,
635
+ // but assetIdsOf filters it out via wearerOf cross-check.)
636
+ }
637
+
638
+ /// @notice Move background from body A to body B.
639
+ function test_moveBackground_betweenOwnedBodies() public {
640
+ uint256[] memory empty = new uint256[](0);
641
+
642
+ // Background on body A.
643
+ vm.prank(alice);
644
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
645
+
646
+ // Move to body B.
647
+ vm.prank(alice);
648
+ resolver.decorateBannyWith(address(hook), BODY_B, BACKGROUND_1, empty);
649
+
650
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_B);
651
+ (uint256 bgA,) = resolver.assetIdsOf(address(hook), BODY_A);
652
+ assertEq(bgA, 0, "body A has no background");
653
+ }
654
+
655
+ /// @notice After body transfer, new owner can undress and reclaim all outfits.
656
+ function test_bodyTransfer_newOwnerCanUndress() public {
657
+ // Alice fully dresses body A.
658
+ uint256[] memory outfits = new uint256[](2);
659
+ outfits[0] = NECKLACE_1;
660
+ outfits[1] = MOUTH;
661
+ vm.prank(alice);
662
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfits);
663
+
664
+ // Transfer body A to bob (simulated).
665
+ hook.setOwner(BODY_A, bob);
666
+
667
+ // Bob undresses body A — all assets returned to bob.
668
+ uint256[] memory empty = new uint256[](0);
669
+ vm.prank(bob);
670
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
671
+
672
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace goes to bob");
673
+ assertEq(hook.ownerOf(MOUTH), bob, "mouth goes to bob");
674
+ assertEq(hook.ownerOf(BACKGROUND_1), bob, "background goes to bob");
675
+ }
676
+
677
+ // =========================================================================
678
+ // SECTION 6: Authorization Edge Cases
679
+ // =========================================================================
680
+
681
+ /// @notice Non-owner of body cannot decorate it.
682
+ function test_auth_nonBodyOwnerCannotDecorate() public {
683
+ uint256[] memory empty = new uint256[](0);
684
+
685
+ vm.prank(bob);
686
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedBannyBody.selector);
687
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
688
+ }
689
+
690
+ /// @notice Body owner cannot use someone else's unworn outfit.
691
+ function test_auth_bodyOwnerCannotUseOthersUnwornOutfit() public {
692
+ // Bob owns a body, charlie owns a necklace.
693
+ hook.setOwner(BODY_C, bob);
694
+ hook.setOwner(NECKLACE_1, charlie);
695
+
696
+ uint256[] memory outfits = new uint256[](1);
697
+ outfits[0] = NECKLACE_1;
698
+
699
+ vm.prank(bob);
700
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedOutfit.selector);
701
+ resolver.decorateBannyWith(address(hook), BODY_C, 0, outfits);
702
+ }
703
+
704
+ /// @notice Body owner cannot use an outfit worn by someone else's body.
705
+ function test_auth_cannotUseOutfitWornByOthersBody() public {
706
+ // Alice dresses body A with necklace. Bob owns body B.
707
+ uint256[] memory outfits = new uint256[](1);
708
+ outfits[0] = NECKLACE_1;
709
+ vm.prank(alice);
710
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
711
+
712
+ hook.setOwner(BODY_C, bob);
713
+
714
+ // Bob tries to steal necklace from alice's body A to put on his body C.
715
+ // Bob doesn't own body A, so he can't authorize the necklace.
716
+ uint256[] memory bobOutfits = new uint256[](1);
717
+ bobOutfits[0] = NECKLACE_1;
718
+
719
+ vm.prank(bob);
720
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_UnauthorizedOutfit.selector);
721
+ resolver.decorateBannyWith(address(hook), BODY_C, 0, bobOutfits);
722
+ }
723
+
724
+ /// @notice Body owner CAN use an outfit currently worn by their own other body.
725
+ function test_auth_canUseOutfitFromOwnOtherBody() public {
726
+ // Alice dresses body A with necklace.
727
+ uint256[] memory outfits = new uint256[](1);
728
+ outfits[0] = NECKLACE_1;
729
+ vm.prank(alice);
730
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
731
+
732
+ // Alice owns body A (the wearer), so she can move the outfit to body B.
733
+ uint256[] memory outfitsB = new uint256[](1);
734
+ outfitsB[0] = NECKLACE_1;
735
+ vm.prank(alice);
736
+ resolver.decorateBannyWith(address(hook), BODY_B, 0, outfitsB);
737
+
738
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_B);
739
+ }
740
+
741
+ // =========================================================================
742
+ // SECTION 7: Lock Mechanism Interactions
743
+ // =========================================================================
744
+
745
+ /// @notice Locked banny cannot be decorated.
746
+ function test_lock_preventsDecoration() public {
747
+ vm.prank(alice);
748
+ resolver.lockOutfitChangesFor(address(hook), BODY_A);
749
+
750
+ uint256[] memory outfits = new uint256[](1);
751
+ outfits[0] = NECKLACE_1;
752
+
753
+ vm.prank(alice);
754
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_OutfitChangesLocked.selector);
755
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
756
+ }
757
+
758
+ /// @notice After lock expires, decoration succeeds.
759
+ function test_lock_canDecorateAfterExpiry() public {
760
+ vm.prank(alice);
761
+ resolver.lockOutfitChangesFor(address(hook), BODY_A);
762
+
763
+ vm.warp(block.timestamp + 7 days + 1);
764
+
765
+ uint256[] memory outfits = new uint256[](1);
766
+ outfits[0] = NECKLACE_1;
767
+
768
+ vm.prank(alice);
769
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
770
+
771
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
772
+ }
773
+
774
+ /// @notice Lock does not prevent undressing after body transfer and lock expiry.
775
+ function test_lock_newOwnerCanUndressAfterExpiry() public {
776
+ // Alice dresses and locks body A.
777
+ uint256[] memory outfits = new uint256[](1);
778
+ outfits[0] = NECKLACE_1;
779
+ vm.prank(alice);
780
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
781
+ vm.prank(alice);
782
+ resolver.lockOutfitChangesFor(address(hook), BODY_A);
783
+
784
+ // Transfer body to bob.
785
+ hook.setOwner(BODY_A, bob);
786
+
787
+ // Bob can't undress during lock.
788
+ uint256[] memory empty = new uint256[](0);
789
+ vm.prank(bob);
790
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_OutfitChangesLocked.selector);
791
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
792
+
793
+ // After lock expires, bob can undress.
794
+ vm.warp(block.timestamp + 7 days + 1);
795
+ vm.prank(bob);
796
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
797
+
798
+ assertEq(hook.ownerOf(NECKLACE_1), bob, "necklace returned to bob");
799
+ }
800
+
801
+ // =========================================================================
802
+ // SECTION 8: Complex Multi-Step Scenarios
803
+ // =========================================================================
804
+
805
+ /// @notice Full lifecycle: dress → sell body → new owner undresses → old outfits go to buyer.
806
+ function test_lifecycle_dressTransferUndress() public {
807
+ // Step 1: Alice dresses body A with necklace + eyes + background.
808
+ uint256[] memory outfits = new uint256[](2);
809
+ outfits[0] = NECKLACE_1;
810
+ outfits[1] = EYES;
811
+ vm.prank(alice);
812
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, outfits);
813
+
814
+ // Step 2: Alice sells body A to bob.
815
+ hook.setOwner(BODY_A, bob);
816
+
817
+ // Step 3: Bob undresses body A.
818
+ uint256[] memory empty = new uint256[](0);
819
+ vm.prank(bob);
820
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
821
+
822
+ // All assets now belong to bob.
823
+ assertEq(hook.ownerOf(NECKLACE_1), bob);
824
+ assertEq(hook.ownerOf(EYES), bob);
825
+ assertEq(hook.ownerOf(BACKGROUND_1), bob);
826
+ }
827
+
828
+ /// @notice Dress → replace one outfit → undress → verify only remaining are returned.
829
+ function test_lifecycle_dressReplaceUndress() public {
830
+ // Step 1: Dress with necklace 1 + mouth.
831
+ uint256[] memory outfits1 = new uint256[](2);
832
+ outfits1[0] = NECKLACE_1; // cat 3
833
+ outfits1[1] = MOUTH; // cat 7
834
+ vm.prank(alice);
835
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits1);
836
+
837
+ // Step 2: Replace necklace with necklace 2 (keep mouth).
838
+ uint256[] memory outfits2 = new uint256[](2);
839
+ outfits2[0] = NECKLACE_2; // cat 3 (replaces necklace 1)
840
+ outfits2[1] = MOUTH; // cat 7 (kept)
841
+ vm.prank(alice);
842
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits2);
843
+
844
+ // Necklace 1 returned, necklace 2 + mouth worn.
845
+ assertEq(hook.ownerOf(NECKLACE_1), alice, "old necklace returned");
846
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_2), BODY_A);
847
+ assertEq(resolver.wearerOf(address(hook), MOUTH), BODY_A);
848
+
849
+ // Step 3: Undress completely.
850
+ uint256[] memory empty = new uint256[](0);
851
+ vm.prank(alice);
852
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
853
+
854
+ assertEq(hook.ownerOf(NECKLACE_2), alice, "necklace 2 returned");
855
+ assertEq(hook.ownerOf(MOUTH), alice, "mouth returned");
856
+ }
857
+
858
+ /// @notice Multi-body scenario: dress body A, dress body B, undress body A,
859
+ /// verify body B's outfits are unaffected.
860
+ function test_lifecycle_multiBodiesIndependent() public {
861
+ // Dress body A with necklace.
862
+ uint256[] memory outfitsA = new uint256[](1);
863
+ outfitsA[0] = NECKLACE_1;
864
+ vm.prank(alice);
865
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfitsA);
866
+
867
+ // Dress body B with mouth.
868
+ uint256[] memory outfitsB = new uint256[](1);
869
+ outfitsB[0] = MOUTH;
870
+ vm.prank(alice);
871
+ resolver.decorateBannyWith(address(hook), BODY_B, 0, outfitsB);
872
+
873
+ // Undress body A.
874
+ uint256[] memory empty = new uint256[](0);
875
+ vm.prank(alice);
876
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
877
+
878
+ // Body A stripped, body B unaffected.
879
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0, "necklace removed from A");
880
+ assertEq(hook.ownerOf(NECKLACE_1), alice, "necklace returned");
881
+ assertEq(resolver.wearerOf(address(hook), MOUTH), BODY_B, "mouth still on B");
882
+ assertEq(hook.ownerOf(MOUTH), address(resolver), "mouth still held by resolver");
883
+ }
884
+
885
+ /// @notice Rapid re-dressing: dress → undress → dress → undress. No state leaks.
886
+ function test_lifecycle_rapidDressUndressCycles() public {
887
+ uint256[] memory outfits = new uint256[](1);
888
+ outfits[0] = NECKLACE_1;
889
+ uint256[] memory empty = new uint256[](0);
890
+
891
+ for (uint256 i; i < 5; i++) {
892
+ // Dress.
893
+ vm.prank(alice);
894
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
895
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
896
+ assertEq(hook.ownerOf(NECKLACE_1), address(resolver));
897
+
898
+ // Undress.
899
+ vm.prank(alice);
900
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
901
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), 0);
902
+ assertEq(hook.ownerOf(NECKLACE_1), alice);
903
+ }
904
+ }
905
+
906
+ /// @notice Three bodies, one outfit — prove outfit can only be on one body at a time.
907
+ function test_outfitExclusivity_acrossThreeBodies() public {
908
+ uint256[] memory outfits = new uint256[](1);
909
+ outfits[0] = NECKLACE_1;
910
+
911
+ // Put necklace on body A.
912
+ vm.prank(alice);
913
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
914
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_A);
915
+
916
+ // Move to body B. wearerOf should update atomically.
917
+ vm.prank(alice);
918
+ resolver.decorateBannyWith(address(hook), BODY_B, 0, outfits);
919
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_B);
920
+
921
+ // Move to body C.
922
+ vm.prank(alice);
923
+ resolver.decorateBannyWith(address(hook), BODY_C, 0, outfits);
924
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_1), BODY_C);
925
+
926
+ // Only body C shows the necklace as worn. The other bodies' stale entries
927
+ // are filtered by wearerOf's cross-check against _attachedOutfitIdsOf.
928
+ }
929
+
930
+ // =========================================================================
931
+ // SECTION 9: Category Conflict Rules
932
+ // =========================================================================
933
+
934
+ /// @notice Head + glasses conflict.
935
+ function test_conflict_headAndGlasses() public {
936
+ uint256[] memory outfits = new uint256[](2);
937
+ outfits[0] = HEAD; // cat 4
938
+ outfits[1] = GLASSES; // cat 6
939
+
940
+ vm.prank(alice);
941
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_HeadAlreadyAdded.selector);
942
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
943
+ }
944
+
945
+ /// @notice Head + headtop conflict.
946
+ function test_conflict_headAndHeadtop() public {
947
+ uint256[] memory outfits = new uint256[](2);
948
+ outfits[0] = HEAD; // cat 4
949
+ outfits[1] = HEADTOP; // cat 12
950
+
951
+ vm.prank(alice);
952
+ vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_HeadAlreadyAdded.selector);
953
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
954
+ }
955
+
956
+ /// @notice Suit bottom + suit top (no full suit) is valid.
957
+ function test_noConflict_suitBottomAndTop() public {
958
+ uint256[] memory outfits = new uint256[](2);
959
+ outfits[0] = SUIT_BOTTOM; // cat 10
960
+ outfits[1] = SUIT_TOP; // cat 11
961
+
962
+ vm.prank(alice);
963
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
964
+
965
+ assertEq(resolver.wearerOf(address(hook), SUIT_BOTTOM), BODY_A);
966
+ assertEq(resolver.wearerOf(address(hook), SUIT_TOP), BODY_A);
967
+ }
968
+
969
+ /// @notice Eyes + mouth + glasses (no head) is valid.
970
+ function test_noConflict_eyesMouthGlasses() public {
971
+ uint256[] memory outfits = new uint256[](3);
972
+ outfits[0] = EYES; // cat 5
973
+ outfits[1] = GLASSES; // cat 6
974
+ outfits[2] = MOUTH; // cat 7
975
+
976
+ vm.prank(alice);
977
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, outfits);
978
+
979
+ assertEq(resolver.wearerOf(address(hook), EYES), BODY_A);
980
+ assertEq(resolver.wearerOf(address(hook), GLASSES), BODY_A);
981
+ assertEq(resolver.wearerOf(address(hook), MOUTH), BODY_A);
982
+ }
983
+
984
+ // =========================================================================
985
+ // SECTION 10: Edge Cases
986
+ // =========================================================================
987
+
988
+ /// @notice Decorating with no outfits and no background is a no-op.
989
+ function test_edge_emptyDecoration() public {
990
+ uint256[] memory empty = new uint256[](0);
991
+
992
+ vm.prank(alice);
993
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
994
+
995
+ (uint256 bgId, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
996
+ assertEq(bgId, 0);
997
+ assertEq(outfitIds.length, 0);
998
+ }
999
+
1000
+ /// @notice Undressing an already undressed body is a no-op (no revert).
1001
+ function test_edge_undressAlreadyNaked() public {
1002
+ uint256[] memory empty = new uint256[](0);
1003
+
1004
+ // Should not revert even though there's nothing to remove.
1005
+ vm.prank(alice);
1006
+ resolver.decorateBannyWith(address(hook), BODY_A, 0, empty);
1007
+
1008
+ (uint256 bgId, uint256[] memory outfitIds) = resolver.assetIdsOf(address(hook), BODY_A);
1009
+ assertEq(bgId, 0);
1010
+ assertEq(outfitIds.length, 0);
1011
+ }
1012
+
1013
+ /// @notice Same background on same body — no unnecessary transfer.
1014
+ function test_edge_sameBackgroundSameBody() public {
1015
+ uint256[] memory empty = new uint256[](0);
1016
+
1017
+ // Attach background.
1018
+ vm.prank(alice);
1019
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
1020
+
1021
+ // Re-apply same background. Should not revert.
1022
+ vm.prank(alice);
1023
+ resolver.decorateBannyWith(address(hook), BODY_A, BACKGROUND_1, empty);
1024
+
1025
+ assertEq(resolver.userOf(address(hook), BACKGROUND_1), BODY_A);
1026
+ }
1027
+
1028
+ // =========================================================================
1029
+ // Helpers
1030
+ // =========================================================================
1031
+
1032
+ function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
1033
+ hook.setTier(tokenId, tierId, category);
1034
+ store.setTier(
1035
+ address(hook),
1036
+ tokenId,
1037
+ JB721Tier({
1038
+ id: tierId,
1039
+ price: 0,
1040
+ remainingSupply: 100,
1041
+ initialSupply: 100,
1042
+ votingUnits: 0,
1043
+ reserveFrequency: 0,
1044
+ reserveBeneficiary: address(0),
1045
+ encodedIPFSUri: bytes32(0),
1046
+ category: category,
1047
+ discountPercent: 0,
1048
+ allowOwnerMint: false,
1049
+ transfersPausable: false,
1050
+ cannotBeRemoved: false,
1051
+ cannotIncreaseDiscountPercent: false,
1052
+ resolvedUri: ""
1053
+ })
1054
+ );
1055
+ }
1056
+ }