@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.
- package/AUDIT_INSTRUCTIONS.md +327 -0
- package/CHANGE_LOG.md +222 -0
- package/RISKS.md +30 -148
- package/USER_JOURNEYS.md +523 -0
- package/package.json +8 -8
- package/script/Add.Denver.s.sol +6 -4
- package/script/Deploy.s.sol +5 -8
- package/script/Drop1.s.sol +10 -2
- package/script/helpers/BannyverseDeploymentLib.sol +2 -2
- package/src/Banny721TokenUriResolver.sol +28 -10
- package/test/Banny721TokenUriResolver.t.sol +12 -10
- package/test/BannyAttacks.t.sol +2 -0
- package/test/DecorateFlow.t.sol +2 -0
- package/test/Fork.t.sol +12 -9
- package/test/OutfitTransferLifecycle.t.sol +391 -0
- package/test/TestAuditGaps.sol +720 -0
- package/test/TestQALastMile.t.sol +443 -0
- package/test/regression/BodyCategoryValidation.t.sol +1 -0
- package/test/regression/BurnedTokenCheck.t.sol +1 -0
- package/test/regression/CEIReorder.t.sol +1 -0
- package/test/regression/MsgSenderEvents.t.sol +1 -0
- package/test/regression/RemovedTierDesync.t.sol +1 -0
|
@@ -0,0 +1,720 @@
|
|
|
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 for audit gap testing.
|
|
11
|
+
contract AuditGapMockHook {
|
|
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 audit gap testing.
|
|
62
|
+
contract AuditGapMockStore {
|
|
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 TestAuditGaps
|
|
85
|
+
/// @notice Tests for ERC-2771 meta-transaction support and SVG rendering edge cases.
|
|
86
|
+
contract TestAuditGaps is Test {
|
|
87
|
+
Banny721TokenUriResolver resolver;
|
|
88
|
+
Banny721TokenUriResolver resolverWithForwarder;
|
|
89
|
+
AuditGapMockHook hook;
|
|
90
|
+
AuditGapMockStore store;
|
|
91
|
+
|
|
92
|
+
address deployer = makeAddr("deployer");
|
|
93
|
+
address alice = makeAddr("alice");
|
|
94
|
+
address bob = makeAddr("bob");
|
|
95
|
+
address forwarder = makeAddr("forwarder");
|
|
96
|
+
|
|
97
|
+
// Token IDs: product ID * 1_000_000_000 + sequence.
|
|
98
|
+
uint256 constant BODY_TOKEN = 4_000_000_001; // Original body (UPC 4, category 0)
|
|
99
|
+
uint256 constant ALIEN_BODY_TOKEN = 1_000_000_001; // Alien body (UPC 1, category 0)
|
|
100
|
+
uint256 constant BACKGROUND_TOKEN = 5_000_000_001; // category 1
|
|
101
|
+
uint256 constant NECKLACE_TOKEN = 10_000_000_001; // category 3
|
|
102
|
+
uint256 constant EYES_TOKEN = 30_000_000_001; // category 5
|
|
103
|
+
uint256 constant MOUTH_TOKEN = 40_000_000_001; // category 7
|
|
104
|
+
|
|
105
|
+
function setUp() public {
|
|
106
|
+
store = new AuditGapMockStore();
|
|
107
|
+
hook = new AuditGapMockHook(address(store));
|
|
108
|
+
|
|
109
|
+
// Deploy resolver WITHOUT trusted forwarder (address(0)).
|
|
110
|
+
vm.prank(deployer);
|
|
111
|
+
resolver = new Banny721TokenUriResolver(
|
|
112
|
+
"<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, address(0)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Deploy resolver WITH trusted forwarder.
|
|
116
|
+
vm.prank(deployer);
|
|
117
|
+
resolverWithForwarder = new Banny721TokenUriResolver(
|
|
118
|
+
"<path/>", "<necklace/>", "<mouth/>", "<eyes/>", "<alieneyes/>", deployer, forwarder
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Set up tier data for both resolvers.
|
|
122
|
+
_setupTier(BODY_TOKEN, 4, 0);
|
|
123
|
+
_setupTier(ALIEN_BODY_TOKEN, 1, 0);
|
|
124
|
+
_setupTier(BACKGROUND_TOKEN, 5, 1);
|
|
125
|
+
_setupTier(NECKLACE_TOKEN, 10, 3);
|
|
126
|
+
_setupTier(EYES_TOKEN, 30, 5);
|
|
127
|
+
_setupTier(MOUTH_TOKEN, 40, 7);
|
|
128
|
+
|
|
129
|
+
// Give alice all tokens.
|
|
130
|
+
hook.setOwner(BODY_TOKEN, alice);
|
|
131
|
+
hook.setOwner(ALIEN_BODY_TOKEN, alice);
|
|
132
|
+
hook.setOwner(BACKGROUND_TOKEN, alice);
|
|
133
|
+
hook.setOwner(NECKLACE_TOKEN, alice);
|
|
134
|
+
hook.setOwner(EYES_TOKEN, alice);
|
|
135
|
+
hook.setOwner(MOUTH_TOKEN, alice);
|
|
136
|
+
|
|
137
|
+
// Approve both resolvers for alice.
|
|
138
|
+
vm.startPrank(alice);
|
|
139
|
+
hook.setApprovalForAll(address(resolver), true);
|
|
140
|
+
hook.setApprovalForAll(address(resolverWithForwarder), true);
|
|
141
|
+
vm.stopPrank();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
|
|
145
|
+
hook.setTier(tokenId, tierId, category);
|
|
146
|
+
store.setTier(
|
|
147
|
+
address(hook),
|
|
148
|
+
tokenId,
|
|
149
|
+
JB721Tier({
|
|
150
|
+
id: tierId,
|
|
151
|
+
price: 0.01 ether,
|
|
152
|
+
remainingSupply: 100,
|
|
153
|
+
initialSupply: 100,
|
|
154
|
+
votingUnits: 0,
|
|
155
|
+
reserveFrequency: 0,
|
|
156
|
+
reserveBeneficiary: address(0),
|
|
157
|
+
encodedIPFSUri: bytes32(0),
|
|
158
|
+
category: category,
|
|
159
|
+
discountPercent: 0,
|
|
160
|
+
allowOwnerMint: false,
|
|
161
|
+
transfersPausable: false,
|
|
162
|
+
cannotBeRemoved: false,
|
|
163
|
+
cannotIncreaseDiscountPercent: false,
|
|
164
|
+
splitPercent: 0,
|
|
165
|
+
resolvedUri: ""
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/// @notice Helper to build ERC-2771 calldata: original calldata + 20-byte sender suffix.
|
|
171
|
+
function _buildForwarderCalldata(
|
|
172
|
+
bytes memory originalCalldata,
|
|
173
|
+
address sender
|
|
174
|
+
)
|
|
175
|
+
internal
|
|
176
|
+
pure
|
|
177
|
+
returns (bytes memory)
|
|
178
|
+
{
|
|
179
|
+
return abi.encodePacked(originalCalldata, sender);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
//*********************************************************************//
|
|
183
|
+
// --- Meta-Transaction (ERC-2771) Tests ----------------------------- //
|
|
184
|
+
//*********************************************************************//
|
|
185
|
+
|
|
186
|
+
/// @notice When no forwarder is set (address(0)), _msgSender() returns msg.sender.
|
|
187
|
+
function test_metaTx_noForwarder_msgSenderIsCaller() public {
|
|
188
|
+
// When forwarder is address(0), directly calling as alice should work.
|
|
189
|
+
uint256[] memory outfitIds = new uint256[](0);
|
|
190
|
+
vm.prank(alice);
|
|
191
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
|
|
192
|
+
// No revert means _msgSender() returned alice (the actual caller).
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/// @notice The resolver properly reports the trusted forwarder.
|
|
196
|
+
function test_metaTx_trustedForwarderIsSet() public view {
|
|
197
|
+
assertEq(resolverWithForwarder.trustedForwarder(), forwarder, "forwarder should match");
|
|
198
|
+
assertTrue(resolverWithForwarder.isTrustedForwarder(forwarder), "should recognize forwarder");
|
|
199
|
+
assertFalse(resolverWithForwarder.isTrustedForwarder(alice), "alice is not forwarder");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/// @notice When forwarder is address(0), the resolver reports no trusted forwarder.
|
|
203
|
+
function test_metaTx_zeroForwarderReportsNone() public view {
|
|
204
|
+
assertEq(resolver.trustedForwarder(), address(0), "no forwarder when address(0)");
|
|
205
|
+
assertFalse(resolver.isTrustedForwarder(forwarder), "should not trust any forwarder");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/// @notice Trusted forwarder can relay a lockOutfitChangesFor call on behalf of alice.
|
|
209
|
+
function test_metaTx_forwarderRelaysLock() public {
|
|
210
|
+
// Build the calldata that the forwarder would send: original function calldata + alice's address.
|
|
211
|
+
bytes memory originalCalldata =
|
|
212
|
+
abi.encodeCall(Banny721TokenUriResolver.lockOutfitChangesFor, (address(hook), BODY_TOKEN));
|
|
213
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, alice);
|
|
214
|
+
|
|
215
|
+
// Call from the forwarder address with suffixed calldata.
|
|
216
|
+
vm.prank(forwarder);
|
|
217
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
218
|
+
assertTrue(success, "forwarder relay should succeed");
|
|
219
|
+
|
|
220
|
+
// Verify the lock was applied.
|
|
221
|
+
uint256 lockedUntil = resolverWithForwarder.outfitLockedUntil(address(hook), BODY_TOKEN);
|
|
222
|
+
assertEq(lockedUntil, block.timestamp + 7 days, "lock should be set for 7 days");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/// @notice Trusted forwarder can relay decorateBannyWith on behalf of alice.
|
|
226
|
+
function test_metaTx_forwarderRelaysDecorate() public {
|
|
227
|
+
uint256[] memory outfitIds = new uint256[](1);
|
|
228
|
+
outfitIds[0] = NECKLACE_TOKEN;
|
|
229
|
+
|
|
230
|
+
bytes memory originalCalldata =
|
|
231
|
+
abi.encodeCall(Banny721TokenUriResolver.decorateBannyWith, (address(hook), BODY_TOKEN, 0, outfitIds));
|
|
232
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, alice);
|
|
233
|
+
|
|
234
|
+
vm.prank(forwarder);
|
|
235
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
236
|
+
assertTrue(success, "forwarder relay of decorate should succeed");
|
|
237
|
+
|
|
238
|
+
// Verify the necklace is worn by the body.
|
|
239
|
+
assertEq(resolverWithForwarder.wearerOf(address(hook), NECKLACE_TOKEN), BODY_TOKEN, "necklace should be worn");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/// @notice Non-forwarder cannot spoof _msgSender by appending an address suffix.
|
|
243
|
+
function test_metaTx_nonForwarderCannotSpoof() public {
|
|
244
|
+
// Bob (not the forwarder) tries to send calldata with alice's address appended.
|
|
245
|
+
// When msg.sender is not the forwarder, the suffix is ignored and msg.sender is used.
|
|
246
|
+
uint256[] memory outfitIds = new uint256[](0);
|
|
247
|
+
|
|
248
|
+
bytes memory originalCalldata =
|
|
249
|
+
abi.encodeCall(Banny721TokenUriResolver.decorateBannyWith, (address(hook), BODY_TOKEN, 0, outfitIds));
|
|
250
|
+
bytes memory spoofedCalldata = _buildForwarderCalldata(originalCalldata, alice);
|
|
251
|
+
|
|
252
|
+
// Bob calls with spoofed calldata. Since bob is not the forwarder,
|
|
253
|
+
// _msgSender() returns bob (not alice), so this should revert
|
|
254
|
+
// because bob doesn't own the body.
|
|
255
|
+
vm.prank(bob);
|
|
256
|
+
(bool success,) = address(resolverWithForwarder).call(spoofedCalldata);
|
|
257
|
+
assertFalse(success, "non-forwarder spoof should fail");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/// @notice When the forwarder relays but the suffixed sender does not own the body, it reverts.
|
|
261
|
+
function test_metaTx_forwarderRelayUnauthorizedUser() public {
|
|
262
|
+
// Relay on behalf of bob, who does not own the body token.
|
|
263
|
+
uint256[] memory outfitIds = new uint256[](0);
|
|
264
|
+
bytes memory originalCalldata =
|
|
265
|
+
abi.encodeCall(Banny721TokenUriResolver.decorateBannyWith, (address(hook), BODY_TOKEN, 0, outfitIds));
|
|
266
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, bob);
|
|
267
|
+
|
|
268
|
+
vm.prank(forwarder);
|
|
269
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
270
|
+
assertFalse(success, "forwarder relay for non-owner should revert");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/// @notice Owner-only operations (setMetadata) work via meta-transaction when relayed by forwarder on behalf of
|
|
274
|
+
/// owner.
|
|
275
|
+
function test_metaTx_forwarderRelaysOwnerAction() public {
|
|
276
|
+
bytes memory originalCalldata = abi.encodeCall(
|
|
277
|
+
Banny721TokenUriResolver.setMetadata, ("Meta desc", "https://meta.url", "https://meta.base/")
|
|
278
|
+
);
|
|
279
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, deployer);
|
|
280
|
+
|
|
281
|
+
vm.prank(forwarder);
|
|
282
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
283
|
+
assertTrue(success, "forwarder relay of owner setMetadata should succeed");
|
|
284
|
+
|
|
285
|
+
assertEq(resolverWithForwarder.svgDescription(), "Meta desc");
|
|
286
|
+
assertEq(resolverWithForwarder.svgExternalUrl(), "https://meta.url");
|
|
287
|
+
assertEq(resolverWithForwarder.svgBaseUri(), "https://meta.base/");
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/// @notice Owner-only operations fail when forwarder relays on behalf of non-owner.
|
|
291
|
+
function test_metaTx_forwarderRelaysNonOwnerAction_reverts() public {
|
|
292
|
+
bytes memory originalCalldata =
|
|
293
|
+
abi.encodeCall(Banny721TokenUriResolver.setMetadata, ("Evil", "https://evil.url", "https://evil.base/"));
|
|
294
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, alice);
|
|
295
|
+
|
|
296
|
+
vm.prank(forwarder);
|
|
297
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
298
|
+
assertFalse(success, "forwarder relay of non-owner setMetadata should revert");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/// @notice Direct call to resolver with forwarder works normally (no ERC-2771 decoding for non-forwarder callers).
|
|
302
|
+
function test_metaTx_directCallStillWorks() public {
|
|
303
|
+
// Alice calls directly (not via forwarder). Should still work.
|
|
304
|
+
uint256[] memory outfitIds = new uint256[](0);
|
|
305
|
+
vm.prank(alice);
|
|
306
|
+
resolverWithForwarder.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
|
|
307
|
+
// No revert means it worked.
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// @notice Forwarder relaying setProductNames on behalf of owner succeeds.
|
|
311
|
+
function test_metaTx_forwarderRelaysSetProductNames() public {
|
|
312
|
+
uint256[] memory upcs = new uint256[](1);
|
|
313
|
+
upcs[0] = 100;
|
|
314
|
+
string[] memory names = new string[](1);
|
|
315
|
+
names[0] = "Relayed Hat";
|
|
316
|
+
|
|
317
|
+
bytes memory originalCalldata = abi.encodeCall(Banny721TokenUriResolver.setProductNames, (upcs, names));
|
|
318
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, deployer);
|
|
319
|
+
|
|
320
|
+
vm.prank(forwarder);
|
|
321
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
322
|
+
assertTrue(success, "forwarder relay of setProductNames should succeed");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/// @notice Forwarder relaying setSvgHashesOf on behalf of owner succeeds.
|
|
326
|
+
function test_metaTx_forwarderRelaysSetSvgHashes() public {
|
|
327
|
+
uint256[] memory upcs = new uint256[](1);
|
|
328
|
+
upcs[0] = 100;
|
|
329
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
330
|
+
hashes[0] = keccak256("relayed-svg");
|
|
331
|
+
|
|
332
|
+
bytes memory originalCalldata = abi.encodeCall(Banny721TokenUriResolver.setSvgHashesOf, (upcs, hashes));
|
|
333
|
+
bytes memory forwarderCalldata = _buildForwarderCalldata(originalCalldata, deployer);
|
|
334
|
+
|
|
335
|
+
vm.prank(forwarder);
|
|
336
|
+
(bool success,) = address(resolverWithForwarder).call(forwarderCalldata);
|
|
337
|
+
assertTrue(success, "forwarder relay of setSvgHashesOf should succeed");
|
|
338
|
+
|
|
339
|
+
assertEq(resolverWithForwarder.svgHashOf(100), keccak256("relayed-svg"));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
//*********************************************************************//
|
|
343
|
+
// --- SVG Sanitization / Rendering Edge Cases ----------------------- //
|
|
344
|
+
//*********************************************************************//
|
|
345
|
+
|
|
346
|
+
/// @notice SVG content with special HTML characters is stored and rendered faithfully (no sanitization needed
|
|
347
|
+
/// because output is base64-encoded).
|
|
348
|
+
function test_svg_specialCharactersInContent() public {
|
|
349
|
+
string memory svgWithSpecialChars = '<rect x="10" y="20" width="100" height="100" fill="red"/>';
|
|
350
|
+
|
|
351
|
+
uint256[] memory upcs = new uint256[](1);
|
|
352
|
+
upcs[0] = 100;
|
|
353
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
354
|
+
hashes[0] = keccak256(abi.encodePacked(svgWithSpecialChars));
|
|
355
|
+
|
|
356
|
+
vm.prank(deployer);
|
|
357
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
358
|
+
|
|
359
|
+
string[] memory contents = new string[](1);
|
|
360
|
+
contents[0] = svgWithSpecialChars;
|
|
361
|
+
|
|
362
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
363
|
+
// No revert means content was stored successfully.
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/// @notice SVG content containing script tags is stored without filtering (the contract stores
|
|
367
|
+
/// content as-is; sanitization is expected at the display layer).
|
|
368
|
+
function test_svg_scriptTagContent() public {
|
|
369
|
+
string memory maliciousSvg = '<script>alert("xss")</script><rect width="10" height="10"/>';
|
|
370
|
+
|
|
371
|
+
uint256[] memory upcs = new uint256[](1);
|
|
372
|
+
upcs[0] = 200;
|
|
373
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
374
|
+
hashes[0] = keccak256(abi.encodePacked(maliciousSvg));
|
|
375
|
+
|
|
376
|
+
vm.prank(deployer);
|
|
377
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
378
|
+
|
|
379
|
+
string[] memory contents = new string[](1);
|
|
380
|
+
contents[0] = maliciousSvg;
|
|
381
|
+
|
|
382
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
383
|
+
// Content stored as-is. Hash validation passed.
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/// @notice SVG content with onload event handler is stored faithfully.
|
|
387
|
+
function test_svg_eventHandlerContent() public {
|
|
388
|
+
string memory eventHandlerSvg = '<rect onload="alert(1)" width="400" height="400"/>';
|
|
389
|
+
|
|
390
|
+
uint256[] memory upcs = new uint256[](1);
|
|
391
|
+
upcs[0] = 201;
|
|
392
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
393
|
+
hashes[0] = keccak256(abi.encodePacked(eventHandlerSvg));
|
|
394
|
+
|
|
395
|
+
vm.prank(deployer);
|
|
396
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
397
|
+
|
|
398
|
+
string[] memory contents = new string[](1);
|
|
399
|
+
contents[0] = eventHandlerSvg;
|
|
400
|
+
|
|
401
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/// @notice Empty SVG content cannot be stored because the hash of "" would need to be pre-stored,
|
|
405
|
+
/// and an empty content means no SVG is rendered.
|
|
406
|
+
function test_svg_emptyContentMatchesHash() public {
|
|
407
|
+
string memory emptyContent = "";
|
|
408
|
+
|
|
409
|
+
uint256[] memory upcs = new uint256[](1);
|
|
410
|
+
upcs[0] = 300;
|
|
411
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
412
|
+
hashes[0] = keccak256(abi.encodePacked(emptyContent));
|
|
413
|
+
|
|
414
|
+
vm.prank(deployer);
|
|
415
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
416
|
+
|
|
417
|
+
string[] memory contents = new string[](1);
|
|
418
|
+
contents[0] = emptyContent;
|
|
419
|
+
|
|
420
|
+
// This should succeed because keccak256("") matches.
|
|
421
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/// @notice SVG content with unicode characters (emoji, CJK, etc.) is handled correctly.
|
|
425
|
+
function test_svg_unicodeContent() public {
|
|
426
|
+
// Unicode characters in SVG text elements.
|
|
427
|
+
string memory unicodeSvg = unicode'<text x="10" y="50" font-size="20">Hello \u4e16\u754c</text>';
|
|
428
|
+
|
|
429
|
+
uint256[] memory upcs = new uint256[](1);
|
|
430
|
+
upcs[0] = 301;
|
|
431
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
432
|
+
hashes[0] = keccak256(abi.encodePacked(unicodeSvg));
|
|
433
|
+
|
|
434
|
+
vm.prank(deployer);
|
|
435
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
436
|
+
|
|
437
|
+
string[] memory contents = new string[](1);
|
|
438
|
+
contents[0] = unicodeSvg;
|
|
439
|
+
|
|
440
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/// @notice A very long SVG string can be stored (gas cost scales but no overflow).
|
|
444
|
+
function test_svg_veryLongContent() public {
|
|
445
|
+
// Build a 10KB SVG string.
|
|
446
|
+
bytes memory longSvg = bytes('<rect width="400" height="400" fill="blue"/>');
|
|
447
|
+
bytes memory padding = new bytes(10_000);
|
|
448
|
+
for (uint256 i; i < padding.length; i++) {
|
|
449
|
+
padding[i] = "A";
|
|
450
|
+
}
|
|
451
|
+
string memory longContent = string(abi.encodePacked(longSvg, padding));
|
|
452
|
+
|
|
453
|
+
uint256[] memory upcs = new uint256[](1);
|
|
454
|
+
upcs[0] = 302;
|
|
455
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
456
|
+
hashes[0] = keccak256(abi.encodePacked(longContent));
|
|
457
|
+
|
|
458
|
+
vm.prank(deployer);
|
|
459
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
460
|
+
|
|
461
|
+
string[] memory contents = new string[](1);
|
|
462
|
+
contents[0] = longContent;
|
|
463
|
+
|
|
464
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/// @notice SVG content with JSON-breaking characters (quotes, backslashes) is stored correctly.
|
|
468
|
+
/// The contract wraps everything in base64, so JSON special chars in the SVG do not break the output.
|
|
469
|
+
function test_svg_jsonBreakingCharacters() public {
|
|
470
|
+
// Quotes and backslashes that would break naive JSON assembly.
|
|
471
|
+
string memory jsonBreakingSvg = '<text>"Hello \\ World"</text>';
|
|
472
|
+
|
|
473
|
+
uint256[] memory upcs = new uint256[](1);
|
|
474
|
+
upcs[0] = 303;
|
|
475
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
476
|
+
hashes[0] = keccak256(abi.encodePacked(jsonBreakingSvg));
|
|
477
|
+
|
|
478
|
+
vm.prank(deployer);
|
|
479
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
480
|
+
|
|
481
|
+
string[] memory contents = new string[](1);
|
|
482
|
+
contents[0] = jsonBreakingSvg;
|
|
483
|
+
|
|
484
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/// @notice tokenUriOf returns empty string for a non-existent product (tier ID 0).
|
|
488
|
+
function test_svg_tokenUriOfNonExistentProduct() public view {
|
|
489
|
+
// Token 99_000_000_001 has no tier set up, so product.id == 0.
|
|
490
|
+
string memory uri = resolver.tokenUriOf(address(hook), 99_000_000_001);
|
|
491
|
+
assertEq(bytes(uri).length, 0, "non-existent product should return empty URI");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/// @notice svgOf returns empty string for a non-existent product.
|
|
495
|
+
function test_svg_svgOfNonExistentProduct() public view {
|
|
496
|
+
string memory svg = resolver.svgOf(address(hook), 99_000_000_001, true, true);
|
|
497
|
+
assertEq(bytes(svg).length, 0, "non-existent product should return empty SVG");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/// @notice svgOf for a body with no outfits still produces valid SVG with default decorations.
|
|
501
|
+
function test_svg_nakedBodyProducesValidSvg() public view {
|
|
502
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, true, true);
|
|
503
|
+
// Should contain the SVG wrapper.
|
|
504
|
+
assertTrue(bytes(svg).length > 0, "naked body should produce SVG");
|
|
505
|
+
// Check it starts with the expected SVG opening tag.
|
|
506
|
+
assertTrue(_startsWith(svg, "<svg"), "should start with <svg");
|
|
507
|
+
// Check it ends with </svg>.
|
|
508
|
+
assertTrue(_endsWith(svg, "</svg>"), "should end with </svg>");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/// @notice svgOf for an alien body uses alien-specific eyes defaults.
|
|
512
|
+
function test_svg_alienBodyUsesAlienEyes() public view {
|
|
513
|
+
string memory svg = resolver.svgOf(address(hook), ALIEN_BODY_TOKEN, true, false);
|
|
514
|
+
assertTrue(bytes(svg).length > 0, "alien body should produce SVG");
|
|
515
|
+
// The alien body SVG should contain the alien eyes default.
|
|
516
|
+
assertTrue(_contains(svg, "<alieneyes/>"), "alien body should include default alien eyes");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/// @notice svgOf for an original body uses standard eyes defaults.
|
|
520
|
+
function test_svg_originalBodyUsesStandardEyes() public view {
|
|
521
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, true, false);
|
|
522
|
+
assertTrue(bytes(svg).length > 0, "original body should produce SVG");
|
|
523
|
+
// The original body SVG should contain the standard eyes default.
|
|
524
|
+
assertTrue(_contains(svg, "<eyes/>"), "original body should include default standard eyes");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/// @notice svgOf for a body with shouldDressBannyBody=false omits default outfits.
|
|
528
|
+
function test_svg_undressedBodyOmitsOutfits() public view {
|
|
529
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, false, false);
|
|
530
|
+
assertTrue(bytes(svg).length > 0, "undressed body should produce SVG");
|
|
531
|
+
// Should NOT contain default necklace, eyes, or mouth since dressing is disabled.
|
|
532
|
+
assertFalse(_contains(svg, "<necklace/>"), "undressed body should not include default necklace");
|
|
533
|
+
assertFalse(_contains(svg, "<eyes/>"), "undressed body should not include default eyes");
|
|
534
|
+
assertFalse(_contains(svg, "<mouth/>"), "undressed body should not include default mouth");
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/// @notice Metadata with special characters in description and URL is stored and retrieved correctly.
|
|
538
|
+
function test_svg_metadataWithSpecialChars() public {
|
|
539
|
+
// Set metadata with characters that could break JSON if not base64-encoded.
|
|
540
|
+
vm.prank(deployer);
|
|
541
|
+
resolver.setMetadata(
|
|
542
|
+
'A "special" description with <tags> & ampersands', "https://example.com/path?a=1&b=2", "https://base.uri/"
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
assertEq(resolver.svgDescription(), 'A "special" description with <tags> & ampersands');
|
|
546
|
+
assertEq(resolver.svgExternalUrl(), "https://example.com/path?a=1&b=2");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/// @notice tokenUriOf for a body produces base64-encoded data URI.
|
|
550
|
+
function test_svg_tokenUriOfBodyProducesDataUri() public view {
|
|
551
|
+
string memory uri = resolver.tokenUriOf(address(hook), BODY_TOKEN);
|
|
552
|
+
assertTrue(bytes(uri).length > 0, "body token should have URI");
|
|
553
|
+
// The tokenUriOf should start with data:application/json;base64,
|
|
554
|
+
assertTrue(_startsWith(uri, "data:application/json;base64,"), "URI should be a base64-encoded data URI");
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/// @notice Product name with special characters in setProductNames is stored and retrievable.
|
|
558
|
+
function test_svg_productNameWithSpecialChars() public {
|
|
559
|
+
uint256[] memory upcs = new uint256[](1);
|
|
560
|
+
upcs[0] = 400;
|
|
561
|
+
string[] memory names = new string[](1);
|
|
562
|
+
names[0] = 'Cool "Hat" <special>';
|
|
563
|
+
|
|
564
|
+
vm.prank(deployer);
|
|
565
|
+
resolver.setProductNames(upcs, names);
|
|
566
|
+
|
|
567
|
+
// Set up a tier with UPC 400 to verify via namesOf.
|
|
568
|
+
uint256 tokenId = 400_000_000_001;
|
|
569
|
+
_setupTier(tokenId, 400, 3); // category 3 = necklace
|
|
570
|
+
|
|
571
|
+
(,, string memory productName) = resolver.namesOf(address(hook), tokenId);
|
|
572
|
+
assertEq(productName, 'Cool "Hat" <special>');
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/// @notice SVG content with newlines and tabs is stored faithfully.
|
|
576
|
+
function test_svg_newlinesAndTabs() public {
|
|
577
|
+
string memory svgWithWhitespace = "<g>\n\t<rect width=\"10\" height=\"10\"/>\n</g>";
|
|
578
|
+
|
|
579
|
+
uint256[] memory upcs = new uint256[](1);
|
|
580
|
+
upcs[0] = 304;
|
|
581
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
582
|
+
hashes[0] = keccak256(abi.encodePacked(svgWithWhitespace));
|
|
583
|
+
|
|
584
|
+
vm.prank(deployer);
|
|
585
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
586
|
+
|
|
587
|
+
string[] memory contents = new string[](1);
|
|
588
|
+
contents[0] = svgWithWhitespace;
|
|
589
|
+
|
|
590
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/// @notice SVG rendering: body with stored SVG outfit content renders it in the output.
|
|
594
|
+
function test_svg_renderedOutfitContentInSvg() public {
|
|
595
|
+
// Store SVG content for necklace (UPC 10).
|
|
596
|
+
string memory necklaceSvg = '<circle cx="200" cy="300" r="20" fill="gold"/>';
|
|
597
|
+
uint256[] memory upcs = new uint256[](1);
|
|
598
|
+
upcs[0] = 10;
|
|
599
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
600
|
+
hashes[0] = keccak256(abi.encodePacked(necklaceSvg));
|
|
601
|
+
|
|
602
|
+
vm.prank(deployer);
|
|
603
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
604
|
+
|
|
605
|
+
string[] memory contents = new string[](1);
|
|
606
|
+
contents[0] = necklaceSvg;
|
|
607
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
608
|
+
|
|
609
|
+
// Equip the necklace on the body.
|
|
610
|
+
uint256[] memory outfitIds = new uint256[](1);
|
|
611
|
+
outfitIds[0] = NECKLACE_TOKEN;
|
|
612
|
+
vm.prank(alice);
|
|
613
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
|
|
614
|
+
|
|
615
|
+
// Render the body with outfits.
|
|
616
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, true, false);
|
|
617
|
+
assertTrue(bytes(svg).length > 0, "should render SVG");
|
|
618
|
+
// The necklace content should be present in the rendered SVG.
|
|
619
|
+
// Note: custom necklaces are layered after suit_top (category 11) in _outfitContentsFor.
|
|
620
|
+
assertTrue(_contains(svg, necklaceSvg), "rendered SVG should contain necklace content");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/// @notice SVG rendering: body with background produces SVG containing the background.
|
|
624
|
+
function test_svg_renderedBackgroundInSvg() public {
|
|
625
|
+
// Store SVG content for background (UPC 5).
|
|
626
|
+
string memory bgSvg = '<rect width="400" height="400" fill="skyblue"/>';
|
|
627
|
+
uint256[] memory upcs = new uint256[](1);
|
|
628
|
+
upcs[0] = 5;
|
|
629
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
630
|
+
hashes[0] = keccak256(abi.encodePacked(bgSvg));
|
|
631
|
+
|
|
632
|
+
vm.prank(deployer);
|
|
633
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
634
|
+
|
|
635
|
+
string[] memory contents = new string[](1);
|
|
636
|
+
contents[0] = bgSvg;
|
|
637
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
638
|
+
|
|
639
|
+
// Equip the background on the body.
|
|
640
|
+
uint256[] memory outfitIds = new uint256[](0);
|
|
641
|
+
vm.prank(alice);
|
|
642
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BACKGROUND_TOKEN, outfitIds);
|
|
643
|
+
|
|
644
|
+
// Render with background included.
|
|
645
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, true, true);
|
|
646
|
+
assertTrue(_contains(svg, bgSvg), "rendered SVG should contain background content");
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/// @notice SVG rendering: body with background but shouldIncludeBackgroundOnBannyBody=false omits it.
|
|
650
|
+
function test_svg_backgroundExcludedWhenFlagFalse() public {
|
|
651
|
+
// Store SVG content for background (UPC 5).
|
|
652
|
+
string memory bgSvg = '<rect width="400" height="400" fill="skyblue"/>';
|
|
653
|
+
uint256[] memory upcs = new uint256[](1);
|
|
654
|
+
upcs[0] = 5;
|
|
655
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
656
|
+
hashes[0] = keccak256(abi.encodePacked(bgSvg));
|
|
657
|
+
|
|
658
|
+
vm.prank(deployer);
|
|
659
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
660
|
+
|
|
661
|
+
string[] memory contents = new string[](1);
|
|
662
|
+
contents[0] = bgSvg;
|
|
663
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
664
|
+
|
|
665
|
+
// Equip background.
|
|
666
|
+
uint256[] memory outfitIds = new uint256[](0);
|
|
667
|
+
vm.prank(alice);
|
|
668
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BACKGROUND_TOKEN, outfitIds);
|
|
669
|
+
|
|
670
|
+
// Render WITHOUT background.
|
|
671
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, true, false);
|
|
672
|
+
assertFalse(_contains(svg, bgSvg), "background should not appear when flag is false");
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
//*********************************************************************//
|
|
676
|
+
// --- Helpers ------------------------------------------------------- //
|
|
677
|
+
//*********************************************************************//
|
|
678
|
+
|
|
679
|
+
/// @notice Check if a string starts with a prefix.
|
|
680
|
+
function _startsWith(string memory str, string memory prefix) internal pure returns (bool) {
|
|
681
|
+
bytes memory strBytes = bytes(str);
|
|
682
|
+
bytes memory prefixBytes = bytes(prefix);
|
|
683
|
+
if (prefixBytes.length > strBytes.length) return false;
|
|
684
|
+
for (uint256 i; i < prefixBytes.length; i++) {
|
|
685
|
+
if (strBytes[i] != prefixBytes[i]) return false;
|
|
686
|
+
}
|
|
687
|
+
return true;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/// @notice Check if a string ends with a suffix.
|
|
691
|
+
function _endsWith(string memory str, string memory suffix) internal pure returns (bool) {
|
|
692
|
+
bytes memory strBytes = bytes(str);
|
|
693
|
+
bytes memory suffixBytes = bytes(suffix);
|
|
694
|
+
if (suffixBytes.length > strBytes.length) return false;
|
|
695
|
+
uint256 offset = strBytes.length - suffixBytes.length;
|
|
696
|
+
for (uint256 i; i < suffixBytes.length; i++) {
|
|
697
|
+
if (strBytes[offset + i] != suffixBytes[i]) return false;
|
|
698
|
+
}
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/// @notice Check if a string contains a substring.
|
|
703
|
+
function _contains(string memory str, string memory sub) internal pure returns (bool) {
|
|
704
|
+
bytes memory strBytes = bytes(str);
|
|
705
|
+
bytes memory subBytes = bytes(sub);
|
|
706
|
+
if (subBytes.length > strBytes.length) return false;
|
|
707
|
+
if (subBytes.length == 0) return true;
|
|
708
|
+
for (uint256 i; i <= strBytes.length - subBytes.length; i++) {
|
|
709
|
+
bool found = true;
|
|
710
|
+
for (uint256 j; j < subBytes.length; j++) {
|
|
711
|
+
if (strBytes[i + j] != subBytes[j]) {
|
|
712
|
+
found = false;
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
if (found) return true;
|
|
717
|
+
}
|
|
718
|
+
return false;
|
|
719
|
+
}
|
|
720
|
+
}
|