@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,443 @@
|
|
|
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
|
+
import {Base64} from "lib/base64/base64.sol";
|
|
8
|
+
|
|
9
|
+
import {Banny721TokenUriResolver} from "../src/Banny721TokenUriResolver.sol";
|
|
10
|
+
|
|
11
|
+
/// @notice Mock hook for QA last-mile testing.
|
|
12
|
+
contract QAMockHook {
|
|
13
|
+
mapping(uint256 tokenId => address) public ownerOf;
|
|
14
|
+
mapping(uint256 tokenId => uint32) public tierIdOf;
|
|
15
|
+
mapping(uint256 tokenId => uint24) public categoryOf;
|
|
16
|
+
address public immutable MOCK_STORE;
|
|
17
|
+
mapping(address owner => mapping(address operator => bool)) public isApprovedForAll;
|
|
18
|
+
|
|
19
|
+
constructor(address store) {
|
|
20
|
+
MOCK_STORE = store;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function STORE() external view returns (address) {
|
|
24
|
+
return MOCK_STORE;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setOwner(uint256 tokenId, address _owner) external {
|
|
28
|
+
ownerOf[tokenId] = _owner;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function setTier(uint256 tokenId, uint32 tierId, uint24 category) external {
|
|
32
|
+
tierIdOf[tokenId] = tierId;
|
|
33
|
+
categoryOf[tokenId] = category;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setApprovalForAll(address operator, bool approved) external {
|
|
37
|
+
isApprovedForAll[msg.sender][operator] = approved;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function safeTransferFrom(address from, address to, uint256 tokenId) external {
|
|
41
|
+
require(
|
|
42
|
+
msg.sender == ownerOf[tokenId] || msg.sender == from || isApprovedForAll[from][msg.sender],
|
|
43
|
+
"MockHook: not authorized"
|
|
44
|
+
);
|
|
45
|
+
ownerOf[tokenId] = to;
|
|
46
|
+
|
|
47
|
+
if (to.code.length > 0) {
|
|
48
|
+
bytes4 retval = IERC721Receiver(to).onERC721Received(msg.sender, from, tokenId, "");
|
|
49
|
+
require(retval == IERC721Receiver.onERC721Received.selector, "MockHook: receiver rejected");
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function pricingContext() external pure returns (uint256, uint256) {
|
|
54
|
+
return (1, 18);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function baseURI() external pure returns (string memory) {
|
|
58
|
+
return "ipfs://";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/// @notice Mock store for QA last-mile testing.
|
|
63
|
+
contract QAMockStore {
|
|
64
|
+
mapping(address hook => mapping(uint256 tokenId => JB721Tier)) public tiers;
|
|
65
|
+
mapping(address hook => mapping(uint256 tierId => bytes32)) public ipfsUris;
|
|
66
|
+
|
|
67
|
+
function setTier(address hook, uint256 tokenId, JB721Tier memory tier) external {
|
|
68
|
+
tiers[hook][tokenId] = tier;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tierOfTokenId(address hook, uint256 tokenId, bool) external view returns (JB721Tier memory) {
|
|
72
|
+
return tiers[hook][tokenId];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// forge-lint: disable-next-line(mixed-case-function)
|
|
76
|
+
function setEncodedIPFSUri(address hook, uint256 tierId, bytes32 uri) external {
|
|
77
|
+
ipfsUris[hook][tierId] = uri;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// forge-lint: disable-next-line(mixed-case-function)
|
|
81
|
+
function encodedTierIPFSUriOf(address, uint256) external pure returns (bytes32) {
|
|
82
|
+
return bytes32(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// forge-lint: disable-next-line(mixed-case-function)
|
|
86
|
+
function encodedIPFSUriOf(address hook, uint256 tierId) external view returns (bytes32) {
|
|
87
|
+
return ipfsUris[hook][tierId];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/// @title TestQALastMile
|
|
92
|
+
/// @notice Last-mile QA tests: tokenURI round-trip decode, SVG-to-IPFS fallback, and 9-outfit gas ceiling.
|
|
93
|
+
contract TestQALastMile is Test {
|
|
94
|
+
Banny721TokenUriResolver resolver;
|
|
95
|
+
QAMockHook hook;
|
|
96
|
+
QAMockStore store;
|
|
97
|
+
|
|
98
|
+
address deployer = makeAddr("deployer");
|
|
99
|
+
address alice = makeAddr("alice");
|
|
100
|
+
|
|
101
|
+
// Body: UPC 4, category 0 (Original body).
|
|
102
|
+
uint256 constant BODY_TOKEN = 4_000_000_001;
|
|
103
|
+
|
|
104
|
+
// Background: UPC 5, category 1.
|
|
105
|
+
uint256 constant BACKGROUND_TOKEN = 5_000_000_001;
|
|
106
|
+
|
|
107
|
+
// Outfit tokens (one per non-conflicting outfit category).
|
|
108
|
+
uint256 constant NECKLACE_TOKEN = 6_000_000_001; // category 3
|
|
109
|
+
uint256 constant EYES_TOKEN = 8_000_000_001; // category 5
|
|
110
|
+
uint256 constant GLASSES_TOKEN = 9_000_000_001; // category 6
|
|
111
|
+
uint256 constant MOUTH_TOKEN = 10_000_000_001; // category 7
|
|
112
|
+
uint256 constant LEGS_TOKEN = 11_000_000_001; // category 8
|
|
113
|
+
uint256 constant SUIT_BOTTOM_TOKEN = 13_000_000_001; // category 10
|
|
114
|
+
uint256 constant SUIT_TOP_TOKEN = 14_000_000_001; // category 11
|
|
115
|
+
uint256 constant HEADTOP_TOKEN = 15_000_000_001; // category 12
|
|
116
|
+
uint256 constant HAND_TOKEN = 16_000_000_001; // category 13
|
|
117
|
+
|
|
118
|
+
function setUp() public {
|
|
119
|
+
store = new QAMockStore();
|
|
120
|
+
hook = new QAMockHook(address(store));
|
|
121
|
+
|
|
122
|
+
// Deploy resolver with abbreviated SVG constants.
|
|
123
|
+
vm.prank(deployer);
|
|
124
|
+
resolver = new Banny721TokenUriResolver(
|
|
125
|
+
'<g class="b1"><path d="M173 53h4v17h-4z"/></g>',
|
|
126
|
+
'<g class="o"><path d="M190 173h-37v-3h-10"/></g>',
|
|
127
|
+
'<g class="o"><path d="M183 160v-4h-20v4" fill="#ad71c8"/></g>',
|
|
128
|
+
'<g class="o"><path d="M177 140v3h6v11h10v-11h4v-3h-20z"/></g>',
|
|
129
|
+
'<g class="o"><path d="M190 127h3v3h-3z"/></g>',
|
|
130
|
+
deployer,
|
|
131
|
+
address(0)
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Set metadata so tokenURI JSON is populated.
|
|
135
|
+
vm.prank(deployer);
|
|
136
|
+
resolver.setMetadata("A piece of Banny Retail.", "https://retail.banny.eth.shop", "https://bannyverse.test/");
|
|
137
|
+
|
|
138
|
+
// Set up all tiers.
|
|
139
|
+
_setupTier(BODY_TOKEN, 4, 0);
|
|
140
|
+
_setupTier(BACKGROUND_TOKEN, 5, 1);
|
|
141
|
+
_setupTier(NECKLACE_TOKEN, 6, 3);
|
|
142
|
+
_setupTier(EYES_TOKEN, 8, 5);
|
|
143
|
+
_setupTier(GLASSES_TOKEN, 9, 6);
|
|
144
|
+
_setupTier(MOUTH_TOKEN, 10, 7);
|
|
145
|
+
_setupTier(LEGS_TOKEN, 11, 8);
|
|
146
|
+
_setupTier(SUIT_BOTTOM_TOKEN, 13, 10);
|
|
147
|
+
_setupTier(SUIT_TOP_TOKEN, 14, 11);
|
|
148
|
+
_setupTier(HEADTOP_TOKEN, 15, 12);
|
|
149
|
+
_setupTier(HAND_TOKEN, 16, 13);
|
|
150
|
+
|
|
151
|
+
// Give alice all tokens.
|
|
152
|
+
hook.setOwner(BODY_TOKEN, alice);
|
|
153
|
+
hook.setOwner(BACKGROUND_TOKEN, alice);
|
|
154
|
+
hook.setOwner(NECKLACE_TOKEN, alice);
|
|
155
|
+
hook.setOwner(EYES_TOKEN, alice);
|
|
156
|
+
hook.setOwner(GLASSES_TOKEN, alice);
|
|
157
|
+
hook.setOwner(MOUTH_TOKEN, alice);
|
|
158
|
+
hook.setOwner(LEGS_TOKEN, alice);
|
|
159
|
+
hook.setOwner(SUIT_BOTTOM_TOKEN, alice);
|
|
160
|
+
hook.setOwner(SUIT_TOP_TOKEN, alice);
|
|
161
|
+
hook.setOwner(HEADTOP_TOKEN, alice);
|
|
162
|
+
hook.setOwner(HAND_TOKEN, alice);
|
|
163
|
+
|
|
164
|
+
// Approve resolver for alice.
|
|
165
|
+
vm.prank(alice);
|
|
166
|
+
hook.setApprovalForAll(address(resolver), true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _setupTier(uint256 tokenId, uint32 tierId, uint24 category) internal {
|
|
170
|
+
hook.setTier(tokenId, tierId, category);
|
|
171
|
+
store.setTier(
|
|
172
|
+
address(hook),
|
|
173
|
+
tokenId,
|
|
174
|
+
JB721Tier({
|
|
175
|
+
id: tierId,
|
|
176
|
+
price: 0.01 ether,
|
|
177
|
+
remainingSupply: 100,
|
|
178
|
+
initialSupply: 100,
|
|
179
|
+
votingUnits: 0,
|
|
180
|
+
reserveFrequency: 0,
|
|
181
|
+
reserveBeneficiary: address(0),
|
|
182
|
+
encodedIPFSUri: bytes32(0),
|
|
183
|
+
category: category,
|
|
184
|
+
discountPercent: 0,
|
|
185
|
+
allowOwnerMint: false,
|
|
186
|
+
transfersPausable: false,
|
|
187
|
+
cannotBeRemoved: false,
|
|
188
|
+
cannotIncreaseDiscountPercent: false,
|
|
189
|
+
splitPercent: 0,
|
|
190
|
+
resolvedUri: ""
|
|
191
|
+
})
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
//*********************************************************************//
|
|
196
|
+
// --- Task 1: tokenURI Round-Trip Decode Test ------------------------ //
|
|
197
|
+
//*********************************************************************//
|
|
198
|
+
|
|
199
|
+
/// @notice Mint a complete Banny (body + outfit + background), call tokenUriOf(), decode the base64
|
|
200
|
+
/// data URI, parse the JSON, and validate it has name, image, and attributes fields.
|
|
201
|
+
function test_tokenUri_roundTripDecode() public {
|
|
202
|
+
// Equip one outfit and a background on the body.
|
|
203
|
+
uint256[] memory outfitIds = new uint256[](1);
|
|
204
|
+
outfitIds[0] = NECKLACE_TOKEN;
|
|
205
|
+
|
|
206
|
+
vm.prank(alice);
|
|
207
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BACKGROUND_TOKEN, outfitIds);
|
|
208
|
+
|
|
209
|
+
// Call tokenUriOf.
|
|
210
|
+
string memory uri = resolver.tokenUriOf(address(hook), BODY_TOKEN);
|
|
211
|
+
|
|
212
|
+
// Verify the data URI prefix.
|
|
213
|
+
bytes memory uriBytes = bytes(uri);
|
|
214
|
+
string memory prefix = "data:application/json;base64,";
|
|
215
|
+
bytes memory prefixBytes = bytes(prefix);
|
|
216
|
+
assertGt(uriBytes.length, prefixBytes.length, "URI should be longer than the prefix");
|
|
217
|
+
for (uint256 i; i < prefixBytes.length; i++) {
|
|
218
|
+
assertEq(uriBytes[i], prefixBytes[i], "URI prefix mismatch");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Extract the base64-encoded portion (everything after the prefix).
|
|
222
|
+
bytes memory base64Portion = new bytes(uriBytes.length - prefixBytes.length);
|
|
223
|
+
for (uint256 i; i < base64Portion.length; i++) {
|
|
224
|
+
base64Portion[i] = uriBytes[prefixBytes.length + i];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Decode the base64 payload to get raw JSON bytes.
|
|
228
|
+
bytes memory jsonBytes = Base64.decode(string(base64Portion));
|
|
229
|
+
string memory json = string(jsonBytes);
|
|
230
|
+
assertGt(jsonBytes.length, 0, "decoded JSON should not be empty");
|
|
231
|
+
|
|
232
|
+
// Validate the JSON contains required fields: "name", "image", "attributes".
|
|
233
|
+
assertTrue(_contains(json, '"name"'), 'JSON should contain "name" field');
|
|
234
|
+
assertTrue(_contains(json, '"image"'), 'JSON should contain "image" field');
|
|
235
|
+
assertTrue(_contains(json, '"attributes"'), 'JSON should contain "attributes" field');
|
|
236
|
+
|
|
237
|
+
// Validate the "image" field contains a valid SVG data URI.
|
|
238
|
+
assertTrue(_contains(json, '"image":"data:image/svg+xml;base64,'), "image field should contain SVG data URI");
|
|
239
|
+
|
|
240
|
+
// Extract and decode the SVG from the image field.
|
|
241
|
+
// Find the SVG base64 start marker.
|
|
242
|
+
string memory svgMarker = '"image":"data:image/svg+xml;base64,';
|
|
243
|
+
bytes memory markerBytes = bytes(svgMarker);
|
|
244
|
+
uint256 svgBase64Start = _indexOf(json, svgMarker);
|
|
245
|
+
assertTrue(svgBase64Start != type(uint256).max, "should find SVG base64 start");
|
|
246
|
+
|
|
247
|
+
// The SVG base64 starts after the marker and ends at the closing quote + brace.
|
|
248
|
+
uint256 svgDataStart = svgBase64Start + markerBytes.length;
|
|
249
|
+
// Find the closing `"}` which ends the JSON.
|
|
250
|
+
uint256 svgDataEnd = jsonBytes.length - 2; // skip trailing `"}`
|
|
251
|
+
|
|
252
|
+
bytes memory svgBase64 = new bytes(svgDataEnd - svgDataStart);
|
|
253
|
+
for (uint256 i; i < svgBase64.length; i++) {
|
|
254
|
+
svgBase64[i] = jsonBytes[svgDataStart + i];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Decode the SVG.
|
|
258
|
+
bytes memory svgBytes = Base64.decode(string(svgBase64));
|
|
259
|
+
string memory svg = string(svgBytes);
|
|
260
|
+
assertGt(svgBytes.length, 0, "decoded SVG should not be empty");
|
|
261
|
+
|
|
262
|
+
// Validate the SVG structure.
|
|
263
|
+
assertTrue(_startsWith(svg, "<svg"), "SVG should start with <svg");
|
|
264
|
+
assertTrue(_endsWith(svg, "</svg>"), "SVG should end with </svg>");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
//*********************************************************************//
|
|
268
|
+
// --- Task 2: Missing SVG -> IPFS Fallback Test ---------------------- //
|
|
269
|
+
//*********************************************************************//
|
|
270
|
+
|
|
271
|
+
/// @notice When an outfit's _svgContentOf[upc] is empty and an IPFS hash is set on the store,
|
|
272
|
+
/// the _svgOf() function falls back to an <image href="..."> tag with the IPFS-decoded URI.
|
|
273
|
+
function test_tokenUri_svgToIpfsFallback() public {
|
|
274
|
+
// Set a non-zero IPFS hash for the necklace tier (UPC 6) in the mock store.
|
|
275
|
+
// This simulates having an IPFS hash but no on-chain SVG content.
|
|
276
|
+
bytes32 fakeIpfsHash = keccak256("fake-ipfs-content");
|
|
277
|
+
store.setEncodedIPFSUri(address(hook), 6, fakeIpfsHash);
|
|
278
|
+
|
|
279
|
+
// Do NOT store any SVG content for UPC 6 (necklace). The _svgContentOf[6] remains empty.
|
|
280
|
+
// This means _svgOf() will fall back to constructing an <image href="..."> tag.
|
|
281
|
+
|
|
282
|
+
// Equip the necklace on the body.
|
|
283
|
+
uint256[] memory outfitIds = new uint256[](1);
|
|
284
|
+
outfitIds[0] = NECKLACE_TOKEN;
|
|
285
|
+
|
|
286
|
+
vm.prank(alice);
|
|
287
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, 0, outfitIds);
|
|
288
|
+
|
|
289
|
+
// Render the body SVG. The necklace should appear as an <image> tag with the IPFS URI
|
|
290
|
+
// instead of inline SVG content.
|
|
291
|
+
string memory svg = resolver.svgOf(address(hook), BODY_TOKEN, true, false);
|
|
292
|
+
assertGt(bytes(svg).length, 0, "SVG should not be empty");
|
|
293
|
+
|
|
294
|
+
// The fallback constructs: <image href="<baseUri><base58Hash>" width="400" height="400"/>
|
|
295
|
+
// where baseUri is svgBaseUri ("https://bannyverse.test/").
|
|
296
|
+
assertTrue(_contains(svg, "<image href="), "SVG should contain an <image href= fallback tag");
|
|
297
|
+
assertTrue(
|
|
298
|
+
_contains(svg, 'width="400" height="400"/>'), "IPFS fallback image should have width and height attributes"
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// Also verify that the tokenUriOf still produces a valid data URI (not the raw IPFS fallback path).
|
|
302
|
+
string memory uri = resolver.tokenUriOf(address(hook), BODY_TOKEN);
|
|
303
|
+
assertTrue(
|
|
304
|
+
_startsWith(uri, "data:application/json;base64,"),
|
|
305
|
+
"tokenURI should still be a base64 data URI even with IPFS fallback outfit"
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
//*********************************************************************//
|
|
310
|
+
// --- Task 3: Gas Snapshot for 9-Outfit tokenURI --------------------- //
|
|
311
|
+
//*********************************************************************//
|
|
312
|
+
|
|
313
|
+
/// @notice Equip 9 non-conflicting outfits plus a background, then call tokenUriOf() and measure gas.
|
|
314
|
+
/// This establishes a gas ceiling for the worst-case on-chain SVG rendering.
|
|
315
|
+
function test_tokenUri_gasSnapshot_9outfits() public {
|
|
316
|
+
// Store SVG content for all outfit UPCs so we exercise the full rendering path.
|
|
317
|
+
_storeSvgContent(5, '<rect width="400" height="400" fill="skyblue"/>'); // background
|
|
318
|
+
_storeSvgContent(6, '<circle cx="200" cy="300" r="20" fill="gold"/>'); // necklace
|
|
319
|
+
_storeSvgContent(8, '<circle cx="180" cy="140" r="5" fill="black"/>'); // eyes
|
|
320
|
+
_storeSvgContent(9, '<rect x="170" y="135" width="30" height="10" fill="brown"/>'); // glasses
|
|
321
|
+
_storeSvgContent(10, '<path d="M180 160h20v5h-20z" fill="red"/>'); // mouth
|
|
322
|
+
_storeSvgContent(11, '<rect x="180" y="250" width="20" height="80" fill="blue"/>'); // legs
|
|
323
|
+
_storeSvgContent(13, '<rect x="175" y="200" width="25" height="40" fill="green"/>'); // suit_bottom
|
|
324
|
+
_storeSvgContent(14, '<rect x="175" y="170" width="25" height="30" fill="purple"/>'); // suit_top
|
|
325
|
+
_storeSvgContent(15, '<circle cx="190" cy="100" r="15" fill="yellow"/>'); // headtop
|
|
326
|
+
_storeSvgContent(16, '<rect x="210" y="200" width="10" height="30" fill="orange"/>'); // hand
|
|
327
|
+
|
|
328
|
+
// Equip all 9 non-conflicting outfits (sorted by ascending category).
|
|
329
|
+
uint256[] memory outfits = new uint256[](9);
|
|
330
|
+
outfits[0] = NECKLACE_TOKEN; // cat 3
|
|
331
|
+
outfits[1] = EYES_TOKEN; // cat 5
|
|
332
|
+
outfits[2] = GLASSES_TOKEN; // cat 6
|
|
333
|
+
outfits[3] = MOUTH_TOKEN; // cat 7
|
|
334
|
+
outfits[4] = LEGS_TOKEN; // cat 8
|
|
335
|
+
outfits[5] = SUIT_BOTTOM_TOKEN; // cat 10
|
|
336
|
+
outfits[6] = SUIT_TOP_TOKEN; // cat 11
|
|
337
|
+
outfits[7] = HEADTOP_TOKEN; // cat 12
|
|
338
|
+
outfits[8] = HAND_TOKEN; // cat 13
|
|
339
|
+
|
|
340
|
+
vm.prank(alice);
|
|
341
|
+
resolver.decorateBannyWith(address(hook), BODY_TOKEN, BACKGROUND_TOKEN, outfits);
|
|
342
|
+
|
|
343
|
+
// Verify all 9 outfits are attached.
|
|
344
|
+
(, uint256[] memory attachedOutfits) = resolver.assetIdsOf(address(hook), BODY_TOKEN);
|
|
345
|
+
assertEq(attachedOutfits.length, 9, "should have 9 outfits attached");
|
|
346
|
+
|
|
347
|
+
// Measure gas for tokenUriOf with maximum outfit count.
|
|
348
|
+
uint256 gasBefore = gasleft();
|
|
349
|
+
string memory uri = resolver.tokenUriOf(address(hook), BODY_TOKEN);
|
|
350
|
+
uint256 gasUsed = gasBefore - gasleft();
|
|
351
|
+
|
|
352
|
+
// Verify the URI is valid.
|
|
353
|
+
assertGt(bytes(uri).length, 0, "9-outfit tokenURI should not be empty");
|
|
354
|
+
assertTrue(_startsWith(uri, "data:application/json;base64,"), "9-outfit tokenURI should be a base64 data URI");
|
|
355
|
+
|
|
356
|
+
// Log gas used for snapshot tracking.
|
|
357
|
+
emit log_named_uint("Gas used for 9-outfit tokenUriOf", gasUsed);
|
|
358
|
+
|
|
359
|
+
// Assert gas stays under 2M (generous ceiling for view calls; typical RPC node limit is 30M+).
|
|
360
|
+
assertLt(gasUsed, 2_000_000, "9-outfit tokenUriOf should use less than 2M gas");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//*********************************************************************//
|
|
364
|
+
// --- Helpers -------------------------------------------------------- //
|
|
365
|
+
//*********************************************************************//
|
|
366
|
+
|
|
367
|
+
/// @notice Store SVG content for a given UPC, with hash pre-commitment.
|
|
368
|
+
function _storeSvgContent(uint256 upc, string memory svgContent) internal {
|
|
369
|
+
uint256[] memory upcs = new uint256[](1);
|
|
370
|
+
upcs[0] = upc;
|
|
371
|
+
|
|
372
|
+
bytes32[] memory hashes = new bytes32[](1);
|
|
373
|
+
hashes[0] = keccak256(abi.encodePacked(svgContent));
|
|
374
|
+
|
|
375
|
+
vm.prank(deployer);
|
|
376
|
+
resolver.setSvgHashesOf(upcs, hashes);
|
|
377
|
+
|
|
378
|
+
string[] memory contents = new string[](1);
|
|
379
|
+
contents[0] = svgContent;
|
|
380
|
+
resolver.setSvgContentsOf(upcs, contents);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/// @notice Check if a string starts with a prefix.
|
|
384
|
+
function _startsWith(string memory str, string memory prefix) internal pure returns (bool) {
|
|
385
|
+
bytes memory strBytes = bytes(str);
|
|
386
|
+
bytes memory prefixBytes = bytes(prefix);
|
|
387
|
+
if (prefixBytes.length > strBytes.length) return false;
|
|
388
|
+
for (uint256 i; i < prefixBytes.length; i++) {
|
|
389
|
+
if (strBytes[i] != prefixBytes[i]) return false;
|
|
390
|
+
}
|
|
391
|
+
return true;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/// @notice Check if a string ends with a suffix.
|
|
395
|
+
function _endsWith(string memory str, string memory suffix) internal pure returns (bool) {
|
|
396
|
+
bytes memory strBytes = bytes(str);
|
|
397
|
+
bytes memory suffixBytes = bytes(suffix);
|
|
398
|
+
if (suffixBytes.length > strBytes.length) return false;
|
|
399
|
+
uint256 offset = strBytes.length - suffixBytes.length;
|
|
400
|
+
for (uint256 i; i < suffixBytes.length; i++) {
|
|
401
|
+
if (strBytes[offset + i] != suffixBytes[i]) return false;
|
|
402
|
+
}
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// @notice Check if a string contains a substring.
|
|
407
|
+
function _contains(string memory str, string memory sub) internal pure returns (bool) {
|
|
408
|
+
bytes memory strBytes = bytes(str);
|
|
409
|
+
bytes memory subBytes = bytes(sub);
|
|
410
|
+
if (subBytes.length > strBytes.length) return false;
|
|
411
|
+
if (subBytes.length == 0) return true;
|
|
412
|
+
for (uint256 i; i <= strBytes.length - subBytes.length; i++) {
|
|
413
|
+
bool found = true;
|
|
414
|
+
for (uint256 j; j < subBytes.length; j++) {
|
|
415
|
+
if (strBytes[i + j] != subBytes[j]) {
|
|
416
|
+
found = false;
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (found) return true;
|
|
421
|
+
}
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/// @notice Find the index of a substring in a string. Returns type(uint256).max if not found.
|
|
426
|
+
function _indexOf(string memory str, string memory sub) internal pure returns (uint256) {
|
|
427
|
+
bytes memory strBytes = bytes(str);
|
|
428
|
+
bytes memory subBytes = bytes(sub);
|
|
429
|
+
if (subBytes.length > strBytes.length) return type(uint256).max;
|
|
430
|
+
if (subBytes.length == 0) return 0;
|
|
431
|
+
for (uint256 i; i <= strBytes.length - subBytes.length; i++) {
|
|
432
|
+
bool found = true;
|
|
433
|
+
for (uint256 j; j < subBytes.length; j++) {
|
|
434
|
+
if (strBytes[i + j] != subBytes[j]) {
|
|
435
|
+
found = false;
|
|
436
|
+
break;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (found) return i;
|
|
440
|
+
}
|
|
441
|
+
return type(uint256).max;
|
|
442
|
+
}
|
|
443
|
+
}
|