@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 +172 -0
- package/dist/fixtures.d.ts +117 -0
- package/dist/fixtures.js +222 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1045 -0
- package/dist/lib/chain.d.ts +127 -0
- package/dist/lib/chain.js +202 -0
- package/dist/lib/erc.d.ts +122 -0
- package/dist/lib/erc.js +238 -0
- package/dist/lib/lists.d.ts +78 -0
- package/dist/lib/lists.js +290 -0
- package/dist/lib/onchain.d.ts +26 -0
- package/dist/lib/onchain.js +48 -0
- package/dist/lib/uri.d.ts +66 -0
- package/dist/lib/uri.js +203 -0
- package/package.json +48 -0
package/dist/lib/erc.js
ADDED
|
@@ -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;
|