@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,238 @@
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 const SELECTOR = Object.freeze({
21
+ /** ERC-20 `balanceOf(address)`. */
22
+ balanceOf: "0x70a08231",
23
+ /** ERC-20 `allowance(address,address)`. */
24
+ allowance: "0xdd62ed3e",
25
+ decimals: "0x313ce567",
26
+ symbol: "0x95d89b41",
27
+ name: "0x06fdde03",
28
+ totalSupply: "0x18160ddd",
29
+ ownerOf: "0x6352211e",
30
+ tokenURI: "0xc87b56dd",
31
+ /** ERC-1155 `uri(uint256)`. */
32
+ uri: "0x0e89341c",
33
+ /** ERC-1155 `balanceOf(address,uint256)` — a different selector from ERC-20's. */
34
+ balanceOf1155: "0x00fdd58e",
35
+ supportsInterface: "0x01ffc9a7",
36
+ /** Multicall3's own helpers, callable inside the same batch. */
37
+ getEthBalance: "0x4d2301cc",
38
+ 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 const INTERFACE_ID = Object.freeze({
46
+ erc165: "0x01ffc9a7",
47
+ erc721: "0x80ac58cd",
48
+ erc721Metadata: "0x5b5e139f",
49
+ erc721Enumerable: "0x780e9d63",
50
+ erc1155: "0xd9b67a26",
51
+ 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
+ invalid: "0xffffffff",
58
+ });
59
+ /** A decoded string longer than this is not a symbol or a name. */
60
+ export const MAX_STRING_BYTES = 4096;
61
+ const HEX_BODY = /^[0-9a-fA-F]*$/;
62
+ export function hexToBytes(hex, what = "data") {
63
+ const body = hex.startsWith("0x") || hex.startsWith("0X") ? hex.slice(2) : hex;
64
+ if (body.length % 2 !== 0)
65
+ throw new Error(`${what}: an odd number of hex digits`);
66
+ if (!HEX_BODY.test(body))
67
+ throw new Error(`${what}: not hex`);
68
+ const out = new Uint8Array(body.length / 2);
69
+ for (let i = 0; i < out.length; i++)
70
+ out[i] = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16);
71
+ return out;
72
+ }
73
+ export function bytesToHex(bytes) {
74
+ let out = "";
75
+ for (const b of bytes)
76
+ out += b.toString(16).padStart(2, "0");
77
+ return out;
78
+ }
79
+ /** A 32-byte word holding a right-aligned address. */
80
+ export function addressWord(address) {
81
+ const body = address.replace(/^0x/i, "").toLowerCase();
82
+ if (body.length !== 40)
83
+ throw new Error(`an address is 20 bytes, got ${body.length / 2}`);
84
+ return body.padStart(64, "0");
85
+ }
86
+ /** A 32-byte word holding a right-aligned unsigned integer. */
87
+ export function uintWord(value) {
88
+ if (value < 0n)
89
+ throw new Error(`${value} is negative, and the type is unsigned`);
90
+ if (value >= 1n << 256n)
91
+ throw new Error(`${value} does not fit in a uint256`);
92
+ return value.toString(16).padStart(64, "0");
93
+ }
94
+ /**
95
+ * A 32-byte word holding a LEFT-aligned `bytes4`.
96
+ *
97
+ * Fixed bytes are left-aligned and integers right-aligned. Getting this
98
+ * backwards makes every `supportsInterface` answer false, which reads as "not
99
+ * an NFT" rather than as a bug.
100
+ */
101
+ export function bytes4Word(id) {
102
+ const body = id.replace(/^0x/i, "").toLowerCase();
103
+ if (body.length !== 8)
104
+ throw new Error(`an interface id is 4 bytes, got "${id}"`);
105
+ return body.padEnd(64, "0");
106
+ }
107
+ /** Assemble calldata from a selector and pre-padded words. */
108
+ export function callData(selector, ...words) {
109
+ return `${selector}${words.join("")}`;
110
+ }
111
+ // ---------------------------------------------------------------------------
112
+ // reading the answer
113
+ // ---------------------------------------------------------------------------
114
+ const WORD = 32;
115
+ /** Read one 32-byte word as an unsigned integer, or null when it is not there. */
116
+ export function decodeUint(data) {
117
+ const bytes = hexToBytes(data, "return data");
118
+ if (bytes.length < WORD)
119
+ return null;
120
+ let value = 0n;
121
+ for (const b of bytes.subarray(0, WORD))
122
+ value = (value << 8n) | BigInt(b);
123
+ return value;
124
+ }
125
+ /**
126
+ * Read one word as an address.
127
+ *
128
+ * The top 12 bytes must be zero. A word with anything up there is not an
129
+ * address the caller can use, and masking it off would turn a contract's
130
+ * garbage into a plausible destination.
131
+ */
132
+ export function decodeAddressValue(data) {
133
+ const bytes = hexToBytes(data, "return data");
134
+ if (bytes.length < WORD)
135
+ return null;
136
+ for (let i = 0; i < 12; i++)
137
+ if (bytes[i] !== 0)
138
+ return null;
139
+ return `0x${bytesToHex(bytes.subarray(12, WORD))}`;
140
+ }
141
+ /** Read one word as a bool. Anything other than 0 or 1 is not a bool. */
142
+ export function decodeBool(data) {
143
+ const value = decodeUint(data);
144
+ if (value === null || value > 1n)
145
+ return null;
146
+ return value === 1n;
147
+ }
148
+ /** UTF-8, strictly. A symbol that is not text must not come back as U+FFFD replacement characters. */
149
+ const strictUtf8 = new TextDecoder("utf-8", { fatal: true });
150
+ function decodeBytes32Text(word, raw, note) {
151
+ let end = word.length;
152
+ while (end > 0 && word[end - 1] === 0)
153
+ end--;
154
+ const body = word.subarray(0, end);
155
+ if (body.some((b) => b === 0)) {
156
+ // Interior NULs mean this is not a NUL-padded string, whatever the
157
+ // trailing zeros suggested.
158
+ return { value: null, encoding: "undecodable", note: `${note}interior NUL bytes`, raw };
159
+ }
160
+ try {
161
+ return { value: strictUtf8.decode(body), encoding: "bytes32", note, raw };
162
+ }
163
+ catch {
164
+ return { value: null, encoding: "undecodable", note: `${note}not valid UTF-8`, raw };
165
+ }
166
+ }
167
+ /**
168
+ * Read `symbol()` or `name()` from whatever the token actually returned.
169
+ *
170
+ * Three shapes are real and all three are in production:
171
+ *
172
+ * - a normal dynamic `string` (offset, length, bytes);
173
+ * - exactly one word, which is the `bytes32` form MKR and several other
174
+ * 2017-era tokens use — the ABI was not settled when they deployed;
175
+ * - nothing at all, because the function is absent or reverted.
176
+ *
177
+ * The third is reported as `absent`, never as an empty name. "" and "we could
178
+ * not read it" are different facts and a caller displaying the first as the
179
+ * second shows a blank where a warning belongs.
180
+ */
181
+ export function decodeStringish(data) {
182
+ const raw = data.startsWith("0x") ? data : `0x${data}`;
183
+ const bytes = hexToBytes(data, "return data");
184
+ if (bytes.length === 0) {
185
+ return { value: null, encoding: "absent", note: "the call returned no data", raw };
186
+ }
187
+ if (bytes.length === WORD) {
188
+ return decodeBytes32Text(bytes, raw, "returned bytes32 rather than string; ");
189
+ }
190
+ if (bytes.length >= WORD * 2) {
191
+ const offset = decodeUint(data);
192
+ const length = decodeUint(`0x${bytesToHex(bytes.subarray(WORD, WORD * 2))}`);
193
+ const wellFormed = offset === 32n &&
194
+ length <= BigInt(bytes.length - WORD * 2) &&
195
+ length <= BigInt(MAX_STRING_BYTES);
196
+ if (wellFormed) {
197
+ const body = bytes.subarray(WORD * 2, WORD * 2 + Number(length));
198
+ try {
199
+ return { value: strictUtf8.decode(body), encoding: "string", note: "", raw };
200
+ }
201
+ catch {
202
+ return { value: null, encoding: "undecodable", note: "not valid UTF-8", raw };
203
+ }
204
+ }
205
+ if (length > BigInt(MAX_STRING_BYTES)) {
206
+ return {
207
+ value: null,
208
+ encoding: "undecodable",
209
+ note: `declares ${length} bytes, past the ${MAX_STRING_BYTES}-byte cap for a symbol or a name`,
210
+ raw,
211
+ };
212
+ }
213
+ // A blob that is neither a well-formed string nor one word. The first word
214
+ // is still the most likely place a bytes32 symbol is sitting.
215
+ return decodeBytes32Text(bytes.subarray(0, WORD), raw, "not a well-formed dynamic string; read the first word as bytes32; ");
216
+ }
217
+ return {
218
+ value: null,
219
+ encoding: "undecodable",
220
+ note: `${bytes.length} bytes is neither a word nor a dynamic string`,
221
+ raw,
222
+ };
223
+ }
224
+ /**
225
+ * The 64-hex-digit, zero-padded, LOWERCASE form of a token id, for the `{id}`
226
+ * placeholder in an ERC-1155 URI.
227
+ *
228
+ * The spec is explicit and almost everyone gets it wrong: it is not the
229
+ * decimal id and not `0x`-prefixed. A gateway handed `{id}` replaced with "1"
230
+ * returns a 404, or worse, somebody else's metadata.
231
+ */
232
+ export function padTokenIdHex(tokenId) {
233
+ if (tokenId < 0n)
234
+ throw new Error(`a token id cannot be negative, got ${tokenId}`);
235
+ if (tokenId >= 1n << 256n)
236
+ throw new Error(`${tokenId} does not fit in a uint256`);
237
+ return tokenId.toString(16).padStart(64, "0");
238
+ }
@@ -0,0 +1,78 @@
1
+ /** One token as a list carries it. This is the Uniswap token-list shape. */
2
+ export type TokenListEntry = {
3
+ readonly chainId: number;
4
+ readonly address: string;
5
+ readonly symbol: string;
6
+ readonly name: string;
7
+ readonly decimals: number;
8
+ readonly tags?: ReadonlyArray<string>;
9
+ };
10
+ export type TokenList = {
11
+ /** How the answer refers to this list. Two lists may not share an id. */
12
+ readonly id: string;
13
+ readonly name?: string;
14
+ readonly tokens: ReadonlyArray<TokenListEntry>;
15
+ };
16
+ /** One list's claim about one address. */
17
+ export type ListClaim = {
18
+ readonly listId: string;
19
+ readonly symbol: string;
20
+ readonly name: string;
21
+ readonly decimals: number;
22
+ readonly tags: ReadonlyArray<string>;
23
+ };
24
+ export type MatchKind = "address" | "symbol" | "name" | "symbol-confusable" | "name-confusable";
25
+ /** One address a query could mean, with every list that carries it. */
26
+ export type Candidate = {
27
+ /** Lowercase. Addresses are compared lowercased; the checksummed form is for display. */
28
+ readonly address: string;
29
+ readonly claims: ReadonlyArray<ListClaim>;
30
+ readonly matchedBy: ReadonlyArray<MatchKind>;
31
+ };
32
+ /** The text with every zero-width and formatting character removed. */
33
+ export declare function stripInvisible(text: string): string;
34
+ /** Which invisible code points are present, in order, so a report can name them. */
35
+ export declare function invisibleCodePoints(text: string): ReadonlyArray<number>;
36
+ /**
37
+ * Fold a symbol or a name towards a comparison key.
38
+ *
39
+ * Only ever used to ADD candidates. Never used to decide which address a
40
+ * query resolves to.
41
+ */
42
+ export declare function confusableSkeleton(text: string): string;
43
+ /** What is odd about a piece of text a contract or a list supplied. */
44
+ export type TextConcern = {
45
+ readonly code: "invisible-characters" | "non-ascii" | "confusable" | "whitespace" | "over-long";
46
+ readonly detail: string;
47
+ };
48
+ /**
49
+ * Everything worth saying about a symbol or a name before a human reads it.
50
+ *
51
+ * These are reported, not acted on. A legitimate token can have a non-ASCII
52
+ * name; none of them need a zero-width space in the ticker.
53
+ */
54
+ export declare function textConcerns(text: string): ReadonlyArray<TextConcern>;
55
+ export declare const ADDRESS_SHAPE: RegExp;
56
+ /**
57
+ * A stable digest of the lists a resolution was made against.
58
+ *
59
+ * Reproducibility evidence, not a security control: the same query against
60
+ * the same fingerprint gives the same candidates, and a fingerprint that
61
+ * moved explains why yesterday's answer differs from today's. Only the fields
62
+ * that affect the outcome go in, sorted, so a list that reordered its tokens
63
+ * or gained a logo URL still fingerprints the same.
64
+ */
65
+ export declare function fingerprintLists(lists: ReadonlyArray<TokenList>, chainId: number): string;
66
+ /**
67
+ * Every address on the given lists that the query could mean, for one chain.
68
+ *
69
+ * Matching is by exact symbol, exact name, and by confusable skeleton of
70
+ * both. The four are reported separately in `matchedBy` so the caller can see
71
+ * WHY an address is in the set — a candidate that only matched by skeleton is
72
+ * the impostor the literal match would have missed.
73
+ */
74
+ export declare function findCandidates(lists: ReadonlyArray<TokenList>, chainId: number, query: string): ReadonlyArray<Candidate>;
75
+ /** The distinct decimals the lists claim for one address, in order found. */
76
+ export declare function claimedDecimals(candidate: Candidate): ReadonlyArray<number>;
77
+ /** The distinct symbols the lists claim for one address. */
78
+ export declare function claimedSymbols(candidate: Candidate): ReadonlyArray<string>;
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Token lists, and the reason a symbol is not an identifier.
3
+ *
4
+ * A symbol is a string a deployer chose. Nothing enforces uniqueness, nothing
5
+ * stops a second contract from calling itself USDC with six decimals and a
6
+ * convincing name, and two reputable lists routinely carry different
7
+ * addresses for the same ticker on the same chain. So the matching here never
8
+ * picks: it gathers every entry a query could plausibly mean and hands the
9
+ * whole set back. Choosing between two addresses that both say "USDC" is a
10
+ * decision with a wrong answer that costs money, and it is not a decision a
11
+ * string match is entitled to make.
12
+ *
13
+ * ## The skeleton, and why folding is safe HERE
14
+ *
15
+ * `confusableSkeleton` folds a symbol towards ASCII so that "USDC" and
16
+ * "USDС" — the second with a Cyrillic С — land on the same key. It is a
17
+ * deliberately small table: the common Latin/Cyrillic/Greek homoglyphs,
18
+ * invisible formatting characters, and two digit shapes. It is NOT UTS-39,
19
+ * and a symbol that survives it is not thereby proven safe.
20
+ *
21
+ * That looseness is affordable because of the DIRECTION the fold is used in.
22
+ * It only ever WIDENS the candidate set, and a wider set can only move the
23
+ * answer towards a refusal — it can never resolve a query to a different
24
+ * address. This is exactly the property ENS normalisation does not have,
25
+ * which is why `EnsResolve` is not in this package: there, a fold that is
26
+ * wrong by one codepoint resolves a name to the wrong address and sends funds
27
+ * there, so it needs the real UTS-46 table and a real dependency.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+ // ---------------------------------------------------------------------------
31
+ // the skeleton
32
+ // ---------------------------------------------------------------------------
33
+ /**
34
+ * Code points that occupy no width, as explicit ranges.
35
+ *
36
+ * A table rather than a character class on purpose: a regex whose source
37
+ * contains the very characters it matches is unreadable, unreviewable, and one
38
+ * careless paste away from silently losing an entry. Every code point below is
39
+ * written as a number and named.
40
+ *
41
+ * A symbol padded with these is a different string from the one a human reads,
42
+ * which is the entire trick: "USD" + U+200B + "C" displays as USDC in every
43
+ * wallet and matches nothing a caller typed.
44
+ */
45
+ const INVISIBLE_RANGES = Object.freeze([
46
+ [0x00ad, 0x00ad], // soft hyphen
47
+ [0x034f, 0x034f], // combining grapheme joiner
48
+ [0x061c, 0x061c], // arabic letter mark
49
+ [0x115f, 0x1160], // hangul choseong and jungseong fillers
50
+ [0x17b4, 0x17b5], // khmer inherent vowels, which render as nothing
51
+ [0x180b, 0x180e], // mongolian variation selectors and vowel separator
52
+ [0x200b, 0x200f], // zero-width space and joiners, LTR and RTL marks
53
+ [0x202a, 0x202e], // bidi embeddings and overrides - the Trojan Source shape
54
+ [0x2060, 0x2064], // word joiner and the invisible operators
55
+ [0x2066, 0x206f], // bidi isolates and deprecated format characters
56
+ [0x3164, 0x3164], // hangul filler
57
+ [0xfe00, 0xfe0f], // variation selectors
58
+ [0xfeff, 0xfeff], // byte order mark, also known as zero-width no-break space
59
+ [0xffa0, 0xffa0], // halfwidth hangul filler
60
+ [0xe0000, 0xe007f], // tag characters
61
+ ]);
62
+ function isInvisible(codePoint) {
63
+ return INVISIBLE_RANGES.some(([from, to]) => codePoint >= from && codePoint <= to);
64
+ }
65
+ /** The text with every zero-width and formatting character removed. */
66
+ export function stripInvisible(text) {
67
+ let out = "";
68
+ for (const ch of text)
69
+ if (!isInvisible(ch.codePointAt(0)))
70
+ out += ch;
71
+ return out;
72
+ }
73
+ /** Which invisible code points are present, in order, so a report can name them. */
74
+ export function invisibleCodePoints(text) {
75
+ const out = [];
76
+ for (const ch of text) {
77
+ const point = ch.codePointAt(0);
78
+ if (isInvisible(point))
79
+ out.push(point);
80
+ }
81
+ return out;
82
+ }
83
+ /** Combining marks, which NFKD leaves behind after decomposing an accent. */
84
+ const COMBINING = /\p{M}/gu;
85
+ /**
86
+ * The homoglyph table, lowercase, applied after case folding. Small on
87
+ * purpose — see the note at the top of this file about which direction a
88
+ * wrong fold can move the answer.
89
+ */
90
+ const CONFUSABLE = Object.freeze({
91
+ // Cyrillic
92
+ а: "a",
93
+ б: "b",
94
+ в: "b",
95
+ е: "e",
96
+ ё: "e",
97
+ ѕ: "s",
98
+ і: "i",
99
+ ј: "j",
100
+ к: "k",
101
+ м: "m",
102
+ н: "h",
103
+ о: "o",
104
+ р: "p",
105
+ с: "c",
106
+ т: "t",
107
+ у: "y",
108
+ х: "x",
109
+ ԁ: "d",
110
+ ԛ: "q",
111
+ ԝ: "w",
112
+ // Greek
113
+ α: "a",
114
+ β: "b",
115
+ ε: "e",
116
+ ζ: "z",
117
+ η: "n",
118
+ ι: "i",
119
+ κ: "k",
120
+ μ: "u",
121
+ ν: "v",
122
+ ο: "o",
123
+ ρ: "p",
124
+ σ: "o",
125
+ τ: "t",
126
+ υ: "u",
127
+ χ: "x",
128
+ ϲ: "c",
129
+ // Latin letters NFKD does not decompose
130
+ ł: "l",
131
+ ø: "o",
132
+ đ: "d",
133
+ ħ: "h",
134
+ ı: "i",
135
+ ȷ: "j",
136
+ ſ: "s",
137
+ });
138
+ /** Digit shapes that read as letters in an all-caps ticker. */
139
+ const DIGIT_SHAPE = Object.freeze({
140
+ "0": "o",
141
+ "1": "l",
142
+ "|": "l",
143
+ "!": "l",
144
+ "5": "s",
145
+ });
146
+ /**
147
+ * Fold a symbol or a name towards a comparison key.
148
+ *
149
+ * Only ever used to ADD candidates. Never used to decide which address a
150
+ * query resolves to.
151
+ */
152
+ export function confusableSkeleton(text) {
153
+ const stripped = stripInvisible(text).normalize("NFKD").replace(COMBINING, "");
154
+ let out = "";
155
+ for (const ch of stripped.toLowerCase()) {
156
+ out += CONFUSABLE[ch] ?? DIGIT_SHAPE[ch] ?? ch;
157
+ }
158
+ return out.trim();
159
+ }
160
+ const ASCII_PRINTABLE = /^[\x20-\x7E]*$/;
161
+ /**
162
+ * Everything worth saying about a symbol or a name before a human reads it.
163
+ *
164
+ * These are reported, not acted on. A legitimate token can have a non-ASCII
165
+ * name; none of them need a zero-width space in the ticker.
166
+ */
167
+ export function textConcerns(text) {
168
+ const out = [];
169
+ const invisible = invisibleCodePoints(text);
170
+ if (invisible.length > 0) {
171
+ const points = [...new Set(invisible)]
172
+ .map((point) => `U+${point.toString(16).toUpperCase().padStart(4, "0")}`)
173
+ .join(", ");
174
+ out.push({
175
+ code: "invisible-characters",
176
+ detail: `contains ${invisible.length} zero-width or formatting character(s): ${points}`,
177
+ });
178
+ }
179
+ if (!ASCII_PRINTABLE.test(stripInvisible(text))) {
180
+ out.push({ code: "non-ascii", detail: "contains characters outside printable ASCII" });
181
+ const skeleton = confusableSkeleton(text);
182
+ if (skeleton !== text.toLowerCase().trim()) {
183
+ out.push({
184
+ code: "confusable",
185
+ detail: `folds to "${skeleton}", so it displays like a different ticker than it is`,
186
+ });
187
+ }
188
+ }
189
+ if (/^\s|\s$|[\r\n\t]/.test(text)) {
190
+ out.push({ code: "whitespace", detail: "has leading, trailing or embedded whitespace" });
191
+ }
192
+ if (text.length > 64) {
193
+ out.push({ code: "over-long", detail: `${text.length} characters is not a symbol or a name` });
194
+ }
195
+ return out;
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // matching
199
+ // ---------------------------------------------------------------------------
200
+ export const ADDRESS_SHAPE = /^0x[0-9a-fA-F]{40}$/;
201
+ /**
202
+ * A stable digest of the lists a resolution was made against.
203
+ *
204
+ * Reproducibility evidence, not a security control: the same query against
205
+ * the same fingerprint gives the same candidates, and a fingerprint that
206
+ * moved explains why yesterday's answer differs from today's. Only the fields
207
+ * that affect the outcome go in, sorted, so a list that reordered its tokens
208
+ * or gained a logo URL still fingerprints the same.
209
+ */
210
+ export function fingerprintLists(lists, chainId) {
211
+ const rows = [];
212
+ for (const list of lists) {
213
+ for (const token of list.tokens) {
214
+ if (token.chainId !== chainId)
215
+ continue;
216
+ rows.push(`${list.id}\u0000${token.address.toLowerCase()}\u0000${token.symbol}\u0000${token.name}\u0000${token.decimals}`);
217
+ }
218
+ }
219
+ rows.sort();
220
+ return `sha256:${createHash("sha256").update(rows.join("\n")).digest("hex")}`;
221
+ }
222
+ function claimOf(list, token) {
223
+ return {
224
+ listId: list.id,
225
+ symbol: token.symbol,
226
+ name: token.name,
227
+ decimals: token.decimals,
228
+ tags: token.tags ?? [],
229
+ };
230
+ }
231
+ /**
232
+ * Every address on the given lists that the query could mean, for one chain.
233
+ *
234
+ * Matching is by exact symbol, exact name, and by confusable skeleton of
235
+ * both. The four are reported separately in `matchedBy` so the caller can see
236
+ * WHY an address is in the set — a candidate that only matched by skeleton is
237
+ * the impostor the literal match would have missed.
238
+ */
239
+ export function findCandidates(lists, chainId, query) {
240
+ const wanted = query.trim();
241
+ const byAddress = ADDRESS_SHAPE.test(wanted) ? wanted.toLowerCase() : null;
242
+ const folded = wanted.toLowerCase();
243
+ const skeleton = confusableSkeleton(wanted);
244
+ const found = new Map();
245
+ const note = (address, claim, kind) => {
246
+ const key = address.toLowerCase();
247
+ const entry = found.get(key) ?? { claims: [], matchedBy: new Set() };
248
+ if (!entry.claims.some((c) => c.listId === claim.listId))
249
+ entry.claims.push(claim);
250
+ entry.matchedBy.add(kind);
251
+ found.set(key, entry);
252
+ };
253
+ for (const list of lists) {
254
+ for (const token of list.tokens) {
255
+ if (token.chainId !== chainId)
256
+ continue;
257
+ const claim = claimOf(list, token);
258
+ if (byAddress !== null) {
259
+ if (token.address.toLowerCase() === byAddress)
260
+ note(token.address, claim, "address");
261
+ continue;
262
+ }
263
+ const symbol = token.symbol ?? "";
264
+ const name = token.name ?? "";
265
+ if (symbol.toLowerCase() === folded)
266
+ note(token.address, claim, "symbol");
267
+ else if (confusableSkeleton(symbol) === skeleton)
268
+ note(token.address, claim, "symbol-confusable");
269
+ if (name.toLowerCase() === folded)
270
+ note(token.address, claim, "name");
271
+ else if (confusableSkeleton(name) === skeleton)
272
+ note(token.address, claim, "name-confusable");
273
+ }
274
+ }
275
+ return [...found.entries()]
276
+ .map(([address, entry]) => ({
277
+ address,
278
+ claims: entry.claims,
279
+ matchedBy: [...entry.matchedBy].sort(),
280
+ }))
281
+ .sort((a, b) => (a.address < b.address ? -1 : a.address > b.address ? 1 : 0));
282
+ }
283
+ /** The distinct decimals the lists claim for one address, in order found. */
284
+ export function claimedDecimals(candidate) {
285
+ return [...new Set(candidate.claims.map((c) => c.decimals))];
286
+ }
287
+ /** The distinct symbols the lists claim for one address. */
288
+ export function claimedSymbols(candidate) {
289
+ return [...new Set(candidate.claims.map((c) => c.symbol))];
290
+ }
@@ -0,0 +1,26 @@
1
+ /** What `AddressCheck` says about one address. */
2
+ export type AddressVerdict = {
3
+ readonly valid: boolean;
4
+ /** The EIP-55 mixed-case form; "" when the input is not a well-formed address. */
5
+ readonly checksummed: string;
6
+ /** The comparison key. Addresses are compared lowercased, never checksummed. */
7
+ readonly lowercase: string;
8
+ /** True when the input carried a checksum that was verified, not merely a shape. */
9
+ readonly hadChecksum: boolean;
10
+ readonly isZero: boolean;
11
+ readonly reason: string;
12
+ };
13
+ export declare function checkAddress(raw: string): Promise<AddressVerdict>;
14
+ /**
15
+ * Render base units as an exact decimal string.
16
+ *
17
+ * `decimals` is the token's own answer, never a default. There is no path
18
+ * through this file that supplies 18 for a token that did not say 18.
19
+ */
20
+ export declare function formatUnits(baseUnits: bigint, decimals: number): Promise<string>;
21
+ /**
22
+ * The widest decimals `TokenUnits` will convert. A token reporting more than
23
+ * this is reported with its raw balance and no decimal form: uint8 decimals
24
+ * goes to 255, and nothing legitimate is out there at 200.
25
+ */
26
+ export declare const MAX_FORMATTABLE_DECIMALS = 77;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The bridge to `@crewhaus/tool-onchain`, which owns the arithmetic.
3
+ *
4
+ * EIP-55 checksumming and decimal↔base-unit conversion are NOT re-implemented
5
+ * here. Both are short enough to look trivial and both have a failure mode
6
+ * that is silent: a checksum derived from a keccak that is really SHA3-256
7
+ * validates nothing, and a `formatUnits` that rounds loses the low digits of
8
+ * a balance without saying so. The repository already has one of each,
9
+ * checked against the EIP's own vectors, so this package calls those rather
10
+ * than becoming the second copy that disagrees.
11
+ *
12
+ * They arrive as tools rather than as functions because `tool-onchain`
13
+ * publishes only its tool surface. That makes these wrappers `async` for work
14
+ * that is pure — a real cost, paid a handful of times per call, and cheaper
15
+ * than a second EIP-55.
16
+ */
17
+ import { addressCheck, tokenUnits } from "@crewhaus/tool-onchain";
18
+ /**
19
+ * `ToolExecuteResult` is a union of a string and a structured content block.
20
+ * Both of these tools return the string arm; anything else means the tool
21
+ * changed shape underneath us, and saying so beats a cast that decodes
22
+ * garbage.
23
+ */
24
+ async function textResult(result, tool) {
25
+ const value = await result;
26
+ if (typeof value !== "string")
27
+ throw new Error(`${tool} did not return a string result`);
28
+ return value;
29
+ }
30
+ export async function checkAddress(raw) {
31
+ return JSON.parse(await textResult(addressCheck.execute({ address: raw }), "AddressCheck"));
32
+ }
33
+ /**
34
+ * Render base units as an exact decimal string.
35
+ *
36
+ * `decimals` is the token's own answer, never a default. There is no path
37
+ * through this file that supplies 18 for a token that did not say 18.
38
+ */
39
+ export async function formatUnits(baseUnits, decimals) {
40
+ const out = JSON.parse(await textResult(tokenUnits.execute({ amount: baseUnits.toString(), decimals, toDecimal: true }), "TokenUnits"));
41
+ return out.decimal;
42
+ }
43
+ /**
44
+ * The widest decimals `TokenUnits` will convert. A token reporting more than
45
+ * this is reported with its raw balance and no decimal form: uint8 decimals
46
+ * goes to 255, and nothing legitimate is out there at 200.
47
+ */
48
+ export const MAX_FORMATTABLE_DECIMALS = 77;