@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type RpcClient, type RpcOutcome } from "./rpc";
|
|
2
|
+
export type RawLog = {
|
|
3
|
+
readonly address: string;
|
|
4
|
+
readonly topics: ReadonlyArray<string>;
|
|
5
|
+
readonly data: string;
|
|
6
|
+
readonly blockNumber: string;
|
|
7
|
+
readonly blockHash: string;
|
|
8
|
+
readonly transactionHash: string;
|
|
9
|
+
readonly transactionIndex: number | null;
|
|
10
|
+
readonly logIndex: number;
|
|
11
|
+
readonly removed: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type LogFilter = {
|
|
14
|
+
readonly address?: ReadonlyArray<string>;
|
|
15
|
+
readonly topics?: ReadonlyArray<string | ReadonlyArray<string> | null>;
|
|
16
|
+
};
|
|
17
|
+
export type ScanOptions = {
|
|
18
|
+
readonly from: bigint;
|
|
19
|
+
readonly to: bigint;
|
|
20
|
+
/** Blocks per request to start with. Halved on a range error, and after a truncation. */
|
|
21
|
+
readonly span: bigint;
|
|
22
|
+
/**
|
|
23
|
+
* The count at or above which a chunk is not believed without checking.
|
|
24
|
+
* Default 1000, which is under every public cap this was written against, so
|
|
25
|
+
* the check fires before the cap does rather than after.
|
|
26
|
+
*/
|
|
27
|
+
readonly suspectAt: number;
|
|
28
|
+
/** Refuse past this many logs rather than returning a slice of them. */
|
|
29
|
+
readonly maxLogs: number;
|
|
30
|
+
/** Refuse past this many requests, so one call cannot become a crawl. */
|
|
31
|
+
readonly maxCalls: number;
|
|
32
|
+
};
|
|
33
|
+
export type ScanReport = {
|
|
34
|
+
readonly logs: ReadonlyArray<RawLog>;
|
|
35
|
+
readonly chunks: number;
|
|
36
|
+
readonly calls: number;
|
|
37
|
+
/** The span the scan ended up using, in blocks. Smaller than `span` means the endpoint pushed back. */
|
|
38
|
+
readonly finalSpan: string;
|
|
39
|
+
/** How many chunks came back truncated with no error saying so. */
|
|
40
|
+
readonly silentTruncations: number;
|
|
41
|
+
/** How many extra requests were spent proving a chunk was complete. */
|
|
42
|
+
readonly verifications: number;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* What a provider's range complaint suggested, when it suggested anything
|
|
46
|
+
* usable.
|
|
47
|
+
*
|
|
48
|
+
* Both quantifiers are bounded, and so is the prefix they run over. Unbounded,
|
|
49
|
+
* `(\d[\d,_]*)\s*(k)?\s*blocks?` backtracks once per character of a digit run
|
|
50
|
+
* for every position that run could start at, so a message that is nothing but
|
|
51
|
+
* digits costs O(n²): 160k digits measured at a minute, and a full-size body
|
|
52
|
+
* is not a slow call but a hang, in code that runs AFTER the request timeout
|
|
53
|
+
* has already been cleared. A block span has at most a handful of digits and a
|
|
54
|
+
* block number at most sixteen hex characters, so nothing real is lost.
|
|
55
|
+
*/
|
|
56
|
+
export declare function parseSuggestedSpan(message: string): bigint | null;
|
|
57
|
+
/**
|
|
58
|
+
* Whether an unsuccessful call is something a smaller range might fix.
|
|
59
|
+
*
|
|
60
|
+
* Deliberately not a match on error text: providers phrase the same cap five
|
|
61
|
+
* ways and invent new phrasings between releases, so the decision is made on
|
|
62
|
+
* the SHAPE of the failure. A JSON-RPC error, an oversized body or a timeout
|
|
63
|
+
* are all things a smaller range plausibly fixes. A 401, a 403 or a 429 are
|
|
64
|
+
* not, and retrying them in halves turns one rejected request into sixteen —
|
|
65
|
+
* a denial-of-service against the endpoint, executed by the tool that was
|
|
66
|
+
* trying to be careful.
|
|
67
|
+
*/
|
|
68
|
+
export declare function shouldNarrow(outcome: Extract<RpcOutcome, {
|
|
69
|
+
ok: false;
|
|
70
|
+
}>): boolean;
|
|
71
|
+
export declare function projectLog(raw: unknown): RawLog;
|
|
72
|
+
/**
|
|
73
|
+
* Scan a fixed block range and return every matching log, or refuse.
|
|
74
|
+
*
|
|
75
|
+
* The range must already be resolved to numbers by the caller. A scan whose
|
|
76
|
+
* upper bound is the string `latest` is a scan whose answer changes while it
|
|
77
|
+
* runs, and "complete" is not a claim anyone can make about that.
|
|
78
|
+
*/
|
|
79
|
+
export declare function scanLogs(client: RpcClient, filter: LogFilter, options: ScanOptions): Promise<ScanReport>;
|
package/dist/lib/logs.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paging `eth_getLogs`, and the part that matters: knowing when the answer is
|
|
3
|
+
* incomplete.
|
|
4
|
+
*
|
|
5
|
+
* Public endpoints cap log queries, and they disagree about how. Some cap the
|
|
6
|
+
* block span and say so in an error. Some cap the result count and say so.
|
|
7
|
+
* Some cap the result count and DO NOT say so — they return two hundred logs
|
|
8
|
+
* for a range that contains three hundred, with a 200 status and a
|
|
9
|
+
* well-formed array, and nothing in the response distinguishes it from a range
|
|
10
|
+
* that really did contain two hundred.
|
|
11
|
+
*
|
|
12
|
+
* That last case is the one worth writing code for. A harness that scans for
|
|
13
|
+
* `Transfer` and finds nothing concludes no transfer happened; a missing log is
|
|
14
|
+
* an event that did not happen, as far as everything downstream is concerned.
|
|
15
|
+
* So the count is never trusted on its own: a chunk whose count is large enough
|
|
16
|
+
* to be at somebody's cap is SPLIT and re-queried, and if the halves together
|
|
17
|
+
* hold more logs than the whole did, the whole was truncated — proof, not a
|
|
18
|
+
* heuristic about round numbers. When the split cannot go further because the
|
|
19
|
+
* chunk is one block, there is nothing left to prove it with, and the scan
|
|
20
|
+
* refuses instead of handing back a set it cannot vouch for.
|
|
21
|
+
*
|
|
22
|
+
* The cheap half is the documented cap: halve the span and retry on any error
|
|
23
|
+
* the range could have caused, and when the endpoint names a workable range in
|
|
24
|
+
* its error text, jump to that instead of halving down to it — clamped, never
|
|
25
|
+
* trusted upward.
|
|
26
|
+
*/
|
|
27
|
+
import { ChainReadError, field, hexToBigint, lowerHex, toHexQuantity } from "./quantity";
|
|
28
|
+
import { failed } from "./rpc";
|
|
29
|
+
/**
|
|
30
|
+
* How much of an error message is read looking for a range hint.
|
|
31
|
+
*
|
|
32
|
+
* Every provider puts the hint in the first sentence, and the rest of the
|
|
33
|
+
* string is written by the endpoint — which, for a tool that dials a URL a
|
|
34
|
+
* model chose, is an untrusted party with a sixteen-megabyte budget.
|
|
35
|
+
*/
|
|
36
|
+
const HINT_WINDOW = 512;
|
|
37
|
+
/**
|
|
38
|
+
* What a provider's range complaint suggested, when it suggested anything
|
|
39
|
+
* usable.
|
|
40
|
+
*
|
|
41
|
+
* Both quantifiers are bounded, and so is the prefix they run over. Unbounded,
|
|
42
|
+
* `(\d[\d,_]*)\s*(k)?\s*blocks?` backtracks once per character of a digit run
|
|
43
|
+
* for every position that run could start at, so a message that is nothing but
|
|
44
|
+
* digits costs O(n²): 160k digits measured at a minute, and a full-size body
|
|
45
|
+
* is not a slow call but a hang, in code that runs AFTER the request timeout
|
|
46
|
+
* has already been cleared. A block span has at most a handful of digits and a
|
|
47
|
+
* block number at most sixteen hex characters, so nothing real is lost.
|
|
48
|
+
*/
|
|
49
|
+
export function parseSuggestedSpan(message) {
|
|
50
|
+
const text = message.length > HINT_WINDOW ? message.slice(0, HINT_WINDOW) : message;
|
|
51
|
+
// Alchemy names the exact range that would have worked: "[0x2932e0, 0x293acf]".
|
|
52
|
+
const explicit = text.match(/\[\s*(0x[0-9a-fA-F]{1,16})\s*,\s*(0x[0-9a-fA-F]{1,16})\s*\]/);
|
|
53
|
+
const lo = explicit?.[1];
|
|
54
|
+
const hi = explicit?.[2];
|
|
55
|
+
if (lo !== undefined && hi !== undefined) {
|
|
56
|
+
const span = BigInt(hi) - BigInt(lo) + 1n;
|
|
57
|
+
if (span > 0n)
|
|
58
|
+
return span;
|
|
59
|
+
}
|
|
60
|
+
// "up to a 10000 block range", "limited to a 2k block range", "max 5,000 blocks".
|
|
61
|
+
const named = text.match(/(\d[\d,_]{0,23})\s*(k)?\s*(?:block|blocks)\b/i);
|
|
62
|
+
const digits = named?.[1];
|
|
63
|
+
if (digits !== undefined) {
|
|
64
|
+
const value = BigInt(digits.replace(/[,_]/g, ""));
|
|
65
|
+
const scaled = named?.[2] === undefined ? value : value * 1000n;
|
|
66
|
+
if (scaled > 0n)
|
|
67
|
+
return scaled;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Whether an unsuccessful call is something a smaller range might fix.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately not a match on error text: providers phrase the same cap five
|
|
75
|
+
* ways and invent new phrasings between releases, so the decision is made on
|
|
76
|
+
* the SHAPE of the failure. A JSON-RPC error, an oversized body or a timeout
|
|
77
|
+
* are all things a smaller range plausibly fixes. A 401, a 403 or a 429 are
|
|
78
|
+
* not, and retrying them in halves turns one rejected request into sixteen —
|
|
79
|
+
* a denial-of-service against the endpoint, executed by the tool that was
|
|
80
|
+
* trying to be careful.
|
|
81
|
+
*/
|
|
82
|
+
export function shouldNarrow(outcome) {
|
|
83
|
+
if (outcome.kind === "rpcError" || outcome.kind === "tooLarge" || outcome.kind === "timeout") {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (outcome.kind !== "status")
|
|
87
|
+
return false;
|
|
88
|
+
const status = outcome.status ?? 0;
|
|
89
|
+
return status !== 401 && status !== 403 && status !== 429;
|
|
90
|
+
}
|
|
91
|
+
export function projectLog(raw) {
|
|
92
|
+
if (typeof raw !== "object" || raw === null) {
|
|
93
|
+
throw new ChainReadError(`a log entry was ${JSON.stringify(raw)}, not an object`);
|
|
94
|
+
}
|
|
95
|
+
const topics = field(raw, "topics");
|
|
96
|
+
return {
|
|
97
|
+
address: lowerHex(field(raw, "address"), "log address"),
|
|
98
|
+
topics: Array.isArray(topics) ? topics.map((t) => lowerHex(t, "log topic")) : [],
|
|
99
|
+
data: typeof field(raw, "data") === "string" ? lowerHex(field(raw, "data"), "log data") : "0x",
|
|
100
|
+
blockNumber: hexToBigint(field(raw, "blockNumber"), "log blockNumber").toString(),
|
|
101
|
+
blockHash: lowerHex(field(raw, "blockHash"), "log blockHash"),
|
|
102
|
+
transactionHash: lowerHex(field(raw, "transactionHash"), "log transactionHash"),
|
|
103
|
+
transactionIndex: field(raw, "transactionIndex") === undefined
|
|
104
|
+
? null
|
|
105
|
+
: Number(hexToBigint(field(raw, "transactionIndex"), "log transactionIndex")),
|
|
106
|
+
logIndex: Number(hexToBigint(field(raw, "logIndex"), "log logIndex")),
|
|
107
|
+
removed: field(raw, "removed") === true,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Scan a fixed block range and return every matching log, or refuse.
|
|
112
|
+
*
|
|
113
|
+
* The range must already be resolved to numbers by the caller. A scan whose
|
|
114
|
+
* upper bound is the string `latest` is a scan whose answer changes while it
|
|
115
|
+
* runs, and "complete" is not a claim anyone can make about that.
|
|
116
|
+
*/
|
|
117
|
+
export async function scanLogs(client, filter, options) {
|
|
118
|
+
if (options.from > options.to) {
|
|
119
|
+
throw new ChainReadError(`the scan range is inverted: fromBlock ${options.from} is after toBlock ${options.to}`);
|
|
120
|
+
}
|
|
121
|
+
const budget = {
|
|
122
|
+
calls: 0,
|
|
123
|
+
chunks: 0,
|
|
124
|
+
verifications: 0,
|
|
125
|
+
silentTruncations: 0,
|
|
126
|
+
span: options.span < 1n ? 1n : options.span,
|
|
127
|
+
blockHashes: new Map(),
|
|
128
|
+
};
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const collected = [];
|
|
131
|
+
let cursor = options.from;
|
|
132
|
+
while (cursor <= options.to) {
|
|
133
|
+
const end = min(cursor + budget.span - 1n, options.to);
|
|
134
|
+
const logs = await fetchRange(client, filter, cursor, end, options, budget);
|
|
135
|
+
for (const log of logs)
|
|
136
|
+
admit(log, seen, collected, options);
|
|
137
|
+
budget.chunks += 1;
|
|
138
|
+
cursor = end + 1n;
|
|
139
|
+
}
|
|
140
|
+
collected.sort(byBlockThenIndex);
|
|
141
|
+
return {
|
|
142
|
+
logs: collected,
|
|
143
|
+
chunks: budget.chunks,
|
|
144
|
+
calls: budget.calls,
|
|
145
|
+
finalSpan: budget.span.toString(),
|
|
146
|
+
silentTruncations: budget.silentTruncations,
|
|
147
|
+
verifications: budget.verifications,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* One chunk, narrowed as often as the endpoint demands and verified when its
|
|
152
|
+
* size makes it suspicious. Returns the logs for `[from, to]` in whatever order
|
|
153
|
+
* they arrived; the caller sorts.
|
|
154
|
+
*/
|
|
155
|
+
async function fetchRange(client, filter, from, to, options, budget) {
|
|
156
|
+
spend(budget, options);
|
|
157
|
+
const outcome = await client.call("eth_getLogs", [
|
|
158
|
+
{
|
|
159
|
+
fromBlock: toHexQuantity(from),
|
|
160
|
+
toBlock: toHexQuantity(to),
|
|
161
|
+
...(filter.address !== undefined && filter.address.length > 0
|
|
162
|
+
? { address: filter.address.length === 1 ? filter.address[0] : filter.address }
|
|
163
|
+
: {}),
|
|
164
|
+
...(filter.topics !== undefined ? { topics: filter.topics } : {}),
|
|
165
|
+
},
|
|
166
|
+
]);
|
|
167
|
+
if (!outcome.ok) {
|
|
168
|
+
if (!shouldNarrow(outcome)) {
|
|
169
|
+
throw failed(outcome, `scanning blocks ${from}–${to}`);
|
|
170
|
+
}
|
|
171
|
+
if (from === to) {
|
|
172
|
+
throw new ChainReadError(`blocks ${from}–${to} is a single block and this endpoint still refuses it (${outcome.message}) — there is no smaller range to fall back to, so narrow the filter (address, topics) or use an endpoint that will serve this block`);
|
|
173
|
+
}
|
|
174
|
+
const hinted = outcome.kind === "rpcError" ? parseSuggestedSpan(outcome.message) : null;
|
|
175
|
+
const current = to - from + 1n;
|
|
176
|
+
// The hint is a ceiling, never a licence to grow: an endpoint that answers
|
|
177
|
+
// "try 10000 blocks" to a 500-block request is describing its own limit,
|
|
178
|
+
// not this request, and widening on it walks straight back into the error.
|
|
179
|
+
const next = hinted !== null && hinted < current ? hinted : current / 2n;
|
|
180
|
+
budget.span = clampSpan(next, budget.span);
|
|
181
|
+
return splitAndFetch(client, filter, from, to, options, budget, budget.span);
|
|
182
|
+
}
|
|
183
|
+
const raw = outcome.result;
|
|
184
|
+
if (!Array.isArray(raw)) {
|
|
185
|
+
throw new ChainReadError(`scanning blocks ${from}–${to}: the endpoint answered eth_getLogs with ${JSON.stringify(raw)}, not an array`);
|
|
186
|
+
}
|
|
187
|
+
const logs = raw.map(projectLog);
|
|
188
|
+
vouch(logs, from, to, budget);
|
|
189
|
+
if (logs.length < options.suspectAt)
|
|
190
|
+
return logs;
|
|
191
|
+
if (from === to) {
|
|
192
|
+
// One block, at the cap, and no way to subdivide it — so there is no
|
|
193
|
+
// evidence available either way. Returning it would be presenting an
|
|
194
|
+
// unverifiable set as a complete one.
|
|
195
|
+
throw new ChainReadError(`block ${from} alone returned ${logs.length} logs, which is at or above the ${options.suspectAt}-log mark where this endpoint may be truncating silently. A single block cannot be split to prove otherwise, so this refuses rather than returning a set it cannot vouch for: narrow the filter (address, topics), or raise suspectAt if you know this endpoint's cap is higher.`);
|
|
196
|
+
}
|
|
197
|
+
// Large enough to be at somebody's cap. Split, re-query, and compare: if the
|
|
198
|
+
// halves hold more than the whole did, the whole was truncated and nothing
|
|
199
|
+
// in the response said so.
|
|
200
|
+
budget.verifications += 1;
|
|
201
|
+
const halves = await splitAndFetch(client, filter, from, to, options, budget, null);
|
|
202
|
+
if (halves.length > logs.length) {
|
|
203
|
+
budget.silentTruncations += 1;
|
|
204
|
+
// Keep working at the size that was provably complete, so the rest of the
|
|
205
|
+
// scan does not re-discover the same cap chunk after chunk.
|
|
206
|
+
budget.span = clampSpan((to - from + 1n) / 2n, budget.span);
|
|
207
|
+
}
|
|
208
|
+
return halves;
|
|
209
|
+
}
|
|
210
|
+
/** Split `[from, to]` into pieces of `pieceSpan` (or two halves) and fetch each. */
|
|
211
|
+
async function splitAndFetch(client, filter, from, to, options, budget, pieceSpan) {
|
|
212
|
+
const span = pieceSpan === null ? maxBig((to - from + 1n) / 2n, 1n) : maxBig(pieceSpan, 1n);
|
|
213
|
+
const out = [];
|
|
214
|
+
let cursor = from;
|
|
215
|
+
while (cursor <= to) {
|
|
216
|
+
const end = min(cursor + span - 1n, to);
|
|
217
|
+
out.push(...(await fetchRange(client, filter, cursor, end, options, budget)));
|
|
218
|
+
cursor = end + 1n;
|
|
219
|
+
}
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Check a batch against the question that was asked, before any of it counts.
|
|
224
|
+
*
|
|
225
|
+
* All three checks are about an endpoint answering something other than what it
|
|
226
|
+
* was asked, and all three make everything else in the scan untrustworthy — so
|
|
227
|
+
* they refuse rather than filter.
|
|
228
|
+
*/
|
|
229
|
+
function vouch(logs, from, to, budget) {
|
|
230
|
+
for (const log of logs) {
|
|
231
|
+
const number = BigInt(log.blockNumber);
|
|
232
|
+
if (number < from || number > to) {
|
|
233
|
+
throw new ChainReadError(`the endpoint returned a log from block ${number} for a query over blocks ${from}–${to} — it is not answering the question that was asked`);
|
|
234
|
+
}
|
|
235
|
+
if (log.removed) {
|
|
236
|
+
throw new ChainReadError(`block ${number} returned a log marked removed, which means it belongs to a block the chain has reorganised away — rescan once the range is behind the reorg depth`);
|
|
237
|
+
}
|
|
238
|
+
const known = budget.blockHashes.get(log.blockNumber);
|
|
239
|
+
if (known === undefined) {
|
|
240
|
+
budget.blockHashes.set(log.blockNumber, log.blockHash);
|
|
241
|
+
}
|
|
242
|
+
else if (known !== log.blockHash) {
|
|
243
|
+
// Two requests, two different blocks at the same height: the chain
|
|
244
|
+
// reorganised while this scan was running, and anything already collected
|
|
245
|
+
// is from a history that no longer exists.
|
|
246
|
+
throw new ChainReadError(`block ${number} came back with hash ${log.blockHash} after an earlier request said ${known} — the chain reorganised while this scan was running, so the result would mix two histories`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
function admit(log, seen, collected, options) {
|
|
251
|
+
// The transaction hash is in the key because `logIndex` is per BLOCK in the
|
|
252
|
+
// spec and per TRANSACTION on several endpoints. Keyed on the block and the
|
|
253
|
+
// index alone, two logs from two transactions in one block are one log here,
|
|
254
|
+
// and the one that loses is dropped under `complete: true` — the failure
|
|
255
|
+
// this whole file exists to make impossible. A genuine duplicate still
|
|
256
|
+
// matches on all three, so nothing is admitted twice.
|
|
257
|
+
const key = `${log.blockHash}:${log.transactionHash}:${log.logIndex}`;
|
|
258
|
+
if (seen.has(key))
|
|
259
|
+
return;
|
|
260
|
+
seen.add(key);
|
|
261
|
+
collected.push(log);
|
|
262
|
+
if (collected.length > options.maxLogs) {
|
|
263
|
+
throw new ChainReadError(`this range holds more than maxLogs (${options.maxLogs}) matching logs — refusing to return the first ${options.maxLogs} of them, because a truncated set is indistinguishable from a complete one once it leaves here. Scan a smaller range, or raise maxLogs.`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function spend(budget, options) {
|
|
267
|
+
budget.calls += 1;
|
|
268
|
+
if (budget.calls > options.maxCalls) {
|
|
269
|
+
throw new ChainReadError(`this scan needed more than maxCalls (${options.maxCalls}) requests — the endpoint is narrowing the range faster than the range is shrinking. Scan a smaller range, or narrow the filter.`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function clampSpan(next, current) {
|
|
273
|
+
const bounded = next < 1n ? 1n : next;
|
|
274
|
+
return bounded < current ? bounded : current;
|
|
275
|
+
}
|
|
276
|
+
const min = (a, b) => (a < b ? a : b);
|
|
277
|
+
const maxBig = (a, b) => (a > b ? a : b);
|
|
278
|
+
function byBlockThenIndex(a, b) {
|
|
279
|
+
const an = BigInt(a.blockNumber);
|
|
280
|
+
const bn = BigInt(b.blockNumber);
|
|
281
|
+
if (an !== bn)
|
|
282
|
+
return an < bn ? -1 : 1;
|
|
283
|
+
return a.logIndex - b.logIndex;
|
|
284
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain quantities, in and out.
|
|
3
|
+
*
|
|
4
|
+
* Every number a node sends is a hex QUANTITY string, and every one of them is
|
|
5
|
+
* a candidate for silent precision loss: a uint256 balance does not fit in a
|
|
6
|
+
* double, and neither does a chain id on some testnets. So nothing here ever
|
|
7
|
+
* produces a JS number from chain data. Hex goes to `bigint`, `bigint` goes to
|
|
8
|
+
* a decimal STRING on the way out, and a caller that wants arithmetic gets
|
|
9
|
+
* digits it can feed to `TokenUnits` or `MoneyMultiply` rather than a float
|
|
10
|
+
* that is already wrong.
|
|
11
|
+
*
|
|
12
|
+
* Block numbers get the same treatment even though today's heads fit in a
|
|
13
|
+
* double. They are compared, subtracted and used as search bounds all over
|
|
14
|
+
* this package, and one accidental `Number(...)` in that chain of operations
|
|
15
|
+
* is a bug that only shows up on a chain nobody tested against.
|
|
16
|
+
*/
|
|
17
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
18
|
+
/** A refusal raised by this package: a bad input, a bad answer, or a scan it will not vouch for. */
|
|
19
|
+
export declare class ChainReadError extends CrewhausError {
|
|
20
|
+
readonly name = "ChainReadError";
|
|
21
|
+
constructor(message: string);
|
|
22
|
+
}
|
|
23
|
+
/** Block tags every EVM node understands; `safe`/`finalized` are post-Merge and not universal. */
|
|
24
|
+
export declare const BLOCK_TAGS: readonly ["latest", "earliest", "pending", "safe", "finalized"];
|
|
25
|
+
export type BlockTag = (typeof BLOCK_TAGS)[number];
|
|
26
|
+
export declare function isBlockTag(value: string): value is BlockTag;
|
|
27
|
+
/** 32 bytes of hex with the prefix: a block hash or a transaction hash. */
|
|
28
|
+
export declare function isHash32(value: string): boolean;
|
|
29
|
+
export declare function isAddress(value: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* Parse a hex QUANTITY as a bigint.
|
|
32
|
+
*
|
|
33
|
+
* Nodes are not consistent about the spec's "no leading zeroes" rule — Erigon
|
|
34
|
+
* and several L2 sequencers pad — so padding is accepted. What is not accepted
|
|
35
|
+
* is a decimal string that happens to be all digits: `"100"` from a node would
|
|
36
|
+
* mean 0x100, and reading it as one hundred is a 156-block error in a search
|
|
37
|
+
* bound. Anything that is not `0x`-prefixed hex is a malformed answer.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hexToBigint(value: unknown, what: string): bigint;
|
|
40
|
+
/** The same, but a missing field is `null` rather than an error. Pre-1559 blocks have no base fee. */
|
|
41
|
+
export declare function optionalHexToBigint(value: unknown, what: string): bigint | null;
|
|
42
|
+
/** A bigint as the hex QUANTITY the JSON-RPC wire format wants. */
|
|
43
|
+
export declare function toHexQuantity(value: bigint): string;
|
|
44
|
+
/**
|
|
45
|
+
* Accept the three shapes a caller might hand us for an integer, and refuse a
|
|
46
|
+
* float or an unsafe number instead of rounding it.
|
|
47
|
+
*
|
|
48
|
+
* A number is allowed because a block number typed by hand is a number, and
|
|
49
|
+
* refusing it would be pedantry. A number past `Number.MAX_SAFE_INTEGER` is
|
|
50
|
+
* refused because by then it has ALREADY lost its low digits — accepting it
|
|
51
|
+
* would be accepting a value the caller can no longer see is wrong.
|
|
52
|
+
*/
|
|
53
|
+
export declare function toBigint(value: string | number, what: string): bigint;
|
|
54
|
+
/**
|
|
55
|
+
* A unix timestamp as ISO-8601.
|
|
56
|
+
*
|
|
57
|
+
* Seconds, not milliseconds: an EVM block timestamp is seconds, and the
|
|
58
|
+
* thousand-fold mistake produces a date in 1970 that looks like data rather
|
|
59
|
+
* than like an error. A timestamp past what `Date` can express comes back
|
|
60
|
+
* `null` rather than "Invalid Date", which would land in output as a string.
|
|
61
|
+
*/
|
|
62
|
+
export declare function unixToIso(seconds: bigint): string | null;
|
|
63
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
64
|
+
export declare const json: (value: unknown) => string;
|
|
65
|
+
/** Read a field off a node's answer without `any` and without assuming it is there. */
|
|
66
|
+
export declare function field(source: unknown, key: string): unknown;
|
|
67
|
+
export declare function requireObject(value: unknown, what: string): Record<string, unknown>;
|
|
68
|
+
/** A hex string as lowercase, so `0xAbC…` and `0xabc…` compare and dedupe as one address. */
|
|
69
|
+
export declare function lowerHex(value: unknown, what: string): string;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chain quantities, in and out.
|
|
3
|
+
*
|
|
4
|
+
* Every number a node sends is a hex QUANTITY string, and every one of them is
|
|
5
|
+
* a candidate for silent precision loss: a uint256 balance does not fit in a
|
|
6
|
+
* double, and neither does a chain id on some testnets. So nothing here ever
|
|
7
|
+
* produces a JS number from chain data. Hex goes to `bigint`, `bigint` goes to
|
|
8
|
+
* a decimal STRING on the way out, and a caller that wants arithmetic gets
|
|
9
|
+
* digits it can feed to `TokenUnits` or `MoneyMultiply` rather than a float
|
|
10
|
+
* that is already wrong.
|
|
11
|
+
*
|
|
12
|
+
* Block numbers get the same treatment even though today's heads fit in a
|
|
13
|
+
* double. They are compared, subtracted and used as search bounds all over
|
|
14
|
+
* this package, and one accidental `Number(...)` in that chain of operations
|
|
15
|
+
* is a bug that only shows up on a chain nobody tested against.
|
|
16
|
+
*/
|
|
17
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
18
|
+
/** A refusal raised by this package: a bad input, a bad answer, or a scan it will not vouch for. */
|
|
19
|
+
export class ChainReadError extends CrewhausError {
|
|
20
|
+
name = "ChainReadError";
|
|
21
|
+
constructor(message) {
|
|
22
|
+
super("tool", message);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** Block tags every EVM node understands; `safe`/`finalized` are post-Merge and not universal. */
|
|
26
|
+
export const BLOCK_TAGS = ["latest", "earliest", "pending", "safe", "finalized"];
|
|
27
|
+
export function isBlockTag(value) {
|
|
28
|
+
return BLOCK_TAGS.includes(value);
|
|
29
|
+
}
|
|
30
|
+
/** 32 bytes of hex with the prefix: a block hash or a transaction hash. */
|
|
31
|
+
export function isHash32(value) {
|
|
32
|
+
return /^0x[0-9a-fA-F]{64}$/.test(value);
|
|
33
|
+
}
|
|
34
|
+
export function isAddress(value) {
|
|
35
|
+
return /^0x[0-9a-fA-F]{40}$/.test(value);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Parse a hex QUANTITY as a bigint.
|
|
39
|
+
*
|
|
40
|
+
* Nodes are not consistent about the spec's "no leading zeroes" rule — Erigon
|
|
41
|
+
* and several L2 sequencers pad — so padding is accepted. What is not accepted
|
|
42
|
+
* is a decimal string that happens to be all digits: `"100"` from a node would
|
|
43
|
+
* mean 0x100, and reading it as one hundred is a 156-block error in a search
|
|
44
|
+
* bound. Anything that is not `0x`-prefixed hex is a malformed answer.
|
|
45
|
+
*/
|
|
46
|
+
export function hexToBigint(value, what) {
|
|
47
|
+
if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) {
|
|
48
|
+
throw new ChainReadError(`${what}: expected a 0x hex quantity, got ${JSON.stringify(value)} — the endpoint answered something this package cannot read`);
|
|
49
|
+
}
|
|
50
|
+
return BigInt(value);
|
|
51
|
+
}
|
|
52
|
+
/** The same, but a missing field is `null` rather than an error. Pre-1559 blocks have no base fee. */
|
|
53
|
+
export function optionalHexToBigint(value, what) {
|
|
54
|
+
if (value === undefined || value === null)
|
|
55
|
+
return null;
|
|
56
|
+
return hexToBigint(value, what);
|
|
57
|
+
}
|
|
58
|
+
/** A bigint as the hex QUANTITY the JSON-RPC wire format wants. */
|
|
59
|
+
export function toHexQuantity(value) {
|
|
60
|
+
if (value < 0n)
|
|
61
|
+
throw new ChainReadError(`cannot encode a negative quantity (${value})`);
|
|
62
|
+
return `0x${value.toString(16)}`;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Accept the three shapes a caller might hand us for an integer, and refuse a
|
|
66
|
+
* float or an unsafe number instead of rounding it.
|
|
67
|
+
*
|
|
68
|
+
* A number is allowed because a block number typed by hand is a number, and
|
|
69
|
+
* refusing it would be pedantry. A number past `Number.MAX_SAFE_INTEGER` is
|
|
70
|
+
* refused because by then it has ALREADY lost its low digits — accepting it
|
|
71
|
+
* would be accepting a value the caller can no longer see is wrong.
|
|
72
|
+
*/
|
|
73
|
+
export function toBigint(value, what) {
|
|
74
|
+
if (typeof value === "number") {
|
|
75
|
+
if (!Number.isSafeInteger(value)) {
|
|
76
|
+
throw new ChainReadError(`${what}: ${value} is not a safe integer — past 2^53 a JS number has already lost digits, so pass it as a string`);
|
|
77
|
+
}
|
|
78
|
+
return BigInt(value);
|
|
79
|
+
}
|
|
80
|
+
const text = value.trim();
|
|
81
|
+
if (!/^(0x[0-9a-fA-F]+|\d+)$/.test(text)) {
|
|
82
|
+
throw new ChainReadError(`${what}: "${value}" is not a non-negative integer`);
|
|
83
|
+
}
|
|
84
|
+
return BigInt(text);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A unix timestamp as ISO-8601.
|
|
88
|
+
*
|
|
89
|
+
* Seconds, not milliseconds: an EVM block timestamp is seconds, and the
|
|
90
|
+
* thousand-fold mistake produces a date in 1970 that looks like data rather
|
|
91
|
+
* than like an error. A timestamp past what `Date` can express comes back
|
|
92
|
+
* `null` rather than "Invalid Date", which would land in output as a string.
|
|
93
|
+
*/
|
|
94
|
+
export function unixToIso(seconds) {
|
|
95
|
+
const ms = seconds * 1000n;
|
|
96
|
+
if (ms > 8640000000000000n || ms < -8640000000000000n)
|
|
97
|
+
return null;
|
|
98
|
+
return new Date(Number(ms)).toISOString();
|
|
99
|
+
}
|
|
100
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
101
|
+
export const json = (value) => JSON.stringify(value);
|
|
102
|
+
/** Read a field off a node's answer without `any` and without assuming it is there. */
|
|
103
|
+
export function field(source, key) {
|
|
104
|
+
if (typeof source !== "object" || source === null)
|
|
105
|
+
return undefined;
|
|
106
|
+
return source[key];
|
|
107
|
+
}
|
|
108
|
+
export function requireObject(value, what) {
|
|
109
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
110
|
+
throw new ChainReadError(`${what}: expected an object, got ${JSON.stringify(value)}`);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
/** A hex string as lowercase, so `0xAbC…` and `0xabc…` compare and dedupe as one address. */
|
|
115
|
+
export function lowerHex(value, what) {
|
|
116
|
+
if (typeof value !== "string" || !/^0x[0-9a-fA-F]*$/.test(value)) {
|
|
117
|
+
throw new ChainReadError(`${what}: expected 0x hex, got ${JSON.stringify(value)}`);
|
|
118
|
+
}
|
|
119
|
+
return value.toLowerCase();
|
|
120
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { ChainReadError } from "./quantity";
|
|
2
|
+
export declare const DEFAULT_TIMEOUT_MS = 20000;
|
|
3
|
+
export declare const MAX_TIMEOUT_MS = 120000;
|
|
4
|
+
/**
|
|
5
|
+
* 16 MB. A single `eth_getLogs` page of ten thousand logs is a few megabytes;
|
|
6
|
+
* this is generous enough that no honest answer hits it and small enough that a
|
|
7
|
+
* broken endpoint streaming forever cannot pin the process.
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_MAX_BYTES: number;
|
|
10
|
+
/**
|
|
11
|
+
* Why a call did not produce a result. These are not collapsible into one
|
|
12
|
+
* boolean: `rpcError` is the node answering a question, `transport` is the node
|
|
13
|
+
* not answering, and the log scanner treats them completely differently.
|
|
14
|
+
*/
|
|
15
|
+
export type RpcFailureKind = "transport" | "timeout" | "status" | "malformed" | "tooLarge" | "rpcError";
|
|
16
|
+
export type RpcOutcome = {
|
|
17
|
+
readonly ok: true;
|
|
18
|
+
readonly result: unknown;
|
|
19
|
+
} | {
|
|
20
|
+
readonly ok: false;
|
|
21
|
+
readonly kind: RpcFailureKind;
|
|
22
|
+
readonly message: string;
|
|
23
|
+
/** The JSON-RPC error code, when the node sent one. -32000 is "server error, good luck". */
|
|
24
|
+
readonly code?: number;
|
|
25
|
+
readonly status?: number;
|
|
26
|
+
readonly data?: unknown;
|
|
27
|
+
};
|
|
28
|
+
export type RpcFetch = (req: Request, pinnedIp: string) => Promise<Response>;
|
|
29
|
+
/**
|
|
30
|
+
* Test seam — `_setFetch(undefined)` restores the production dialer. A suite
|
|
31
|
+
* that sets it must restore it, or the next file in the same bun process
|
|
32
|
+
* inherits the stub.
|
|
33
|
+
*/
|
|
34
|
+
export declare function _setFetch(fn: RpcFetch | undefined): void;
|
|
35
|
+
export type RpcClientOptions = {
|
|
36
|
+
readonly signal?: AbortSignal;
|
|
37
|
+
readonly timeoutMs?: number;
|
|
38
|
+
readonly maxBytes?: number;
|
|
39
|
+
};
|
|
40
|
+
export type RpcClient = {
|
|
41
|
+
/** Scheme + host + port. Safe to print; the path, which may hold a key, is not. */
|
|
42
|
+
readonly origin: string;
|
|
43
|
+
/** How many requests this client has issued, so a tool can report what it cost. */
|
|
44
|
+
calls: number;
|
|
45
|
+
call(method: string, params: ReadonlyArray<unknown>): Promise<RpcOutcome>;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Vet the URL once, then reuse the verdict for every call on it.
|
|
49
|
+
*
|
|
50
|
+
* `EvmEventScan` issues dozens of requests against one endpoint and
|
|
51
|
+
* `EvmWaitForReceipt` issues one per poll; re-resolving the host each time
|
|
52
|
+
* would be a DNS query per call, and — worse — would let the answer change
|
|
53
|
+
* mid-scan, which is precisely the rebinding the pin defends against.
|
|
54
|
+
*/
|
|
55
|
+
export declare function openRpc(rawUrl: string, options?: RpcClientOptions): Promise<RpcClient>;
|
|
56
|
+
/** The longest JSON-RPC error message this package will carry. */
|
|
57
|
+
export declare const MAX_ERROR_MESSAGE_CHARS = 2000;
|
|
58
|
+
/** Turn a failed outcome into the refusal a tool throws, with the reason kept. */
|
|
59
|
+
export declare function failed(outcome: Extract<RpcOutcome, {
|
|
60
|
+
ok: false;
|
|
61
|
+
}>, what: string): ChainReadError;
|
|
62
|
+
/** Call, or throw the refusal. For the steps where there is nothing to fall back to. */
|
|
63
|
+
export declare function callOrThrow(client: RpcClient, method: string, params: ReadonlyArray<unknown>, what: string): Promise<unknown>;
|