@crewhaus/tool-chainread 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 +396 -0
- package/dist/fixtures.d.ts +123 -0
- package/dist/fixtures.js +309 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +1302 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +192 -0
- package/dist/lib/clock.d.ts +34 -0
- package/dist/lib/clock.js +55 -0
- package/dist/lib/endpoint.d.ts +91 -0
- package/dist/lib/endpoint.js +320 -0
- package/dist/lib/history.d.ts +196 -0
- package/dist/lib/history.js +663 -0
- package/dist/lib/logs.d.ts +79 -0
- package/dist/lib/logs.js +284 -0
- package/dist/lib/quantity.d.ts +69 -0
- package/dist/lib/quantity.js +120 -0
- package/dist/lib/rpc.d.ts +63 -0
- package/dist/lib/rpc.js +330 -0
- package/dist/lib/transfers.d.ts +134 -0
- package/dist/lib/transfers.js +231 -0
- package/package.json +48 -0
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/** A deterministic 32-byte hash from a label, so fixtures are readable in a diff. */
|
|
2
|
+
export function hash32(label) {
|
|
3
|
+
const hex = [...label].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
|
|
4
|
+
return `0x${(hex + "0".repeat(64)).slice(0, 64)}`;
|
|
5
|
+
}
|
|
6
|
+
/** A deterministic 20-byte address from a label. */
|
|
7
|
+
export function address20(label) {
|
|
8
|
+
const hex = [...label].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
|
|
9
|
+
return `0x${(hex + "0".repeat(40)).slice(0, 40)}`;
|
|
10
|
+
}
|
|
11
|
+
const hex = (value) => `0x${value.toString(16)}`;
|
|
12
|
+
export const TOKEN = address20("token");
|
|
13
|
+
export const ALICE = address20("alice");
|
|
14
|
+
export const BOB = address20("bob");
|
|
15
|
+
export const ROUTER = address20("router");
|
|
16
|
+
export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
17
|
+
export const APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925";
|
|
18
|
+
export function topicFor(address) {
|
|
19
|
+
return `0x${"0".repeat(24)}${address.slice(2)}`;
|
|
20
|
+
}
|
|
21
|
+
export function word(value) {
|
|
22
|
+
return value.toString(16).padStart(64, "0");
|
|
23
|
+
}
|
|
24
|
+
export function makeChain(options = {}) {
|
|
25
|
+
const count = options.blocks ?? 100;
|
|
26
|
+
const start = options.startTimestamp ?? 1700000000n;
|
|
27
|
+
const step = options.blockTime ?? 12n;
|
|
28
|
+
const blocks = [];
|
|
29
|
+
for (let i = 0; i < count; i++) {
|
|
30
|
+
const number = BigInt(i);
|
|
31
|
+
blocks.push({
|
|
32
|
+
number,
|
|
33
|
+
timestamp: start + number * step,
|
|
34
|
+
hash: hash32(`block-${i}`),
|
|
35
|
+
baseFeePerGas: options.baseFee === undefined ? 1000000000n : options.baseFee,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
chainId: options.chainId ?? 8453n,
|
|
40
|
+
blocks,
|
|
41
|
+
logs: [],
|
|
42
|
+
txs: new Map(),
|
|
43
|
+
receipts: new Map(),
|
|
44
|
+
nonces: new Map(),
|
|
45
|
+
head: BigInt(count - 1),
|
|
46
|
+
safe: BigInt(Math.max(0, count - 32)),
|
|
47
|
+
finalized: BigInt(Math.max(0, count - 64)),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Add `count` ERC-20 Transfer logs to one block, from ALICE to BOB. */
|
|
51
|
+
export function addTransferLogs(chain, blockNumber, count, token = TOKEN) {
|
|
52
|
+
const existing = chain.logs.filter((l) => l.blockNumber === blockNumber).length;
|
|
53
|
+
for (let i = 0; i < count; i++) {
|
|
54
|
+
chain.logs.push({
|
|
55
|
+
blockNumber,
|
|
56
|
+
logIndex: existing + i,
|
|
57
|
+
address: token,
|
|
58
|
+
topics: [TRANSFER_TOPIC, topicFor(ALICE), topicFor(BOB)],
|
|
59
|
+
data: `0x${word(BigInt(1_000 + i))}`,
|
|
60
|
+
transactionHash: hash32(`tx-${blockNumber}-${i}`),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function blockOf(chain, number) {
|
|
65
|
+
return chain.blocks.find((b) => b.number === number);
|
|
66
|
+
}
|
|
67
|
+
function renderBlock(chain, block, hydrate) {
|
|
68
|
+
const txs = [...chain.txs.values()].filter((t) => t.blockNumber === block.number);
|
|
69
|
+
return {
|
|
70
|
+
number: hex(block.number),
|
|
71
|
+
hash: block.hash,
|
|
72
|
+
parentHash: block.number === 0n ? hash32("genesis-parent") : hash32(`block-${block.number - 1n}`),
|
|
73
|
+
timestamp: hex(block.timestamp),
|
|
74
|
+
gasLimit: hex(30000000n),
|
|
75
|
+
gasUsed: hex(15000000n),
|
|
76
|
+
...(block.baseFeePerGas === null ? {} : { baseFeePerGas: hex(block.baseFeePerGas) }),
|
|
77
|
+
miner: address20("miner"),
|
|
78
|
+
transactions: hydrate ? txs.map((t) => renderTx(t)) : txs.map((t) => t.hash),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function renderTx(tx) {
|
|
82
|
+
return {
|
|
83
|
+
hash: tx.hash,
|
|
84
|
+
from: tx.from,
|
|
85
|
+
to: tx.to,
|
|
86
|
+
value: hex(tx.value),
|
|
87
|
+
nonce: hex(tx.nonce),
|
|
88
|
+
input: tx.input,
|
|
89
|
+
gas: hex(210000n),
|
|
90
|
+
gasPrice: hex(1500000000n),
|
|
91
|
+
maxFeePerGas: hex(2000000000n),
|
|
92
|
+
maxPriorityFeePerGas: hex(100000000n),
|
|
93
|
+
type: "0x2",
|
|
94
|
+
blockNumber: tx.blockNumber === null ? null : hex(tx.blockNumber),
|
|
95
|
+
blockHash: tx.blockNumber === null ? null : hash32(`block-${tx.blockNumber}`),
|
|
96
|
+
transactionIndex: tx.blockNumber === null ? null : "0x0",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function renderLog(log) {
|
|
100
|
+
return {
|
|
101
|
+
address: log.address,
|
|
102
|
+
topics: log.topics,
|
|
103
|
+
data: log.data,
|
|
104
|
+
blockNumber: hex(log.blockNumber),
|
|
105
|
+
blockHash: hash32(`block-${log.blockNumber}`),
|
|
106
|
+
transactionHash: log.transactionHash,
|
|
107
|
+
transactionIndex: "0x0",
|
|
108
|
+
logIndex: hex(BigInt(log.logIndex)),
|
|
109
|
+
removed: false,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
function renderReceipt(receipt) {
|
|
113
|
+
return {
|
|
114
|
+
transactionHash: receipt.hash,
|
|
115
|
+
blockNumber: hex(receipt.blockNumber),
|
|
116
|
+
blockHash: hash32(`block-${receipt.blockNumber}`),
|
|
117
|
+
transactionIndex: hex(BigInt(receipt.transactionIndex ?? 0)),
|
|
118
|
+
gasUsed: hex(receipt.gasUsed),
|
|
119
|
+
...(receipt.status === null ? {} : { status: receipt.status }),
|
|
120
|
+
...(receipt.root === undefined ? {} : { root: receipt.root }),
|
|
121
|
+
...(receipt.effectiveGasPrice === null
|
|
122
|
+
? {}
|
|
123
|
+
: { effectiveGasPrice: hex(receipt.effectiveGasPrice) }),
|
|
124
|
+
...(receipt.l1Fee === undefined ? {} : { l1Fee: hex(receipt.l1Fee) }),
|
|
125
|
+
...(receipt.gasUsedForL1 === undefined ? {} : { gasUsedForL1: hex(receipt.gasUsedForL1) }),
|
|
126
|
+
contractAddress: receipt.contractAddress ?? null,
|
|
127
|
+
logs: receipt.logs.map(renderLog),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/** Build an endpoint that serves `chain`, misbehaving in whatever way `options` says. */
|
|
131
|
+
export function rpcStub(chain, options = {}) {
|
|
132
|
+
const requests = [];
|
|
133
|
+
const stub = {
|
|
134
|
+
requests,
|
|
135
|
+
count: (method) => requests.filter((r) => r.method === method).length,
|
|
136
|
+
fetch: async (req) => {
|
|
137
|
+
if (options.unreachable === true)
|
|
138
|
+
throw new TypeError("connect ECONNREFUSED");
|
|
139
|
+
const body = (await req.json());
|
|
140
|
+
const params = body.params ?? [];
|
|
141
|
+
requests.push({ method: body.method, params });
|
|
142
|
+
if (options.httpStatus !== undefined && options.httpStatus !== 200) {
|
|
143
|
+
return new Response(`{"error":"rate limited"}`, { status: options.httpStatus });
|
|
144
|
+
}
|
|
145
|
+
const answer = dispatch(chain, options, body.method, params);
|
|
146
|
+
const envelope = "error" in answer
|
|
147
|
+
? { jsonrpc: "2.0", id: body.id, error: answer.error }
|
|
148
|
+
: { jsonrpc: "2.0", id: body.id, result: answer.result };
|
|
149
|
+
return new Response(JSON.stringify(envelope), {
|
|
150
|
+
status: 200,
|
|
151
|
+
headers: { "content-type": "application/json" },
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
return stub;
|
|
156
|
+
}
|
|
157
|
+
function dispatch(chain, options, method, params) {
|
|
158
|
+
switch (method) {
|
|
159
|
+
case "eth_chainId":
|
|
160
|
+
return { result: hex(chain.chainId) };
|
|
161
|
+
case "net_version":
|
|
162
|
+
return { result: chain.chainId.toString() };
|
|
163
|
+
case "eth_blockNumber":
|
|
164
|
+
return { result: hex(chain.head) };
|
|
165
|
+
case "eth_getBlockByNumber": {
|
|
166
|
+
const tag = params[0];
|
|
167
|
+
const hydrate = params[1] === true;
|
|
168
|
+
const number = resolveTag(chain, options, tag);
|
|
169
|
+
if (number === null)
|
|
170
|
+
return { result: null };
|
|
171
|
+
const block = blockOf(chain, number);
|
|
172
|
+
if (block === undefined)
|
|
173
|
+
return { result: null };
|
|
174
|
+
const rendered = renderBlock(chain, block, hydrate);
|
|
175
|
+
// The pending block is the head's contents with no identity yet: a node
|
|
176
|
+
// answers it with number and hash both null.
|
|
177
|
+
if (tag === "pending")
|
|
178
|
+
return { result: { ...rendered, number: null, hash: null } };
|
|
179
|
+
return { result: rendered };
|
|
180
|
+
}
|
|
181
|
+
case "eth_getBlockByHash": {
|
|
182
|
+
const wanted = params[0].toLowerCase();
|
|
183
|
+
const block = chain.blocks.find((b) => b.hash === wanted);
|
|
184
|
+
return { result: block === undefined ? null : renderBlock(chain, block, params[1] === true) };
|
|
185
|
+
}
|
|
186
|
+
case "eth_getTransactionByHash": {
|
|
187
|
+
const tx = chain.txs.get(params[0].toLowerCase());
|
|
188
|
+
return { result: tx === undefined ? null : renderTx(tx) };
|
|
189
|
+
}
|
|
190
|
+
case "eth_getTransactionReceipt": {
|
|
191
|
+
const receipt = chain.receipts.get(params[0].toLowerCase());
|
|
192
|
+
return { result: receipt === undefined ? null : renderReceipt(receipt) };
|
|
193
|
+
}
|
|
194
|
+
case "eth_getTransactionCount": {
|
|
195
|
+
const who = params[0].toLowerCase();
|
|
196
|
+
const account = chain.nonces.get(who) ?? { latest: 0n, pending: 0n };
|
|
197
|
+
const tag = params[1];
|
|
198
|
+
if (typeof tag === "string" && tag.startsWith("0x")) {
|
|
199
|
+
// A HISTORICAL nonce, which is account state and not a counter the node
|
|
200
|
+
// keeps lying around: a pruned endpoint refuses it exactly as it
|
|
201
|
+
// refuses a historical balance, and a sync that proves its block
|
|
202
|
+
// coverage against this has to cope with both answers.
|
|
203
|
+
const at = BigInt(tag);
|
|
204
|
+
if (options.archive === "no" && chain.head - at > 128n) {
|
|
205
|
+
return { error: { code: -32000, message: "missing trie node 0xabc (path )" } };
|
|
206
|
+
}
|
|
207
|
+
const mined = [...chain.txs.values()].filter((t) => t.blockNumber !== null && t.from.toLowerCase() === who);
|
|
208
|
+
// Derived from the chain's own transactions so the two answers cannot
|
|
209
|
+
// disagree by accident: a test that wants them to disagree says so.
|
|
210
|
+
const base = chain.nonces.has(who) ? account.latest : BigInt(mined.length);
|
|
211
|
+
const after = mined.filter((t) => t.blockNumber > at).length;
|
|
212
|
+
const value = base - BigInt(after);
|
|
213
|
+
return { result: hex(value < 0n ? 0n : value) };
|
|
214
|
+
}
|
|
215
|
+
const wantsPending = tag === "pending";
|
|
216
|
+
// An endpoint with no mempool view answers both tags with the mined
|
|
217
|
+
// count, which is the case that makes a naive "pending === latest means
|
|
218
|
+
// clear" read wrong.
|
|
219
|
+
if (wantsPending && options.mempoolAware !== false)
|
|
220
|
+
return { result: hex(account.pending) };
|
|
221
|
+
return { result: hex(account.latest) };
|
|
222
|
+
}
|
|
223
|
+
case "eth_getBalance": {
|
|
224
|
+
const at = params[1];
|
|
225
|
+
const number = at.startsWith("0x") && at.length < 20 ? BigInt(at) : chain.head;
|
|
226
|
+
if (options.archive === "no" && chain.head - number > 128n) {
|
|
227
|
+
return { error: { code: -32000, message: "missing trie node 0xabc (path )" } };
|
|
228
|
+
}
|
|
229
|
+
if (options.archive === "error" && chain.head - number > 128n) {
|
|
230
|
+
return { error: { code: -32601, message: "the method eth_getBalance does not exist" } };
|
|
231
|
+
}
|
|
232
|
+
return { result: hex(1234000000000000000n) };
|
|
233
|
+
}
|
|
234
|
+
case "eth_getLogs":
|
|
235
|
+
return getLogs(chain, options, params[0]);
|
|
236
|
+
default:
|
|
237
|
+
return { error: { code: -32601, message: `the method ${method} does not exist` } };
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function resolveTag(chain, options, tag) {
|
|
241
|
+
if (tag === "latest")
|
|
242
|
+
return chain.head;
|
|
243
|
+
if (tag === "earliest")
|
|
244
|
+
return 0n;
|
|
245
|
+
if (tag === "pending")
|
|
246
|
+
return chain.head;
|
|
247
|
+
if (tag === "safe" || tag === "finalized") {
|
|
248
|
+
if (options.noFinalityTags === true)
|
|
249
|
+
return null;
|
|
250
|
+
return tag === "safe" ? chain.safe : chain.finalized;
|
|
251
|
+
}
|
|
252
|
+
return BigInt(tag);
|
|
253
|
+
}
|
|
254
|
+
function getLogs(chain, options, filter) {
|
|
255
|
+
const from = BigInt(filter["fromBlock"]);
|
|
256
|
+
const to = BigInt(filter["toBlock"]);
|
|
257
|
+
const span = to - from + 1n;
|
|
258
|
+
if (options.maxRangeBlocks !== undefined && span > options.maxRangeBlocks) {
|
|
259
|
+
// Alchemy's shape: it names a range that would have worked, in hex.
|
|
260
|
+
const suggestedTo = from + options.maxRangeBlocks - 1n;
|
|
261
|
+
return {
|
|
262
|
+
error: {
|
|
263
|
+
code: -32600,
|
|
264
|
+
message: `Log response size exceeded. You can make eth_getLogs requests with up to a ${options.maxRangeBlocks} block range; based on your parameters this block range should work: [${hex(from)}, ${hex(suggestedTo)}]`,
|
|
265
|
+
},
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
const addressFilter = filter["address"];
|
|
269
|
+
const addresses = addressFilter === undefined
|
|
270
|
+
? null
|
|
271
|
+
: new Set((Array.isArray(addressFilter) ? addressFilter : [addressFilter]).map((a) => String(a).toLowerCase()));
|
|
272
|
+
const topics = filter["topics"];
|
|
273
|
+
const matched = chain.logs
|
|
274
|
+
.filter((log) => log.blockNumber >= from && log.blockNumber <= to)
|
|
275
|
+
.filter((log) => addresses === null || addresses.has(log.address.toLowerCase()))
|
|
276
|
+
.filter((log) => topicsMatch(log, topics))
|
|
277
|
+
.sort((a, b) => a.blockNumber === b.blockNumber
|
|
278
|
+
? a.logIndex - b.logIndex
|
|
279
|
+
: a.blockNumber < b.blockNumber
|
|
280
|
+
? -1
|
|
281
|
+
: 1);
|
|
282
|
+
if (options.maxResults !== undefined && matched.length > options.maxResults) {
|
|
283
|
+
return {
|
|
284
|
+
error: {
|
|
285
|
+
code: -32005,
|
|
286
|
+
message: `query returned more than ${options.maxResults} results`,
|
|
287
|
+
},
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (options.silentLogCap !== undefined && matched.length > options.silentLogCap) {
|
|
291
|
+
// The dangerous one: a 200, a well-formed array, and nothing saying it is
|
|
292
|
+
// short. This is what the scanner has to catch without being told.
|
|
293
|
+
return { result: matched.slice(0, options.silentLogCap).map(renderLog) };
|
|
294
|
+
}
|
|
295
|
+
return { result: matched.map(renderLog) };
|
|
296
|
+
}
|
|
297
|
+
function topicsMatch(log, topics) {
|
|
298
|
+
if (topics === undefined)
|
|
299
|
+
return true;
|
|
300
|
+
return topics.every((position, index) => {
|
|
301
|
+
if (position === null)
|
|
302
|
+
return true;
|
|
303
|
+
const actual = log.topics[index];
|
|
304
|
+
if (actual === undefined)
|
|
305
|
+
return false;
|
|
306
|
+
const wanted = Array.isArray(position) ? position : [position];
|
|
307
|
+
return wanted.some((t) => String(t).toLowerCase() === actual.toLowerCase());
|
|
308
|
+
});
|
|
309
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
2
|
+
import { type BlockSelector } from "./lib/blocks";
|
|
3
|
+
export { _setFetch, type RpcFetch, type RpcOutcome, openRpc } from "./lib/rpc";
|
|
4
|
+
export { _setClock, type Clock, virtualClock } from "./lib/clock";
|
|
5
|
+
export { _setDnsLookup, type DnsLookupFn, type RpcEndpointPolicy, RpcEndpointError, getRpcEndpointPolicy, setRpcEndpointPolicy, } from "./lib/endpoint";
|
|
6
|
+
export { ChainReadError } from "./lib/quantity";
|
|
7
|
+
export { parseSuggestedSpan, shouldNarrow, type RawLog } from "./lib/logs";
|
|
8
|
+
export { type AssetUnits, type HistoryReport, type ParkedRow, type RowDetail, type SentProof, collectHistory, toMinorUnits, } from "./lib/history";
|
|
9
|
+
export { TRANSFER_TOPIC, APPROVAL_TOPIC, APPROVAL_FOR_ALL_TOPIC, TRANSFER_SINGLE_TOPIC, TOPIC_SIGNATURES, addressToTopic, decodeApproval, decodeTransfer, feeBreakdown, topicToAddress, } from "./lib/transfers";
|
|
10
|
+
/**
|
|
11
|
+
* Work out what a caller meant by a block.
|
|
12
|
+
*
|
|
13
|
+
* A tag, a 32-byte hash and a hex number all arrive as strings, and the hash is
|
|
14
|
+
* distinguished by LENGTH — 66 characters including the prefix. Guessing wrong
|
|
15
|
+
* sends `eth_getBlockByNumber` a hash, which most nodes answer with `null`:
|
|
16
|
+
* "no such block" for a block that exists.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseBlockInput(value: string | number, what: string): BlockSelector;
|
|
19
|
+
export declare const evmGetBlock: RegisteredTool;
|
|
20
|
+
export declare const evmBlockAtTimestamp: RegisteredTool;
|
|
21
|
+
/**
|
|
22
|
+
* Seconds, or an ISO instant, but never milliseconds.
|
|
23
|
+
*
|
|
24
|
+
* `Date.now()` pasted into a tool that wants seconds is a timestamp in the year
|
|
25
|
+
* 55000, which every search resolves to "at or past the head" and answers with
|
|
26
|
+
* the head block — a wrong answer that looks completely ordinary. So the
|
|
27
|
+
* thousand-fold mistake is refused by name.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseTimestamp(value: string | number): bigint;
|
|
30
|
+
export declare const evmRpcHealth: RegisteredTool;
|
|
31
|
+
export declare const evmNonceStatus: RegisteredTool;
|
|
32
|
+
export declare const evmWaitForReceipt: RegisteredTool;
|
|
33
|
+
export declare const evmTransactionSummary: RegisteredTool;
|
|
34
|
+
export declare const evmEventScan: RegisteredTool;
|
|
35
|
+
export declare const onchainTransactionsSync: RegisteredTool;
|
|
36
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
37
|
+
export declare const CHAINREAD_TOOLS: ReadonlyArray<RegisteredTool>;
|