@crewhaus/tool-token 0.7.0

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/dist/index.js ADDED
@@ -0,0 +1,1045 @@
1
+ /**
2
+ * @crewhaus/tool-token — which token is this, and how much of it is there.
3
+ *
4
+ * Three reads, and one rule that shapes all of them: **ambiguity is a
5
+ * refusal, not a best guess.**
6
+ *
7
+ * A symbol is not an identifier. Nothing stops a second contract from calling
8
+ * itself USDC with six decimals and a convincing name, reputable token lists
9
+ * routinely disagree about which address a ticker means, and a harness that
10
+ * sends funds to the first match of a symbol is the accident this package
11
+ * exists to prevent. So `TokenResolve` never returns a "most likely" — when a
12
+ * query could mean more than one address it returns every candidate, each
13
+ * flagged with what is wrong with it, and `resolved: false`.
14
+ *
15
+ * The other rule is arithmetic. Decimals come from the contract, never from a
16
+ * list and never from a default: a token whose `decimals()` reverts is
17
+ * reported as unknown, and its balances come back as exact base units with no
18
+ * decimal form at all. Assuming 18 is how a six-decimal transfer becomes a
19
+ * trillion-fold one. Every quantity here is a bigint or a decimal string;
20
+ * nothing that came off a chain is ever a JS number.
21
+ *
22
+ * ## Nothing here signs or sends
23
+ *
24
+ * No schema in this package has a field a private key could be passed to, no
25
+ * code path composes a transaction, and the three JSON-RPC methods it can
26
+ * emit are `eth_call`, `eth_getBalance` and `eth_getCode` — enforced in
27
+ * `lib/chain.ts` and asserted in `index.test.ts`.
28
+ *
29
+ * ## Two seams, no dialling code
30
+ *
31
+ * There is no RPC client and no HTTP client in this package. Chain reads
32
+ * leave through `_setChainReader` and metadata fetches through
33
+ * `_setMetadataFetch`; the runtime binds both at boot and every test drives
34
+ * them from recorded answers, so the suite cannot reach the network even by
35
+ * accident.
36
+ */
37
+ import { createHash } from "node:crypto";
38
+ import { buildTool } from "@crewhaus/tool-builder";
39
+ import { MULTICALL3_ADDRESS } from "@crewhaus/tool-onchain";
40
+ import { z } from "zod";
41
+ import { TokenError, hasChainReader, nativeBalanceCall, readCalls, readHasCode, readNativeBalance, } from "./lib/chain";
42
+ import { INTERFACE_ID, SELECTOR, addressWord, bytes4Word, callData, decodeAddressValue, decodeBool, decodeStringish, decodeUint, padTokenIdHex, uintWord, } from "./lib/erc";
43
+ import { ADDRESS_SHAPE, confusableSkeleton, findCandidates, fingerprintLists, textConcerns, } from "./lib/lists";
44
+ import { MAX_FORMATTABLE_DECIMALS, checkAddress, formatUnits } from "./lib/onchain";
45
+ import { DEFAULT_MAX_METADATA_BYTES, decodeDataUri, fetchDocument, planUri, referencedUrls, } from "./lib/uri";
46
+ export { READ_METHODS, TokenError, _setChainReader, chainReaderFromAdapters, hasChainReader, } from "./lib/chain";
47
+ export { DEFAULT_MAX_METADATA_BYTES, _setMetadataFetch, planUri, } from "./lib/uri";
48
+ export { confusableSkeleton, findCandidates, textConcerns, } from "./lib/lists";
49
+ const json = (value) => JSON.stringify(value);
50
+ const LIMITS = { accounts: 100, lists: 16, tokensPerList: 20_000, hosts: 32 };
51
+ // ---------------------------------------------------------------------------
52
+ // shared schema pieces
53
+ // ---------------------------------------------------------------------------
54
+ const chainIdField = z
55
+ .number()
56
+ .int()
57
+ .positive()
58
+ .describe("EVM chain id — 1 for Ethereum mainnet, 8453 for Base");
59
+ const blockTagField = z
60
+ .string()
61
+ .min(1)
62
+ .max(32)
63
+ .optional()
64
+ .describe("latest (default), safe, finalized, or a 0x hex block number for a fixed snapshot");
65
+ const batchField = z
66
+ .boolean()
67
+ .optional()
68
+ .describe("read every call in one Multicall3 eth_call so the answers share a block; default true. false reads them one at a time, which is not a snapshot");
69
+ const multicallField = z
70
+ .string()
71
+ .optional()
72
+ .describe(`Multicall3's address on this chain; default ${MULTICALL3_ADDRESS}`);
73
+ // ---------------------------------------------------------------------------
74
+ // shared helpers
75
+ // ---------------------------------------------------------------------------
76
+ /** Validate an address, or refuse by name so the caller knows WHICH one is wrong. */
77
+ async function requireAddress(raw, what) {
78
+ const verdict = await checkAddress(raw);
79
+ if (!verdict.valid)
80
+ throw new TokenError(`${what} "${raw}": ${verdict.reason}`);
81
+ return verdict.checksummed;
82
+ }
83
+ const lower = (address) => address.toLowerCase();
84
+ function textField(outcome) {
85
+ if (outcome === undefined) {
86
+ return { value: null, encoding: "absent", note: "not read", concerns: [] };
87
+ }
88
+ if (!outcome.ok) {
89
+ return {
90
+ value: null,
91
+ encoding: "absent",
92
+ note: `the call failed: ${outcome.revert?.reason ?? outcome.revert?.kind ?? outcome.error ?? "no reason given"}`,
93
+ concerns: [],
94
+ };
95
+ }
96
+ const read = decodeStringish(outcome.data);
97
+ return {
98
+ value: read.value,
99
+ encoding: read.encoding,
100
+ note: read.note,
101
+ concerns: read.value === null ? [] : textConcerns(read.value),
102
+ };
103
+ }
104
+ function uintField(outcome) {
105
+ if (outcome === undefined || !outcome.ok)
106
+ return null;
107
+ return decodeUint(outcome.data);
108
+ }
109
+ function decimalsFrom(outcome) {
110
+ const raw = uintField(outcome);
111
+ if (raw === null) {
112
+ return {
113
+ value: null,
114
+ source: "unreadable",
115
+ note: outcome !== undefined && !outcome.ok
116
+ ? `decimals() reverted (${outcome.revert?.reason ?? outcome.revert?.kind ?? outcome.error ?? "no reason"}), so the token's scale is unknown — it is NOT 18 by default`
117
+ : "decimals() returned no data, so the token's scale is unknown — it is NOT 18 by default",
118
+ formattable: false,
119
+ };
120
+ }
121
+ if (raw > 255n) {
122
+ return {
123
+ value: null,
124
+ source: "unreadable",
125
+ note: `decimals() answered ${raw}, which does not fit in the uint8 the standard declares`,
126
+ formattable: false,
127
+ };
128
+ }
129
+ const value = Number(raw);
130
+ if (value > MAX_FORMATTABLE_DECIMALS) {
131
+ return {
132
+ value,
133
+ source: "contract",
134
+ note: `the token claims ${value} decimals; amounts are left in base units because nothing legitimate is scaled that far`,
135
+ formattable: false,
136
+ };
137
+ }
138
+ return { value, source: "contract", note: "", formattable: true };
139
+ }
140
+ /** Base units as an exact decimal string, or null when the scale is unknown. */
141
+ async function scaled(raw, decimals) {
142
+ if (!decimals.formattable || decimals.value === null)
143
+ return null;
144
+ return formatUnits(raw, decimals.value);
145
+ }
146
+ const ERC20_KEYS = {
147
+ decimals: "decimals",
148
+ symbol: "symbol",
149
+ name: "name",
150
+ supply: "supply",
151
+ };
152
+ function erc20MetadataCalls(prefix, token) {
153
+ return [
154
+ {
155
+ key: `${prefix}${ERC20_KEYS.decimals}`,
156
+ target: token,
157
+ callData: callData(SELECTOR.decimals),
158
+ },
159
+ { key: `${prefix}${ERC20_KEYS.symbol}`, target: token, callData: callData(SELECTOR.symbol) },
160
+ { key: `${prefix}${ERC20_KEYS.name}`, target: token, callData: callData(SELECTOR.name) },
161
+ {
162
+ key: `${prefix}${ERC20_KEYS.supply}`,
163
+ target: token,
164
+ callData: callData(SELECTOR.totalSupply),
165
+ },
166
+ ];
167
+ }
168
+ function erc20Facts(prefix, outcomes) {
169
+ const supply = uintField(outcomes.get(`${prefix}${ERC20_KEYS.supply}`));
170
+ return {
171
+ decimals: decimalsFrom(outcomes.get(`${prefix}${ERC20_KEYS.decimals}`)),
172
+ symbol: textField(outcomes.get(`${prefix}${ERC20_KEYS.symbol}`)),
173
+ name: textField(outcomes.get(`${prefix}${ERC20_KEYS.name}`)),
174
+ totalSupply: supply === null ? null : supply.toString(),
175
+ };
176
+ }
177
+ /** Everything odd about a token's own answers, as lines a human can act on. */
178
+ function metadataWarnings(facts) {
179
+ const out = [];
180
+ if (!facts.decimals.formattable)
181
+ out.push(facts.decimals.note);
182
+ for (const [label, field] of [
183
+ ["symbol", facts.symbol],
184
+ ["name", facts.name],
185
+ ]) {
186
+ if (field.encoding === "bytes32") {
187
+ out.push(`${label}() returned a bytes32 rather than a string — the pre-standard form MKR and other 2017-era tokens use. It was decoded as text.`);
188
+ }
189
+ if (field.encoding === "absent" || field.encoding === "undecodable") {
190
+ out.push(`${label}() could not be read: ${field.note}`);
191
+ }
192
+ for (const concern of field.concerns)
193
+ out.push(`${label} ${concern.detail}`);
194
+ }
195
+ if (facts.totalSupply === null)
196
+ out.push("totalSupply() could not be read");
197
+ return out;
198
+ }
199
+ /**
200
+ * A batch whose `getBlockNumber()` did not answer is not the snapshot the
201
+ * batched path promises. The calls still shared one `eth_call`, so they are
202
+ * consistent with each other — but nothing in the answer says which block
203
+ * they are consistent AT, and a caller pinning a later read to it has nothing
204
+ * to pin to.
205
+ */
206
+ const NO_BLOCK_NUMBER = "the batch did not report a block number, so these answers cannot be pinned to a block — they share an eth_call, but which block that read is unknown";
207
+ const sha256 = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
208
+ // ---------------------------------------------------------------------------
209
+ // TokenResolve
210
+ // ---------------------------------------------------------------------------
211
+ const tokenListSchema = z.object({
212
+ id: z.string().min(1).max(128).describe("how the answer refers to this list"),
213
+ name: z.string().max(256).optional(),
214
+ tokens: z
215
+ .array(z.object({
216
+ chainId: z.number().int().positive(),
217
+ address: z.string().min(1),
218
+ symbol: z.string(),
219
+ name: z.string(),
220
+ decimals: z.number().int().min(0).max(255),
221
+ tags: z.array(z.string()).optional(),
222
+ }))
223
+ .max(LIMITS.tokensPerList),
224
+ });
225
+ /** The concerns that mean a name was BUILT to read as another one. */
226
+ const NAME_ATTACK_CODES = new Set(["invisible-characters", "confusable"]);
227
+ /** Confirm one candidate against the chain and say what does not line up. */
228
+ function flagsFor(candidate, facts) {
229
+ const flags = [];
230
+ if (candidate.claims.length === 0)
231
+ flags.push("not-listed");
232
+ else if (candidate.claims.length === 1)
233
+ flags.push("single-list");
234
+ if (candidate.matchedBy.includes("symbol-confusable") ||
235
+ candidate.matchedBy.includes("name-confusable")) {
236
+ flags.push("confusable-match");
237
+ }
238
+ const claimedSymbols = new Set(candidate.claims.map((c) => c.symbol));
239
+ const claimedDecimals = new Set(candidate.claims.map((c) => c.decimals));
240
+ if (claimedSymbols.size > 1)
241
+ flags.push("lists-disagree-on-symbol");
242
+ if (claimedDecimals.size > 1)
243
+ flags.push("lists-disagree-on-decimals");
244
+ for (const claim of candidate.claims) {
245
+ if (textConcerns(claim.symbol).length > 0)
246
+ flags.push("suspicious-listed-symbol");
247
+ }
248
+ if (facts === null)
249
+ return [...new Set(flags)];
250
+ if (facts.hasCode === false)
251
+ flags.push("no-code");
252
+ if (facts.decimals.value === null)
253
+ flags.push("decimals-unreadable");
254
+ if (facts.symbol.encoding === "absent")
255
+ flags.push("symbol-unreadable");
256
+ if (facts.symbol.concerns.length > 0)
257
+ flags.push("suspicious-onchain-symbol");
258
+ // The name is the string a wallet puts in front of a human, so a homoglyph
259
+ // or a zero-width character in it is the same attack as one in the symbol.
260
+ // Only those two codes, not every concern: plenty of real tokens have a
261
+ // non-ASCII name, and flagging those would make `verified` mean nothing.
262
+ if (facts.name.concerns.some((c) => NAME_ATTACK_CODES.has(c.code))) {
263
+ flags.push("suspicious-onchain-name");
264
+ }
265
+ for (const claim of candidate.claims) {
266
+ if (facts.decimals.value !== null && claim.decimals !== facts.decimals.value) {
267
+ flags.push("decimals-mismatch");
268
+ }
269
+ if (facts.symbol.value !== null &&
270
+ claim.symbol !== "" &&
271
+ claim.symbol.toLowerCase() !== facts.symbol.value.toLowerCase()) {
272
+ flags.push("symbol-mismatch");
273
+ }
274
+ }
275
+ if (facts.totalSupply === "0")
276
+ flags.push("zero-total-supply");
277
+ return [...new Set(flags)];
278
+ }
279
+ /**
280
+ * The flags that mean "do not act on this without looking". Everything else
281
+ * `flagsFor` produces is context: being on one list is how a new token starts
282
+ * out, and reporting it as a defect would make `verified` useless.
283
+ */
284
+ const CONCERNING_FLAGS = new Set([
285
+ "no-code",
286
+ "not-listed",
287
+ "confusable-match",
288
+ "lists-disagree-on-symbol",
289
+ "lists-disagree-on-decimals",
290
+ "suspicious-listed-symbol",
291
+ "suspicious-onchain-symbol",
292
+ "suspicious-onchain-name",
293
+ "decimals-unreadable",
294
+ "decimals-mismatch",
295
+ "symbol-unreadable",
296
+ "symbol-mismatch",
297
+ "zero-total-supply",
298
+ ]);
299
+ export const tokenResolve = buildTool({
300
+ name: "TokenResolve",
301
+ description: "Turn a token symbol, name or address into ONE checksummed address, confirmed against the contract itself — or refuse and show you why. Use it before any transfer, balance read or approval, because a symbol is not an identifier: nothing stops a second contract calling itself USDC with six decimals and a convincing name, and reputable token lists carry different addresses for the same ticker. When a query could mean more than one address this returns EVERY candidate with resolved:false rather than the first hit or a most-likely, because picking between them is a decision with a wrong answer that costs money. Symbols that differ only by a Cyrillic lookalike or a zero-width space are pulled into the same candidate set on purpose, so an impostor a literal match would miss becomes an ambiguity you have to look at. The contract's own decimals and symbol are compared against what the lists claim, and a decimals mismatch is a refusal rather than a footnote.",
302
+ inputSchema: z
303
+ .object({
304
+ chainId: chainIdField,
305
+ query: z
306
+ .string()
307
+ .min(1)
308
+ .max(256)
309
+ .describe("a symbol (USDC), a name (USD Coin), or a 0x contract address"),
310
+ lists: z
311
+ .array(tokenListSchema)
312
+ .max(LIMITS.lists)
313
+ .optional()
314
+ .describe("token lists to resolve against, in the Uniswap token-list shape. Supplied by the caller rather than fetched here, so the same lists give the same answer"),
315
+ policy: z
316
+ .enum(["listed-only", "allow-unlisted"])
317
+ .optional()
318
+ .describe("listed-only (default) refuses an address no supplied list carries; allow-unlisted resolves it anyway and flags it"),
319
+ confirmOnchain: z
320
+ .boolean()
321
+ .optional()
322
+ .describe("read decimals/symbol/name/totalSupply from the contract; default true when a chain reader is bound"),
323
+ checkCode: z
324
+ .boolean()
325
+ .optional()
326
+ .describe("check there is a contract at the address; default true"),
327
+ maxCandidates: z
328
+ .number()
329
+ .int()
330
+ .min(1)
331
+ .max(32)
332
+ .optional()
333
+ .describe("how many candidates to confirm onchain before refusing outright; default 8"),
334
+ blockTag: blockTagField,
335
+ batch: batchField,
336
+ multicall3Address: multicallField,
337
+ })
338
+ .strict(),
339
+ readOnly: true,
340
+ concurrencySafe: true,
341
+ // Pillar 3 sink-side: the confirmation reads cross a network boundary
342
+ // through whatever the runtime bound to the seam.
343
+ scope: "external",
344
+ ioCapability: "network",
345
+ execute: async (input) => {
346
+ const query = input.query.trim();
347
+ const lists = (input.lists ?? []);
348
+ const policy = input.policy ?? "listed-only";
349
+ const maxCandidates = input.maxCandidates ?? 8;
350
+ const blockTag = input.blockTag ?? "latest";
351
+ const isAddressQuery = ADDRESS_SHAPE.test(query);
352
+ const base = {
353
+ chainId: input.chainId,
354
+ query,
355
+ queryKind: isAddressQuery ? "address" : "symbol-or-name",
356
+ listsSearched: lists.map((l) => l.id),
357
+ listsFingerprint: fingerprintLists(lists, input.chainId),
358
+ };
359
+ const refuse = (code, message, extra = {}) => json({ ...base, resolved: false, refusal: { code, message }, ...extra });
360
+ // A query that is itself confusable is the direction of this attack that
361
+ // widening the candidate set cannot help with: the caller pasted the
362
+ // impostor's ticker, so a literal match finds the impostor and nothing
363
+ // looks wrong. It is refused before anything is searched.
364
+ const queryConcerns = textConcerns(query);
365
+ if (!isAddressQuery && queryConcerns.some((c) => c.code !== "over-long")) {
366
+ return refuse("confusable-query", `the query itself is not plain ASCII: ${queryConcerns.map((c) => c.detail).join("; ")}. It folds to "${confusableSkeleton(query)}". Resolving it would find whatever token was named with those exact characters, which is how a pasted ticker leads somewhere else — pass the address instead.`, { queryConcerns });
367
+ }
368
+ if (isAddressQuery) {
369
+ const verdict = await checkAddress(query);
370
+ if (!verdict.valid) {
371
+ return refuse("invalid-address", `"${query}": ${verdict.reason}`);
372
+ }
373
+ if (verdict.isZero) {
374
+ return refuse("zero-address", "the zero address is not a token");
375
+ }
376
+ }
377
+ if (!isAddressQuery && lists.length === 0) {
378
+ return refuse("no-lists", "a symbol cannot be resolved without a token list to resolve it against, and this package will not pick one for you. Supply lists, or pass the contract address.");
379
+ }
380
+ let candidates = findCandidates(lists, input.chainId, query);
381
+ if (isAddressQuery && candidates.length === 0) {
382
+ // An address is self-identifying, so an unlisted one is still a
383
+ // candidate — with nothing vouching for it, which is the flag.
384
+ candidates = [{ address: lower(query), claims: [], matchedBy: ["address"] }];
385
+ }
386
+ if (candidates.length === 0) {
387
+ return refuse("no-candidates", `no token on ${lists.length} list(s) for chain ${input.chainId} has the symbol or name "${query}"`);
388
+ }
389
+ if (candidates.length > maxCandidates) {
390
+ return refuse("too-many-candidates", `"${query}" matches ${candidates.length} different addresses on chain ${input.chainId}, more than the ${maxCandidates} this will confirm. A symbol matching that many contracts is not a way to name one.`, { candidateAddresses: candidates.map((c) => c.address) });
391
+ }
392
+ // Nothing in the token-list schema says the `address` field holds an
393
+ // address — a list is caller input and carries whatever it carries. An
394
+ // entry that is not one used to travel all the way to the answer as
395
+ // `token.address: ""`, which is what the checksummer returns for a string
396
+ // it cannot read, or to reach the ABI encoder and come back as
397
+ // "call[1].target" with no mention of the list that caused it. Checked
398
+ // here, after the candidate cap, so a 20,000-token list is not
399
+ // checksummed end to end to answer one query.
400
+ for (const candidate of candidates) {
401
+ const verdict = await checkAddress(candidate.address);
402
+ const listedBy = candidate.claims.map((c) => c.listId).join(", ");
403
+ if (!verdict.valid) {
404
+ return refuse("invalid-list-address", `${listedBy === "" ? "a candidate" : `list "${listedBy}"`} carries "${candidate.address}" as the address of "${query}", and that is not an address: ${verdict.reason}. A list that cannot spell an address is not one to resolve a token against.`, { badAddress: candidate.address });
405
+ }
406
+ // The zero address is refused for a pasted address already. It reaches
407
+ // here by the other door — a list entry whose address field is zero —
408
+ // and burns tokens just as irreversibly when it does.
409
+ if (verdict.isZero) {
410
+ return refuse("zero-address", `"${query}" resolves to the zero address${listedBy === "" ? "" : ` on list "${listedBy}"`}. The zero address is not a token: it is where tokens go to be burned.`);
411
+ }
412
+ }
413
+ const wantsOnchain = input.confirmOnchain ?? true;
414
+ if (wantsOnchain && !hasChainReader()) {
415
+ if (input.confirmOnchain === true) {
416
+ throw new TokenError("confirmOnchain was asked for but no chain reader is bound; bind one with _setChainReader() or pass confirmOnchain:false to answer from the lists alone");
417
+ }
418
+ }
419
+ const confirm = wantsOnchain && hasChainReader();
420
+ const facts = new Map();
421
+ let blockNumber = null;
422
+ if (confirm) {
423
+ const calls = [];
424
+ for (const [i, candidate] of candidates.entries()) {
425
+ calls.push(...erc20MetadataCalls(`c${i}:`, candidate.address));
426
+ }
427
+ const read = await readCalls({
428
+ chainId: input.chainId,
429
+ calls,
430
+ blockTag,
431
+ batch: input.batch ?? true,
432
+ ...(input.multicall3Address !== undefined
433
+ ? { multicall3Address: input.multicall3Address }
434
+ : {}),
435
+ });
436
+ blockNumber = read.blockNumber;
437
+ for (const [i, candidate] of candidates.entries()) {
438
+ const hasCode = (input.checkCode ?? true)
439
+ ? (await readHasCode(input.chainId, candidate.address, blockTag)).hasCode
440
+ : null;
441
+ facts.set(candidate.address, { ...erc20Facts(`c${i}:`, read.outcomes), hasCode });
442
+ }
443
+ }
444
+ const flagged = [];
445
+ for (const candidate of candidates) {
446
+ const onchain = facts.get(candidate.address) ?? null;
447
+ flagged.push({
448
+ address: candidate.address,
449
+ checksummed: (await checkAddress(candidate.address)).checksummed,
450
+ matchedBy: candidate.matchedBy,
451
+ claims: candidate.claims,
452
+ onchain,
453
+ flags: flagsFor(candidate, onchain),
454
+ });
455
+ }
456
+ const shared = { ...base, blockNumber, confirmedOnchain: confirm, candidates: flagged };
457
+ if (flagged.length > 1) {
458
+ return json({
459
+ ...shared,
460
+ resolved: false,
461
+ refusal: {
462
+ code: "ambiguous-symbol",
463
+ message: `"${query}" means ${flagged.length} different addresses on chain ${input.chainId}. Every one of them is below with what is wrong with it; none is returned as the answer, because choosing between two contracts that both say "${query}" is not something a string match is entitled to do. Pass the address you mean.`,
464
+ },
465
+ });
466
+ }
467
+ const only = flagged[0];
468
+ if (only.flags.includes("no-code")) {
469
+ return json({
470
+ ...shared,
471
+ resolved: false,
472
+ refusal: {
473
+ code: "not-a-contract",
474
+ message: `there is no contract at ${only.checksummed} at block ${blockNumber ?? blockTag}. Every call to it answers 0x, which decodes as a zero balance for everyone and an empty symbol — it is somebody's wallet, or a typo.`,
475
+ },
476
+ });
477
+ }
478
+ if (only.flags.includes("decimals-mismatch")) {
479
+ return json({
480
+ ...shared,
481
+ resolved: false,
482
+ refusal: {
483
+ code: "decimals-mismatch",
484
+ message: `the contract at ${only.checksummed} reports ${only.onchain?.decimals.value} decimals and the list says ${only.claims.map((c) => c.decimals).join("/")}. That is not a cosmetic disagreement: every amount computed from the wrong one is off by a power of ten.`,
485
+ },
486
+ });
487
+ }
488
+ if (policy === "listed-only" && only.claims.length === 0) {
489
+ return json({
490
+ ...shared,
491
+ resolved: false,
492
+ refusal: {
493
+ code: "unlisted",
494
+ message: `${only.checksummed} is on none of the supplied lists${lists.length === 0 ? " (none were supplied)" : ""}. Pass policy:"allow-unlisted" to resolve it anyway — the answer will still say nothing vouches for it.`,
495
+ },
496
+ });
497
+ }
498
+ const warnings = only.onchain === null ? [] : metadataWarnings(only.onchain);
499
+ if (!confirm) {
500
+ warnings.push("nothing was confirmed against the chain, so this is what the lists say and no more");
501
+ }
502
+ return json({
503
+ ...shared,
504
+ resolved: true,
505
+ token: {
506
+ address: only.checksummed,
507
+ decimals: only.onchain?.decimals ?? null,
508
+ symbol: only.onchain?.symbol ?? null,
509
+ name: only.onchain?.name ?? null,
510
+ totalSupply: only.onchain?.totalSupply ?? null,
511
+ listedBy: only.claims.map((c) => c.listId),
512
+ },
513
+ /** True only when a contract confirmed the lists and nothing concerning was flagged. */
514
+ verified: confirm && !only.flags.some((f) => CONCERNING_FLAGS.has(f)),
515
+ warnings,
516
+ });
517
+ },
518
+ });
519
+ // ---------------------------------------------------------------------------
520
+ // Erc20Balance
521
+ // ---------------------------------------------------------------------------
522
+ export const erc20Balance = buildTool({
523
+ name: "Erc20Balance",
524
+ description: "Read ERC-20 balances, and optionally an allowance, for one or many accounts at one block — with the token's decimals taken from the CONTRACT, never from a list and never defaulted. Use it instead of a raw eth_call: every amount comes back both as exact base units and as a decimal string, so a uint256 never becomes a JS number and eighteen digits of precision never quietly become fifteen. A token whose decimals() reverts is reported as unknown and its balances stay in base units rather than being scaled by an assumed 18, which is how a six-decimal transfer becomes a trillion-fold one. Tokens that answer symbol() with a bytes32 instead of a string — MKR and other 2017-era contracts — are decoded rather than crashed on, and an address with no code at it is refused instead of reporting everyone's balance as zero. Pass \"native\" as the token to read the chain's own currency in the same snapshot. It takes a contract ADDRESS, not a symbol: use TokenResolve first.",
525
+ inputSchema: z
526
+ .object({
527
+ chainId: chainIdField,
528
+ token: z
529
+ .string()
530
+ .min(1)
531
+ .describe('the token CONTRACT ADDRESS, or "native" for the chain\'s own currency'),
532
+ accounts: z
533
+ .array(z.string().min(1))
534
+ .min(1)
535
+ .max(LIMITS.accounts)
536
+ .describe("the addresses to read balances for"),
537
+ spender: z
538
+ .string()
539
+ .optional()
540
+ .describe("also read allowance(account, spender) for each account"),
541
+ checkCode: z
542
+ .boolean()
543
+ .optional()
544
+ .describe("check there is a contract at the token; default true"),
545
+ blockTag: blockTagField,
546
+ batch: batchField,
547
+ multicall3Address: multicallField,
548
+ })
549
+ .strict(),
550
+ readOnly: true,
551
+ concurrencySafe: true,
552
+ scope: "external",
553
+ ioCapability: "network",
554
+ execute: async (input) => {
555
+ const blockTag = input.blockTag ?? "latest";
556
+ const batch = input.batch ?? true;
557
+ const multicall = input.multicall3Address ?? MULTICALL3_ADDRESS;
558
+ const isNative = input.token.trim().toLowerCase() === "native";
559
+ if (!isNative && !ADDRESS_SHAPE.test(input.token.trim())) {
560
+ throw new TokenError(`Erc20Balance takes a token contract address, not "${input.token}". A symbol is not an identifier — several contracts can claim the same one — so resolve it with TokenResolve first, which returns every candidate when a symbol is ambiguous rather than picking one.`);
561
+ }
562
+ const accounts = [];
563
+ for (const [i, raw] of input.accounts.entries()) {
564
+ accounts.push({ input: raw, checksummed: await requireAddress(raw, `accounts[${i}]`) });
565
+ }
566
+ const spender = input.spender === undefined ? null : await requireAddress(input.spender, "spender");
567
+ if (isNative) {
568
+ const decimals = {
569
+ value: 18,
570
+ source: "chain-convention",
571
+ note: "the native currency's 18 decimals are the EVM convention, not a contract read — nothing was asked",
572
+ formattable: true,
573
+ };
574
+ const balances = [];
575
+ const warnings = [];
576
+ let blockNumber = null;
577
+ if (batch) {
578
+ const read = await readCalls({
579
+ chainId: input.chainId,
580
+ calls: accounts.map((a, i) => nativeBalanceCall(`n${i}`, a.checksummed, multicall)),
581
+ blockTag,
582
+ batch: true,
583
+ multicall3Address: multicall,
584
+ });
585
+ blockNumber = read.blockNumber;
586
+ for (const [i, account] of accounts.entries()) {
587
+ const outcome = read.outcomes.get(`n${i}`);
588
+ const raw = uintField(outcome);
589
+ const row = {
590
+ account: account.checksummed,
591
+ raw: raw === null ? null : raw.toString(),
592
+ decimal: raw === null ? null : await scaled(raw, decimals),
593
+ };
594
+ if (raw === null) {
595
+ // A balance nobody answered is not a balance of zero. Inside the
596
+ // batch this is a contract call like any other — a Multicall3
597
+ // deployment without the helper, or a sub-call that ran out of gas
598
+ // — and "0 ETH" is the one wrong answer a gas check or a
599
+ // drained-wallet check would act on without looking further.
600
+ row["error"] =
601
+ `getEthBalance did not answer through the Multicall3 at ${multicall}: ${outcome?.revert?.reason ?? outcome?.revert?.kind ?? outcome?.error ?? "no data"}. Pass batch:false to read it with eth_getBalance directly.`;
602
+ }
603
+ balances.push(row);
604
+ }
605
+ }
606
+ else {
607
+ for (const account of accounts) {
608
+ const raw = await readNativeBalance(input.chainId, account.checksummed, blockTag);
609
+ balances.push({
610
+ account: account.checksummed,
611
+ raw: raw.toString(),
612
+ decimal: await scaled(raw, decimals),
613
+ });
614
+ }
615
+ // The declaration the ERC-20 path makes. It was missing here, which
616
+ // made batch:false the one degraded mode that did not declare itself
617
+ // — and a caller reading `batched` alone has no warning to weigh.
618
+ warnings.push("batch:false read these one call at a time, so the balances are not guaranteed to be from the same block");
619
+ }
620
+ if (batch && blockNumber === null)
621
+ warnings.push(NO_BLOCK_NUMBER);
622
+ if (spender !== null) {
623
+ warnings.push("the native currency has no allowance; spender was ignored");
624
+ }
625
+ return json({
626
+ chainId: input.chainId,
627
+ blockTag,
628
+ blockNumber,
629
+ batched: batch,
630
+ token: { kind: "native", address: null, decimals },
631
+ balances,
632
+ warnings,
633
+ });
634
+ }
635
+ const token = await requireAddress(input.token, "token");
636
+ const code = (input.checkCode ?? true) ? await readHasCode(input.chainId, token, blockTag) : null;
637
+ if (code !== null && !code.hasCode) {
638
+ throw new TokenError(`there is no contract at ${token} at block ${blockTag}. Every call to it answers 0x, which decodes as a zero balance for every account and an empty symbol — this is not a token. Pass checkCode:false only if you know the node cannot answer eth_getCode.`);
639
+ }
640
+ const calls = erc20MetadataCalls("t:", token);
641
+ for (const [i, account] of accounts.entries()) {
642
+ calls.push({
643
+ key: `b${i}`,
644
+ target: token,
645
+ callData: callData(SELECTOR.balanceOf, addressWord(account.checksummed)),
646
+ });
647
+ if (spender !== null) {
648
+ calls.push({
649
+ key: `a${i}`,
650
+ target: token,
651
+ callData: callData(SELECTOR.allowance, addressWord(account.checksummed), addressWord(spender)),
652
+ });
653
+ }
654
+ }
655
+ const read = await readCalls({
656
+ chainId: input.chainId,
657
+ calls,
658
+ blockTag,
659
+ batch,
660
+ multicall3Address: multicall,
661
+ });
662
+ const facts = erc20Facts("t:", read.outcomes);
663
+ const balances = [];
664
+ for (const [i, account] of accounts.entries()) {
665
+ const outcome = read.outcomes.get(`b${i}`);
666
+ const raw = uintField(outcome);
667
+ const row = {
668
+ account: account.checksummed,
669
+ raw: raw === null ? null : raw.toString(),
670
+ decimal: raw === null ? null : await scaled(raw, facts.decimals),
671
+ };
672
+ if (raw === null) {
673
+ row["error"] =
674
+ `balanceOf reverted or returned nothing: ${outcome?.revert?.reason ?? outcome?.revert?.kind ?? outcome?.error ?? "no data"}`;
675
+ }
676
+ if (spender !== null) {
677
+ const allowanceRaw = uintField(read.outcomes.get(`a${i}`));
678
+ row["allowance"] =
679
+ allowanceRaw === null
680
+ ? { raw: null, decimal: null, error: "allowance reverted or returned nothing" }
681
+ : {
682
+ spender,
683
+ raw: allowanceRaw.toString(),
684
+ decimal: await scaled(allowanceRaw, facts.decimals),
685
+ // 2^256-1 is what "infinite approval" is spelled as, and it
686
+ // is worth naming: it is not a large allowance, it is all of
687
+ // them, for as long as the approval stands.
688
+ unlimited: allowanceRaw === (1n << 256n) - 1n,
689
+ };
690
+ }
691
+ balances.push(row);
692
+ }
693
+ const warnings = metadataWarnings(facts);
694
+ if (!batch) {
695
+ warnings.push("batch:false read these one call at a time, so the balances and the decimals are not guaranteed to be from the same block");
696
+ }
697
+ if (read.batched && read.blockNumber === null)
698
+ warnings.push(NO_BLOCK_NUMBER);
699
+ return json({
700
+ chainId: input.chainId,
701
+ blockTag,
702
+ blockNumber: read.blockNumber,
703
+ batched: read.batched,
704
+ token: {
705
+ kind: "erc20",
706
+ address: token,
707
+ hasCode: code?.hasCode ?? null,
708
+ decimals: facts.decimals,
709
+ symbol: facts.symbol,
710
+ name: facts.name,
711
+ totalSupply: facts.totalSupply,
712
+ },
713
+ balances,
714
+ warnings,
715
+ });
716
+ },
717
+ });
718
+ // ---------------------------------------------------------------------------
719
+ // Erc721TokenInfo
720
+ // ---------------------------------------------------------------------------
721
+ const INTERFACE_KEYS = {
722
+ erc165: "i165",
723
+ invalid: "iBad",
724
+ erc721: "i721",
725
+ erc721Metadata: "i721m",
726
+ erc1155: "i1155",
727
+ erc1155MetadataUri: "i1155m",
728
+ };
729
+ export const erc721TokenInfo = buildTool({
730
+ name: "Erc721TokenInfo",
731
+ description: "Read one NFT: which standard the contract actually implements, the collection's name and symbol, who owns the token (or an ERC-1155 holder's balance of it), and its tokenURI. Use it to check what an NFT is before buying, listing or transferring it. Metadata is read ONLY when it costs no trust: a data: URI is decoded in-process, an ipfs: URI is fetched through a gateway YOU named, and an https: URI only from a host YOU allow-listed — a URL that came out of contract data is not a reason to dial it, and following one is a server-side request forgery with extra steps. The answer lists every URI it read and every one it skipped, with the reason. ERC-1155's {id} placeholder is substituted with the 64-hex-digit zero-padded form the spec requires and almost everyone gets wrong, ERC-1155 has no ownerOf so ownership is answered as a balance or not at all rather than as a misleading null, and a contract that claims to support the invalid interface id is reported as one whose supportsInterface answers mean nothing.",
732
+ inputSchema: z
733
+ .object({
734
+ chainId: chainIdField,
735
+ contract: z.string().min(1).describe("the NFT contract address"),
736
+ tokenId: z
737
+ .string()
738
+ .min(1)
739
+ .max(80)
740
+ .describe("the token id, decimal or 0x hex — a string, because a uint256 is not a JS number"),
741
+ owner: z
742
+ .string()
743
+ .optional()
744
+ .describe("an account to read the ERC-1155 balance of, or to check an ERC-721 owner against"),
745
+ standard: z
746
+ .enum(["auto", "erc721", "erc1155"])
747
+ .optional()
748
+ .describe("auto (default) asks the contract through ERC-165"),
749
+ fetchMetadata: z
750
+ .boolean()
751
+ .optional()
752
+ .describe("read the metadata document when policy allows it; default true"),
753
+ ipfsGateway: z
754
+ .string()
755
+ .optional()
756
+ .describe('a URL PREFIX such as "https://cloudflare-ipfs.com/ipfs/"; without it, ipfs URIs are skipped'),
757
+ allowedHosts: z
758
+ .array(z.string().min(1))
759
+ .max(LIMITS.hosts)
760
+ .optional()
761
+ .describe("hosts an https metadata URI may be fetched from; without it, https URIs are skipped"),
762
+ maxMetadataBytes: z
763
+ .number()
764
+ .int()
765
+ .positive()
766
+ .max(4 * 1024 * 1024)
767
+ .optional()
768
+ .describe(`cap on the metadata document; default ${DEFAULT_MAX_METADATA_BYTES}`),
769
+ checkCode: z.boolean().optional(),
770
+ blockTag: blockTagField,
771
+ batch: batchField,
772
+ multicall3Address: multicallField,
773
+ })
774
+ .strict(),
775
+ readOnly: true,
776
+ concurrencySafe: true,
777
+ scope: "external",
778
+ ioCapability: "network",
779
+ execute: async (input, ctx) => {
780
+ const blockTag = input.blockTag ?? "latest";
781
+ const contract = await requireAddress(input.contract, "contract");
782
+ const owner = input.owner === undefined ? null : await requireAddress(input.owner, "owner");
783
+ const maxBytes = input.maxMetadataBytes ?? DEFAULT_MAX_METADATA_BYTES;
784
+ let tokenId;
785
+ try {
786
+ const text = input.tokenId.trim();
787
+ if (!/^(0x[0-9a-fA-F]+|\d+)$/.test(text))
788
+ throw new Error("not an integer");
789
+ tokenId = BigInt(text);
790
+ padTokenIdHex(tokenId);
791
+ }
792
+ catch {
793
+ throw new TokenError(`"${input.tokenId}" is not a token id; give a decimal or 0x hex uint256 as a string`);
794
+ }
795
+ const code = (input.checkCode ?? true) ? await readHasCode(input.chainId, contract, blockTag) : null;
796
+ if (code !== null && !code.hasCode) {
797
+ throw new TokenError(`there is no contract at ${contract} at block ${blockTag} — every call to it answers 0x, so an NFT read against it would report no owner and no URI rather than saying this is not a contract`);
798
+ }
799
+ const supports = (key, id) => ({
800
+ key,
801
+ target: contract,
802
+ callData: callData(SELECTOR.supportsInterface, bytes4Word(id)),
803
+ });
804
+ const idWord = uintWord(tokenId);
805
+ const calls = [
806
+ supports(INTERFACE_KEYS.erc165, INTERFACE_ID.erc165),
807
+ supports(INTERFACE_KEYS.invalid, INTERFACE_ID.invalid),
808
+ supports(INTERFACE_KEYS.erc721, INTERFACE_ID.erc721),
809
+ supports(INTERFACE_KEYS.erc721Metadata, INTERFACE_ID.erc721Metadata),
810
+ supports(INTERFACE_KEYS.erc1155, INTERFACE_ID.erc1155),
811
+ supports(INTERFACE_KEYS.erc1155MetadataUri, INTERFACE_ID.erc1155MetadataUri),
812
+ { key: "name", target: contract, callData: callData(SELECTOR.name) },
813
+ { key: "symbol", target: contract, callData: callData(SELECTOR.symbol) },
814
+ { key: "ownerOf", target: contract, callData: callData(SELECTOR.ownerOf, idWord) },
815
+ { key: "tokenURI", target: contract, callData: callData(SELECTOR.tokenURI, idWord) },
816
+ { key: "uri", target: contract, callData: callData(SELECTOR.uri, idWord) },
817
+ ];
818
+ if (owner !== null) {
819
+ calls.push({
820
+ key: "balance1155",
821
+ target: contract,
822
+ callData: callData(SELECTOR.balanceOf1155, addressWord(owner), idWord),
823
+ });
824
+ }
825
+ const read = await readCalls({
826
+ chainId: input.chainId,
827
+ calls,
828
+ blockTag,
829
+ batch: input.batch ?? true,
830
+ ...(input.multicall3Address !== undefined
831
+ ? { multicall3Address: input.multicall3Address }
832
+ : {}),
833
+ });
834
+ const says = (key) => {
835
+ const outcome = read.outcomes.get(key);
836
+ if (outcome === undefined || !outcome.ok)
837
+ return null;
838
+ return decodeBool(outcome.data);
839
+ };
840
+ const interfaces = {
841
+ erc165: says(INTERFACE_KEYS.erc165),
842
+ erc721: says(INTERFACE_KEYS.erc721),
843
+ erc721Metadata: says(INTERFACE_KEYS.erc721Metadata),
844
+ erc1155: says(INTERFACE_KEYS.erc1155),
845
+ erc1155MetadataUri: says(INTERFACE_KEYS.erc1155MetadataUri),
846
+ /** ERC-165 requires this to be false. True means every other answer is worthless. */
847
+ claimsInvalidInterface: says(INTERFACE_KEYS.invalid) === true,
848
+ };
849
+ const flags = [];
850
+ if (interfaces.claimsInvalidInterface) {
851
+ flags.push("bogus-erc165: the contract answers true for the invalid interface id 0xffffffff, which ERC-165 requires to be false — it answers true to everything, so its other interface answers mean nothing");
852
+ }
853
+ const trustInterfaces = !interfaces.claimsInvalidInterface;
854
+ const ownerOfOutcome = read.outcomes.get("ownerOf");
855
+ const ownerOfValue = ownerOfOutcome?.ok === true ? decodeAddressValue(ownerOfOutcome.data) : null;
856
+ const uriRead = textField(read.outcomes.get("uri"));
857
+ const tokenUriRead = textField(read.outcomes.get("tokenURI"));
858
+ let standard;
859
+ let standardSource;
860
+ if (input.standard !== undefined && input.standard !== "auto") {
861
+ standard = input.standard;
862
+ standardSource = "declared by the caller";
863
+ const contradicted = (standard === "erc721" && interfaces.erc1155 === true && interfaces.erc721 !== true) ||
864
+ (standard === "erc1155" && interfaces.erc721 === true && interfaces.erc1155 !== true);
865
+ if (trustInterfaces && contradicted) {
866
+ flags.push(`standard-disagreement: you declared ${standard}, ERC-165 says otherwise`);
867
+ }
868
+ }
869
+ else if (trustInterfaces && interfaces.erc721 === true) {
870
+ standard = "erc721";
871
+ standardSource = "erc165";
872
+ }
873
+ else if (trustInterfaces && interfaces.erc1155 === true) {
874
+ standard = "erc1155";
875
+ standardSource = "erc165";
876
+ }
877
+ else if (ownerOfValue !== null) {
878
+ standard = "erc721";
879
+ standardSource = "inferred: ownerOf answered, which ERC-1155 has no equivalent of";
880
+ flags.push("no-erc165: the contract did not declare ERC-721 through supportsInterface");
881
+ }
882
+ else if (uriRead.value !== null) {
883
+ standard = "erc1155";
884
+ standardSource = "inferred: uri(uint256) answered";
885
+ flags.push("no-erc165: the contract did not declare ERC-1155 through supportsInterface");
886
+ }
887
+ else {
888
+ standard = "unknown";
889
+ standardSource = "nothing the contract answered identifies it";
890
+ flags.push("unknown-standard");
891
+ }
892
+ // ERC-1155 has no ownerOf: one id is held by many addresses at once, so
893
+ // ownership is a balance or it is not answerable. Returning null would
894
+ // read as "nobody owns it".
895
+ let ownership;
896
+ if (standard === "erc1155") {
897
+ const balanceOutcome = read.outcomes.get("balance1155");
898
+ const balance = uintField(balanceOutcome);
899
+ if (owner === null) {
900
+ ownership = {
901
+ kind: "erc1155",
902
+ answerable: false,
903
+ reason: "ERC-1155 has no ownerOf — a token id is held by many addresses at once. Pass owner to read balanceOf(owner, id).",
904
+ };
905
+ }
906
+ else if (balance === null) {
907
+ // The same rule as the missing owner above, one call along: a null
908
+ // under a named account reads as "this account holds none of it",
909
+ // which is the answer a listing check would act on. The call not
910
+ // answering is a different fact and says so.
911
+ ownership = {
912
+ kind: "erc1155",
913
+ account: owner,
914
+ answerable: false,
915
+ balance: null,
916
+ reason: `balanceOf(${owner}, ${tokenId}) did not answer: ${balanceOutcome?.revert?.reason ?? balanceOutcome?.revert?.kind ?? balanceOutcome?.error ?? "no data"} — this is not a balance of zero`,
917
+ };
918
+ flags.push("balance-unreadable");
919
+ }
920
+ else {
921
+ ownership = { kind: "erc1155", account: owner, balance: balance.toString() };
922
+ }
923
+ }
924
+ else {
925
+ const checked = ownerOfValue === null ? null : (await checkAddress(ownerOfValue)).checksummed;
926
+ ownership = {
927
+ // "unknown" rather than "erc721" when nothing identified the contract:
928
+ // a null owner under an erc721 label reads as "this NFT has no owner"
929
+ // instead of "this may not be an NFT".
930
+ kind: standard,
931
+ owner: checked,
932
+ matchesOwnerArgument: owner === null || checked === null ? null : checked === owner,
933
+ reason: checked === null
934
+ ? `ownerOf(${tokenId}) did not answer: ${ownerOfOutcome?.revert?.reason ?? ownerOfOutcome?.revert?.kind ?? ownerOfOutcome?.error ?? "no data"} — the token may never have been minted, or may have been burned`
935
+ : "",
936
+ };
937
+ if (checked === null)
938
+ flags.push("owner-unreadable");
939
+ }
940
+ // Prefer the standard's own call, and fall back to the other one rather
941
+ // than reporting no URI for a contract that answered the wrong question.
942
+ const primary = standard === "erc1155" ? uriRead : tokenUriRead;
943
+ const fallback = standard === "erc1155" ? tokenUriRead : uriRead;
944
+ const rawUri = primary.value ?? fallback.value;
945
+ const uriSource = primary.value !== null
946
+ ? standard === "erc1155"
947
+ ? "uri(uint256)"
948
+ : "tokenURI(uint256)"
949
+ : fallback.value !== null
950
+ ? standard === "erc1155"
951
+ ? "tokenURI(uint256)"
952
+ : "uri(uint256)"
953
+ : "none";
954
+ // The {id} placeholder is 64 lowercase hex digits, zero-padded, with no
955
+ // 0x. Substituting the decimal id returns a 404 or, worse, somebody
956
+ // else's metadata.
957
+ const substituted = rawUri === null ? null : rawUri.replaceAll("{id}", padTokenIdHex(tokenId));
958
+ let plan = null;
959
+ let metadata = { attempted: false, reason: "no URI to read" };
960
+ if (substituted !== null) {
961
+ try {
962
+ plan = planUri(substituted, {
963
+ ...(input.ipfsGateway !== undefined ? { ipfsGateway: input.ipfsGateway } : {}),
964
+ ...(input.allowedHosts !== undefined ? { allowedHosts: input.allowedHosts } : {}),
965
+ });
966
+ }
967
+ catch (err) {
968
+ throw new TokenError(err.message);
969
+ }
970
+ if (input.fetchMetadata === false) {
971
+ metadata = { attempted: false, reason: "fetchMetadata was false" };
972
+ }
973
+ else if (plan.action === "skip") {
974
+ metadata = { attempted: false, reason: plan.reason };
975
+ }
976
+ else {
977
+ try {
978
+ const bytes = plan.action === "inline"
979
+ ? decodeDataUri(substituted, maxBytes)
980
+ : (await fetchDocument(plan.url, maxBytes, ctx?.signal)).bytes;
981
+ let parsed = null;
982
+ let parseError = null;
983
+ try {
984
+ parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
985
+ }
986
+ catch (err) {
987
+ parseError = err.message;
988
+ }
989
+ metadata = {
990
+ attempted: true,
991
+ fetched: true,
992
+ source: plan.action === "inline" ? "data:" : plan.url,
993
+ bytes: bytes.length,
994
+ sha256: sha256(bytes),
995
+ json: parsed,
996
+ parseError,
997
+ // Third-party content, which is why it is reported rather than
998
+ // merged into the answer's own fields.
999
+ untrusted: "this document came from whoever deployed the contract; nothing in it was verified",
1000
+ referencedUrls: referencedUrls(parsed),
1001
+ referencedUrlsNote: "listed, never fetched — an image URL is the same untrusted string one level deeper",
1002
+ };
1003
+ }
1004
+ catch (err) {
1005
+ metadata = { attempted: true, fetched: false, reason: err.message };
1006
+ }
1007
+ }
1008
+ }
1009
+ return json({
1010
+ chainId: input.chainId,
1011
+ contract,
1012
+ tokenId: tokenId.toString(),
1013
+ blockTag,
1014
+ blockNumber: read.blockNumber,
1015
+ batched: read.batched,
1016
+ standard,
1017
+ standardSource,
1018
+ interfaces,
1019
+ collection: {
1020
+ name: textField(read.outcomes.get("name")),
1021
+ symbol: textField(read.outcomes.get("symbol")),
1022
+ },
1023
+ ownership,
1024
+ tokenUri: {
1025
+ source: uriSource,
1026
+ raw: rawUri,
1027
+ substituted,
1028
+ idPlaceholderSubstituted: rawUri?.includes("{id}") ?? false,
1029
+ plan,
1030
+ },
1031
+ // What was read and what was skipped, both: `tokenUri.plan` is the
1032
+ // decision and `metadata` is what came of it. The reason is not repeated
1033
+ // a third time, because every byte returned is a byte in somebody's
1034
+ // context window.
1035
+ metadata,
1036
+ flags,
1037
+ });
1038
+ },
1039
+ });
1040
+ /** Every tool this package registers, in the order a catalog should list them. */
1041
+ export const TOKEN_TOOLS = Object.freeze([
1042
+ erc20Balance,
1043
+ erc721TokenInfo,
1044
+ tokenResolve,
1045
+ ]);