@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/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
|
+
};
|
package/dist/fixtures.js
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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>;
|