@bannynet/core-v6 0.0.8 → 0.0.10

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.
@@ -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
+ }