@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/index.js
ADDED
|
@@ -0,0 +1,1302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-chainread — what the chain actually recorded.
|
|
3
|
+
*
|
|
4
|
+
* Eight read-only questions about an EVM chain, answered over a public
|
|
5
|
+
* JSON-RPC endpoint the caller names. `@crewhaus/tool-onchain` next door does
|
|
6
|
+
* the arithmetic offline; this package is the half that dials.
|
|
7
|
+
*
|
|
8
|
+
* **Nothing here signs or sends.** No schema accepts a private key, a mnemonic
|
|
9
|
+
* or a signed payload, and no code path can reach `eth_sendRawTransaction`:
|
|
10
|
+
* every method name goes through `assertReadOnlyMethod` from
|
|
11
|
+
* `@crewhaus/chain-adapter-base` before a socket opens, so the guarantee is
|
|
12
|
+
* structural rather than a promise about the call sites. `index.test.ts`
|
|
13
|
+
* asserts both halves.
|
|
14
|
+
*
|
|
15
|
+
* The recurring theme is what these tools refuse to say:
|
|
16
|
+
*
|
|
17
|
+
* - `EvmWaitForReceipt` distinguishes mined-and-succeeded, mined-and-REVERTED
|
|
18
|
+
* and still-pending-at-the-deadline. A reverted transaction has a receipt,
|
|
19
|
+
* with `status: 0x0`; reporting "no receipt" for it is a lie a caller acts
|
|
20
|
+
* on by broadcasting again.
|
|
21
|
+
* - `EvmEventScan` refuses rather than returning a log set it cannot prove is
|
|
22
|
+
* complete. A missing log is an event that did not happen, as far as
|
|
23
|
+
* everything downstream is concerned.
|
|
24
|
+
* - `EvmBlockAtTimestamp` returns the last block at or before the timestamp
|
|
25
|
+
* and the block after it, so the invariant can be checked from the answer;
|
|
26
|
+
* a timestamp before genesis is a refusal, not block zero.
|
|
27
|
+
* - `EvmRpcHealth` reports what it could not determine as unknown. An
|
|
28
|
+
* endpoint that might or might not serve archive state is not an endpoint
|
|
29
|
+
* that does not.
|
|
30
|
+
* - `EvmTransactionSummary` says in its output which movements it cannot see.
|
|
31
|
+
* - `OnchainTransactionsSync` hands back `@crewhaus/tool-money`'s
|
|
32
|
+
* `Transaction` — the row shape a statement parses into — rather than a
|
|
33
|
+
* second one of its own, and PARKS a movement it cannot write as a row
|
|
34
|
+
* instead of rounding one to fit.
|
|
35
|
+
*
|
|
36
|
+
* Every chain quantity — wei, token amounts, nonces, block numbers — crosses
|
|
37
|
+
* this boundary as a decimal STRING. A uint256 does not fit in a double. The
|
|
38
|
+
* one place a `number` is unavoidable is the statement row's `amountMinor`,
|
|
39
|
+
* which is not this package's field, and that is exactly where the parking is.
|
|
40
|
+
*/
|
|
41
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
42
|
+
import { z } from "zod";
|
|
43
|
+
import { fetchBlock, headNumber, searchBlockAtTimestamp, } from "./lib/blocks";
|
|
44
|
+
import { nowMs, sleep } from "./lib/clock";
|
|
45
|
+
import { getRpcEndpointPolicy } from "./lib/endpoint";
|
|
46
|
+
import { UNKNOWN_UNITS, collectHistory } from "./lib/history";
|
|
47
|
+
import { projectLog, scanLogs } from "./lib/logs";
|
|
48
|
+
import { ChainReadError, hexToBigint, isAddress, isBlockTag, isHash32, json, lowerHex, requireObject, toBigint, toHexQuantity, unixToIso, } from "./lib/quantity";
|
|
49
|
+
import { callOrThrow, openRpc } from "./lib/rpc";
|
|
50
|
+
import { decodeApproval, decodeTransfer, feeBreakdown, projectReceipt, projectTransaction, } from "./lib/transfers";
|
|
51
|
+
export { _setFetch, openRpc } from "./lib/rpc";
|
|
52
|
+
export { _setClock, virtualClock } from "./lib/clock";
|
|
53
|
+
export { _setDnsLookup, RpcEndpointError, getRpcEndpointPolicy, setRpcEndpointPolicy, } from "./lib/endpoint";
|
|
54
|
+
export { ChainReadError } from "./lib/quantity";
|
|
55
|
+
export { parseSuggestedSpan, shouldNarrow } from "./lib/logs";
|
|
56
|
+
export { collectHistory, toMinorUnits, } from "./lib/history";
|
|
57
|
+
export { TRANSFER_TOPIC, APPROVAL_TOPIC, APPROVAL_FOR_ALL_TOPIC, TRANSFER_SINGLE_TOPIC, TOPIC_SIGNATURES, addressToTopic, decodeApproval, decodeTransfer, feeBreakdown, topicToAddress, } from "./lib/transfers";
|
|
58
|
+
/** Ceilings that exist so one call cannot become a crawl of somebody's endpoint. */
|
|
59
|
+
const LIMITS = {
|
|
60
|
+
compareEndpoints: 7,
|
|
61
|
+
broadcastHashes: 32,
|
|
62
|
+
addresses: 32,
|
|
63
|
+
topicPositions: 4,
|
|
64
|
+
topicAlternatives: 64,
|
|
65
|
+
scanCalls: 512,
|
|
66
|
+
defaultMaxLogs: 5_000,
|
|
67
|
+
hardMaxLogs: 100_000,
|
|
68
|
+
defaultSpan: 2000n,
|
|
69
|
+
searchProbes: 64,
|
|
70
|
+
/** Assets a caller may declare units for in one sync. */
|
|
71
|
+
assets: 64,
|
|
72
|
+
/** Native value costs one request per block, so the range a sync will hydrate is bounded. */
|
|
73
|
+
defaultHydratedBlocks: 256,
|
|
74
|
+
hardHydratedBlocks: 10_000,
|
|
75
|
+
defaultRows: 5_000,
|
|
76
|
+
hardRows: 50_000,
|
|
77
|
+
syncCalls: 4_096,
|
|
78
|
+
};
|
|
79
|
+
const rpcUrlField = z
|
|
80
|
+
.string()
|
|
81
|
+
.min(8)
|
|
82
|
+
.describe("the JSON-RPC endpoint to read from, e.g. https://mainnet.base.org");
|
|
83
|
+
const timeoutField = z
|
|
84
|
+
.number()
|
|
85
|
+
.int()
|
|
86
|
+
.min(1_000)
|
|
87
|
+
.max(120_000)
|
|
88
|
+
.optional()
|
|
89
|
+
.describe("per-request timeout in milliseconds; default 20000");
|
|
90
|
+
const blockField = z
|
|
91
|
+
.union([z.string().min(1), z.number().int().nonnegative()])
|
|
92
|
+
.describe('a block number, or one of "latest", "earliest", "safe", "finalized", "pending"');
|
|
93
|
+
const addressField = z.string().length(42).describe("a 0x-prefixed 20-byte address");
|
|
94
|
+
const hashField = z.string().length(66).describe("a 0x-prefixed 32-byte hash");
|
|
95
|
+
async function client(input, ctx) {
|
|
96
|
+
return openRpc(input.rpcUrl, {
|
|
97
|
+
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
98
|
+
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Work out what a caller meant by a block.
|
|
103
|
+
*
|
|
104
|
+
* A tag, a 32-byte hash and a hex number all arrive as strings, and the hash is
|
|
105
|
+
* distinguished by LENGTH — 66 characters including the prefix. Guessing wrong
|
|
106
|
+
* sends `eth_getBlockByNumber` a hash, which most nodes answer with `null`:
|
|
107
|
+
* "no such block" for a block that exists.
|
|
108
|
+
*/
|
|
109
|
+
export function parseBlockInput(value, what) {
|
|
110
|
+
if (typeof value === "number")
|
|
111
|
+
return { kind: "number", value: toBigint(value, what) };
|
|
112
|
+
const text = value.trim();
|
|
113
|
+
if (isBlockTag(text))
|
|
114
|
+
return { kind: "tag", tag: text };
|
|
115
|
+
if (isHash32(text))
|
|
116
|
+
return { kind: "hash", hash: text.toLowerCase() };
|
|
117
|
+
if (/^(0x[0-9a-fA-F]+|\d+)$/.test(text))
|
|
118
|
+
return { kind: "number", value: toBigint(text, what) };
|
|
119
|
+
throw new ChainReadError(`${what}: "${value}" is not a block number, a 32-byte block hash, or one of latest/earliest/safe/finalized/pending`);
|
|
120
|
+
}
|
|
121
|
+
function requireAddress(value, what) {
|
|
122
|
+
if (!isAddress(value)) {
|
|
123
|
+
throw new ChainReadError(`${what}: "${value}" is not a 20-byte 0x address`);
|
|
124
|
+
}
|
|
125
|
+
return value.toLowerCase();
|
|
126
|
+
}
|
|
127
|
+
function requireHash(value, what) {
|
|
128
|
+
if (!isHash32(value)) {
|
|
129
|
+
throw new ChainReadError(`${what}: "${value}" is not a 32-byte 0x hash`);
|
|
130
|
+
}
|
|
131
|
+
return value.toLowerCase();
|
|
132
|
+
}
|
|
133
|
+
/** Resolve a block input to a concrete number, because a scan over a moving target is not reproducible. */
|
|
134
|
+
async function resolveBlockNumber(rpc, value, what) {
|
|
135
|
+
const selector = parseBlockInput(value, what);
|
|
136
|
+
if (selector.kind === "number")
|
|
137
|
+
return selector.value;
|
|
138
|
+
if (selector.kind === "tag" && selector.tag === "latest")
|
|
139
|
+
return headNumber(rpc);
|
|
140
|
+
if (selector.kind === "tag" && selector.tag === "earliest")
|
|
141
|
+
return 0n;
|
|
142
|
+
const block = await fetchBlock(rpc, selector);
|
|
143
|
+
if (block === null || block.number === null) {
|
|
144
|
+
throw new ChainReadError(`${what}: this endpoint has no numbered block for ${selector.kind === "tag" ? `"${selector.tag}"` : selector.hash}${selector.kind === "tag"
|
|
145
|
+
? ' — "pending" has no number until it is sealed, and "safe" and "finalized" are post-Merge tags that not every chain or endpoint serves'
|
|
146
|
+
: ""}`);
|
|
147
|
+
}
|
|
148
|
+
return BigInt(block.number);
|
|
149
|
+
}
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
export const evmGetBlock = buildTool({
|
|
152
|
+
name: "EvmGetBlock",
|
|
153
|
+
description: "Read one block's header from a JSON-RPC endpoint — number, hash, parent, timestamp (unix and ISO), gas limit and usage, base fee and transaction count — by number, by tag, or by block hash. Use it to anchor anything that needs a block: a historical read, a confirmation count, or the time a range covers. It picks getBlockByHash or getBlockByNumber from the shape of what you pass, reports a chain with no base fee as legacy rather than putting a null into your fee arithmetic, and answers a block that does not exist with found:false rather than an error, because that is a fact about the chain and not a failure.",
|
|
154
|
+
inputSchema: z
|
|
155
|
+
.object({
|
|
156
|
+
rpcUrl: rpcUrlField,
|
|
157
|
+
block: blockField.optional().describe('default "latest"'),
|
|
158
|
+
includeTransactions: z
|
|
159
|
+
.boolean()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe("include the transaction hashes in the block; default false"),
|
|
162
|
+
timeoutMs: timeoutField,
|
|
163
|
+
})
|
|
164
|
+
.strict(),
|
|
165
|
+
readOnly: true,
|
|
166
|
+
concurrencySafe: true,
|
|
167
|
+
scope: "external",
|
|
168
|
+
ioCapability: "network",
|
|
169
|
+
execute: async (input, ctx) => {
|
|
170
|
+
const rpc = await client(input, ctx);
|
|
171
|
+
const selector = parseBlockInput(input.block ?? "latest", "block");
|
|
172
|
+
const block = await fetchBlock(rpc, selector);
|
|
173
|
+
if (block === null) {
|
|
174
|
+
return json({
|
|
175
|
+
found: false,
|
|
176
|
+
requested: input.block ?? "latest",
|
|
177
|
+
resolvedBy: selector.kind,
|
|
178
|
+
note: "this endpoint has no such block — it may be ahead of the head, or the endpoint may not serve this part of the chain",
|
|
179
|
+
calls: rpc.calls,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return json({
|
|
183
|
+
found: true,
|
|
184
|
+
resolvedBy: selector.kind,
|
|
185
|
+
block: {
|
|
186
|
+
...block,
|
|
187
|
+
...(input.includeTransactions === true ? {} : { transactions: undefined }),
|
|
188
|
+
},
|
|
189
|
+
...(block.pending
|
|
190
|
+
? {
|
|
191
|
+
note: "this is the pending block: it has no number and no hash yet, and its contents change until it is sealed",
|
|
192
|
+
}
|
|
193
|
+
: {}),
|
|
194
|
+
calls: rpc.calls,
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
export const evmBlockAtTimestamp = buildTool({
|
|
199
|
+
name: "EvmBlockAtTimestamp",
|
|
200
|
+
description: "Find the block a chain was at, at a given moment: the LAST block whose timestamp is at or before the one you name, together with the block after it so the answer can be checked. Use it to pin a historical read — an end-of-quarter balance, the state before an incident — to a block number, instead of guessing from an average block time. A timestamp before the chain's first block is refused rather than answered with block zero, a timestamp at or past the head returns the head and says so, and a chain whose block timestamps run backwards is refused rather than searched, because a binary search there has no single right answer.",
|
|
201
|
+
inputSchema: z
|
|
202
|
+
.object({
|
|
203
|
+
rpcUrl: rpcUrlField,
|
|
204
|
+
timestamp: z
|
|
205
|
+
.union([z.string().min(1), z.number().int()])
|
|
206
|
+
.describe("unix SECONDS, or an ISO-8601 instant like 2026-03-01T00:00:00Z"),
|
|
207
|
+
fromBlock: blockField.optional().describe("lower bound for the search; default 0"),
|
|
208
|
+
toBlock: blockField.optional().describe("upper bound; default the current head"),
|
|
209
|
+
timeoutMs: timeoutField,
|
|
210
|
+
})
|
|
211
|
+
.strict(),
|
|
212
|
+
readOnly: true,
|
|
213
|
+
concurrencySafe: true,
|
|
214
|
+
scope: "external",
|
|
215
|
+
ioCapability: "network",
|
|
216
|
+
execute: async (input, ctx) => {
|
|
217
|
+
const rpc = await client(input, ctx);
|
|
218
|
+
const target = parseTimestamp(input.timestamp);
|
|
219
|
+
const low = input.fromBlock === undefined
|
|
220
|
+
? 0n
|
|
221
|
+
: await resolveBlockNumber(rpc, input.fromBlock, "fromBlock");
|
|
222
|
+
const high = input.toBlock === undefined
|
|
223
|
+
? await headNumber(rpc)
|
|
224
|
+
: await resolveBlockNumber(rpc, input.toBlock, "toBlock");
|
|
225
|
+
const search = await searchBlockAtTimestamp(rpc, target, { low, high }, LIMITS.searchProbes);
|
|
226
|
+
if (search.outcome === "beforeRange") {
|
|
227
|
+
throw new ChainReadError(`nothing was recorded at or before ${target} (${unixToIso(target) ?? "out of range"}): the earliest block in the search range, block ${search.first.number}, is already stamped ${search.first.timestamp} (${search.first.timestampIso}). ${input.fromBlock === undefined
|
|
228
|
+
? "That timestamp predates this chain."
|
|
229
|
+
: "Widen fromBlock if the chain is older than the bound you gave."}`);
|
|
230
|
+
}
|
|
231
|
+
return json({
|
|
232
|
+
invariant: "block is the last block whose timestamp is <= target; next is the first block after it, and its timestamp is > target",
|
|
233
|
+
target: { unix: target.toString(), iso: unixToIso(target) },
|
|
234
|
+
block: search.at,
|
|
235
|
+
next: search.next,
|
|
236
|
+
atHead: search.atHead,
|
|
237
|
+
// The two numbers that make the invariant checkable without another call.
|
|
238
|
+
certificate: {
|
|
239
|
+
blockTimestamp: search.at.timestamp,
|
|
240
|
+
targetTimestamp: target.toString(),
|
|
241
|
+
nextBlockTimestamp: search.next?.timestamp ?? null,
|
|
242
|
+
},
|
|
243
|
+
...(search.atHead
|
|
244
|
+
? {
|
|
245
|
+
note: "the target is at or past the chain head, so this answer will change as the chain advances — there is no block after it yet",
|
|
246
|
+
}
|
|
247
|
+
: {}),
|
|
248
|
+
searchedBlocks: { low: low.toString(), high: high.toString() },
|
|
249
|
+
probes: search.probes,
|
|
250
|
+
calls: rpc.calls,
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
/**
|
|
255
|
+
* Seconds, or an ISO instant, but never milliseconds.
|
|
256
|
+
*
|
|
257
|
+
* `Date.now()` pasted into a tool that wants seconds is a timestamp in the year
|
|
258
|
+
* 55000, which every search resolves to "at or past the head" and answers with
|
|
259
|
+
* the head block — a wrong answer that looks completely ordinary. So the
|
|
260
|
+
* thousand-fold mistake is refused by name.
|
|
261
|
+
*/
|
|
262
|
+
export function parseTimestamp(value) {
|
|
263
|
+
if (typeof value === "string" && !/^-?\d+$/.test(value.trim())) {
|
|
264
|
+
const ms = Date.parse(value.trim());
|
|
265
|
+
if (Number.isNaN(ms)) {
|
|
266
|
+
throw new ChainReadError(`timestamp: "${value}" is neither unix seconds nor an ISO-8601 instant`);
|
|
267
|
+
}
|
|
268
|
+
return BigInt(Math.floor(ms / 1000));
|
|
269
|
+
}
|
|
270
|
+
const seconds = toBigint(typeof value === "number" ? value : value.trim(), "timestamp");
|
|
271
|
+
if (seconds > 100000000000n) {
|
|
272
|
+
throw new ChainReadError(`timestamp: ${seconds} is far past any plausible block time — this looks like milliseconds. Block timestamps are unix SECONDS; divide by 1000.`);
|
|
273
|
+
}
|
|
274
|
+
return seconds;
|
|
275
|
+
}
|
|
276
|
+
export const evmRpcHealth = buildTool({
|
|
277
|
+
name: "EvmRpcHealth",
|
|
278
|
+
description: "Probe a JSON-RPC endpoint and report what it is and what it can serve: chain id, head block and how old that head is, and whether it keeps archive state. Use it before trusting a chain read, and to compare several endpoints against each other — two endpoints reporting different chain ids is a misconfiguration that would otherwise show up as impossible data. Archive support is decided by reading a historic balance and interpreting the failure, and when the evidence does not settle it — a chain too short to tell, an error that means something else — it reports unknown rather than false, because 'we could not tell' and 'it does not' lead to different decisions.",
|
|
279
|
+
inputSchema: z
|
|
280
|
+
.object({
|
|
281
|
+
rpcUrl: rpcUrlField,
|
|
282
|
+
compareWith: z
|
|
283
|
+
.array(z.string().min(8))
|
|
284
|
+
.max(LIMITS.compareEndpoints)
|
|
285
|
+
.optional()
|
|
286
|
+
.describe("other endpoints to probe and compare heads and chain ids against"),
|
|
287
|
+
archiveProbeBlock: blockField
|
|
288
|
+
.optional()
|
|
289
|
+
.describe("the historic block to probe for archive state; default head - 200000"),
|
|
290
|
+
staleAfterSeconds: z
|
|
291
|
+
.number()
|
|
292
|
+
.int()
|
|
293
|
+
.min(1)
|
|
294
|
+
.optional()
|
|
295
|
+
.describe("head age past which the endpoint is reported stalled; default 900"),
|
|
296
|
+
timeoutMs: timeoutField,
|
|
297
|
+
})
|
|
298
|
+
.strict(),
|
|
299
|
+
readOnly: true,
|
|
300
|
+
concurrencySafe: true,
|
|
301
|
+
scope: "external",
|
|
302
|
+
ioCapability: "network",
|
|
303
|
+
execute: async (input, ctx) => {
|
|
304
|
+
const staleAfter = BigInt(input.staleAfterSeconds ?? 900);
|
|
305
|
+
const urls = [input.rpcUrl, ...(input.compareWith ?? [])];
|
|
306
|
+
const probes = await Promise.all(urls.map((url) => probeEndpoint(url, ctx, input.timeoutMs, input.archiveProbeBlock, staleAfter)));
|
|
307
|
+
const reachable = probes.filter((p) => p.reachable);
|
|
308
|
+
const chainIds = [...new Set(reachable.map((p) => p.chainId).filter((c) => c !== null))];
|
|
309
|
+
const heads = reachable
|
|
310
|
+
.map((p) => (p.headBlock === null ? null : BigInt(p.headBlock)))
|
|
311
|
+
.filter((h) => h !== null);
|
|
312
|
+
const bestHead = heads.length === 0 ? null : heads.reduce((a, b) => (a > b ? a : b));
|
|
313
|
+
return json({
|
|
314
|
+
endpoints: probes.map((p) => ({
|
|
315
|
+
...p,
|
|
316
|
+
lagBlocks: bestHead === null || p.headBlock === null
|
|
317
|
+
? null
|
|
318
|
+
: (bestHead - BigInt(p.headBlock)).toString(),
|
|
319
|
+
})),
|
|
320
|
+
comparison: urls.length === 1
|
|
321
|
+
? null
|
|
322
|
+
: {
|
|
323
|
+
chainIdsAgree: chainIds.length <= 1,
|
|
324
|
+
chainIds,
|
|
325
|
+
...(chainIds.length > 1
|
|
326
|
+
? {
|
|
327
|
+
conflict: "these endpoints are not on the same chain — reading from both produces data that cannot be reconciled",
|
|
328
|
+
}
|
|
329
|
+
: {}),
|
|
330
|
+
bestHead: bestHead?.toString() ?? null,
|
|
331
|
+
},
|
|
332
|
+
// The verdict is made from facts that are stable between two calls. A
|
|
333
|
+
// single latency sample behind a load balancer is not one, which is why
|
|
334
|
+
// no timing is measured here at all.
|
|
335
|
+
verdictRules: {
|
|
336
|
+
stalledIf: `headAgeSeconds > ${staleAfter}`,
|
|
337
|
+
unreachableIf: "the chainId or head probe failed",
|
|
338
|
+
archiveUnknownIf: "the probe block is within 128 blocks of the head, or the error was not a pruning error",
|
|
339
|
+
},
|
|
340
|
+
policy: describePolicy(),
|
|
341
|
+
});
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
/** Phrases every major client uses when the state for a block has been pruned away. */
|
|
345
|
+
const PRUNED_STATE = [
|
|
346
|
+
"missing trie node",
|
|
347
|
+
"state is not available",
|
|
348
|
+
"state not available",
|
|
349
|
+
"state unavailable",
|
|
350
|
+
"missing state",
|
|
351
|
+
"pruned",
|
|
352
|
+
"header not found",
|
|
353
|
+
"not found: state",
|
|
354
|
+
"distance to target block exceeds",
|
|
355
|
+
"archive",
|
|
356
|
+
];
|
|
357
|
+
async function probeEndpoint(url, ctx, timeoutMs, archiveProbeBlock, staleAfter) {
|
|
358
|
+
const errors = [];
|
|
359
|
+
let rpc;
|
|
360
|
+
try {
|
|
361
|
+
rpc = await openRpc(url, {
|
|
362
|
+
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
363
|
+
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
return {
|
|
368
|
+
origin: safeOrigin(url),
|
|
369
|
+
reachable: false,
|
|
370
|
+
chainId: null,
|
|
371
|
+
headBlock: null,
|
|
372
|
+
headTimestamp: null,
|
|
373
|
+
headAgeSeconds: null,
|
|
374
|
+
clockSkewSuspected: false,
|
|
375
|
+
stalled: null,
|
|
376
|
+
archiveState: "unknown",
|
|
377
|
+
archiveEvidence: "the endpoint was never dialled",
|
|
378
|
+
errors: [err instanceof Error ? err.message : String(err)],
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
let chainId = null;
|
|
382
|
+
const idOutcome = await rpc.call("eth_chainId", []);
|
|
383
|
+
if (idOutcome.ok) {
|
|
384
|
+
try {
|
|
385
|
+
chainId = hexToBigint(idOutcome.result, "eth_chainId").toString();
|
|
386
|
+
}
|
|
387
|
+
catch (err) {
|
|
388
|
+
// A 200, a well-formed envelope, and a chain id that is not a quantity.
|
|
389
|
+
// That is this endpoint being sick, which is the fact this tool exists to
|
|
390
|
+
// report — and every probe runs inside one Promise.all, so throwing here
|
|
391
|
+
// lost the report for the healthy endpoints too. The sicker the endpoint,
|
|
392
|
+
// the less the health check could say about anything.
|
|
393
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
errors.push(`eth_chainId: ${idOutcome.message}`);
|
|
398
|
+
}
|
|
399
|
+
if (chainId === null) {
|
|
400
|
+
// Clients older than the eth_chainId RPC still answer net_version, and it
|
|
401
|
+
// is the same number in decimal. Worth one extra call before giving up.
|
|
402
|
+
const legacy = await rpc.call("net_version", []);
|
|
403
|
+
if (legacy.ok && typeof legacy.result === "string" && /^\d+$/.test(legacy.result)) {
|
|
404
|
+
chainId = legacy.result;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
let head = null;
|
|
408
|
+
try {
|
|
409
|
+
head = await fetchBlock(rpc, { kind: "tag", tag: "latest" });
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
413
|
+
}
|
|
414
|
+
const headNumberValue = head?.number === null || head === null ? null : BigInt(head.number);
|
|
415
|
+
const headTs = head === null ? null : BigInt(head.timestamp);
|
|
416
|
+
const ageSeconds = headTs === null ? null : BigInt(Math.floor(nowMs() / 1000)) - headTs;
|
|
417
|
+
const archive = headNumberValue === null
|
|
418
|
+
? {
|
|
419
|
+
state: "unknown",
|
|
420
|
+
evidence: "the head is unknown, so there is no block to probe",
|
|
421
|
+
}
|
|
422
|
+
: await probeArchive(rpc, headNumberValue, archiveProbeBlock);
|
|
423
|
+
return {
|
|
424
|
+
origin: rpc.origin,
|
|
425
|
+
reachable: chainId !== null && headNumberValue !== null,
|
|
426
|
+
chainId,
|
|
427
|
+
headBlock: headNumberValue?.toString() ?? null,
|
|
428
|
+
headTimestamp: headTs?.toString() ?? null,
|
|
429
|
+
headAgeSeconds: ageSeconds?.toString() ?? null,
|
|
430
|
+
// A head stamped in the future is this machine's clock disagreeing with the
|
|
431
|
+
// chain's, not a fast chain. Reporting a negative age as "fresh" would hide
|
|
432
|
+
// a skew that makes every other age wrong too.
|
|
433
|
+
clockSkewSuspected: ageSeconds !== null && ageSeconds < -5n,
|
|
434
|
+
stalled: ageSeconds === null ? null : ageSeconds > staleAfter,
|
|
435
|
+
archiveState: archive.state,
|
|
436
|
+
archiveEvidence: archive.evidence,
|
|
437
|
+
errors,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Decide whether the endpoint serves historic state, by asking it for some.
|
|
442
|
+
*
|
|
443
|
+
* The probe block has to be more than 128 blocks behind the head, because a
|
|
444
|
+
* plain full node keeps the most recent 128 blocks of state: a successful read
|
|
445
|
+
* inside that window proves nothing, and a short-lived devnet is entirely
|
|
446
|
+
* inside it. That case reports `unknown`, which is the honest answer — "we
|
|
447
|
+
* could not tell" and "it does not" are different, and only one of them means
|
|
448
|
+
* find another endpoint.
|
|
449
|
+
*/
|
|
450
|
+
async function probeArchive(rpc, head, requested) {
|
|
451
|
+
const probe = probeBlockNumber(head, requested);
|
|
452
|
+
if (head - probe <= 128n) {
|
|
453
|
+
return {
|
|
454
|
+
state: "unknown",
|
|
455
|
+
evidence: `block ${probe} is within 128 blocks of the head (${head}), which every full node keeps — a successful read there would not distinguish an archive node from a pruning one`,
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const outcome = await rpc.call("eth_getBalance", [
|
|
459
|
+
"0x0000000000000000000000000000000000000000",
|
|
460
|
+
toHexQuantity(probe),
|
|
461
|
+
]);
|
|
462
|
+
if (outcome.ok) {
|
|
463
|
+
return {
|
|
464
|
+
state: "yes",
|
|
465
|
+
evidence: `read a balance at block ${probe} (${head - probe} behind head)`,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const message = outcome.message.toLowerCase();
|
|
469
|
+
if (outcome.kind === "rpcError" && PRUNED_STATE.some((p) => message.includes(p))) {
|
|
470
|
+
return { state: "no", evidence: `block ${probe}: ${outcome.message}` };
|
|
471
|
+
}
|
|
472
|
+
return {
|
|
473
|
+
state: "unknown",
|
|
474
|
+
evidence: `the probe at block ${probe} failed for a reason that is not about pruning (${outcome.kind}): ${outcome.message}`,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
/** The block to ask about: the caller's, or 200k behind the head, clamped into the chain. */
|
|
478
|
+
function probeBlockNumber(head, requested) {
|
|
479
|
+
if (requested === undefined)
|
|
480
|
+
return head > 200000n ? head - 200000n : 1n;
|
|
481
|
+
const selector = parseBlockInput(requested, "archiveProbeBlock");
|
|
482
|
+
if (selector.kind !== "number") {
|
|
483
|
+
throw new ChainReadError("archiveProbeBlock must be a block NUMBER — a tag would resolve to a block whose state every node still has, which proves nothing about archive support");
|
|
484
|
+
}
|
|
485
|
+
return selector.value > head ? head : selector.value;
|
|
486
|
+
}
|
|
487
|
+
function safeOrigin(url) {
|
|
488
|
+
try {
|
|
489
|
+
return new URL(url).origin;
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return "(unparseable url)";
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function describePolicy() {
|
|
496
|
+
const policy = getRpcEndpointPolicy();
|
|
497
|
+
return {
|
|
498
|
+
allowPrivateHosts: policy.allowPrivateHosts === true,
|
|
499
|
+
allowedOrigins: policy.allowedOrigins ?? null,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
export const evmNonceStatus = buildTool({
|
|
503
|
+
name: "EvmNonceStatus",
|
|
504
|
+
description: "Compare an account's latest and pending nonce and say what that means: clear, transactions waiting, a nonce gap, or — when the evidence does not support a verdict — unknown. Use it before sending anything from an account a harness manages, and to find out why a broadcast seems stuck. Pass the transaction hashes you broadcast and it will tell you which are mined, which are still in this endpoint's mempool and which it has never heard of. It degrades loudly: many endpoints do not track a public mempool and answer the pending nonce with the latest one, so a confident 'clear' from those would be wrong, and this reports the endpoint's mempool visibility instead of guessing.",
|
|
505
|
+
inputSchema: z
|
|
506
|
+
.object({
|
|
507
|
+
rpcUrl: rpcUrlField,
|
|
508
|
+
address: addressField,
|
|
509
|
+
broadcast: z
|
|
510
|
+
.array(z.object({
|
|
511
|
+
hash: hashField,
|
|
512
|
+
nonce: z
|
|
513
|
+
.union([z.string(), z.number().int().nonnegative()])
|
|
514
|
+
.optional()
|
|
515
|
+
.describe("the nonce you sent it with; lets a dropped tx be told apart from a replaced one"),
|
|
516
|
+
}))
|
|
517
|
+
.max(LIMITS.broadcastHashes)
|
|
518
|
+
.optional()
|
|
519
|
+
.describe("transactions you sent from this account and want the status of"),
|
|
520
|
+
timeoutMs: timeoutField,
|
|
521
|
+
})
|
|
522
|
+
.strict(),
|
|
523
|
+
readOnly: true,
|
|
524
|
+
concurrencySafe: true,
|
|
525
|
+
scope: "external",
|
|
526
|
+
ioCapability: "network",
|
|
527
|
+
execute: async (input, ctx) => {
|
|
528
|
+
const rpc = await client(input, ctx);
|
|
529
|
+
const address = requireAddress(input.address, "address");
|
|
530
|
+
const latest = hexToBigint(await callOrThrow(rpc, "eth_getTransactionCount", [address, "latest"], "reading the latest nonce"), "latest nonce");
|
|
531
|
+
const pending = hexToBigint(await callOrThrow(rpc, "eth_getTransactionCount", [address, "pending"], "reading the pending nonce"), "pending nonce");
|
|
532
|
+
const rows = await Promise.all((input.broadcast ?? []).map(async (entry) => {
|
|
533
|
+
const hash = requireHash(entry.hash, "broadcast.hash");
|
|
534
|
+
const raw = await callOrThrow(rpc, "eth_getTransactionByHash", [hash], `looking up ${hash}`);
|
|
535
|
+
const declared = entry.nonce === undefined ? null : toBigint(entry.nonce, "broadcast.nonce");
|
|
536
|
+
if (raw === null || raw === undefined) {
|
|
537
|
+
// The endpoint has never heard of it. With the nonce it was sent
|
|
538
|
+
// with, that is decidable: if the account has already moved past
|
|
539
|
+
// that nonce, something ELSE used it — a replacement, or a
|
|
540
|
+
// different transaction entirely.
|
|
541
|
+
const state = declared === null
|
|
542
|
+
? "unknownToEndpoint"
|
|
543
|
+
: declared < latest
|
|
544
|
+
? "replacedOrSuperseded"
|
|
545
|
+
: "notInThisMempool";
|
|
546
|
+
return {
|
|
547
|
+
hash,
|
|
548
|
+
state,
|
|
549
|
+
nonce: declared?.toString() ?? null,
|
|
550
|
+
note: state === "replacedOrSuperseded"
|
|
551
|
+
? `the account's nonce is already ${latest}, so nonce ${declared} was consumed by a different transaction`
|
|
552
|
+
: "this endpoint does not know this hash; it may never have propagated here, or it may have been evicted",
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const view = projectTransaction(requireObject(raw, `the transaction ${hash}`));
|
|
556
|
+
return {
|
|
557
|
+
hash,
|
|
558
|
+
state: view.blockNumber === null ? "pendingHere" : "mined",
|
|
559
|
+
nonce: view.nonce,
|
|
560
|
+
blockNumber: view.blockNumber,
|
|
561
|
+
};
|
|
562
|
+
}));
|
|
563
|
+
const pendingHere = rows.filter((r) => r.state === "pendingHere");
|
|
564
|
+
// The whole reason this tool cannot just subtract two numbers: an endpoint
|
|
565
|
+
// that does not track the public mempool answers `pending` with `latest`,
|
|
566
|
+
// and a caller reading that as "nothing is queued" broadcasts a duplicate.
|
|
567
|
+
// A transaction this endpoint admits is unmined, on an account whose
|
|
568
|
+
// pending nonce has not moved, proves the pending view is not mempool-aware.
|
|
569
|
+
const mempoolVisible = pendingHere.length === 0 ? null : pending > latest;
|
|
570
|
+
const gapEvidence = pendingHere.find((row) => row.nonce !== null && BigInt(row.nonce) > pending);
|
|
571
|
+
const verdict = pending < latest
|
|
572
|
+
? "unknown"
|
|
573
|
+
: mempoolVisible === false
|
|
574
|
+
? "unknown"
|
|
575
|
+
: gapEvidence !== undefined
|
|
576
|
+
? "gap"
|
|
577
|
+
: pending > latest
|
|
578
|
+
? "pending"
|
|
579
|
+
: "clear";
|
|
580
|
+
return json({
|
|
581
|
+
address,
|
|
582
|
+
latestNonce: latest.toString(),
|
|
583
|
+
pendingNonce: pending.toString(),
|
|
584
|
+
queued: pending > latest ? (pending - latest).toString() : "0",
|
|
585
|
+
nextNonce: pending > latest ? pending.toString() : latest.toString(),
|
|
586
|
+
verdict,
|
|
587
|
+
mempoolVisible,
|
|
588
|
+
broadcast: rows,
|
|
589
|
+
...(pending < latest
|
|
590
|
+
? {
|
|
591
|
+
endpointInconsistent: true,
|
|
592
|
+
note: "this endpoint reported a pending nonce BELOW the latest nonce, which cannot be true of one node — the two calls were probably answered by different machines behind a load balancer, so neither number can be trusted for a decision",
|
|
593
|
+
}
|
|
594
|
+
: {}),
|
|
595
|
+
...(mempoolVisible === false
|
|
596
|
+
? {
|
|
597
|
+
note: "this endpoint reports a transaction of yours as unmined while its pending nonce has not moved past the latest one, so its pending view does not include the mempool. Treat 'clear' from this endpoint as unknown.",
|
|
598
|
+
}
|
|
599
|
+
: {}),
|
|
600
|
+
...(gapEvidence !== undefined
|
|
601
|
+
? {
|
|
602
|
+
note: `transaction ${gapEvidence.hash} is waiting at nonce ${gapEvidence.nonce} while nothing has taken nonce ${pending} — nothing after the hole can be mined until it is filled`,
|
|
603
|
+
}
|
|
604
|
+
: {}),
|
|
605
|
+
gapDetection: input.broadcast === undefined || input.broadcast.length === 0
|
|
606
|
+
? "no broadcast hashes were supplied, so a nonce gap could not be looked for: a gapped transaction sits in a node's queued pool and is invisible to both nonce counts"
|
|
607
|
+
: "checked against the supplied broadcast hashes",
|
|
608
|
+
calls: rpc.calls,
|
|
609
|
+
});
|
|
610
|
+
},
|
|
611
|
+
});
|
|
612
|
+
export const evmWaitForReceipt = buildTool({
|
|
613
|
+
name: "EvmWaitForReceipt",
|
|
614
|
+
description: "Wait, with a deadline, for a transaction to be mined, and report which of three things happened: it was mined and succeeded, it was mined and REVERTED, or the deadline passed with it still unmined. Use it after broadcasting anything. The three outcomes are the point — a reverted transaction has a receipt with status 0x0, so a tool that reports 'no receipt' for it tells a caller to broadcast again, which is how a failed transaction becomes two. It also tells a transaction this endpoint has never seen apart from one it is holding in its mempool, and can wait for a number of confirmations or for the chain's own safe/finalized tag instead of a block count.",
|
|
615
|
+
inputSchema: z
|
|
616
|
+
.object({
|
|
617
|
+
rpcUrl: rpcUrlField,
|
|
618
|
+
txHash: hashField,
|
|
619
|
+
waitMs: z
|
|
620
|
+
.number()
|
|
621
|
+
.int()
|
|
622
|
+
.min(0)
|
|
623
|
+
.max(3_600_000)
|
|
624
|
+
.optional()
|
|
625
|
+
.describe("how long to wait in total, in milliseconds; default 60000"),
|
|
626
|
+
pollIntervalMs: z
|
|
627
|
+
.number()
|
|
628
|
+
.int()
|
|
629
|
+
.min(100)
|
|
630
|
+
.max(60_000)
|
|
631
|
+
.optional()
|
|
632
|
+
.describe("delay between polls; default 2000"),
|
|
633
|
+
confirmations: z
|
|
634
|
+
.number()
|
|
635
|
+
.int()
|
|
636
|
+
.min(0)
|
|
637
|
+
.max(1_000)
|
|
638
|
+
.optional()
|
|
639
|
+
.describe("also wait until the receipt is this many blocks deep; default 0"),
|
|
640
|
+
finality: z
|
|
641
|
+
.enum(["confirmations", "safe", "finalized"])
|
|
642
|
+
.optional()
|
|
643
|
+
.describe('what "settled" means; default "confirmations"'),
|
|
644
|
+
timeoutMs: timeoutField,
|
|
645
|
+
})
|
|
646
|
+
.strict(),
|
|
647
|
+
readOnly: true,
|
|
648
|
+
concurrencySafe: true,
|
|
649
|
+
scope: "external",
|
|
650
|
+
ioCapability: "network",
|
|
651
|
+
execute: async (input, ctx) => {
|
|
652
|
+
const rpc = await client(input, ctx);
|
|
653
|
+
const hash = requireHash(input.txHash, "txHash");
|
|
654
|
+
const waitMs = input.waitMs ?? 60_000;
|
|
655
|
+
const intervalMs = input.pollIntervalMs ?? 2_000;
|
|
656
|
+
const wanted = input.confirmations ?? 0;
|
|
657
|
+
const finality = input.finality ?? "confirmations";
|
|
658
|
+
const startedAt = nowMs();
|
|
659
|
+
const deadline = startedAt + waitMs;
|
|
660
|
+
let polls = 0;
|
|
661
|
+
let everSeenInMempool = false;
|
|
662
|
+
/** Held so the deadline branch can still report a receipt that arrived but was not deep enough yet. */
|
|
663
|
+
let mined = null;
|
|
664
|
+
for (;;) {
|
|
665
|
+
polls += 1;
|
|
666
|
+
const receiptRaw = await callOrThrow(rpc, "eth_getTransactionReceipt", [hash], `reading the receipt for ${hash}`);
|
|
667
|
+
if (receiptRaw !== null && receiptRaw !== undefined) {
|
|
668
|
+
const receipt = projectReceipt(requireObject(receiptRaw, `the receipt for ${hash}`), projectLog);
|
|
669
|
+
const settled = await settlement(rpc, receipt.blockNumber, finality, wanted);
|
|
670
|
+
mined = { receipt, settled };
|
|
671
|
+
if (settled.settled) {
|
|
672
|
+
return json(minedResult(mined, finality, polls, nowMs() - startedAt, rpc.calls));
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
const txRaw = await callOrThrow(rpc, "eth_getTransactionByHash", [hash], `looking up ${hash}`);
|
|
677
|
+
if (txRaw !== null && txRaw !== undefined)
|
|
678
|
+
everSeenInMempool = true;
|
|
679
|
+
}
|
|
680
|
+
const remaining = deadline - nowMs();
|
|
681
|
+
if (remaining <= 0) {
|
|
682
|
+
// A receipt that arrived but is not yet as deep as the caller asked for
|
|
683
|
+
// is still a receipt. Reporting "not mined" here because the clock ran
|
|
684
|
+
// out is the confusion this tool exists to remove.
|
|
685
|
+
if (mined !== null) {
|
|
686
|
+
return json(minedResult(mined, finality, polls, nowMs() - startedAt, rpc.calls));
|
|
687
|
+
}
|
|
688
|
+
return json({
|
|
689
|
+
// Not an error: "still unmined after 60 seconds" is an answer, and
|
|
690
|
+
// the caller's next move depends on which of the two reasons it is.
|
|
691
|
+
outcome: "deadline",
|
|
692
|
+
status: "notMined",
|
|
693
|
+
reason: everSeenInMempool
|
|
694
|
+
? "the deadline passed while this endpoint still held the transaction in its mempool — it is unmined, not failed, and rebroadcasting the same nonce will not help"
|
|
695
|
+
: "the deadline passed and this endpoint has never seen this transaction — it may not have propagated here, it may have been dropped, or it may have been sent to a different chain",
|
|
696
|
+
knownToEndpoint: everSeenInMempool,
|
|
697
|
+
polls,
|
|
698
|
+
waitedMs: nowMs() - startedAt,
|
|
699
|
+
waitMs,
|
|
700
|
+
calls: rpc.calls,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
await sleep(Math.min(intervalMs, remaining), ctx?.signal);
|
|
704
|
+
}
|
|
705
|
+
},
|
|
706
|
+
});
|
|
707
|
+
function minedResult(mined, finality, polls, elapsedMs, calls) {
|
|
708
|
+
const { receipt, settled } = mined;
|
|
709
|
+
return {
|
|
710
|
+
outcome: "mined",
|
|
711
|
+
// The three-way answer, said plainly, because "ok: false" on a revert reads
|
|
712
|
+
// the same as "ok: false" on a timeout and they are opposite instructions
|
|
713
|
+
// to the caller.
|
|
714
|
+
status: receipt.status,
|
|
715
|
+
...(receipt.statusReason !== undefined ? { statusReason: receipt.statusReason } : {}),
|
|
716
|
+
reverted: receipt.status === "reverted",
|
|
717
|
+
settled: settled.settled,
|
|
718
|
+
...(settled.settled ? {} : { settlementNote: settled.note }),
|
|
719
|
+
confirmations: settled.confirmations,
|
|
720
|
+
finality,
|
|
721
|
+
receipt: {
|
|
722
|
+
blockNumber: receipt.blockNumber,
|
|
723
|
+
blockHash: receipt.blockHash,
|
|
724
|
+
transactionIndex: receipt.transactionIndex,
|
|
725
|
+
gasUsed: receipt.gasUsed,
|
|
726
|
+
effectiveGasPrice: receipt.effectiveGasPrice,
|
|
727
|
+
contractAddress: receipt.contractAddress,
|
|
728
|
+
logCount: receipt.logs.length,
|
|
729
|
+
},
|
|
730
|
+
polls,
|
|
731
|
+
elapsedMs,
|
|
732
|
+
calls,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
async function settlement(rpc, receiptBlock, finality, wanted) {
|
|
736
|
+
const mined = BigInt(receiptBlock);
|
|
737
|
+
const head = await headNumber(rpc);
|
|
738
|
+
const confirmations = head >= mined ? (head - mined + 1n).toString() : "0";
|
|
739
|
+
if (finality === "confirmations") {
|
|
740
|
+
return {
|
|
741
|
+
settled: BigInt(confirmations) >= BigInt(wanted),
|
|
742
|
+
confirmations,
|
|
743
|
+
note: `${confirmations} of ${wanted} confirmations`,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
// A block count is not finality on a chain that reorganises in bursts; the
|
|
747
|
+
// chain's own tag is. Not every chain or endpoint serves one, and that is a
|
|
748
|
+
// refusal rather than a quiet fallback to counting — falling back would
|
|
749
|
+
// report "finalized" for something that is not.
|
|
750
|
+
const tagged = await fetchBlock(rpc, { kind: "tag", tag: finality });
|
|
751
|
+
if (tagged === null || tagged.number === null) {
|
|
752
|
+
throw new ChainReadError(`this endpoint does not serve the "${finality}" block tag, so finality cannot be established that way — use finality:"confirmations" with a confirmation count you trust for this chain`);
|
|
753
|
+
}
|
|
754
|
+
const settled = BigInt(tagged.number) >= mined;
|
|
755
|
+
return {
|
|
756
|
+
settled,
|
|
757
|
+
confirmations,
|
|
758
|
+
note: `the chain's ${finality} block is ${tagged.number}; this transaction is in block ${receiptBlock}`,
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
export const evmTransactionSummary = buildTool({
|
|
762
|
+
name: "EvmTransactionSummary",
|
|
763
|
+
description: "Explain one transaction in bookkeeping terms: who sent it, what it called, which tokens and how much native value moved, which approvals it granted, what it cost in fees, and the net change for an address you name. Use it to reconcile or to explain a transaction without reading raw logs. It is explicit about its own blind spots, in the output and not only in the docs: a receipt carries logs and no trace, so native value moved by a CONTRACT during the call is invisible here and the net deltas say so. Fees are computed from the fields the receipt actually has, so an OP-stack L1 data fee is added and an Arbitrum L1 charge is not double-counted.",
|
|
764
|
+
inputSchema: z
|
|
765
|
+
.object({
|
|
766
|
+
rpcUrl: rpcUrlField,
|
|
767
|
+
txHash: hashField,
|
|
768
|
+
perspective: addressField
|
|
769
|
+
.optional()
|
|
770
|
+
.describe("compute net deltas for this address; defaults to the sender"),
|
|
771
|
+
timeoutMs: timeoutField,
|
|
772
|
+
})
|
|
773
|
+
.strict(),
|
|
774
|
+
readOnly: true,
|
|
775
|
+
concurrencySafe: true,
|
|
776
|
+
scope: "external",
|
|
777
|
+
ioCapability: "network",
|
|
778
|
+
execute: async (input, ctx) => {
|
|
779
|
+
const rpc = await client(input, ctx);
|
|
780
|
+
const hash = requireHash(input.txHash, "txHash");
|
|
781
|
+
const [txRaw, receiptRaw] = await Promise.all([
|
|
782
|
+
callOrThrow(rpc, "eth_getTransactionByHash", [hash], `looking up ${hash}`),
|
|
783
|
+
callOrThrow(rpc, "eth_getTransactionReceipt", [hash], `reading the receipt for ${hash}`),
|
|
784
|
+
]);
|
|
785
|
+
if (txRaw === null || txRaw === undefined) {
|
|
786
|
+
throw new ChainReadError(`this endpoint has no transaction ${hash} — it may be on a different chain, it may never have propagated here, or it may have been dropped from the mempool`);
|
|
787
|
+
}
|
|
788
|
+
const tx = projectTransaction(requireObject(txRaw, `the transaction ${hash}`));
|
|
789
|
+
if (receiptRaw === null || receiptRaw === undefined) {
|
|
790
|
+
return json({
|
|
791
|
+
mined: false,
|
|
792
|
+
note: "this transaction is known to the endpoint but has no receipt yet, so nothing about its effects is settled — there are no transfers to report and no fee to compute",
|
|
793
|
+
transaction: tx,
|
|
794
|
+
calls: rpc.calls,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
const receiptObject = requireObject(receiptRaw, `the receipt for ${hash}`);
|
|
798
|
+
const receipt = projectReceipt(receiptObject, projectLog);
|
|
799
|
+
const block = await fetchBlock(rpc, { kind: "number", value: BigInt(receipt.blockNumber) });
|
|
800
|
+
const movements = [];
|
|
801
|
+
const approvals = [];
|
|
802
|
+
let undecoded = 0;
|
|
803
|
+
for (const log of receipt.logs) {
|
|
804
|
+
const transfer = decodeTransfer(log);
|
|
805
|
+
if (transfer !== null) {
|
|
806
|
+
movements.push(transfer);
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
const approval = decodeApproval(log);
|
|
810
|
+
if (approval !== null) {
|
|
811
|
+
approvals.push(approval);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
undecoded += 1;
|
|
815
|
+
}
|
|
816
|
+
const fees = feeBreakdown(receiptObject, receipt);
|
|
817
|
+
const who = input.perspective === undefined ? tx.from : requireAddress(input.perspective, "perspective");
|
|
818
|
+
const deltas = netDeltas(who, tx, receipt, movements, fees);
|
|
819
|
+
return json({
|
|
820
|
+
mined: true,
|
|
821
|
+
status: receipt.status,
|
|
822
|
+
...(receipt.statusReason !== undefined ? { statusReason: receipt.statusReason } : {}),
|
|
823
|
+
transaction: tx,
|
|
824
|
+
block: block === null
|
|
825
|
+
? null
|
|
826
|
+
: { number: block.number, timestamp: block.timestamp, timestampIso: block.timestampIso },
|
|
827
|
+
// A reverted transaction still costs gas and still has logs from before
|
|
828
|
+
// the revert stripped — except it does not: a revert discards them. Saying
|
|
829
|
+
// so is cheaper than a caller wondering why the arrays are empty.
|
|
830
|
+
...(receipt.status === "reverted"
|
|
831
|
+
? {
|
|
832
|
+
note: "this transaction reverted: its state changes were discarded and it emitted no logs, but the fee below was still charged",
|
|
833
|
+
}
|
|
834
|
+
: {}),
|
|
835
|
+
tokenTransfers: movements,
|
|
836
|
+
approvals,
|
|
837
|
+
undecodedLogs: undecoded,
|
|
838
|
+
fees,
|
|
839
|
+
netDeltas: deltas,
|
|
840
|
+
completeness: {
|
|
841
|
+
internalNativeTransfers: "excluded",
|
|
842
|
+
why: "a receipt contains logs and no trace, so native value moved by a contract during this call leaves no record here. Only the transaction's own top-level value is counted.",
|
|
843
|
+
tokenMetadata: "not resolved",
|
|
844
|
+
tokenMetadataWhy: "amounts are raw base units. Resolving symbol and decimals costs an eth_call per token and a token contract is free to report whatever it likes; use TokenUnits with decimals you trust.",
|
|
845
|
+
},
|
|
846
|
+
calls: rpc.calls,
|
|
847
|
+
});
|
|
848
|
+
},
|
|
849
|
+
});
|
|
850
|
+
function netDeltas(who, tx, receipt, movements, fees) {
|
|
851
|
+
const isSender = tx.from === who;
|
|
852
|
+
// A revert discards the value transfer with everything else it did; the fee
|
|
853
|
+
// is the only thing the chain keeps. Booking `tx.value` anyway put a delta
|
|
854
|
+
// in this object that its own `completeWhy` said had not happened — and a
|
|
855
|
+
// reconciliation reading the number rather than the prose is then wrong by
|
|
856
|
+
// the whole transaction.
|
|
857
|
+
const moved = receipt.status === "reverted" ? 0n : BigInt(tx.value);
|
|
858
|
+
let native = 0n;
|
|
859
|
+
if (isSender)
|
|
860
|
+
native -= moved;
|
|
861
|
+
if (tx.to === who)
|
|
862
|
+
native += moved;
|
|
863
|
+
const tokens = new Map();
|
|
864
|
+
for (const move of movements) {
|
|
865
|
+
if (move.standard === "erc721")
|
|
866
|
+
continue;
|
|
867
|
+
const amount = BigInt(move.amount);
|
|
868
|
+
if (move.from === who)
|
|
869
|
+
tokens.set(move.token, (tokens.get(move.token) ?? 0n) - amount);
|
|
870
|
+
if (move.to === who)
|
|
871
|
+
tokens.set(move.token, (tokens.get(move.token) ?? 0n) + amount);
|
|
872
|
+
}
|
|
873
|
+
const fee = isSender && fees.totalFeeWei !== null ? BigInt(fees.totalFeeWei) : 0n;
|
|
874
|
+
return {
|
|
875
|
+
address: who,
|
|
876
|
+
isSender,
|
|
877
|
+
nativeWei: native.toString(),
|
|
878
|
+
feePaidWei: fee.toString(),
|
|
879
|
+
nativeWeiIncludingFee: (native - fee).toString(),
|
|
880
|
+
tokens: [...tokens.entries()].map(([token, amount]) => ({ token, amount: amount.toString() })),
|
|
881
|
+
nfts: movements
|
|
882
|
+
.filter((m) => m.standard === "erc721" && (m.from === who || m.to === who))
|
|
883
|
+
.map((m) => ({ token: m.token, tokenId: m.tokenId, direction: m.to === who ? "in" : "out" })),
|
|
884
|
+
reverted: receipt.status === "reverted",
|
|
885
|
+
complete: false,
|
|
886
|
+
completeWhy: receipt.status === "reverted"
|
|
887
|
+
? "this transaction reverted, so nothing moved except the fee: the value it carried was returned and is not counted below"
|
|
888
|
+
: receipt.status === "unknown"
|
|
889
|
+
? `this receipt does not record whether the call reverted (${receipt.statusReason ?? "no status field"}), so the top-level value is counted as having moved — which is wrong if it did revert`
|
|
890
|
+
: "internal native transfers are not visible from a receipt, so this is complete only for the top-level value, the fee and the logged token movements",
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
export const evmEventScan = buildTool({
|
|
894
|
+
name: "EvmEventScan",
|
|
895
|
+
description: "Collect every log matching a filter across a block range, paging around whatever limits the endpoint imposes, and refuse rather than return a set it cannot prove is complete. Use it to find what happened onchain over a period — transfers to an address, every emission of one event — without hand-writing the paging. Public endpoints cap log queries and disagree about how: some answer with an error naming a smaller range, and some silently return the first N logs of a range that held more. The first is handled by halving and retrying; the second is caught by re-querying a suspicious chunk in halves and comparing the counts, because a missing log is an event that, to everything downstream, did not happen.",
|
|
896
|
+
inputSchema: z
|
|
897
|
+
.object({
|
|
898
|
+
rpcUrl: rpcUrlField,
|
|
899
|
+
fromBlock: blockField,
|
|
900
|
+
toBlock: blockField.optional().describe('default "latest"'),
|
|
901
|
+
confirmations: z
|
|
902
|
+
.number()
|
|
903
|
+
.int()
|
|
904
|
+
.min(0)
|
|
905
|
+
.max(10_000)
|
|
906
|
+
.optional()
|
|
907
|
+
.describe("stop this many blocks short of the head, so the range is behind any reorg"),
|
|
908
|
+
address: z
|
|
909
|
+
.union([addressField, z.array(addressField).min(1).max(LIMITS.addresses)])
|
|
910
|
+
.optional()
|
|
911
|
+
.describe("only logs from this contract (or these)"),
|
|
912
|
+
topics: z
|
|
913
|
+
.array(z.union([z.string(), z.array(z.string()).max(LIMITS.topicAlternatives), z.null()]))
|
|
914
|
+
.max(LIMITS.topicPositions)
|
|
915
|
+
.optional()
|
|
916
|
+
.describe("standard topic filter; topics[0] is the event signature hash"),
|
|
917
|
+
maxSpan: z
|
|
918
|
+
.number()
|
|
919
|
+
.int()
|
|
920
|
+
.min(1)
|
|
921
|
+
.max(1_000_000)
|
|
922
|
+
.optional()
|
|
923
|
+
.describe("blocks per request to start from; default 2000, narrowed automatically"),
|
|
924
|
+
maxLogs: z
|
|
925
|
+
.number()
|
|
926
|
+
.int()
|
|
927
|
+
.min(1)
|
|
928
|
+
.max(LIMITS.hardMaxLogs)
|
|
929
|
+
.optional()
|
|
930
|
+
.describe("refuse past this many logs rather than truncating; default 5000"),
|
|
931
|
+
suspectAt: z
|
|
932
|
+
.number()
|
|
933
|
+
.int()
|
|
934
|
+
.min(1)
|
|
935
|
+
.optional()
|
|
936
|
+
.describe("log count at which a chunk is re-checked for silent truncation; default 1000"),
|
|
937
|
+
decode: z
|
|
938
|
+
.boolean()
|
|
939
|
+
.optional()
|
|
940
|
+
.describe("also decode ERC-20/721/1155 transfers and approvals; default true"),
|
|
941
|
+
timeoutMs: timeoutField,
|
|
942
|
+
})
|
|
943
|
+
.strict(),
|
|
944
|
+
readOnly: true,
|
|
945
|
+
concurrencySafe: true,
|
|
946
|
+
scope: "external",
|
|
947
|
+
ioCapability: "network",
|
|
948
|
+
execute: async (input, ctx) => {
|
|
949
|
+
const rpc = await client(input, ctx);
|
|
950
|
+
const head = await headNumber(rpc);
|
|
951
|
+
const confirmations = BigInt(input.confirmations ?? 0);
|
|
952
|
+
const from = await resolveBlockNumber(rpc, input.fromBlock, "fromBlock");
|
|
953
|
+
const requestedTo = input.toBlock === undefined ? head : await resolveBlockNumber(rpc, input.toBlock, "toBlock");
|
|
954
|
+
// The range is pinned to numbers before the first query. A scan whose upper
|
|
955
|
+
// bound is still the string "latest" is a scan of a moving target, and
|
|
956
|
+
// "complete" is not a claim anybody can make about one.
|
|
957
|
+
const to = confirmations > 0n ? minBig(requestedTo, head - confirmations) : requestedTo;
|
|
958
|
+
if (to < from) {
|
|
959
|
+
throw new ChainReadError(`there is nothing to scan: fromBlock ${from} is after toBlock ${to}${confirmations > 0n
|
|
960
|
+
? ` (the head is ${head}, and ${confirmations} confirmations put the upper bound at ${head - confirmations})`
|
|
961
|
+
: ""}`);
|
|
962
|
+
}
|
|
963
|
+
const filter = {
|
|
964
|
+
...(input.address === undefined
|
|
965
|
+
? {}
|
|
966
|
+
: {
|
|
967
|
+
address: (Array.isArray(input.address) ? input.address : [input.address]).map((a) => requireAddress(a, "address")),
|
|
968
|
+
}),
|
|
969
|
+
...(input.topics === undefined ? {} : { topics: normalizeTopics(input.topics) }),
|
|
970
|
+
};
|
|
971
|
+
const report = await scanLogs(rpc, filter, {
|
|
972
|
+
from,
|
|
973
|
+
to,
|
|
974
|
+
span: input.maxSpan === undefined ? LIMITS.defaultSpan : BigInt(input.maxSpan),
|
|
975
|
+
suspectAt: input.suspectAt ?? 1_000,
|
|
976
|
+
maxLogs: input.maxLogs ?? LIMITS.defaultMaxLogs,
|
|
977
|
+
maxCalls: LIMITS.scanCalls,
|
|
978
|
+
});
|
|
979
|
+
const decode = input.decode !== false;
|
|
980
|
+
return json({
|
|
981
|
+
// Only ever true. The alternative is a refusal, because a partial set and
|
|
982
|
+
// a complete one are indistinguishable once they leave this tool.
|
|
983
|
+
complete: true,
|
|
984
|
+
range: { fromBlock: from.toString(), toBlock: to.toString(), head: head.toString() },
|
|
985
|
+
logCount: report.logs.length,
|
|
986
|
+
logs: report.logs,
|
|
987
|
+
...(decode
|
|
988
|
+
? {
|
|
989
|
+
transfers: report.logs
|
|
990
|
+
.map(decodeTransfer)
|
|
991
|
+
.filter((m) => m !== null),
|
|
992
|
+
approvals: report.logs
|
|
993
|
+
.map(decodeApproval)
|
|
994
|
+
.filter((a) => a !== null),
|
|
995
|
+
}
|
|
996
|
+
: {}),
|
|
997
|
+
paging: {
|
|
998
|
+
chunks: report.chunks,
|
|
999
|
+
calls: report.calls,
|
|
1000
|
+
finalSpanBlocks: report.finalSpan,
|
|
1001
|
+
verifications: report.verifications,
|
|
1002
|
+
silentTruncations: report.silentTruncations,
|
|
1003
|
+
...(report.silentTruncations > 0
|
|
1004
|
+
? {
|
|
1005
|
+
warning: `${report.silentTruncations} chunk(s) came back truncated with nothing in the response saying so. The missing logs were recovered by re-querying in halves and the set above is complete, but this endpoint will do it again — prefer one that reports its limits, or keep maxSpan small.`,
|
|
1006
|
+
}
|
|
1007
|
+
: {}),
|
|
1008
|
+
},
|
|
1009
|
+
calls: rpc.calls,
|
|
1010
|
+
});
|
|
1011
|
+
},
|
|
1012
|
+
});
|
|
1013
|
+
function normalizeTopics(topics) {
|
|
1014
|
+
return topics.map((position, index) => {
|
|
1015
|
+
if (position === null)
|
|
1016
|
+
return null;
|
|
1017
|
+
if (Array.isArray(position)) {
|
|
1018
|
+
return position.map((t) => lowerHex(t, `topics[${index}]`));
|
|
1019
|
+
}
|
|
1020
|
+
return lowerHex(position, `topics[${index}]`);
|
|
1021
|
+
});
|
|
1022
|
+
}
|
|
1023
|
+
const minBig = (a, b) => (a < b ? a : b);
|
|
1024
|
+
const unitsField = z
|
|
1025
|
+
.object({
|
|
1026
|
+
symbol: z
|
|
1027
|
+
.string()
|
|
1028
|
+
.min(1)
|
|
1029
|
+
.max(32)
|
|
1030
|
+
.optional()
|
|
1031
|
+
.describe("a label for the description text; nothing is read off the chain to check it"),
|
|
1032
|
+
decimals: z
|
|
1033
|
+
.number()
|
|
1034
|
+
.int()
|
|
1035
|
+
.min(0)
|
|
1036
|
+
.max(36)
|
|
1037
|
+
.optional()
|
|
1038
|
+
.describe("the asset's own decimals, e.g. 18 for ether, 6 for USDC"),
|
|
1039
|
+
minorUnitDecimals: z
|
|
1040
|
+
.number()
|
|
1041
|
+
.int()
|
|
1042
|
+
.min(0)
|
|
1043
|
+
.max(36)
|
|
1044
|
+
.optional()
|
|
1045
|
+
.describe("the ledger's minor unit for this asset; default: the asset's own base units"),
|
|
1046
|
+
})
|
|
1047
|
+
.strict();
|
|
1048
|
+
export const onchainTransactionsSync = buildTool({
|
|
1049
|
+
name: "OnchainTransactionsSync",
|
|
1050
|
+
description: "Pull one address's onchain history over a block range and return it as the same normalized rows a bank statement parses into, so a wallet can be reconciled against a ledger instead of read as raw logs. It emits the row shape StatementParse produces and LedgerReconcile consumes — id, date, description, amountMinor, direction, reference, balanceMinor — with the exact uint256 kept alongside each row rather than rounded into it. Use it to close the books on a wallet, or to sync incrementally: it returns a cursor pinned to the block it actually finished at. It refuses rather than under-reporting, the way EvmEventScan does, and it says in the output which movements it cannot see at all: internal native transfers need a trace, and ERC-1155 is not collected.",
|
|
1051
|
+
inputSchema: z
|
|
1052
|
+
.object({
|
|
1053
|
+
rpcUrl: rpcUrlField,
|
|
1054
|
+
address: addressField.describe("the one account to build a statement for; a statement row has no account column, so this is deliberately not a list"),
|
|
1055
|
+
fromBlock: blockField,
|
|
1056
|
+
toBlock: blockField.optional().describe('default "latest"'),
|
|
1057
|
+
confirmations: z
|
|
1058
|
+
.number()
|
|
1059
|
+
.int()
|
|
1060
|
+
.min(0)
|
|
1061
|
+
.max(10_000)
|
|
1062
|
+
.optional()
|
|
1063
|
+
.describe("stop this many blocks short of the head, so the cursor is behind any reorg"),
|
|
1064
|
+
include: z
|
|
1065
|
+
.array(z.enum(["tokenTransfers", "native", "fees"]))
|
|
1066
|
+
.min(1)
|
|
1067
|
+
.max(3)
|
|
1068
|
+
.optional()
|
|
1069
|
+
.describe('what to collect; default all three. Dropping one is reported in "coverage"'),
|
|
1070
|
+
tokens: z
|
|
1071
|
+
.array(z
|
|
1072
|
+
.object({
|
|
1073
|
+
token: addressField,
|
|
1074
|
+
decimals: z.number().int().min(0).max(36),
|
|
1075
|
+
symbol: z.string().min(1).max(32).optional(),
|
|
1076
|
+
minorUnitDecimals: z.number().int().min(0).max(36).optional(),
|
|
1077
|
+
})
|
|
1078
|
+
.strict())
|
|
1079
|
+
.max(LIMITS.assets)
|
|
1080
|
+
.optional()
|
|
1081
|
+
.describe("the tokens whose decimals you know; nothing is read from a token contract"),
|
|
1082
|
+
onlyKnownTokens: z
|
|
1083
|
+
.boolean()
|
|
1084
|
+
.optional()
|
|
1085
|
+
.describe("scan only the tokens listed above; default true when any are listed"),
|
|
1086
|
+
native: unitsField
|
|
1087
|
+
.optional()
|
|
1088
|
+
.describe('units for the chain\'s own coin; default 18 decimals, symbol "native value"'),
|
|
1089
|
+
maxHydratedBlocks: z
|
|
1090
|
+
.number()
|
|
1091
|
+
.int()
|
|
1092
|
+
.min(1)
|
|
1093
|
+
.max(LIMITS.hardHydratedBlocks)
|
|
1094
|
+
.optional()
|
|
1095
|
+
.describe("native value costs one request per block; refuse past this many. Default 256"),
|
|
1096
|
+
maxRows: z
|
|
1097
|
+
.number()
|
|
1098
|
+
.int()
|
|
1099
|
+
.min(1)
|
|
1100
|
+
.max(LIMITS.hardRows)
|
|
1101
|
+
.optional()
|
|
1102
|
+
.describe("refuse past this many rows rather than truncating; default 5000"),
|
|
1103
|
+
maxSpan: z
|
|
1104
|
+
.number()
|
|
1105
|
+
.int()
|
|
1106
|
+
.min(1)
|
|
1107
|
+
.max(1_000_000)
|
|
1108
|
+
.optional()
|
|
1109
|
+
.describe("blocks per log request to start from; default 2000, narrowed automatically"),
|
|
1110
|
+
suspectAt: z
|
|
1111
|
+
.number()
|
|
1112
|
+
.int()
|
|
1113
|
+
.min(1)
|
|
1114
|
+
.optional()
|
|
1115
|
+
.describe("log count at which a chunk is re-checked for silent truncation; default 1000"),
|
|
1116
|
+
timeoutMs: timeoutField,
|
|
1117
|
+
})
|
|
1118
|
+
.strict(),
|
|
1119
|
+
readOnly: true,
|
|
1120
|
+
concurrencySafe: true,
|
|
1121
|
+
scope: "external",
|
|
1122
|
+
ioCapability: "network",
|
|
1123
|
+
execute: async (input, ctx) => {
|
|
1124
|
+
const rpc = await client(input, ctx);
|
|
1125
|
+
const address = requireAddress(input.address, "address");
|
|
1126
|
+
const include = input.include ?? ["tokenTransfers", "native", "fees"];
|
|
1127
|
+
const includeTokens = include.includes("tokenTransfers");
|
|
1128
|
+
const includeNative = include.includes("native");
|
|
1129
|
+
const includeFees = include.includes("fees");
|
|
1130
|
+
const tokenUnits = declaredTokenUnits(input.tokens);
|
|
1131
|
+
const nativeUnits = declaredNativeUnits(input.native);
|
|
1132
|
+
const onlyKnownTokens = input.onlyKnownTokens ?? tokenUnits.size > 0;
|
|
1133
|
+
if (input.onlyKnownTokens === true && tokenUnits.size === 0) {
|
|
1134
|
+
throw new ChainReadError("onlyKnownTokens is true but no tokens were listed, which would scan for nothing and report it as a complete history — list the tokens, or leave onlyKnownTokens off");
|
|
1135
|
+
}
|
|
1136
|
+
const head = await headNumber(rpc);
|
|
1137
|
+
const confirmations = BigInt(input.confirmations ?? 0);
|
|
1138
|
+
const from = await resolveBlockNumber(rpc, input.fromBlock, "fromBlock");
|
|
1139
|
+
const requestedTo = input.toBlock === undefined ? head : await resolveBlockNumber(rpc, input.toBlock, "toBlock");
|
|
1140
|
+
// Pinned to numbers before the first query, exactly as EvmEventScan does it:
|
|
1141
|
+
// the cursor handed back has to name a block, and a run that stopped at
|
|
1142
|
+
// "latest" stopped somewhere nobody can resume from.
|
|
1143
|
+
const to = confirmations > 0n ? minBig(requestedTo, head - confirmations) : requestedTo;
|
|
1144
|
+
if (to < from) {
|
|
1145
|
+
throw new ChainReadError(`there is nothing to sync: fromBlock ${from} is after toBlock ${to}${confirmations > 0n
|
|
1146
|
+
? ` (the head is ${head}, and ${confirmations} confirmations put the upper bound at ${head - confirmations})`
|
|
1147
|
+
: ""}`);
|
|
1148
|
+
}
|
|
1149
|
+
const report = await collectHistory(rpc, {
|
|
1150
|
+
address,
|
|
1151
|
+
from,
|
|
1152
|
+
to,
|
|
1153
|
+
includeTokens,
|
|
1154
|
+
includeNative,
|
|
1155
|
+
includeFees,
|
|
1156
|
+
tokenUnits,
|
|
1157
|
+
onlyKnownTokens,
|
|
1158
|
+
nativeUnits,
|
|
1159
|
+
span: input.maxSpan === undefined ? LIMITS.defaultSpan : BigInt(input.maxSpan),
|
|
1160
|
+
suspectAt: input.suspectAt ?? 1_000,
|
|
1161
|
+
maxRows: input.maxRows ?? LIMITS.defaultRows,
|
|
1162
|
+
maxHydratedBlocks: input.maxHydratedBlocks ?? LIMITS.defaultHydratedBlocks,
|
|
1163
|
+
maxCalls: LIMITS.syncCalls,
|
|
1164
|
+
});
|
|
1165
|
+
return json({
|
|
1166
|
+
address,
|
|
1167
|
+
// Only ever true, and only ever about the sources under `coverage`. A set
|
|
1168
|
+
// that could not be proved complete is an error, not a shorter list.
|
|
1169
|
+
complete: true,
|
|
1170
|
+
completeWhy: "every included source was collected over the whole pinned range or this would have been a refusal; what each source rests on, and what was not collected at all, is named in coverage",
|
|
1171
|
+
range: {
|
|
1172
|
+
fromBlock: from.toString(),
|
|
1173
|
+
toBlock: to.toString(),
|
|
1174
|
+
head: head.toString(),
|
|
1175
|
+
confirmations: confirmations.toString(),
|
|
1176
|
+
},
|
|
1177
|
+
// Resumable because it names the block this run actually finished at, not
|
|
1178
|
+
// the head at the moment it ended: the head has moved, and a cursor set
|
|
1179
|
+
// from it skips every block mined during the run.
|
|
1180
|
+
cursor: {
|
|
1181
|
+
nextFromBlock: (to + 1n).toString(),
|
|
1182
|
+
scannedThrough: to.toString(),
|
|
1183
|
+
note: "pass nextFromBlock as fromBlock to continue without re-reading or skipping a block",
|
|
1184
|
+
},
|
|
1185
|
+
rowCount: report.rows.length,
|
|
1186
|
+
/** Exactly `@crewhaus/tool-money`'s `Transaction`: feed it to LedgerReconcile as kind "lines". */
|
|
1187
|
+
rows: report.rows,
|
|
1188
|
+
/** The same rows keyed by id, with the uint256 that would not fit in one. */
|
|
1189
|
+
detail: report.detail,
|
|
1190
|
+
// Movements that are on the chain and cannot be written as a row. Not an
|
|
1191
|
+
// empty list because nothing happened — a list because something did.
|
|
1192
|
+
unrepresentable: report.unrepresentable,
|
|
1193
|
+
...(report.unrepresentable.length > 0
|
|
1194
|
+
? {
|
|
1195
|
+
unrepresentableWarning: `${report.unrepresentable.length} movement(s) could not be written as a row without rounding. They are listed above with the exact raw amount; give the asset's decimals and the ledger's minorUnitDecimals to bring them into range.`,
|
|
1196
|
+
}
|
|
1197
|
+
: {}),
|
|
1198
|
+
selfTransfers: report.selfTransfers,
|
|
1199
|
+
// The evidence for the half of `coverage` that is a claim rather than a
|
|
1200
|
+
// disclaimer, in the output next to it: a caller that reconciles on these
|
|
1201
|
+
// rows can see what the sent side was checked against.
|
|
1202
|
+
...(report.sentProof === undefined ? {} : { sentProof: report.sentProof }),
|
|
1203
|
+
coverage: coverageOf(includeTokens, includeNative, includeFees, onlyKnownTokens, report.sentProof),
|
|
1204
|
+
paging: {
|
|
1205
|
+
logChunks: report.logChunks,
|
|
1206
|
+
logCalls: report.logCalls,
|
|
1207
|
+
verifications: report.verifications,
|
|
1208
|
+
silentTruncations: report.silentTruncations,
|
|
1209
|
+
hydratedBlocks: report.hydratedBlocks,
|
|
1210
|
+
receiptsRead: report.receiptsRead,
|
|
1211
|
+
...(report.silentTruncations > 0
|
|
1212
|
+
? {
|
|
1213
|
+
warning: `${report.silentTruncations} log chunk(s) came back truncated with nothing in the response saying so. The missing transfers were recovered by re-querying in halves and the rows above are complete, but this endpoint will do it again — prefer one that reports its limits, or keep maxSpan small.`,
|
|
1214
|
+
}
|
|
1215
|
+
: {}),
|
|
1216
|
+
},
|
|
1217
|
+
calls: rpc.calls,
|
|
1218
|
+
});
|
|
1219
|
+
},
|
|
1220
|
+
});
|
|
1221
|
+
/** The caller's token table, checked for the two ways it can be self-contradictory. */
|
|
1222
|
+
function declaredTokenUnits(tokens) {
|
|
1223
|
+
const units = new Map();
|
|
1224
|
+
for (const entry of tokens ?? []) {
|
|
1225
|
+
const token = requireAddress(entry.token, "tokens[].token");
|
|
1226
|
+
if (units.has(token)) {
|
|
1227
|
+
throw new ChainReadError(`tokens lists ${token} twice — two sets of decimals for one token is two different answers for every row of it`);
|
|
1228
|
+
}
|
|
1229
|
+
const minor = entry.minorUnitDecimals ?? entry.decimals;
|
|
1230
|
+
if (minor > entry.decimals) {
|
|
1231
|
+
throw new ChainReadError(`tokens[${token}]: minorUnitDecimals (${minor}) is finer than the token's own decimals (${entry.decimals}) — there are no digits there to scale up into`);
|
|
1232
|
+
}
|
|
1233
|
+
units.set(token, {
|
|
1234
|
+
decimals: entry.decimals,
|
|
1235
|
+
minorUnitDecimals: minor,
|
|
1236
|
+
symbol: entry.symbol ?? null,
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
return units;
|
|
1240
|
+
}
|
|
1241
|
+
function declaredNativeUnits(native) {
|
|
1242
|
+
if (native === undefined)
|
|
1243
|
+
return UNKNOWN_UNITS;
|
|
1244
|
+
// 18 is the EVM native decimal count everywhere it is not overridden, and it
|
|
1245
|
+
// is only a default for the SCALE — the symbol is still whatever the caller
|
|
1246
|
+
// said, because this package never asks a chain what its coin is called.
|
|
1247
|
+
const decimals = native.decimals ?? 18;
|
|
1248
|
+
const minor = native.minorUnitDecimals ?? decimals;
|
|
1249
|
+
if (minor > decimals) {
|
|
1250
|
+
throw new ChainReadError(`native.minorUnitDecimals (${minor}) is finer than native.decimals (${decimals}) — there are no digits there to scale up into`);
|
|
1251
|
+
}
|
|
1252
|
+
return { decimals, minorUnitDecimals: minor, symbol: native.symbol ?? null };
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* What is in these rows and what is not, in the output rather than only in the
|
|
1256
|
+
* docs.
|
|
1257
|
+
*
|
|
1258
|
+
* Three of these are permanent — a receipt has no trace in it, ERC-1155 puts
|
|
1259
|
+
* its parties where this filter does not look, and a public endpoint has no
|
|
1260
|
+
* address index — and a caller who reconciles against rows that silently
|
|
1261
|
+
* omitted them balances to a number that is wrong by exactly what was omitted.
|
|
1262
|
+
*/
|
|
1263
|
+
function coverageOf(tokens, native, fees, onlyKnownTokens, sentProof) {
|
|
1264
|
+
// What the two block-sourced lines below may claim, and no more. A hydrated
|
|
1265
|
+
// block is one response that cannot be split, so the log scan's split-and-
|
|
1266
|
+
// compare proof has nothing to work with here — the account's nonce is the
|
|
1267
|
+
// only evidence there is, and it is evidence about the SENT side only.
|
|
1268
|
+
const sent = sentProof === undefined
|
|
1269
|
+
? "not read"
|
|
1270
|
+
: sentProof.outcome === "proved"
|
|
1271
|
+
? `proved complete against this account's nonce (${sentProof.sent} transaction(s))`
|
|
1272
|
+
: sentProof.outcome === "moreThanTheNonce"
|
|
1273
|
+
? `${sentProof.sent} transaction(s), more than the nonce accounts for — a rollup's system transactions look like this, and nothing is missing`
|
|
1274
|
+
: `NOT proved: ${sentProof.why}`;
|
|
1275
|
+
return {
|
|
1276
|
+
tokenTransfers: tokens
|
|
1277
|
+
? onlyKnownTokens
|
|
1278
|
+
? "complete for the tokens listed in `tokens`, checked on the answer and not only in the filter; transfers of any other token were not scanned for"
|
|
1279
|
+
: "complete: every ERC-20 and ERC-721 Transfer log with this address as a party"
|
|
1280
|
+
: 'excluded: "tokenTransfers" was not in include',
|
|
1281
|
+
nativeTransfers: native
|
|
1282
|
+
? `top-level value transfers, read from the blocks themselves. Sent: ${sent}. Received: whatever this endpoint's blocks contained — a block cannot be re-asked in halves, so a short one cannot be told from a quiet one`
|
|
1283
|
+
: 'excluded: "native" was not in include, so a plain coin payment is NOT in these rows',
|
|
1284
|
+
fees: fees
|
|
1285
|
+
? `one row per transaction this address sent, charged once per transaction and never once per log. Sent: ${sent}`
|
|
1286
|
+
: 'excluded: "fees" was not in include, so gas spend is NOT in these rows',
|
|
1287
|
+
internalNativeTransfers: "excluded: coin moved by a CONTRACT during a call leaves no log and no receipt field. It is only visible in a trace, and debug_traceTransaction is neither on a public endpoint's free tier nor on this package's read-only allow-list",
|
|
1288
|
+
erc1155: "excluded: TransferSingle carries the operator where this filter looks for the sender, and TransferBatch keeps its amounts in data where no topic filter reaches. Neither is scanned for, rather than scanned for badly",
|
|
1289
|
+
tokenMetadata: "not resolved: decimals and symbols are only ever the ones you supplied. Reading decimals() costs an eth_call per token and a token contract is free to report whatever it likes, which turns an airdrop's dust into a five-figure row",
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
1293
|
+
export const CHAINREAD_TOOLS = Object.freeze([
|
|
1294
|
+
evmBlockAtTimestamp,
|
|
1295
|
+
evmEventScan,
|
|
1296
|
+
evmGetBlock,
|
|
1297
|
+
evmNonceStatus,
|
|
1298
|
+
evmRpcHealth,
|
|
1299
|
+
evmTransactionSummary,
|
|
1300
|
+
evmWaitForReceipt,
|
|
1301
|
+
onchainTransactionsSync,
|
|
1302
|
+
]);
|