@bannynet/core-v6 0.0.11 → 0.0.13
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.
- package/ADMINISTRATION.md +42 -31
- package/ARCHITECTURE.md +41 -3
- package/AUDIT_INSTRUCTIONS.md +68 -41
- package/CHANGE_LOG.md +28 -7
- package/README.md +53 -1
- package/RISKS.md +33 -7
- package/SKILLS.md +44 -3
- package/STYLE_GUIDE.md +2 -2
- package/USER_JOURNEYS.md +327 -325
- package/foundry.toml +1 -1
- package/package.json +8 -8
- package/script/Add.Denver.s.sol +1 -1
- package/script/Deploy.s.sol +1 -1
- package/script/Drop1.s.sol +1 -1
- package/script/helpers/BannyverseDeploymentLib.sol +1 -1
- package/script/helpers/MigrationHelper.sol +1 -1
- package/src/Banny721TokenUriResolver.sol +132 -24
- package/test/Banny721TokenUriResolver.t.sol +1 -1
- package/test/BannyAttacks.t.sol +1 -1
- package/test/DecorateFlow.t.sol +1 -1
- package/test/Fork.t.sol +1 -1
- package/test/OutfitTransferLifecycle.t.sol +1 -1
- package/test/TestAuditGaps.sol +1 -1
- package/test/TestQALastMile.t.sol +1 -1
- package/test/audit/AntiStrandingRetention.t.sol +392 -0
- package/test/audit/MergedOutfitExclusivity.t.sol +223 -0
- package/test/audit/TryTransferFromStrandsAssets.t.sol +192 -0
- package/test/regression/ArrayLengthValidation.t.sol +1 -1
- package/test/regression/BodyCategoryValidation.t.sol +1 -1
- package/test/regression/BurnedTokenCheck.t.sol +1 -1
- package/test/regression/CEIReorder.t.sol +1 -1
- package/test/regression/ClearMetadata.t.sol +1 -1
- package/test/regression/MsgSenderEvents.t.sol +1 -1
- package/test/regression/RemovedTierDesync.t.sol +1 -1
- package/deployments/banny-core-v5/arbitrum/Banny721TokenUriResolver.json +0 -1809
- package/deployments/banny-core-v5/arbitrum_sepolia/Banny721TokenUriResolver.json +0 -1795
- package/deployments/banny-core-v5/base/Banny721TokenUriResolver.json +0 -1810
- package/deployments/banny-core-v5/base_sepolia/Banny721TokenUriResolver.json +0 -1796
- package/deployments/banny-core-v5/ethereum/Banny721TokenUriResolver.json +0 -1795
- package/deployments/banny-core-v5/optimism/Banny721TokenUriResolver.json +0 -1810
- package/deployments/banny-core-v5/optimism_sepolia/Banny721TokenUriResolver.json +0 -1796
- package/deployments/banny-core-v5/sepolia/Banny721TokenUriResolver.json +0 -1795
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
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
|
+
/// @dev Mock hook that supports safeTransferFrom with ERC721Receiver checks.
|
|
11
|
+
contract RetentionMockHook {
|
|
12
|
+
mapping(uint256 tokenId => address) public ownerOf;
|
|
13
|
+
mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
|
|
14
|
+
|
|
15
|
+
address public immutable MOCK_STORE;
|
|
16
|
+
|
|
17
|
+
constructor(address store) {
|
|
18
|
+
MOCK_STORE = store;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function STORE() external view returns (address) {
|
|
22
|
+
return MOCK_STORE;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
26
|
+
ownerOf[tokenId] = owner;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setApprovalForAll(address operator, bool approved) external {
|
|
30
|
+
isApprovedForAll[msg.sender][operator] = approved;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function safeTransferFrom(address from, address to, uint256 tokenId) external {
|
|
34
|
+
require(
|
|
35
|
+
msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
|
|
36
|
+
"RetentionMockHook: not authorized"
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
ownerOf[tokenId] = to;
|
|
40
|
+
|
|
41
|
+
if (to.code.length > 0) {
|
|
42
|
+
bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
|
|
43
|
+
require(retval == IERC721Receiver.onERC721Received.selector, "RetentionMockHook: receiver rejected");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pricingContext() external pure returns (uint256, uint256, uint256) {
|
|
48
|
+
return (1, 18, 0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function baseURI() external pure returns (string memory) {
|
|
52
|
+
return "ipfs://";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// @dev Mock store that returns tier data.
|
|
57
|
+
contract RetentionMockStore {
|
|
58
|
+
mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
|
|
59
|
+
|
|
60
|
+
function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
|
|
61
|
+
tiers[hook][tokenId] = tier;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
65
|
+
return tiers[hook][tokenId];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
|
|
69
|
+
return bytes32(0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/// @dev Contract that does NOT implement IERC721Receiver --transfers to it will revert.
|
|
74
|
+
contract NonReceiverContract {
|
|
75
|
+
function approveResolver(RetentionMockHook hook, address resolver) external {
|
|
76
|
+
hook.setApprovalForAll(resolver, true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function decorate(
|
|
80
|
+
Banny721TokenUriResolver resolver,
|
|
81
|
+
address hook,
|
|
82
|
+
uint256 bannyBodyId,
|
|
83
|
+
uint256 backgroundId,
|
|
84
|
+
uint256[] memory outfitIds
|
|
85
|
+
)
|
|
86
|
+
external
|
|
87
|
+
{
|
|
88
|
+
resolver.decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// @dev Contract that DOES implement IERC721Receiver --can receive NFTs.
|
|
93
|
+
contract ReceiverContract is IERC721Receiver {
|
|
94
|
+
bool public canReceive = true;
|
|
95
|
+
|
|
96
|
+
function setCanReceive(bool _canReceive) external {
|
|
97
|
+
canReceive = _canReceive;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function approveResolver(RetentionMockHook hook, address resolver) external {
|
|
101
|
+
hook.setApprovalForAll(resolver, true);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function decorate(
|
|
105
|
+
Banny721TokenUriResolver resolver,
|
|
106
|
+
address hook,
|
|
107
|
+
uint256 bannyBodyId,
|
|
108
|
+
uint256 backgroundId,
|
|
109
|
+
uint256[] memory outfitIds
|
|
110
|
+
)
|
|
111
|
+
external
|
|
112
|
+
{
|
|
113
|
+
resolver.decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function onERC721Received(address, address, uint256, bytes calldata) external view override returns (bytes4) {
|
|
117
|
+
require(canReceive, "ReceiverContract: rejecting");
|
|
118
|
+
return IERC721Receiver.onERC721Received.selector;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
contract AntiStrandingRetentionTest is Test {
|
|
123
|
+
Banny721TokenUriResolver resolver;
|
|
124
|
+
RetentionMockHook hook;
|
|
125
|
+
RetentionMockStore store;
|
|
126
|
+
NonReceiverContract nonReceiver;
|
|
127
|
+
ReceiverContract receiverContract;
|
|
128
|
+
|
|
129
|
+
uint256 constant BODY_TOKEN = 4_000_000_001;
|
|
130
|
+
uint256 constant BG_TOKEN_1 = 5_000_000_001;
|
|
131
|
+
uint256 constant BG_TOKEN_2 = 5_000_000_002;
|
|
132
|
+
uint256 constant NECKLACE_TOKEN = 10_000_000_001;
|
|
133
|
+
uint256 constant HEAD_TOKEN = 11_000_000_001;
|
|
134
|
+
uint256 constant EYES_TOKEN = 12_000_000_001;
|
|
135
|
+
|
|
136
|
+
function setUp() public {
|
|
137
|
+
store = new RetentionMockStore();
|
|
138
|
+
hook = new RetentionMockHook(address(store));
|
|
139
|
+
nonReceiver = new NonReceiverContract();
|
|
140
|
+
receiverContract = new ReceiverContract();
|
|
141
|
+
|
|
142
|
+
resolver = new Banny721TokenUriResolver(
|
|
143
|
+
"<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", address(this), address(0)
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
// Set up tiers: body(cat 0), backgrounds(cat 1), necklace(cat 3), head(cat 4), eyes(cat 5)
|
|
147
|
+
_setupTier(BODY_TOKEN, 4, 0);
|
|
148
|
+
_setupTier(BG_TOKEN_1, 5, 1);
|
|
149
|
+
_setupTier(BG_TOKEN_2, 6, 1);
|
|
150
|
+
_setupTier(NECKLACE_TOKEN, 10, 3);
|
|
151
|
+
_setupTier(HEAD_TOKEN, 11, 4);
|
|
152
|
+
_setupTier(EYES_TOKEN, 12, 5);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -----------------------------------------------------------------------
|
|
156
|
+
// Test 1: Background equip aborted on failed return
|
|
157
|
+
// -----------------------------------------------------------------------
|
|
158
|
+
function test_backgroundEquipAbortedOnFailedReturn() public {
|
|
159
|
+
// Give NonReceiverContract ownership of body and both backgrounds.
|
|
160
|
+
_setOwnerForAll(address(nonReceiver));
|
|
161
|
+
nonReceiver.approveResolver(hook, address(resolver));
|
|
162
|
+
|
|
163
|
+
// Equip BG_TOKEN_1.
|
|
164
|
+
uint256[] memory empty = new uint256[](0);
|
|
165
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, BG_TOKEN_1, empty);
|
|
166
|
+
|
|
167
|
+
assertEq(hook.ownerOf(BG_TOKEN_1), address(resolver), "bg1 in resolver custody");
|
|
168
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg1 attached to body");
|
|
169
|
+
|
|
170
|
+
// Try to replace with BG_TOKEN_2 --returning BG_TOKEN_1 fails (NonReceiverContract).
|
|
171
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, BG_TOKEN_2, empty);
|
|
172
|
+
|
|
173
|
+
// Old background stays attached (return aborted the background change).
|
|
174
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg1 still attached --change was aborted");
|
|
175
|
+
|
|
176
|
+
// New background was NOT equipped.
|
|
177
|
+
(uint256 bgId,) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
178
|
+
assertEq(bgId, BG_TOKEN_1, "body still has bg1, not bg2");
|
|
179
|
+
|
|
180
|
+
// BG_TOKEN_2 stays with the nonReceiver (never transferred in).
|
|
181
|
+
assertEq(hook.ownerOf(BG_TOKEN_2), address(nonReceiver), "bg2 never transferred to resolver");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// -----------------------------------------------------------------------
|
|
185
|
+
// Test 2: Background removal preserved on failed return
|
|
186
|
+
// -----------------------------------------------------------------------
|
|
187
|
+
function test_backgroundRemovalPreservedOnFailedReturn() public {
|
|
188
|
+
_setOwnerForAll(address(nonReceiver));
|
|
189
|
+
nonReceiver.approveResolver(hook, address(resolver));
|
|
190
|
+
|
|
191
|
+
// Equip BG_TOKEN_1.
|
|
192
|
+
uint256[] memory empty = new uint256[](0);
|
|
193
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, BG_TOKEN_1, empty);
|
|
194
|
+
|
|
195
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg1 attached");
|
|
196
|
+
|
|
197
|
+
// Try to remove background (pass 0) --return to NonReceiverContract fails.
|
|
198
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, empty);
|
|
199
|
+
|
|
200
|
+
// Background stays attached because the transfer failed and state was preserved.
|
|
201
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg1 still attached --removal failed");
|
|
202
|
+
(uint256 bgId,) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
203
|
+
assertEq(bgId, BG_TOKEN_1, "body still has bg1");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// -----------------------------------------------------------------------
|
|
207
|
+
// Test 3: Outfit retained on failed return
|
|
208
|
+
// -----------------------------------------------------------------------
|
|
209
|
+
function test_outfitRetainedOnFailedReturn() public {
|
|
210
|
+
_setOwnerForAll(address(nonReceiver));
|
|
211
|
+
nonReceiver.approveResolver(hook, address(resolver));
|
|
212
|
+
|
|
213
|
+
// Equip necklace outfit.
|
|
214
|
+
uint256[] memory outfits = new uint256[](1);
|
|
215
|
+
outfits[0] = NECKLACE_TOKEN;
|
|
216
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, outfits);
|
|
217
|
+
|
|
218
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace worn by body");
|
|
219
|
+
|
|
220
|
+
// Try to remove outfit (pass empty) --return to NonReceiverContract fails.
|
|
221
|
+
uint256[] memory empty = new uint256[](0);
|
|
222
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, empty);
|
|
223
|
+
|
|
224
|
+
// Outfit retained in the attached list.
|
|
225
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace still worn --retained");
|
|
226
|
+
(, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
227
|
+
assertEq(currentOutfits.length, 1, "one outfit retained");
|
|
228
|
+
assertEq(currentOutfits[0], NECKLACE_TOKEN, "retained outfit is the necklace");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -----------------------------------------------------------------------
|
|
232
|
+
// Test 4: Mixed outfit success/failure --EOA succeeds, contract fails
|
|
233
|
+
// -----------------------------------------------------------------------
|
|
234
|
+
function test_mixedOutfitSuccessAndFailure() public {
|
|
235
|
+
// Set up: body owned by nonReceiver, outfits owned by nonReceiver.
|
|
236
|
+
hook.setOwner(BODY_TOKEN, address(nonReceiver));
|
|
237
|
+
hook.setOwner(NECKLACE_TOKEN, address(nonReceiver));
|
|
238
|
+
hook.setOwner(HEAD_TOKEN, address(nonReceiver));
|
|
239
|
+
hook.setOwner(BG_TOKEN_1, address(nonReceiver));
|
|
240
|
+
hook.setOwner(BG_TOKEN_2, address(nonReceiver));
|
|
241
|
+
hook.setOwner(EYES_TOKEN, address(nonReceiver));
|
|
242
|
+
nonReceiver.approveResolver(hook, address(resolver));
|
|
243
|
+
|
|
244
|
+
// Equip necklace and head.
|
|
245
|
+
uint256[] memory outfits = new uint256[](2);
|
|
246
|
+
outfits[0] = NECKLACE_TOKEN;
|
|
247
|
+
outfits[1] = HEAD_TOKEN;
|
|
248
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, outfits);
|
|
249
|
+
|
|
250
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace worn");
|
|
251
|
+
assertEq(resolver.wearerOf(address(hook), HEAD_TOKEN), BODY_TOKEN, "head worn");
|
|
252
|
+
|
|
253
|
+
// Now try to remove all outfits --both transfers will fail (NonReceiverContract).
|
|
254
|
+
uint256[] memory empty = new uint256[](0);
|
|
255
|
+
nonReceiver.decorate(resolver, address(hook), BODY_TOKEN, 0, empty);
|
|
256
|
+
|
|
257
|
+
// Both should be retained.
|
|
258
|
+
(, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
259
|
+
assertEq(currentOutfits.length, 2, "both outfits retained");
|
|
260
|
+
|
|
261
|
+
// Order: retained items are appended after new (empty), so they appear in order.
|
|
262
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace still worn");
|
|
263
|
+
assertEq(resolver.wearerOf(address(hook), HEAD_TOKEN), BODY_TOKEN, "head still worn");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// -----------------------------------------------------------------------
|
|
267
|
+
// Test 5: Recovery path --make contract receivable, retry decoration
|
|
268
|
+
// -----------------------------------------------------------------------
|
|
269
|
+
function test_recoveryAfterMakingContractReceivable() public {
|
|
270
|
+
// Use ReceiverContract with canReceive initially set to false.
|
|
271
|
+
hook.setOwner(BODY_TOKEN, address(receiverContract));
|
|
272
|
+
hook.setOwner(NECKLACE_TOKEN, address(receiverContract));
|
|
273
|
+
hook.setOwner(BG_TOKEN_1, address(receiverContract));
|
|
274
|
+
hook.setOwner(BG_TOKEN_2, address(receiverContract));
|
|
275
|
+
hook.setOwner(HEAD_TOKEN, address(receiverContract));
|
|
276
|
+
hook.setOwner(EYES_TOKEN, address(receiverContract));
|
|
277
|
+
receiverContract.approveResolver(hook, address(resolver));
|
|
278
|
+
|
|
279
|
+
// Equip necklace with receiver accepting.
|
|
280
|
+
uint256[] memory outfits = new uint256[](1);
|
|
281
|
+
outfits[0] = NECKLACE_TOKEN;
|
|
282
|
+
receiverContract.decorate(resolver, address(hook), BODY_TOKEN, BG_TOKEN_1, outfits);
|
|
283
|
+
|
|
284
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace worn");
|
|
285
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg1 attached");
|
|
286
|
+
|
|
287
|
+
// Now disable receiving --simulates the "contract can't receive" scenario.
|
|
288
|
+
receiverContract.setCanReceive(false);
|
|
289
|
+
|
|
290
|
+
// Try to undress --transfers will fail.
|
|
291
|
+
uint256[] memory empty = new uint256[](0);
|
|
292
|
+
receiverContract.decorate(resolver, address(hook), BODY_TOKEN, 0, empty);
|
|
293
|
+
|
|
294
|
+
// Assets retained.
|
|
295
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace retained");
|
|
296
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg1 retained");
|
|
297
|
+
|
|
298
|
+
// Re-enable receiving.
|
|
299
|
+
receiverContract.setCanReceive(true);
|
|
300
|
+
|
|
301
|
+
// Retry undress --now transfers should succeed.
|
|
302
|
+
receiverContract.decorate(resolver, address(hook), BODY_TOKEN, 0, empty);
|
|
303
|
+
|
|
304
|
+
// Background successfully removed.
|
|
305
|
+
(uint256 bgId, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
306
|
+
assertEq(bgId, 0, "bg removed after recovery");
|
|
307
|
+
assertEq(currentOutfits.length, 0, "outfit removed after recovery");
|
|
308
|
+
|
|
309
|
+
// NFTs returned to owner.
|
|
310
|
+
assertEq(hook.ownerOf(BG_TOKEN_1), address(receiverContract), "bg1 returned to owner");
|
|
311
|
+
assertEq(hook.ownerOf(NECKLACE_TOKEN), address(receiverContract), "necklace returned to owner");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// -----------------------------------------------------------------------
|
|
315
|
+
// Test 6: Happy path unchanged --EOA owner equip/unequip works as before
|
|
316
|
+
// -----------------------------------------------------------------------
|
|
317
|
+
function test_happyPathUnchanged_EOAOwner() public {
|
|
318
|
+
address alice = makeAddr("alice");
|
|
319
|
+
|
|
320
|
+
// Set up: Alice (EOA) owns everything.
|
|
321
|
+
hook.setOwner(BODY_TOKEN, alice);
|
|
322
|
+
hook.setOwner(NECKLACE_TOKEN, alice);
|
|
323
|
+
hook.setOwner(HEAD_TOKEN, alice);
|
|
324
|
+
hook.setOwner(BG_TOKEN_1, alice);
|
|
325
|
+
hook.setOwner(BG_TOKEN_2, alice);
|
|
326
|
+
hook.setOwner(EYES_TOKEN, alice);
|
|
327
|
+
|
|
328
|
+
vm.startPrank(alice);
|
|
329
|
+
hook.setApprovalForAll(address(resolver), true);
|
|
330
|
+
|
|
331
|
+
// Equip background + necklace.
|
|
332
|
+
uint256[] memory outfits = new uint256[](1);
|
|
333
|
+
outfits[0] = NECKLACE_TOKEN;
|
|
334
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BG_TOKEN_1, outfits);
|
|
335
|
+
|
|
336
|
+
assertEq(hook.ownerOf(BG_TOKEN_1), address(resolver), "bg in resolver custody");
|
|
337
|
+
assertEq(hook.ownerOf(NECKLACE_TOKEN), address(resolver), "necklace in resolver custody");
|
|
338
|
+
assertEq(resolver.userOf(address(hook), BG_TOKEN_1), BODY_TOKEN, "bg attached");
|
|
339
|
+
assertEq(resolver.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace worn");
|
|
340
|
+
|
|
341
|
+
// Undress --should work fine for EOA.
|
|
342
|
+
uint256[] memory empty = new uint256[](0);
|
|
343
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, empty);
|
|
344
|
+
|
|
345
|
+
// NFTs returned to Alice.
|
|
346
|
+
assertEq(hook.ownerOf(BG_TOKEN_1), alice, "bg returned to alice");
|
|
347
|
+
assertEq(hook.ownerOf(NECKLACE_TOKEN), alice, "necklace returned to alice");
|
|
348
|
+
|
|
349
|
+
// State cleared.
|
|
350
|
+
(uint256 bgId, uint256[] memory currentOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
351
|
+
assertEq(bgId, 0, "no bg attached");
|
|
352
|
+
assertEq(currentOutfits.length, 0, "no outfits attached");
|
|
353
|
+
|
|
354
|
+
vm.stopPrank();
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// -----------------------------------------------------------------------
|
|
358
|
+
// Helpers
|
|
359
|
+
// -----------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
function _setOwnerForAll(address owner) internal {
|
|
362
|
+
hook.setOwner(BODY_TOKEN, owner);
|
|
363
|
+
hook.setOwner(BG_TOKEN_1, owner);
|
|
364
|
+
hook.setOwner(BG_TOKEN_2, owner);
|
|
365
|
+
hook.setOwner(NECKLACE_TOKEN, owner);
|
|
366
|
+
hook.setOwner(HEAD_TOKEN, owner);
|
|
367
|
+
hook.setOwner(EYES_TOKEN, owner);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
|
|
371
|
+
JB721Tier memory tier = 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
|
+
store.setTier(address(hook), tokenId, tier);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
pragma solidity 0.8.28;
|
|
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
|
+
/// @dev Mock hook that supports safeTransferFrom with ERC721Receiver checks.
|
|
11
|
+
contract ExclusivityMockHook {
|
|
12
|
+
// Maps token IDs to their current owner.
|
|
13
|
+
mapping(uint256 tokenId => address) public ownerOf;
|
|
14
|
+
// Maps owners to operator approvals.
|
|
15
|
+
mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
|
|
16
|
+
|
|
17
|
+
// The mock store address, returned by STORE().
|
|
18
|
+
address public immutable MOCK_STORE;
|
|
19
|
+
|
|
20
|
+
constructor(address store) {
|
|
21
|
+
// Store the mock store address at construction time.
|
|
22
|
+
MOCK_STORE = store;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// @dev Returns the mock store address for tier lookups.
|
|
26
|
+
function STORE() external view returns (address) {
|
|
27
|
+
return MOCK_STORE;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// @dev Sets the owner of a token ID (test helper).
|
|
31
|
+
function setOwner(uint256 tokenId, address owner) external {
|
|
32
|
+
ownerOf[tokenId] = owner;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/// @dev Sets operator approval for the caller.
|
|
36
|
+
function setApprovalForAll(address operator, bool approved) external {
|
|
37
|
+
isApprovedForAll[msg.sender][operator] = approved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/// @dev Safe transfer that checks ERC721Receiver on contract recipients.
|
|
41
|
+
function safeTransferFrom(address from, address to, uint256 tokenId) external {
|
|
42
|
+
// Verify the caller is authorized to transfer this token.
|
|
43
|
+
require(
|
|
44
|
+
msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
|
|
45
|
+
"ExclusivityMockHook: not authorized"
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Update ownership to the new owner.
|
|
49
|
+
ownerOf[tokenId] = to;
|
|
50
|
+
|
|
51
|
+
// If the recipient is a contract, check ERC721Receiver.
|
|
52
|
+
if (to.code.length > 0) {
|
|
53
|
+
// Call onERC721Received and verify the return selector.
|
|
54
|
+
bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
|
|
55
|
+
// Revert if the receiver rejects the transfer.
|
|
56
|
+
require(retval == IERC721Receiver.onERC721Received.selector, "ExclusivityMockHook: receiver rejected");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/// @dev Returns mock pricing context (currency=1, decimals=18, prices=0).
|
|
61
|
+
function pricingContext() external pure returns (uint256, uint256, uint256) {
|
|
62
|
+
return (1, 18, 0);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// @dev Returns a mock base URI for metadata.
|
|
66
|
+
function baseURI() external pure returns (string memory) {
|
|
67
|
+
return "ipfs://";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/// @dev Mock store that returns tier data for token IDs.
|
|
72
|
+
contract ExclusivityMockStore {
|
|
73
|
+
// Maps (hook, tokenId) to tier data.
|
|
74
|
+
mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
|
|
75
|
+
|
|
76
|
+
/// @dev Sets the tier data for a given (hook, tokenId) pair.
|
|
77
|
+
function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
|
|
78
|
+
tiers[hook][tokenId] = tier;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/// @dev Returns the tier for a given (hook, tokenId) pair.
|
|
82
|
+
function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
83
|
+
return tiers[hook][tokenId];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// @dev Returns a zero IPFS URI (unused in these tests).
|
|
87
|
+
function encodedIPFSUriOf(address, uint256) external pure returns (bytes32) {
|
|
88
|
+
return bytes32(0);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// @dev Contract that does NOT implement IERC721Receiver -- transfers to it will revert.
|
|
93
|
+
/// Used to simulate a scenario where returning an outfit fails.
|
|
94
|
+
contract ERC721Rejector {
|
|
95
|
+
/// @dev Approves the resolver as an operator on the hook.
|
|
96
|
+
function approveResolver(ExclusivityMockHook hook, address resolver) external {
|
|
97
|
+
hook.setApprovalForAll(resolver, true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/// @dev Calls decorateBannyWith on behalf of this contract.
|
|
101
|
+
function decorate(
|
|
102
|
+
Banny721TokenUriResolver resolver,
|
|
103
|
+
address hook,
|
|
104
|
+
uint256 bannyBodyId,
|
|
105
|
+
uint256 backgroundId,
|
|
106
|
+
uint256[] memory outfitIds
|
|
107
|
+
)
|
|
108
|
+
external
|
|
109
|
+
{
|
|
110
|
+
resolver.decorateBannyWith(hook, bannyBodyId, backgroundId, outfitIds);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/// @title MergedOutfitExclusivityTest
|
|
115
|
+
/// @notice Tests that category exclusivity is enforced on the merged set (retained + new outfits),
|
|
116
|
+
/// not just the new outfit set alone.
|
|
117
|
+
contract MergedOutfitExclusivityTest is Test {
|
|
118
|
+
// The resolver under test.
|
|
119
|
+
Banny721TokenUriResolver resolver;
|
|
120
|
+
// Mock hook for NFT ownership tracking.
|
|
121
|
+
ExclusivityMockHook hook;
|
|
122
|
+
// Mock store for tier/category lookups.
|
|
123
|
+
ExclusivityMockStore store;
|
|
124
|
+
// Contract that rejects ERC721 transfers (no IERC721Receiver).
|
|
125
|
+
ERC721Rejector rejector;
|
|
126
|
+
|
|
127
|
+
// Token IDs follow the pattern used in other banny tests:
|
|
128
|
+
// body is category 0, tier ID 4 => token 4_000_000_001
|
|
129
|
+
uint256 constant BODY_TOKEN = 4_000_000_001;
|
|
130
|
+
// HEAD is category 4, tier ID 11 => token 11_000_000_001
|
|
131
|
+
uint256 constant HEAD_TOKEN = 11_000_000_001;
|
|
132
|
+
// EYES is category 5, tier ID 12 => token 12_000_000_001
|
|
133
|
+
uint256 constant EYES_TOKEN = 12_000_000_001;
|
|
134
|
+
|
|
135
|
+
function setUp() public {
|
|
136
|
+
// Deploy mock store for tier lookups.
|
|
137
|
+
store = new ExclusivityMockStore();
|
|
138
|
+
// Deploy mock hook with the store.
|
|
139
|
+
hook = new ExclusivityMockHook(address(store));
|
|
140
|
+
// Deploy the contract that rejects incoming ERC721 transfers.
|
|
141
|
+
rejector = new ERC721Rejector();
|
|
142
|
+
|
|
143
|
+
// Deploy the resolver with placeholder SVG paths and no trusted forwarder.
|
|
144
|
+
resolver = new Banny721TokenUriResolver(
|
|
145
|
+
"<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", address(this), address(0)
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
// Set up tier data: body (category 0), HEAD (category 4), EYES (category 5).
|
|
149
|
+
_setupTier(BODY_TOKEN, 4, 0);
|
|
150
|
+
_setupTier(HEAD_TOKEN, 11, 4);
|
|
151
|
+
_setupTier(EYES_TOKEN, 12, 5);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// -----------------------------------------------------------------------
|
|
155
|
+
// Test: Merged set exclusivity with retained outfits
|
|
156
|
+
// -----------------------------------------------------------------------
|
|
157
|
+
// Scenario: equip a HEAD outfit, make its return fail, then try to equip
|
|
158
|
+
// an EYES outfit. The merged set (retained HEAD + new EYES) should violate
|
|
159
|
+
// HEAD/EYES exclusivity and revert.
|
|
160
|
+
function test_mergedSetExclusivity_retainedHeadBlocksNewEyes() public {
|
|
161
|
+
// Give the rejector contract ownership of body, HEAD, and EYES tokens.
|
|
162
|
+
hook.setOwner(BODY_TOKEN, address(rejector));
|
|
163
|
+
hook.setOwner(HEAD_TOKEN, address(rejector));
|
|
164
|
+
hook.setOwner(EYES_TOKEN, address(rejector));
|
|
165
|
+
|
|
166
|
+
// Approve the resolver to transfer tokens on behalf of the rejector.
|
|
167
|
+
rejector.approveResolver(hook, address(resolver));
|
|
168
|
+
|
|
169
|
+
// Step 1: Equip the HEAD outfit on the banny body.
|
|
170
|
+
uint256[] memory headOutfit = new uint256[](1);
|
|
171
|
+
headOutfit[0] = HEAD_TOKEN;
|
|
172
|
+
// This transfers HEAD_TOKEN to the resolver's custody.
|
|
173
|
+
rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, headOutfit);
|
|
174
|
+
|
|
175
|
+
// Verify HEAD is now worn by the banny body.
|
|
176
|
+
assertEq(resolver.wearerOf(address(hook), HEAD_TOKEN), BODY_TOKEN, "HEAD should be worn by body");
|
|
177
|
+
// Verify HEAD is in the resolver's custody.
|
|
178
|
+
assertEq(hook.ownerOf(HEAD_TOKEN), address(resolver), "HEAD should be in resolver custody");
|
|
179
|
+
|
|
180
|
+
// Step 2: Try to replace HEAD with EYES.
|
|
181
|
+
// The resolver will try to return HEAD to the rejector, but the rejector
|
|
182
|
+
// does not implement IERC721Receiver, so the transfer fails silently.
|
|
183
|
+
// HEAD is retained in the merged set. The new set contains only EYES.
|
|
184
|
+
// The merged set = [EYES, HEAD] which violates HEAD/EYES exclusivity.
|
|
185
|
+
uint256[] memory eyesOutfit = new uint256[](1);
|
|
186
|
+
eyesOutfit[0] = EYES_TOKEN;
|
|
187
|
+
|
|
188
|
+
// Expect revert because the merged set has both HEAD (retained) and EYES (new).
|
|
189
|
+
vm.expectRevert(Banny721TokenUriResolver.Banny721TokenUriResolver_HeadAlreadyAdded.selector);
|
|
190
|
+
// Attempt to decorate with EYES -- should revert due to exclusivity.
|
|
191
|
+
rejector.decorate(resolver, address(hook), BODY_TOKEN, 0, eyesOutfit);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -----------------------------------------------------------------------
|
|
195
|
+
// Helpers
|
|
196
|
+
// -----------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/// @dev Sets up a tier in the mock store for a given token.
|
|
199
|
+
function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
|
|
200
|
+
// Create tier data with the specified ID and category.
|
|
201
|
+
JB721Tier memory tier = JB721Tier({
|
|
202
|
+
id: tierId,
|
|
203
|
+
price: 0.01 ether,
|
|
204
|
+
remainingSupply: 100,
|
|
205
|
+
initialSupply: 100,
|
|
206
|
+
votingUnits: 0,
|
|
207
|
+
reserveFrequency: 0,
|
|
208
|
+
reserveBeneficiary: address(0),
|
|
209
|
+
encodedIPFSUri: bytes32(0),
|
|
210
|
+
category: category,
|
|
211
|
+
discountPercent: 0,
|
|
212
|
+
allowOwnerMint: false,
|
|
213
|
+
transfersPausable: false,
|
|
214
|
+
cannotBeRemoved: false,
|
|
215
|
+
cannotIncreaseDiscountPercent: false,
|
|
216
|
+
splitPercent: 0,
|
|
217
|
+
resolvedUri: ""
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Store the tier data in the mock store.
|
|
221
|
+
store.setTier(address(hook), tokenId, tier);
|
|
222
|
+
}
|
|
223
|
+
}
|