@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.
@@ -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
+ }
@@ -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>;