@bannynet/core-v6 0.0.5 → 0.0.6

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.
@@ -75,7 +75,7 @@ contract MockStoreI25 {
75
75
  }
76
76
  }
77
77
 
78
- /// @notice Regression test for I-25: _decorateBannyWithBackground follows CEI pattern.
78
+ /// @notice _decorateBannyWithBackground follows CEI pattern.
79
79
  /// @dev The fix reordered state writes (effects) before external transfers (interactions)
80
80
  /// in _decorateBannyWithBackground. This test verifies that after a background replacement,
81
81
  /// state is consistent and both the old background return and new background custody work.
@@ -68,7 +68,7 @@ contract MockStore56 {
68
68
  }
69
69
  }
70
70
 
71
- /// @notice Regression test: L-56 -- events should emit _msgSender(), not msg.sender.
71
+ /// @notice Events should emit _msgSender(), not msg.sender.
72
72
  contract L56_MsgSenderEventsTest is Test {
73
73
  Banny721TokenUriResolver resolver;
74
74
  MockHook56 hook;
@@ -67,7 +67,7 @@ contract MockStore57 {
67
67
  }
68
68
  }
69
69
 
70
- /// @notice Regression test: L-57 -- decorateBannyWith should reject non-body-category tokens as bannyBodyId.
70
+ /// @notice decorateBannyWith should reject non-body-category tokens as bannyBodyId.
71
71
  contract L57_BodyCategoryValidationTest is Test {
72
72
  Banny721TokenUriResolver resolver;
73
73
  MockHook57 hook;
@@ -6,7 +6,7 @@ import {JB721Tier} from "@bananapus/721-hook-v6/src/structs/JB721Tier.sol";
6
6
 
7
7
  import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
8
8
 
9
- /// @notice Regression test: L-58 -- mismatched array lengths should revert.
9
+ /// @notice Mismatched array lengths should revert.
10
10
  contract L58_ArrayLengthValidationTest is Test {
11
11
  Banny721TokenUriResolver resolver;
12
12
  address deployer = makeAddr("deployer");
@@ -5,7 +5,7 @@ import "forge-std/Test.sol";
5
5
 
6
6
  import {Banny721TokenUriResolver} from "../../src/Banny721TokenUriResolver.sol";
7
7
 
8
- /// @notice Regression test: L-59 -- setMetadata should allow clearing fields to empty string.
8
+ /// @notice setMetadata should allow clearing fields to empty string.
9
9
  contract L59_ClearMetadataTest is Test {
10
10
  Banny721TokenUriResolver resolver;
11
11
  address deployer = makeAddr("deployer");
@@ -78,7 +78,7 @@ contract MockStore62 {
78
78
  }
79
79
  }
80
80
 
81
- /// @notice Regression test: L-62 -- burned equipped tokens should not lock the body.
81
+ /// @notice Burned equipped tokens should not lock the body.
82
82
  contract L62_BurnedTokenCheckTest is Test {
83
83
  Banny721TokenUriResolver resolver;
84
84
  MockHook62 hook;
@@ -0,0 +1,341 @@
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 supports tier removal (clearing tier data so tierOfTokenId returns defaults).
11
+ contract MockHookM8 {
12
+ mapping(uint256 => address) public ownerOf;
13
+ mapping(uint256 => uint32) public tierIdOf;
14
+ mapping(uint256 => uint24) public categoryOf;
15
+ address public immutable MOCK_STORE;
16
+ mapping(address => mapping(address => 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
+ if (to.code.length > 0) {
46
+ bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
47
+ require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
48
+ }
49
+ }
50
+
51
+ function pricingContext() external pure returns (uint256, uint256, uint256) {
52
+ return (1, 18, 0);
53
+ }
54
+
55
+ function baseURI() external pure returns (string memory) {
56
+ return "ipfs://";
57
+ }
58
+ }
59
+
60
+ /// @notice Mock store that supports tier removal (deleting tier data so tierOfTokenId returns a zeroed struct).
61
+ contract MockStoreM8 {
62
+ mapping(address => mapping(uint256 => JB721Tier)) public tiers;
63
+ mapping(address => mapping(uint256 => bool)) public tierRemoved;
64
+
65
+ function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
66
+ tiers[hook][tokenId] = tier;
67
+ tierRemoved[hook][tokenId] = false;
68
+ }
69
+
70
+ /// @notice Simulate removing a tier — tierOfTokenId will return a zeroed struct (category = 0).
71
+ function removeTier(address hook, uint256 tokenId) external {
72
+ delete tiers[hook][tokenId];
73
+ tierRemoved[hook][tokenId] = true;
74
+ }
75
+
76
+ function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
77
+ return tiers[hook][tokenId];
78
+ }
79
+
80
+ function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
81
+ return bytes32(0);
82
+ }
83
+ }
84
+
85
+ /// @notice Removed tier causes outfit state desynchronization.
86
+ /// @dev When a previously equipped outfit's tier is removed, `_productOfTokenId` returns category 0.
87
+ /// Before the fix, this caused the first while loop to exit immediately (due to `!= 0` guard),
88
+ /// and the second while loop would transfer out outfits that were being re-equipped.
89
+ contract M8_RemovedTierDesyncTest is Test {
90
+ Banny721TokenUriResolver resolver;
91
+ MockHookM8 hook;
92
+ MockStoreM8 store;
93
+
94
+ address deployer = makeAddr("deployer");
95
+ address alice = makeAddr("alice");
96
+
97
+ // Token IDs: tierId * 1_000_000_000 + sequence.
98
+ uint256 constant BODY_TOKEN = 4_000_000_001;
99
+ uint256 constant NECKLACE_TOKEN = 10_000_000_001; // category 3
100
+ uint256 constant EYES_TOKEN = 30_000_000_001; // category 5
101
+ uint256 constant MOUTH_TOKEN = 40_000_000_001; // category 7
102
+
103
+ function setUp() public {
104
+ store = new MockStoreM8();
105
+ hook = new MockHookM8(address(store));
106
+
107
+ vm.prank(deployer);
108
+ resolver = new Banny721TokenUriResolver(
109
+ "<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
110
+ );
111
+
112
+ _setupTier(BODY_TOKEN, 4, 0);
113
+ _setupTier(NECKLACE_TOKEN, 10, 3);
114
+ _setupTier(EYES_TOKEN, 30, 5);
115
+ _setupTier(MOUTH_TOKEN, 40, 7);
116
+
117
+ hook.setOwner(BODY_TOKEN, alice);
118
+ hook.setOwner(NECKLACE_TOKEN, alice);
119
+ hook.setOwner(EYES_TOKEN, alice);
120
+ hook.setOwner(MOUTH_TOKEN, alice);
121
+
122
+ vm.prank(alice);
123
+ hook.setApprovalForAll(address(resolver), true);
124
+ }
125
+
126
+ /// @notice Equip 3 outfits, remove first outfit's tier, re-equip remaining 2.
127
+ /// The remaining outfits should stay properly equipped and not be transferred back.
128
+ function test_reequipAfterTierRemoval_retainsValidOutfits() public {
129
+ // Step 1: Equip necklace, eyes, and mouth.
130
+ uint256[] memory outfitIds = new uint256[](3);
131
+ outfitIds[0] = NECKLACE_TOKEN; // category 3
132
+ outfitIds[1] = EYES_TOKEN; // category 5
133
+ outfitIds[2] = MOUTH_TOKEN; // category 7
134
+
135
+ vm.prank(alice);
136
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
137
+
138
+ // Verify all equipped.
139
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace should be worn");
140
+ assertEq(resolver.wearerOf(address(hook), EYES_TOKEN), BODY_TOKEN, "eyes should be worn");
141
+ assertEq(resolver.wearerOf(address(hook), MOUTH_TOKEN), BODY_TOKEN, "mouth should be worn");
142
+
143
+ // All outfit tokens should be held by the resolver.
144
+ assertEq(hook.ownerOf(NECKLACE_TOKEN), address(resolver), "necklace held by resolver");
145
+ assertEq(hook.ownerOf(EYES_TOKEN), address(resolver), "eyes held by resolver");
146
+ assertEq(hook.ownerOf(MOUTH_TOKEN), address(resolver), "mouth held by resolver");
147
+
148
+ // Step 2: Admin removes the necklace tier.
149
+ store.removeTier(address(hook), NECKLACE_TOKEN);
150
+
151
+ // Step 3: Re-equip with only the remaining valid outfits (eyes + mouth).
152
+ uint256[] memory newOutfitIds = new uint256[](2);
153
+ newOutfitIds[0] = EYES_TOKEN; // category 5
154
+ newOutfitIds[1] = MOUTH_TOKEN; // category 7
155
+
156
+ vm.prank(alice);
157
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, newOutfitIds);
158
+
159
+ // Step 4: Verify eyes and mouth are STILL properly equipped (not transferred out).
160
+ assertEq(resolver.wearerOf(address(hook), EYES_TOKEN), BODY_TOKEN, "eyes should still be worn after re-equip");
161
+ assertEq(resolver.wearerOf(address(hook), MOUTH_TOKEN), BODY_TOKEN, "mouth should still be worn after re-equip");
162
+
163
+ // Eyes and mouth should still be held by the resolver.
164
+ assertEq(hook.ownerOf(EYES_TOKEN), address(resolver), "eyes should still be held by resolver");
165
+ assertEq(hook.ownerOf(MOUTH_TOKEN), address(resolver), "mouth should still be held by resolver");
166
+
167
+ // Step 5: Verify the assets list only contains eyes and mouth.
168
+ (, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
169
+ assertEq(currentOutfits.length, 2, "should have 2 outfits");
170
+ assertEq(currentOutfits[0], EYES_TOKEN, "first outfit should be eyes");
171
+ assertEq(currentOutfits[1], MOUTH_TOKEN, "second outfit should be mouth");
172
+ }
173
+
174
+ /// @notice Variant: remove a middle outfit's tier, re-equip first and last.
175
+ function test_reequipAfterMiddleTierRemoval_retainsValidOutfits() public {
176
+ // Equip necklace, eyes, and mouth.
177
+ uint256[] memory outfitIds = new uint256[](3);
178
+ outfitIds[0] = NECKLACE_TOKEN; // category 3
179
+ outfitIds[1] = EYES_TOKEN; // category 5
180
+ outfitIds[2] = MOUTH_TOKEN; // category 7
181
+
182
+ vm.prank(alice);
183
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
184
+
185
+ // Remove the eyes tier (middle outfit).
186
+ store.removeTier(address(hook), EYES_TOKEN);
187
+
188
+ // Re-equip with necklace + mouth (skipping the removed eyes).
189
+ uint256[] memory newOutfitIds = new uint256[](2);
190
+ newOutfitIds[0] = NECKLACE_TOKEN; // category 3
191
+ newOutfitIds[1] = MOUTH_TOKEN; // category 7
192
+
193
+ vm.prank(alice);
194
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, newOutfitIds);
195
+
196
+ // Necklace and mouth should still be equipped.
197
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace should still be worn");
198
+ assertEq(resolver.wearerOf(address(hook), MOUTH_TOKEN), BODY_TOKEN, "mouth should still be worn");
199
+ assertEq(hook.ownerOf(NECKLACE_TOKEN), address(resolver), "necklace should still be held by resolver");
200
+ assertEq(hook.ownerOf(MOUTH_TOKEN), address(resolver), "mouth should still be held by resolver");
201
+
202
+ (, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
203
+ assertEq(currentOutfits.length, 2, "should have 2 outfits");
204
+ assertEq(currentOutfits[0], NECKLACE_TOKEN, "first outfit should be necklace");
205
+ assertEq(currentOutfits[1], MOUTH_TOKEN, "second outfit should be mouth");
206
+ }
207
+
208
+ /// @notice Variant: remove the last outfit's tier, re-equip first two.
209
+ function test_reequipAfterLastTierRemoval_retainsValidOutfits() public {
210
+ // Equip necklace, eyes, and mouth.
211
+ uint256[] memory outfitIds = new uint256[](3);
212
+ outfitIds[0] = NECKLACE_TOKEN; // category 3
213
+ outfitIds[1] = EYES_TOKEN; // category 5
214
+ outfitIds[2] = MOUTH_TOKEN; // category 7
215
+
216
+ vm.prank(alice);
217
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
218
+
219
+ // Remove the mouth tier (last outfit).
220
+ store.removeTier(address(hook), MOUTH_TOKEN);
221
+
222
+ // Re-equip with necklace + eyes only.
223
+ uint256[] memory newOutfitIds = new uint256[](2);
224
+ newOutfitIds[0] = NECKLACE_TOKEN; // category 3
225
+ newOutfitIds[1] = EYES_TOKEN; // category 5
226
+
227
+ vm.prank(alice);
228
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, newOutfitIds);
229
+
230
+ // Necklace and eyes should still be equipped.
231
+ assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace should still be worn");
232
+ assertEq(resolver.wearerOf(address(hook), EYES_TOKEN), BODY_TOKEN, "eyes should still be worn");
233
+ assertEq(hook.ownerOf(NECKLACE_TOKEN), address(resolver), "necklace should still be held by resolver");
234
+ assertEq(hook.ownerOf(EYES_TOKEN), address(resolver), "eyes should still be held by resolver");
235
+ }
236
+
237
+ /// @notice Variant: all tiers removed, clear all outfits. Should not revert.
238
+ function test_clearOutfitsAfterAllTiersRemoved() public {
239
+ // Equip necklace and eyes.
240
+ uint256[] memory outfitIds = new uint256[](2);
241
+ outfitIds[0] = NECKLACE_TOKEN;
242
+ outfitIds[1] = EYES_TOKEN;
243
+
244
+ vm.prank(alice);
245
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
246
+
247
+ // Remove both tiers.
248
+ store.removeTier(address(hook), NECKLACE_TOKEN);
249
+ store.removeTier(address(hook), EYES_TOKEN);
250
+
251
+ // Clear all outfits (empty array). Should not revert.
252
+ uint256[] memory emptyOutfits = new uint256[](0);
253
+
254
+ vm.prank(alice);
255
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, emptyOutfits);
256
+
257
+ (, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
258
+ assertEq(currentOutfits.length, 0, "should have no outfits");
259
+ }
260
+
261
+ /// @notice Edge case: single outfit equipped, its tier removed, re-equip with a different outfit.
262
+ function test_replaceRemovedTierOutfitWithNew() public {
263
+ // Equip necklace.
264
+ uint256[] memory outfitIds = new uint256[](1);
265
+ outfitIds[0] = NECKLACE_TOKEN;
266
+
267
+ vm.prank(alice);
268
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
269
+
270
+ // Remove necklace tier.
271
+ store.removeTier(address(hook), NECKLACE_TOKEN);
272
+
273
+ // Equip eyes instead.
274
+ uint256[] memory newOutfitIds = new uint256[](1);
275
+ newOutfitIds[0] = EYES_TOKEN;
276
+
277
+ vm.prank(alice);
278
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, newOutfitIds);
279
+
280
+ // Eyes should be equipped.
281
+ assertEq(resolver.wearerOf(address(hook), EYES_TOKEN), BODY_TOKEN, "eyes should be worn");
282
+ assertEq(hook.ownerOf(EYES_TOKEN), address(resolver), "eyes should be held by resolver");
283
+
284
+ (, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
285
+ assertEq(currentOutfits.length, 1, "should have 1 outfit");
286
+ assertEq(currentOutfits[0], EYES_TOKEN, "outfit should be eyes");
287
+ }
288
+
289
+ /// @notice Edge case: two consecutive removed tiers at the start.
290
+ function test_reequipAfterTwoConsecutiveRemovedTiers() public {
291
+ // Equip necklace, eyes, and mouth.
292
+ uint256[] memory outfitIds = new uint256[](3);
293
+ outfitIds[0] = NECKLACE_TOKEN; // category 3
294
+ outfitIds[1] = EYES_TOKEN; // category 5
295
+ outfitIds[2] = MOUTH_TOKEN; // category 7
296
+
297
+ vm.prank(alice);
298
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
299
+
300
+ // Remove both necklace and eyes tiers.
301
+ store.removeTier(address(hook), NECKLACE_TOKEN);
302
+ store.removeTier(address(hook), EYES_TOKEN);
303
+
304
+ // Re-equip with just mouth.
305
+ uint256[] memory newOutfitIds = new uint256[](1);
306
+ newOutfitIds[0] = MOUTH_TOKEN; // category 7
307
+
308
+ vm.prank(alice);
309
+ resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, newOutfitIds);
310
+
311
+ // Mouth should still be equipped.
312
+ assertEq(resolver.wearerOf(address(hook), MOUTH_TOKEN), BODY_TOKEN, "mouth should still be worn");
313
+ assertEq(hook.ownerOf(MOUTH_TOKEN), address(resolver), "mouth should still be held by resolver");
314
+
315
+ (, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
316
+ assertEq(currentOutfits.length, 1, "should have 1 outfit");
317
+ assertEq(currentOutfits[0], MOUTH_TOKEN, "outfit should be mouth");
318
+ }
319
+
320
+ function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
321
+ JB721Tier memory tier = JB721Tier({
322
+ id: tierId,
323
+ price: 0.01 ether,
324
+ remainingSupply: 100,
325
+ initialSupply: 100,
326
+ votingUnits: 0,
327
+ reserveFrequency: 0,
328
+ reserveBeneficiary: address(0),
329
+ encodedIPFSUri: bytes32(0),
330
+ category: category,
331
+ discountPercent: 0,
332
+ allowOwnerMint: false,
333
+ transfersPausable: false,
334
+ cannotBeRemoved: false,
335
+ cannotIncreaseDiscountPercent: false,
336
+ splitPercent: 0,
337
+ resolvedUri: ""
338
+ });
339
+ store.setTier(address(hook), tokenId, tier);
340
+ }
341
+ }