@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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The one way this package learns anything from a chain — and the reason it
3
+ * is the only way.
4
+ *
5
+ * **There is no dialling code in `@crewhaus/tool-token`.** No URL, no fetch,
6
+ * no socket. Every chain read leaves through `_setChainReader`, which the
7
+ * runtime binds at boot and every test in this package drives from recorded
8
+ * answers. Two things follow, and both are the point:
9
+ *
10
+ * 1. A test cannot reach a public RPC endpoint even by accident. There is
11
+ * nothing under the seam to reach it with. A suite that dialled a real
12
+ * node would fail on a CI runner with no egress and flake on somebody
13
+ * else's rate limit.
14
+ * 2. The set of JSON-RPC methods this package can emit is three, they are
15
+ * listed below, and {@link assertReadMethod} refuses anything else
16
+ * before it reaches the reader. `eth_sendTransaction` and
17
+ * `eth_sendRawTransaction` are not reachable from any code path here;
18
+ * `index.test.ts` asserts it.
19
+ *
20
+ * Reads are batched through Multicall3 by default, because a hundred balances
21
+ * read one at a time are a hundred balances from up to a hundred different
22
+ * blocks. `getBlockNumber()` rides along in the same batch so the answer says
23
+ * which block it is a snapshot of.
24
+ */
25
+ import type { ChainAdapter } from "@crewhaus/chain-adapter-base";
26
+ import { CrewhausError } from "@crewhaus/errors";
27
+ import { type RevertReason } from "@crewhaus/tool-onchain";
28
+ /** A refusal this package makes: a bad input, a missing seam, an unreadable answer. */
29
+ export declare class TokenError extends CrewhausError {
30
+ readonly name = "TokenError";
31
+ constructor(message: string);
32
+ }
33
+ /**
34
+ * Every JSON-RPC method this package is allowed to emit. All three are reads
35
+ * of public state. Nothing on this list can move a token, and nothing that
36
+ * could is reachable from here.
37
+ */
38
+ export declare const READ_METHODS: readonly ["eth_call", "eth_getBalance", "eth_getCode"];
39
+ export type ChainReadMethod = (typeof READ_METHODS)[number];
40
+ export type ChainRead = {
41
+ readonly chainId: number;
42
+ readonly method: ChainReadMethod;
43
+ readonly params: ReadonlyArray<unknown>;
44
+ };
45
+ /** What the runtime binds: one function, one read, one answer. */
46
+ export type ChainReader = (read: ChainRead) => Promise<unknown>;
47
+ /**
48
+ * Bind the chain reader. The runtime calls this at boot; tests call it with a
49
+ * stub over recorded answers. `undefined` restores the unbound state, and a
50
+ * suite that installs a stub must restore it or the next file in the same bun
51
+ * process inherits it.
52
+ */
53
+ export declare function _setChainReader(fn: ChainReader | undefined): void;
54
+ /** True when something is bound — the tools report this rather than failing late. */
55
+ export declare function hasChainReader(): boolean;
56
+ export declare function assertReadMethod(method: string): asserts method is ChainReadMethod;
57
+ /**
58
+ * Wire the seam to `chain-adapter-base` adapters, which is what a compiled
59
+ * bundle has. The adapter applies its own read-only allowlist underneath, so
60
+ * a write method would be refused twice.
61
+ */
62
+ export declare function chainReaderFromAdapters(resolve: (chainId: string) => ChainAdapter | undefined): ChainReader;
63
+ /** One call in a read, keyed so results are never matched up by position here. */
64
+ export type BatchCall = {
65
+ /** The caller's own label. Results come back in a map under it. */
66
+ readonly key: string;
67
+ readonly target: string;
68
+ readonly callData: string;
69
+ };
70
+ /** What one call answered. A revert is an outcome, not an exception. */
71
+ export type CallOutcome = {
72
+ readonly ok: boolean;
73
+ /** Return data on success, revert data on failure. */
74
+ readonly data: string;
75
+ readonly revert: RevertReason | null;
76
+ /**
77
+ * What the node said when it refused the call outright, on the unbatched
78
+ * path. Null on the batched path, where a revert arrives as data and
79
+ * `revert` carries it.
80
+ */
81
+ readonly error: string | null;
82
+ };
83
+ export type BatchResult = {
84
+ readonly outcomes: ReadonlyMap<string, CallOutcome>;
85
+ /** The block the batch was read at, when Multicall3 was asked. */
86
+ readonly blockNumber: string | null;
87
+ readonly batched: boolean;
88
+ readonly callCount: number;
89
+ };
90
+ export declare function checkBlockTag(tag: string): string;
91
+ /**
92
+ * Run a set of calls, batched through Multicall3 when asked.
93
+ *
94
+ * Batched is the default because it is the only way the answers are
95
+ * consistent with each other: one `eth_call` evaluates every sub-call at one
96
+ * block, so a balance and the decimals it is scaled by cannot come from
97
+ * either side of a rebase. The unbatched path exists for chains where
98
+ * Multicall3 is not deployed, and it says so in the result rather than
99
+ * quietly producing a snapshot that is not one.
100
+ *
101
+ * A sub-call that reverts comes back as an outcome with `ok: false`. The
102
+ * BATCH failing is a different event — a Multicall3 address with no contract
103
+ * at it, a node that answered something else — and that throws, because
104
+ * nothing in the answer can be trusted to be about the calls that were sent.
105
+ */
106
+ export declare function readCalls(opts: {
107
+ readonly chainId: number;
108
+ readonly calls: ReadonlyArray<BatchCall>;
109
+ readonly blockTag: string;
110
+ readonly batch: boolean;
111
+ readonly multicall3Address?: string;
112
+ }): Promise<BatchResult>;
113
+ /** The native balance, which is not a contract call and so is never in the batch. */
114
+ export declare function readNativeBalance(chainId: number, address: string, blockTag: string): Promise<bigint>;
115
+ /** The native balance inside the batch, via Multicall3's own `getEthBalance`. */
116
+ export declare function nativeBalanceCall(key: string, account: string, multicall: string): BatchCall;
117
+ /**
118
+ * Whether there is code at an address.
119
+ *
120
+ * An address with no code is not a token — it is somebody's wallet, or a
121
+ * typo. Every `eth_call` against it answers `0x`, which a decoder reads as
122
+ * "this token has no symbol" rather than as "this is not a token".
123
+ */
124
+ export declare function readHasCode(chainId: number, address: string, blockTag: string): Promise<{
125
+ hasCode: boolean;
126
+ codeSize: number;
127
+ }>;
@@ -0,0 +1,202 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ import { MULTICALL3_ADDRESS, decodeAggregate3, encodeAggregate3, } from "@crewhaus/tool-onchain";
3
+ import { SELECTOR, addressWord, callData, decodeUint } from "./erc";
4
+ /** A refusal this package makes: a bad input, a missing seam, an unreadable answer. */
5
+ export class TokenError extends CrewhausError {
6
+ name = "TokenError";
7
+ constructor(message) {
8
+ super("tool", message);
9
+ }
10
+ }
11
+ /**
12
+ * Every JSON-RPC method this package is allowed to emit. All three are reads
13
+ * of public state. Nothing on this list can move a token, and nothing that
14
+ * could is reachable from here.
15
+ */
16
+ export const READ_METHODS = Object.freeze(["eth_call", "eth_getBalance", "eth_getCode"]);
17
+ const unbound = async (read) => {
18
+ throw new TokenError(`no chain reader is bound, so chain ${read.chainId} cannot be read. This package contains no RPC client of its own: the runtime binds one with _setChainReader(), and chainReaderFromAdapters() wires it from the spec's chains[] block in one line.`);
19
+ };
20
+ let reader = unbound;
21
+ /**
22
+ * Bind the chain reader. The runtime calls this at boot; tests call it with a
23
+ * stub over recorded answers. `undefined` restores the unbound state, and a
24
+ * suite that installs a stub must restore it or the next file in the same bun
25
+ * process inherits it.
26
+ */
27
+ export function _setChainReader(fn) {
28
+ reader = fn ?? unbound;
29
+ }
30
+ /** True when something is bound — the tools report this rather than failing late. */
31
+ export function hasChainReader() {
32
+ return reader !== unbound;
33
+ }
34
+ export function assertReadMethod(method) {
35
+ if (!READ_METHODS.includes(method)) {
36
+ throw new TokenError(`${method} is not one of the read methods this package emits (${READ_METHODS.join(", ")}). Nothing here signs or submits anything.`);
37
+ }
38
+ }
39
+ /**
40
+ * Wire the seam to `chain-adapter-base` adapters, which is what a compiled
41
+ * bundle has. The adapter applies its own read-only allowlist underneath, so
42
+ * a write method would be refused twice.
43
+ */
44
+ export function chainReaderFromAdapters(resolve) {
45
+ return async (read) => {
46
+ assertReadMethod(read.method);
47
+ const adapter = resolve(String(read.chainId));
48
+ if (adapter === undefined) {
49
+ throw new TokenError(`no chain adapter is registered for chain ${read.chainId}; declare it in the spec's chains[] block`);
50
+ }
51
+ return adapter.rpcRead(read.method, read.params);
52
+ };
53
+ }
54
+ async function rpc(read) {
55
+ assertReadMethod(read.method);
56
+ return reader(read);
57
+ }
58
+ function asHex(value, what) {
59
+ if (typeof value !== "string" || !/^0x([0-9a-fA-F]{2})*$/.test(value)) {
60
+ throw new TokenError(`${what}: expected 0x-prefixed hex from the node, got ${JSON.stringify(value)?.slice(0, 120) ?? "undefined"}`);
61
+ }
62
+ return value;
63
+ }
64
+ const BLOCK_TAG = /^(latest|earliest|pending|safe|finalized|0x[0-9a-fA-F]+)$/;
65
+ export function checkBlockTag(tag) {
66
+ if (!BLOCK_TAG.test(tag)) {
67
+ throw new TokenError(`"${tag}" is not a block tag; use latest, safe, finalized, earliest, pending, or a 0x hex block number`);
68
+ }
69
+ return tag;
70
+ }
71
+ /**
72
+ * Run a set of calls, batched through Multicall3 when asked.
73
+ *
74
+ * Batched is the default because it is the only way the answers are
75
+ * consistent with each other: one `eth_call` evaluates every sub-call at one
76
+ * block, so a balance and the decimals it is scaled by cannot come from
77
+ * either side of a rebase. The unbatched path exists for chains where
78
+ * Multicall3 is not deployed, and it says so in the result rather than
79
+ * quietly producing a snapshot that is not one.
80
+ *
81
+ * A sub-call that reverts comes back as an outcome with `ok: false`. The
82
+ * BATCH failing is a different event — a Multicall3 address with no contract
83
+ * at it, a node that answered something else — and that throws, because
84
+ * nothing in the answer can be trusted to be about the calls that were sent.
85
+ */
86
+ export async function readCalls(opts) {
87
+ const blockTag = checkBlockTag(opts.blockTag);
88
+ const outcomes = new Map();
89
+ if (opts.calls.length === 0) {
90
+ return { outcomes, blockNumber: null, batched: false, callCount: 0 };
91
+ }
92
+ if (!opts.batch) {
93
+ for (const call of opts.calls) {
94
+ try {
95
+ const answer = await rpc({
96
+ chainId: opts.chainId,
97
+ method: "eth_call",
98
+ params: [{ to: call.target, data: call.callData }, blockTag],
99
+ });
100
+ outcomes.set(call.key, {
101
+ ok: true,
102
+ data: asHex(answer, call.key),
103
+ revert: null,
104
+ error: null,
105
+ });
106
+ }
107
+ catch (err) {
108
+ // A refusal from this package — an unbound seam, a method that is not
109
+ // a read — is about the whole call, not about this one sub-call, and
110
+ // burying it as one of N per-call errors hides the one line that says
111
+ // what to fix.
112
+ if (err instanceof TokenError)
113
+ throw err;
114
+ // An unbatched eth_call cannot report a revert as data — the node
115
+ // errors instead. Recording it as this one call's failure is what
116
+ // keeps a token whose `decimals()` reverts from taking its balances
117
+ // down with it, which is the whole reason the batched path sets
118
+ // allowFailure.
119
+ outcomes.set(call.key, {
120
+ ok: false,
121
+ data: "0x",
122
+ revert: null,
123
+ error: err.message,
124
+ });
125
+ }
126
+ }
127
+ return { outcomes, blockNumber: null, batched: false, callCount: opts.calls.length };
128
+ }
129
+ const multicall = opts.multicall3Address ?? MULTICALL3_ADDRESS;
130
+ const blockKey = "\u0000blockNumber";
131
+ const packed = [
132
+ { key: blockKey, target: multicall, callData: callData(SELECTOR.getBlockNumber) },
133
+ ...opts.calls,
134
+ ];
135
+ const request = encodeAggregate3(packed.map((c) => ({ target: c.target, callData: c.callData, allowFailure: true })), multicall);
136
+ const answer = await rpc({
137
+ chainId: opts.chainId,
138
+ method: "eth_call",
139
+ params: [{ to: request.to, data: request.data }, blockTag],
140
+ });
141
+ let rows;
142
+ try {
143
+ rows = decodeAggregate3(asHex(answer, "the Multicall3 batch"), request.callCount);
144
+ }
145
+ catch (err) {
146
+ throw new TokenError(`the Multicall3 batch at ${request.to} did not answer with results: ${err.message}. If this chain has no Multicall3 deployment at that address, pass multicall3Address, or batch:false to read the calls one at a time.`);
147
+ }
148
+ for (const [i, call] of packed.entries()) {
149
+ const row = rows[i];
150
+ outcomes.set(call.key, {
151
+ ok: row.success,
152
+ data: row.returnData,
153
+ revert: row.revert,
154
+ error: null,
155
+ });
156
+ }
157
+ const block = outcomes.get(blockKey);
158
+ outcomes.delete(blockKey);
159
+ const blockNumber = block?.ok === true ? (decodeUint(block.data)?.toString() ?? null) : null;
160
+ return { outcomes, blockNumber, batched: true, callCount: opts.calls.length };
161
+ }
162
+ /** The native balance, which is not a contract call and so is never in the batch. */
163
+ export async function readNativeBalance(chainId, address, blockTag) {
164
+ const answer = await rpc({
165
+ chainId,
166
+ method: "eth_getBalance",
167
+ params: [address, checkBlockTag(blockTag)],
168
+ });
169
+ // A quantity is at least `0x0`. Bare `0x` is what a node returns when it
170
+ // has nothing to say, and reading it as zero is the mistake this whole
171
+ // package exists to stop: an unanswered balance becoming "this account is
172
+ // empty", which is a sentence a gas check or a drain alarm acts on.
173
+ if (typeof answer !== "string" || !/^0x[0-9a-fA-F]+$/.test(answer)) {
174
+ throw new TokenError(`eth_getBalance answered ${JSON.stringify(answer)}, which is not a quantity — a balance is at least 0x0, and "0x" is an absent answer rather than a zero one`);
175
+ }
176
+ return BigInt(answer);
177
+ }
178
+ /** The native balance inside the batch, via Multicall3's own `getEthBalance`. */
179
+ export function nativeBalanceCall(key, account, multicall) {
180
+ return {
181
+ key,
182
+ target: multicall,
183
+ callData: callData(SELECTOR.getEthBalance, addressWord(account)),
184
+ };
185
+ }
186
+ /**
187
+ * Whether there is code at an address.
188
+ *
189
+ * An address with no code is not a token — it is somebody's wallet, or a
190
+ * typo. Every `eth_call` against it answers `0x`, which a decoder reads as
191
+ * "this token has no symbol" rather than as "this is not a token".
192
+ */
193
+ export async function readHasCode(chainId, address, blockTag) {
194
+ const answer = await rpc({
195
+ chainId,
196
+ method: "eth_getCode",
197
+ params: [address, checkBlockTag(blockTag)],
198
+ });
199
+ const hex = asHex(answer, "eth_getCode");
200
+ const codeSize = (hex.length - 2) / 2;
201
+ return { hasCode: codeSize > 0, codeSize };
202
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The token-standard call surface: the selectors, the interface ids, and the
3
+ * reading of what comes back.
4
+ *
5
+ * This is a narrow codec, not a second copy of `tool-onchain`'s general ABI
6
+ * coder, and the reason is the whole point of this file. A general decoder is
7
+ * told what type to expect and fails when the bytes are not that type. The
8
+ * tokens that break harnesses are exactly the ones whose bytes are not that
9
+ * type: `symbol()` on MKR returns a `bytes32`, not a `string`; `decimals()`
10
+ * on some very old tokens reverts or is simply absent; an EOA answers every
11
+ * call with `0x`. Reading those needs the raw words and a documented
12
+ * fallback, which is a different job from "decode this as a string".
13
+ *
14
+ * Every selector and interface id below is a literal, and every one of them
15
+ * is derived from its signature in `lib.test.ts` through `tool-onchain`'s
16
+ * `FunctionSelector`. A literal that drifts fails a test rather than
17
+ * producing calldata a node accepts and a contract misreads.
18
+ */
19
+ /** Four-byte selectors for the calls this package makes. */
20
+ export declare const SELECTOR: Readonly<{
21
+ /** ERC-20 `balanceOf(address)`. */
22
+ readonly balanceOf: "0x70a08231";
23
+ /** ERC-20 `allowance(address,address)`. */
24
+ readonly allowance: "0xdd62ed3e";
25
+ readonly decimals: "0x313ce567";
26
+ readonly symbol: "0x95d89b41";
27
+ readonly name: "0x06fdde03";
28
+ readonly totalSupply: "0x18160ddd";
29
+ readonly ownerOf: "0x6352211e";
30
+ readonly tokenURI: "0xc87b56dd";
31
+ /** ERC-1155 `uri(uint256)`. */
32
+ readonly uri: "0x0e89341c";
33
+ /** ERC-1155 `balanceOf(address,uint256)` — a different selector from ERC-20's. */
34
+ readonly balanceOf1155: "0x00fdd58e";
35
+ readonly supportsInterface: "0x01ffc9a7";
36
+ /** Multicall3's own helpers, callable inside the same batch. */
37
+ readonly getEthBalance: "0x4d2301cc";
38
+ readonly getBlockNumber: "0x42cbb15c";
39
+ }>;
40
+ /**
41
+ * ERC-165 interface ids. Each is the XOR of the selectors of the functions in
42
+ * its interface, and `lib.test.ts` recomputes each one from that function
43
+ * list rather than trusting the literal.
44
+ */
45
+ export declare const INTERFACE_ID: Readonly<{
46
+ readonly erc165: "0x01ffc9a7";
47
+ readonly erc721: "0x80ac58cd";
48
+ readonly erc721Metadata: "0x5b5e139f";
49
+ readonly erc721Enumerable: "0x780e9d63";
50
+ readonly erc1155: "0xd9b67a26";
51
+ readonly erc1155MetadataUri: "0x0e89341c";
52
+ /**
53
+ * Not an interface. ERC-165 requires `supportsInterface(0xffffffff)` to
54
+ * answer FALSE, so a contract that says true here answers true to
55
+ * everything and its other answers mean nothing.
56
+ */
57
+ readonly invalid: "0xffffffff";
58
+ }>;
59
+ /** A decoded string longer than this is not a symbol or a name. */
60
+ export declare const MAX_STRING_BYTES = 4096;
61
+ export declare function hexToBytes(hex: string, what?: string): Uint8Array;
62
+ export declare function bytesToHex(bytes: Uint8Array): string;
63
+ /** A 32-byte word holding a right-aligned address. */
64
+ export declare function addressWord(address: string): string;
65
+ /** A 32-byte word holding a right-aligned unsigned integer. */
66
+ export declare function uintWord(value: bigint): string;
67
+ /**
68
+ * A 32-byte word holding a LEFT-aligned `bytes4`.
69
+ *
70
+ * Fixed bytes are left-aligned and integers right-aligned. Getting this
71
+ * backwards makes every `supportsInterface` answer false, which reads as "not
72
+ * an NFT" rather than as a bug.
73
+ */
74
+ export declare function bytes4Word(id: string): string;
75
+ /** Assemble calldata from a selector and pre-padded words. */
76
+ export declare function callData(selector: string, ...words: ReadonlyArray<string>): string;
77
+ /** Read one 32-byte word as an unsigned integer, or null when it is not there. */
78
+ export declare function decodeUint(data: string): bigint | null;
79
+ /**
80
+ * Read one word as an address.
81
+ *
82
+ * The top 12 bytes must be zero. A word with anything up there is not an
83
+ * address the caller can use, and masking it off would turn a contract's
84
+ * garbage into a plausible destination.
85
+ */
86
+ export declare function decodeAddressValue(data: string): string | null;
87
+ /** Read one word as a bool. Anything other than 0 or 1 is not a bool. */
88
+ export declare function decodeBool(data: string): boolean | null;
89
+ /** How a text field was actually encoded on the wire. */
90
+ export type StringEncoding = "string" | "bytes32" | "absent" | "undecodable";
91
+ export type StringRead = {
92
+ readonly value: string | null;
93
+ readonly encoding: StringEncoding;
94
+ /** Why it came out this way. Empty when the answer was an ordinary string. */
95
+ readonly note: string;
96
+ /** The raw bytes, always, so an unreadable answer is still actionable. */
97
+ readonly raw: string;
98
+ };
99
+ /**
100
+ * Read `symbol()` or `name()` from whatever the token actually returned.
101
+ *
102
+ * Three shapes are real and all three are in production:
103
+ *
104
+ * - a normal dynamic `string` (offset, length, bytes);
105
+ * - exactly one word, which is the `bytes32` form MKR and several other
106
+ * 2017-era tokens use — the ABI was not settled when they deployed;
107
+ * - nothing at all, because the function is absent or reverted.
108
+ *
109
+ * The third is reported as `absent`, never as an empty name. "" and "we could
110
+ * not read it" are different facts and a caller displaying the first as the
111
+ * second shows a blank where a warning belongs.
112
+ */
113
+ export declare function decodeStringish(data: string): StringRead;
114
+ /**
115
+ * The 64-hex-digit, zero-padded, LOWERCASE form of a token id, for the `{id}`
116
+ * placeholder in an ERC-1155 URI.
117
+ *
118
+ * The spec is explicit and almost everyone gets it wrong: it is not the
119
+ * decimal id and not `0x`-prefixed. A gateway handed `{id}` replaced with "1"
120
+ * returns a 404, or worse, somebody else's metadata.
121
+ */
122
+ export declare function padTokenIdHex(tokenId: bigint): string;