@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/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # @crewhaus/tool-token
2
+
3
+ Which token is this, and how much of it is there.
4
+
5
+ | Tool | Answers |
6
+ |---|---|
7
+ | `TokenResolve` | which address does this symbol actually mean — and is there more than one |
8
+ | `Erc20Balance` | how much of this token does this account hold, scaled by the contract's own decimals |
9
+ | `Erc721TokenInfo` | who owns this NFT, what standard is it really, and what does its URI say |
10
+
11
+ ## Ambiguity is a refusal, not a best guess
12
+
13
+ A symbol is not an identifier. Nothing stops a second contract from calling
14
+ itself USDC with six decimals and a convincing name, reputable token lists
15
+ routinely carry different addresses for the same ticker, and a harness that
16
+ sends funds to the first match of a symbol is the accident this package exists
17
+ to prevent.
18
+
19
+ So `TokenResolve` never returns a most-likely. When a query could mean more
20
+ than one address it returns **every** candidate with `resolved: false`, each
21
+ flagged with what is wrong with it — no code at the address, a total supply of
22
+ zero, a symbol the contract does not agree with, a ticker only one list has
23
+ ever heard of. Choosing between two contracts that both say "USDC" is a
24
+ decision with a wrong answer that costs money, and a string match is not
25
+ entitled to make it.
26
+
27
+ `Erc20Balance` will not take a symbol at all. It takes a contract address, and
28
+ says so when handed anything else. There is no path through this package that
29
+ reads a balance from a ticker nobody resolved.
30
+
31
+ ### Homoglyphs widen the set; they never pick a different address
32
+
33
+ "USDC" and "USD**С**" — the second with a Cyrillic С — land on the same
34
+ comparison key, so an impostor a literal match would have missed becomes an
35
+ *ambiguity you have to look at*. The fold is deliberately small: the common
36
+ Latin/Cyrillic/Greek lookalikes, invisible formatting characters, and two digit
37
+ shapes. It is not UTS-39, and a symbol that survives it is not thereby safe.
38
+
39
+ That looseness is affordable because of the direction it is used in. Folding
40
+ only ever **adds** candidates, and a wider set can only move the answer towards
41
+ a refusal — it can never resolve a query to a different address. A query that
42
+ is *itself* confusable is the one case widening cannot help with, so that is
43
+ refused outright, before anything is read.
44
+
45
+ This is exactly the property ENS normalisation does not have, and why
46
+ `EnsResolve` is **not** in this package: there, a fold that is wrong by one
47
+ codepoint resolves a name to the wrong address and sends funds there. That
48
+ needs the real UTS-46 table (`@adraffy/ens-normalize`), not a hand-rolled one,
49
+ and it is parked pending that dependency decision rather than shipped
50
+ half-done.
51
+
52
+ ## Decimals come from the contract, never from a default
53
+
54
+ `decimals()` reverting is reported as **unknown**. It is not 18. Every balance
55
+ still comes back as exact base units; what is withheld is the decimal form,
56
+ because scaling by a number nobody supplied is how a six-decimal transfer
57
+ becomes a trillion-fold one.
58
+
59
+ Every quantity here is a `bigint` or a decimal string, end to end. A `uint256`
60
+ does not fit in a double, and eighteen digits of precision silently become
61
+ fifteen. The scaling itself is `@crewhaus/tool-onchain`'s, not a second
62
+ implementation — as is EIP-55, so there is one checksum in this repository and
63
+ it is the one checked against the EIP's own vectors.
64
+
65
+ ### Tokens that predate the standard still work
66
+
67
+ - `symbol()` and `name()` returning a **`bytes32`** — MKR and other 2017-era
68
+ contracts, deployed before the ABI settled — are decoded, and the answer says
69
+ the encoding was `bytes32` rather than pretending it was a string.
70
+ - A call that returns **nothing** is reported as `absent`. `""` and "we could
71
+ not read it" are different facts, and showing the first where the second
72
+ belongs puts a blank where a warning goes.
73
+ - Bytes that are not valid UTF-8 come back as `undecodable` with the raw hex,
74
+ never as a row of replacement characters.
75
+ - An address with **no code** is refused. Every call to it answers `0x`, which
76
+ decodes as a zero balance for everyone and an empty symbol — it is somebody's
77
+ wallet, or a typo.
78
+
79
+ ## NFT metadata is read only where it costs no trust
80
+
81
+ `tokenURI()` returns a string chosen by whoever deployed the contract. Fetching
82
+ it because a contract said so is a server-side request forgery with extra
83
+ steps. So:
84
+
85
+ | URI | What happens |
86
+ |---|---|
87
+ | `data:` | decoded in-process — many NFTs are entirely on-chain |
88
+ | `ipfs://` | rewritten onto a gateway **you** named, and re-checked to still be inside it |
89
+ | `https://` | fetched only from a host **you** allow-listed |
90
+ | `http://`, `ar://`, anything else | skipped, with the reason in the answer |
91
+
92
+ The answer lists what was read and what was skipped, both. The document itself
93
+ is third-party content: it is byte-capped, digested, and reported rather than
94
+ merged into the tool's own fields, and the URLs *inside* it (`image`,
95
+ `animation_url`, …) are listed and never followed — an image URL is the same
96
+ untrusted string one level deeper.
97
+
98
+ Two ERC corners it gets right: the `{id}` placeholder is substituted with the
99
+ **64-hex-digit, zero-padded, lowercase** form the spec requires (the decimal id
100
+ returns a 404 or somebody else's metadata), and ERC-1155 has no `ownerOf`, so
101
+ ownership is answered as `balanceOf(owner, id)` or reported as unanswerable —
102
+ never as a `null` that reads like "nobody owns it". A `balanceOf` that reverts
103
+ is unanswerable in the same way, with the revert reason, rather than a null
104
+ that reads like "this account holds none". A contract that claims to
105
+ support the invalid interface id `0xffffffff` says yes to everything, so its
106
+ other ERC-165 answers are discounted and the standard is inferred from what
107
+ actually replied.
108
+
109
+ ## One block, not a hundred
110
+
111
+ Reads are batched through Multicall3 by default, because a hundred balances
112
+ read one at a time are a hundred balances from up to a hundred different
113
+ blocks. `getBlockNumber()` rides along in the same batch, so the answer says
114
+ which block it is a snapshot of, and native balances go through Multicall3's
115
+ own `getEthBalance` to stay in that snapshot. `batch: false` reads them
116
+ one at a time for chains with no Multicall3 deployment, and says in the answer
117
+ that the result is not a snapshot.
118
+
119
+ A sub-call inside that batch is a contract call like any other, and one that
120
+ does not answer is **not a zero**. A `getEthBalance` that came back empty —
121
+ an aggregator without the helper, a sub-call out of gas — is reported as
122
+ `raw: null` with the reason, never as `0`, because "this wallet is empty" is a
123
+ sentence a gas check acts on. The same holds for a batch whose
124
+ `getBlockNumber()` did not answer: the calls still share one `eth_call`, but
125
+ the answer says it cannot be pinned to a block rather than leaving the field
126
+ quietly null.
127
+
128
+ ## Nothing here signs or sends
129
+
130
+ No schema in this package has a field a private key could be passed to, and no
131
+ code path composes or submits a transaction. The JSON-RPC methods it can emit
132
+ are exactly three — `eth_call`, `eth_getBalance`, `eth_getCode` — refused at a
133
+ single gate if anything else is asked for. Both halves are asserted by a test,
134
+ not promised by this paragraph.
135
+
136
+ ## No dialling code at all
137
+
138
+ There is no RPC client and no HTTP client in this package. Both boundaries are
139
+ injected:
140
+
141
+ ```ts
142
+ import { _setChainReader, chainReaderFromAdapters, _setMetadataFetch } from "@crewhaus/tool-token";
143
+
144
+ _setChainReader(chainReaderFromAdapters((chainId) => adapters.get(chainId)));
145
+ _setMetadataFetch(async ({ url, maxBytes, signal }) => /* your redirect + SSRF policy */);
146
+ ```
147
+
148
+ Which means the test suite cannot reach the network even by accident — there is
149
+ nothing under the seam to reach it with. Every test drives recorded answers,
150
+ and the Multicall3 stub does a real ABI round trip through `tool-onchain`'s
151
+ codec rather than a hand-written fake of one.
152
+
153
+ ## What it refuses
154
+
155
+ A tool that cannot say no is not finished. These say no, with the reason:
156
+
157
+ - a symbol that means more than one address — `ambiguous-symbol`, with all of them
158
+ - a query whose own characters are confusable — `confusable-query`, before any read
159
+ - a symbol with no list to resolve it against — `no-lists`
160
+ - an address that is on none of the supplied lists — `unlisted` (opt out with `policy: "allow-unlisted"`)
161
+ - an address whose EIP-55 checksum does not hold — `invalid-address`
162
+ - the zero address — whether it was pasted as the query or carried by a list
163
+ - an address with no contract at it
164
+ - a token list entry whose `address` field is not an address — `invalid-list-address`
165
+ - a contract whose decimals disagree with the list's — `decimals-mismatch`
166
+ - more candidates than it was allowed to confirm — `too-many-candidates`
167
+ - a symbol where `Erc20Balance` wants an address, pointing at `TokenResolve`
168
+ - an `ipfs://` URI that resolves outside the gateway it was given
169
+ - an `https://` metadata host nobody allow-listed, and any `http://` URI
170
+ - a metadata document over the byte cap — refused rather than parsed as a prefix
171
+ - a Multicall3 address that answers with something other than results
172
+ - a block tag that is not one, and a token id that is not a `uint256`
@@ -0,0 +1,117 @@
1
+ import type { ChainRead, ChainReader } from "./lib/chain";
2
+ /** A 32-byte word from an unsigned integer. */
3
+ export declare const word: (value: bigint) => string;
4
+ export declare const encodeUint: (value: bigint) => string;
5
+ export declare const encodeAddress: (address: string) => string;
6
+ export declare const encodeBool: (value: boolean) => string;
7
+ /** A dynamic `string` return: offset, length, then the bytes, padded. */
8
+ export declare function encodeString(text: string): string;
9
+ /** The pre-standard `bytes32` symbol: the text, left-aligned, NUL-padded. */
10
+ export declare function encodeBytes32String(text: string): string;
11
+ /** `Error(string)` revert data — the selector is the one Solidity emits. */
12
+ export declare function revertWith(message: string): Promise<string>;
13
+ /** What one contract answers for one call. A revert is an answer, not a throw. */
14
+ export type CallAnswer = {
15
+ readonly ok: boolean;
16
+ readonly data: string;
17
+ };
18
+ export declare const answers: (data: string) => CallAnswer;
19
+ export declare const reverts: (revertData: string) => CallAnswer;
20
+ /** What a contract without that function returns: nothing at all. */
21
+ export declare const NOTHING: CallAnswer;
22
+ export type ContractHandler = (selector: string, args: string) => CallAnswer;
23
+ export declare const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
24
+ export declare const IMPOSTOR_USDC = "0x1111111111111111111111111111111111111111";
25
+ export declare const CYRILLIC_USDC = "0x2222222222222222222222222222222222222222";
26
+ export declare const MKR = "0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2";
27
+ export declare const BROKEN = "0x3333333333333333333333333333333333333333";
28
+ export declare const NOT_A_CONTRACT = "0x4444444444444444444444444444444444444444";
29
+ export declare const APE_NFT = "0x5555555555555555555555555555555555555555";
30
+ export declare const GAME_1155 = "0x6666666666666666666666666666666666666666";
31
+ export declare const ONCHAIN_NFT = "0x7777777777777777777777777777777777777777";
32
+ export declare const LIAR_165 = "0x8888888888888888888888888888888888888888";
33
+ export declare const ALICE = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
34
+ export declare const BOB = "0x388C818CA8B9251b393131C08a736A67ccB19297";
35
+ export declare const SPENDER = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
36
+ /** A plain ERC-20 whose metadata is all well-formed strings. */
37
+ export declare function erc20(opts: {
38
+ decimals: bigint;
39
+ symbol: string;
40
+ name: string;
41
+ totalSupply: bigint;
42
+ balances?: Record<string, bigint>;
43
+ allowances?: Record<string, bigint>;
44
+ bytes32Metadata?: boolean;
45
+ }): ContractHandler;
46
+ /** An ERC-721 that declares itself properly through ERC-165. */
47
+ export declare function erc721(opts: {
48
+ name: string;
49
+ symbol: string;
50
+ owners: Record<string, string>;
51
+ tokenUris: Record<string, string>;
52
+ missingTokenRevert: string;
53
+ interfaceIds?: ReadonlyArray<string>;
54
+ answersEverything?: boolean;
55
+ }): ContractHandler;
56
+ /** An ERC-1155, which has no ownerOf and whose uri carries the {id} template. */
57
+ export declare function erc1155(opts: {
58
+ uriTemplate: string;
59
+ balances: Record<string, bigint>;
60
+ interfaceIds?: ReadonlyArray<string>;
61
+ }): ContractHandler;
62
+ export type ChainStub = {
63
+ readonly reader: ChainReader;
64
+ /** Every read that left the package, in order, for assertions about what was asked. */
65
+ readonly reads: ChainRead[];
66
+ };
67
+ export declare function chainStub(opts: {
68
+ readonly contracts: Record<string, ContractHandler>;
69
+ readonly nativeBalances?: Record<string, bigint>;
70
+ readonly code?: Record<string, string>;
71
+ readonly blockNumber?: bigint;
72
+ readonly multicall3Address?: string;
73
+ /** Answer the batch call itself with this instead, to test an unusable answer. */
74
+ readonly brokenBatchAnswer?: string;
75
+ /**
76
+ * A Multicall3-shaped contract that implements `aggregate3` and nothing
77
+ * else, so `getEthBalance` and `getBlockNumber` reach its fallback and
78
+ * answer with no data. Real: `multicall3Address` is a caller-supplied
79
+ * address, and an aggregator that is not the canonical deployment has no
80
+ * obligation to carry the helpers.
81
+ */
82
+ readonly withoutMulticallHelpers?: boolean;
83
+ }): ChainStub;
84
+ export declare const UNISWAP_LIST: {
85
+ id: string;
86
+ name: string;
87
+ tokens: {
88
+ chainId: number;
89
+ address: string;
90
+ symbol: string;
91
+ name: string;
92
+ decimals: number;
93
+ }[];
94
+ };
95
+ /** A second reputable list that happens to carry a different address for USDC. */
96
+ export declare const RIVAL_LIST: {
97
+ id: string;
98
+ name: string;
99
+ tokens: {
100
+ chainId: number;
101
+ address: string;
102
+ symbol: string;
103
+ name: string;
104
+ decimals: number;
105
+ }[];
106
+ };
107
+ /** The Cyrillic \u0421 impostor. It is not the same string, and it looks identical. */
108
+ export declare const CYRILLIC_LIST: {
109
+ id: string;
110
+ tokens: {
111
+ chainId: number;
112
+ address: string;
113
+ symbol: string;
114
+ name: string;
115
+ decimals: number;
116
+ }[];
117
+ };
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Recorded chain answers, and a reader stub that serves them.
3
+ *
4
+ * Every test in this package drives `_setChainReader` with one of these. None
5
+ * of them opens a socket, because there is nothing under the seam to open one
6
+ * with — a suite that reached a public RPC endpoint would fail on a CI runner
7
+ * with no egress and flake on somebody else's rate limit.
8
+ *
9
+ * The tokens here are the ones that break naive readers, and they are
10
+ * modelled on real contracts rather than invented:
11
+ *
12
+ * - a six-decimal stablecoin, the ordinary case;
13
+ * - a `bytes32` symbol, which is what MKR and other 2017-era tokens return
14
+ * because the ABI was not settled when they deployed;
15
+ * - a token whose `decimals()` reverts, which must come back as unknown and
16
+ * never as 18;
17
+ * - two impostors claiming a listed ticker, one of them with a Cyrillic \u0421;
18
+ * - an address with no code at all.
19
+ *
20
+ * The Multicall3 stub does a real ABI round trip through `tool-onchain`'s
21
+ * codec rather than a hand-written fake of one: it decodes the batch this
22
+ * package encoded and encodes the `(bool,bytes)[]` the package decodes. A
23
+ * fixture that faked the encoding would pass against a broken encoder.
24
+ */
25
+ import { MULTICALL3_ADDRESS, abiDecode, abiEncodeCall } from "@crewhaus/tool-onchain";
26
+ import { SELECTOR } from "./lib/erc";
27
+ // ─── encoding helpers ───────────────────────────────────────────────────────
28
+ const hexBody = (value) => value.replace(/^0x/, "");
29
+ /** A 32-byte word from an unsigned integer. */
30
+ export const word = (value) => value.toString(16).padStart(64, "0");
31
+ export const encodeUint = (value) => `0x${word(value)}`;
32
+ export const encodeAddress = (address) => `0x${hexBody(address).toLowerCase().padStart(64, "0")}`;
33
+ export const encodeBool = (value) => encodeUint(value ? 1n : 0n);
34
+ /** A dynamic `string` return: offset, length, then the bytes, padded. */
35
+ export function encodeString(text) {
36
+ const bytes = new TextEncoder().encode(text);
37
+ let data = "";
38
+ for (const b of bytes)
39
+ data += b.toString(16).padStart(2, "0");
40
+ const padded = data.padEnd(Math.ceil(bytes.length / 32) * 64, "0");
41
+ return `0x${word(32n)}${word(BigInt(bytes.length))}${padded}`;
42
+ }
43
+ /** The pre-standard `bytes32` symbol: the text, left-aligned, NUL-padded. */
44
+ export function encodeBytes32String(text) {
45
+ const bytes = new TextEncoder().encode(text);
46
+ if (bytes.length > 32)
47
+ throw new Error(`"${text}" does not fit in a bytes32`);
48
+ let data = "";
49
+ for (const b of bytes)
50
+ data += b.toString(16).padStart(2, "0");
51
+ return `0x${data.padEnd(64, "0")}`;
52
+ }
53
+ const strip = (call) => `0x${call.slice(10)}`;
54
+ async function encodeCall(signature, args) {
55
+ const out = await abiEncodeCall.execute({ signature, args });
56
+ if (typeof out !== "string")
57
+ throw new Error("AbiEncodeCall did not return a string");
58
+ return JSON.parse(out).data;
59
+ }
60
+ /** `Error(string)` revert data — the selector is the one Solidity emits. */
61
+ export async function revertWith(message) {
62
+ return encodeCall("Error(string)", [message]);
63
+ }
64
+ export const answers = (data) => ({ ok: true, data });
65
+ export const reverts = (revertData) => ({ ok: false, data: revertData });
66
+ /** What a contract without that function returns: nothing at all. */
67
+ export const NOTHING = { ok: true, data: "0x" };
68
+ export const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
69
+ export const IMPOSTOR_USDC = "0x1111111111111111111111111111111111111111";
70
+ export const CYRILLIC_USDC = "0x2222222222222222222222222222222222222222";
71
+ export const MKR = "0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2";
72
+ export const BROKEN = "0x3333333333333333333333333333333333333333";
73
+ export const NOT_A_CONTRACT = "0x4444444444444444444444444444444444444444";
74
+ export const APE_NFT = "0x5555555555555555555555555555555555555555";
75
+ export const GAME_1155 = "0x6666666666666666666666666666666666666666";
76
+ export const ONCHAIN_NFT = "0x7777777777777777777777777777777777777777";
77
+ export const LIAR_165 = "0x8888888888888888888888888888888888888888";
78
+ export const ALICE = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
79
+ export const BOB = "0x388C818CA8B9251b393131C08a736A67ccB19297";
80
+ export const SPENDER = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
81
+ const addressArg = (args, index = 0) => `0x${args.slice(index * 64 + 24, index * 64 + 64)}`.toLowerCase();
82
+ const uintArg = (args, index = 0) => BigInt(`0x${args.slice(index * 64, index * 64 + 64) || "0"}`);
83
+ /** A plain ERC-20 whose metadata is all well-formed strings. */
84
+ export function erc20(opts) {
85
+ const text = opts.bytes32Metadata ? encodeBytes32String : encodeString;
86
+ return (selector, args) => {
87
+ switch (selector) {
88
+ case SELECTOR.decimals:
89
+ return answers(encodeUint(opts.decimals));
90
+ case SELECTOR.symbol:
91
+ return answers(text(opts.symbol));
92
+ case SELECTOR.name:
93
+ return answers(text(opts.name));
94
+ case SELECTOR.totalSupply:
95
+ return answers(encodeUint(opts.totalSupply));
96
+ case SELECTOR.balanceOf:
97
+ return answers(encodeUint(opts.balances?.[addressArg(args)] ?? 0n));
98
+ case SELECTOR.allowance:
99
+ return answers(encodeUint(opts.allowances?.[`${addressArg(args, 0)}:${addressArg(args, 1)}`] ?? 0n));
100
+ default:
101
+ return NOTHING;
102
+ }
103
+ };
104
+ }
105
+ /** An ERC-721 that declares itself properly through ERC-165. */
106
+ export function erc721(opts) {
107
+ const supported = new Set(opts.interfaceIds ?? ["01ffc9a7", "80ac58cd", "5b5e139f"]);
108
+ return (selector, args) => {
109
+ if (selector === SELECTOR.supportsInterface) {
110
+ const id = args.slice(0, 8).toLowerCase();
111
+ return answers(encodeBool(opts.answersEverything === true || supported.has(id)));
112
+ }
113
+ if (selector === SELECTOR.name)
114
+ return answers(encodeString(opts.name));
115
+ if (selector === SELECTOR.symbol)
116
+ return answers(encodeString(opts.symbol));
117
+ if (selector === SELECTOR.ownerOf) {
118
+ const owner = opts.owners[uintArg(args).toString()];
119
+ return owner === undefined ? reverts(opts.missingTokenRevert) : answers(encodeAddress(owner));
120
+ }
121
+ if (selector === SELECTOR.tokenURI) {
122
+ const uri = opts.tokenUris[uintArg(args).toString()];
123
+ return uri === undefined ? reverts(opts.missingTokenRevert) : answers(encodeString(uri));
124
+ }
125
+ return NOTHING;
126
+ };
127
+ }
128
+ /** An ERC-1155, which has no ownerOf and whose uri carries the {id} template. */
129
+ export function erc1155(opts) {
130
+ const supported = new Set(opts.interfaceIds ?? ["01ffc9a7", "d9b67a26", "0e89341c"]);
131
+ return (selector, args) => {
132
+ if (selector === SELECTOR.supportsInterface) {
133
+ return answers(encodeBool(supported.has(args.slice(0, 8).toLowerCase())));
134
+ }
135
+ if (selector === SELECTOR.uri)
136
+ return answers(encodeString(opts.uriTemplate));
137
+ if (selector === SELECTOR.balanceOf1155) {
138
+ return answers(encodeUint(opts.balances[`${addressArg(args, 0)}:${uintArg(args, 1)}`] ?? 0n));
139
+ }
140
+ return NOTHING;
141
+ };
142
+ }
143
+ export function chainStub(opts) {
144
+ const reads = [];
145
+ const multicall = (opts.multicall3Address ?? MULTICALL3_ADDRESS).toLowerCase();
146
+ const handlerFor = (address) => opts.contracts[address.toLowerCase()];
147
+ const dispatch = (target, data) => {
148
+ const selector = data.slice(0, 10).toLowerCase();
149
+ const args = data.slice(10);
150
+ if (target.toLowerCase() === multicall) {
151
+ if (opts.withoutMulticallHelpers === true)
152
+ return NOTHING;
153
+ if (selector === SELECTOR.getBlockNumber)
154
+ return answers(encodeUint(opts.blockNumber ?? 0n));
155
+ if (selector === SELECTOR.getEthBalance) {
156
+ return answers(encodeUint(opts.nativeBalances?.[addressArg(args)] ?? 0n));
157
+ }
158
+ return NOTHING;
159
+ }
160
+ const handler = handlerFor(target);
161
+ // An address with no code answers every call with nothing. That is the
162
+ // whole point of the NOT_A_CONTRACT fixture.
163
+ return handler === undefined ? NOTHING : handler(selector, args);
164
+ };
165
+ const reader = async (read) => {
166
+ reads.push(read);
167
+ if (read.method === "eth_getCode") {
168
+ const address = String(read.params[0]).toLowerCase();
169
+ if (opts.code?.[address] !== undefined)
170
+ return opts.code[address];
171
+ return handlerFor(address) === undefined ? "0x" : "0x60806040";
172
+ }
173
+ if (read.method === "eth_getBalance") {
174
+ return encodeUint(opts.nativeBalances?.[String(read.params[0]).toLowerCase()] ?? 0n);
175
+ }
176
+ const call = read.params[0];
177
+ if (call.to.toLowerCase() !== multicall || !call.data.startsWith("0x82ad56cb")) {
178
+ const answer = dispatch(call.to, call.data);
179
+ // An unbatched eth_call cannot return a revert as data: the node errors.
180
+ if (!answer.ok)
181
+ throw new Error(`execution reverted (${answer.data})`);
182
+ return answer.data;
183
+ }
184
+ if (opts.brokenBatchAnswer !== undefined)
185
+ return opts.brokenBatchAnswer;
186
+ const decoded = await abiDecode.execute({
187
+ data: strip(call.data),
188
+ types: ["(address,bool,bytes)[]"],
189
+ });
190
+ if (typeof decoded !== "string")
191
+ throw new Error("AbiDecode did not return a string");
192
+ const [batch] = JSON.parse(decoded).values;
193
+ const results = batch.map(([to, , callData]) => {
194
+ const answer = dispatch(to, callData);
195
+ return [answer.ok, answer.data];
196
+ });
197
+ return strip(await encodeCall("f((bool,bytes)[])", [results]));
198
+ };
199
+ return { reader, reads };
200
+ }
201
+ // ─── token lists ────────────────────────────────────────────────────────────
202
+ export const UNISWAP_LIST = {
203
+ id: "uniswap-default",
204
+ name: "Uniswap Labs Default",
205
+ tokens: [
206
+ { chainId: 1, address: USDC, symbol: "USDC", name: "USD Coin", decimals: 6 },
207
+ { chainId: 1, address: MKR, symbol: "MKR", name: "Maker", decimals: 18 },
208
+ ],
209
+ };
210
+ /** A second reputable list that happens to carry a different address for USDC. */
211
+ export const RIVAL_LIST = {
212
+ id: "rival-list",
213
+ name: "Somebody Else's List",
214
+ tokens: [{ chainId: 1, address: IMPOSTOR_USDC, symbol: "USDC", name: "USD Coin", decimals: 6 }],
215
+ };
216
+ /** The Cyrillic \u0421 impostor. It is not the same string, and it looks identical. */
217
+ export const CYRILLIC_LIST = {
218
+ id: "shady-list",
219
+ tokens: [
220
+ { chainId: 1, address: CYRILLIC_USDC, symbol: "USD\u0421", name: "USD Coin", decimals: 6 },
221
+ ],
222
+ };
@@ -0,0 +1,9 @@
1
+ import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
+ export { type ChainRead, type ChainReadMethod, type ChainReader, READ_METHODS, TokenError, _setChainReader, chainReaderFromAdapters, hasChainReader, } from "./lib/chain";
3
+ export { type MetadataFetch, type MetadataResponse, type UriPlan, DEFAULT_MAX_METADATA_BYTES, _setMetadataFetch, planUri, } from "./lib/uri";
4
+ export { type Candidate, type TokenList, type TokenListEntry, confusableSkeleton, findCandidates, textConcerns, } from "./lib/lists";
5
+ export declare const tokenResolve: RegisteredTool;
6
+ export declare const erc20Balance: RegisteredTool;
7
+ export declare const erc721TokenInfo: RegisteredTool;
8
+ /** Every tool this package registers, in the order a catalog should list them. */
9
+ export declare const TOKEN_TOOLS: ReadonlyArray<RegisteredTool>;