@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,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which endpoints this package is willing to dial.
|
|
3
|
+
*
|
|
4
|
+
* Every other networked package here — `tool-registry`, `tool-containers` —
|
|
5
|
+
* builds its URLs from constants, so a model never picks a host. This one
|
|
6
|
+
* cannot: reading a chain the caller names means the caller names the RPC URL,
|
|
7
|
+
* and an unguarded `fetch(input.rpcUrl)` is a server-side request forgery
|
|
8
|
+
* primitive with a model holding the steering wheel. `http://169.254.169.254/`
|
|
9
|
+
* is the cloud metadata service; `http://127.0.0.1:6379/` is somebody's Redis.
|
|
10
|
+
*
|
|
11
|
+
* So the host is vetted before the socket opens, the vetted IP is what gets
|
|
12
|
+
* dialled (resolving here and letting `fetch` re-resolve at connect time is a
|
|
13
|
+
* DNS-rebinding TOCTOU), and the loopback/private ranges are refused unless an
|
|
14
|
+
* OPERATOR opened them — `setRpcEndpointPolicy` is bound at boot by the
|
|
15
|
+
* runtime, exactly like `setEvmAdapterResolver` in `tool-evm`. It is
|
|
16
|
+
* deliberately not a field in any tool's input schema: a gate a model can open
|
|
17
|
+
* for itself is not a gate.
|
|
18
|
+
*
|
|
19
|
+
* This is a narrower guard than `@crewhaus/tool-fetch`'s `assertNotSsrf`, which
|
|
20
|
+
* is the repo's canonical one and is not a dependency of this package. What is
|
|
21
|
+
* here covers the same ground for this package's one shape of request — a
|
|
22
|
+
* single origin, no redirects followed, no credentials attached — and the
|
|
23
|
+
* README says so rather than implying parity.
|
|
24
|
+
*/
|
|
25
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
26
|
+
export class RpcEndpointError extends CrewhausError {
|
|
27
|
+
name = "RpcEndpointError";
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super("tool", message);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let policy = {};
|
|
33
|
+
/** Bind the endpoint policy at boot. Generated daemons call this before registering the tools. */
|
|
34
|
+
export function setRpcEndpointPolicy(next) {
|
|
35
|
+
policy = next;
|
|
36
|
+
}
|
|
37
|
+
/** Read the policy back — for `EvmRpcHealth`, which reports the posture it is operating under. */
|
|
38
|
+
export function getRpcEndpointPolicy() {
|
|
39
|
+
return policy;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolution seam. Production uses `node:dns/promises`; every test in this
|
|
43
|
+
* package replaces it, so the suite never sends a DNS query and never opens a
|
|
44
|
+
* socket — which is the point, because CI has no egress.
|
|
45
|
+
*/
|
|
46
|
+
const defaultDnsLookup = async (host) => {
|
|
47
|
+
const dns = await import("node:dns/promises");
|
|
48
|
+
return dns.lookup(host, { verbatim: false });
|
|
49
|
+
};
|
|
50
|
+
let dnsLookupFn = defaultDnsLookup;
|
|
51
|
+
export function _setDnsLookup(fn) {
|
|
52
|
+
dnsLookupFn = fn ?? defaultDnsLookup;
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// BEGIN SYNCHRONISED BLOCK — private-address classifier
|
|
56
|
+
//
|
|
57
|
+
// This block is BYTE-IDENTICAL across every package that guards an outbound
|
|
58
|
+
// request. Do not edit one copy: `apps/cli/src/tool-registry.test.ts` hashes
|
|
59
|
+
// them all and fails if any differs, and it asserts how many it found, because
|
|
60
|
+
// a copy-scanning guard that matches nothing reports green.
|
|
61
|
+
//
|
|
62
|
+
// It exists in copies rather than a shared package because these files are
|
|
63
|
+
// otherwise independent per-package networking layers; a guard proving the
|
|
64
|
+
// copies are identical is cheaper and safer than the import graph a shared
|
|
65
|
+
// package would need across `crawler`, `computer-use-driver` and ten tools.
|
|
66
|
+
//
|
|
67
|
+
// WHY IT PARSES INSTEAD OF MATCHING TEXT. Six copies were confirmed
|
|
68
|
+
// exploitable on 2026-09-18 because they compared address STRINGS. The WHATWG
|
|
69
|
+
// URL parser rewrites `[::ffff:169.254.169.254]` to `[::ffff:a9fe:a9fe]`, so a
|
|
70
|
+
// text check never sees the spelling it was written for; and `64:ff9b::a9fe:a9fe`
|
|
71
|
+
// IS 169.254.169.254 on any network running DNS64/NAT64. Parsing numerically and
|
|
72
|
+
// recursing into the embedded IPv4 is the only form that holds.
|
|
73
|
+
//
|
|
74
|
+
// WHAT IT CANNOT DO. RFC 6052 lets an operator choose any Network-Specific
|
|
75
|
+
// Prefix for NAT64, so an embedded IPv4 behind an arbitrary NSP is undecidable
|
|
76
|
+
// from the address alone. That case is configuration, and the callers that need
|
|
77
|
+
// it resolve the host and re-check the ANSWER before dialling.
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
/**
|
|
80
|
+
* Canonicalise an IPv4 literal.
|
|
81
|
+
*
|
|
82
|
+
* `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
|
|
83
|
+
* `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
|
|
84
|
+
* only understands dotted-decimal waves every one of them through. Returns
|
|
85
|
+
* `null` when the string is not an IPv4 literal at all.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeIpv4(raw) {
|
|
88
|
+
const trimmed = raw.trim();
|
|
89
|
+
if (trimmed === "" || /[^0-9a-fA-FxX.]/.test(trimmed))
|
|
90
|
+
return null;
|
|
91
|
+
const parts = trimmed.split(".");
|
|
92
|
+
if (parts.length === 0 || parts.length > 4)
|
|
93
|
+
return null;
|
|
94
|
+
const values = [];
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (part === "")
|
|
97
|
+
return null;
|
|
98
|
+
let value;
|
|
99
|
+
if (/^0[xX][0-9a-fA-F]+$/.test(part))
|
|
100
|
+
value = Number.parseInt(part.slice(2), 16);
|
|
101
|
+
else if (/^0[0-7]+$/.test(part))
|
|
102
|
+
value = Number.parseInt(part.slice(1), 8);
|
|
103
|
+
else if (/^\d+$/.test(part))
|
|
104
|
+
value = Number.parseInt(part, 10);
|
|
105
|
+
else
|
|
106
|
+
return null;
|
|
107
|
+
if (!Number.isFinite(value) || value < 0)
|
|
108
|
+
return null;
|
|
109
|
+
values.push(value);
|
|
110
|
+
}
|
|
111
|
+
// The short forms pack the remaining octets into the last part: `127.1` is
|
|
112
|
+
// 127.0.0.1, not 127.1.0.0. Getting this backwards is how a bypass survives.
|
|
113
|
+
const last = values[values.length - 1];
|
|
114
|
+
const leading = values.slice(0, -1);
|
|
115
|
+
if (leading.some((v) => v > 255))
|
|
116
|
+
return null;
|
|
117
|
+
const remaining = 4 - leading.length;
|
|
118
|
+
if (last >= 2 ** (8 * remaining))
|
|
119
|
+
return null;
|
|
120
|
+
const octets = [...leading];
|
|
121
|
+
for (let i = remaining - 1; i >= 0; i--)
|
|
122
|
+
octets.push((last >>> (8 * i)) & 0xff);
|
|
123
|
+
return octets.join(".");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
|
|
127
|
+
* is not one.
|
|
128
|
+
*
|
|
129
|
+
* Classifying IPv6 by its TEXT is where the bypasses live, because one address
|
|
130
|
+
* has many spellings and the one a check was written against is rarely the one
|
|
131
|
+
* that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
|
|
132
|
+
* that form: the WHATWG URL parser re-serialises the embedded quad as hex
|
|
133
|
+
* pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
|
|
134
|
+
* first means the prefix tests below are arithmetic, and spelling stops
|
|
135
|
+
* mattering.
|
|
136
|
+
*/
|
|
137
|
+
export function parseIpv6(raw) {
|
|
138
|
+
let text = raw.trim().toLowerCase();
|
|
139
|
+
if (text.startsWith("["))
|
|
140
|
+
text = text.slice(1);
|
|
141
|
+
if (text.endsWith("]"))
|
|
142
|
+
text = text.slice(0, -1);
|
|
143
|
+
const zone = text.indexOf("%"); // fe80::1%eth0
|
|
144
|
+
if (zone !== -1)
|
|
145
|
+
text = text.slice(0, zone);
|
|
146
|
+
if (!text.includes(":"))
|
|
147
|
+
return null;
|
|
148
|
+
// A trailing dotted quad is the last two groups written in IPv4. Require all
|
|
149
|
+
// three dots: without that, `::1` parses as a one-part inet_aton address and
|
|
150
|
+
// takes a path that has nothing to do with what was written.
|
|
151
|
+
const dotted = /^(.*:)(\d+(?:\.\d+){3})$/.exec(text);
|
|
152
|
+
if (dotted !== null) {
|
|
153
|
+
const quad = normalizeIpv4(dotted[2]);
|
|
154
|
+
if (quad === null)
|
|
155
|
+
return null;
|
|
156
|
+
const o = quad.split(".").map((n) => Number.parseInt(n, 10));
|
|
157
|
+
const hi = (o[0] << 8) | o[1];
|
|
158
|
+
const lo = (o[2] << 8) | o[3];
|
|
159
|
+
text = `${dotted[1]}${hi.toString(16)}:${lo.toString(16)}`;
|
|
160
|
+
}
|
|
161
|
+
const halves = text.split("::");
|
|
162
|
+
if (halves.length > 2)
|
|
163
|
+
return null;
|
|
164
|
+
const pieces = (part) => (part === "" ? [] : part.split(":"));
|
|
165
|
+
const head = pieces(halves[0]);
|
|
166
|
+
const tail = halves.length === 2 ? pieces(halves[1]) : [];
|
|
167
|
+
if (halves.length === 1 && head.length !== 8)
|
|
168
|
+
return null;
|
|
169
|
+
if (head.length + tail.length > 8)
|
|
170
|
+
return null;
|
|
171
|
+
const groups = [];
|
|
172
|
+
for (const piece of head) {
|
|
173
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece))
|
|
174
|
+
return null;
|
|
175
|
+
groups.push(Number.parseInt(piece, 16));
|
|
176
|
+
}
|
|
177
|
+
for (let i = head.length + tail.length; i < 8; i++)
|
|
178
|
+
groups.push(0);
|
|
179
|
+
for (const piece of tail) {
|
|
180
|
+
if (!/^[0-9a-f]{1,4}$/.test(piece))
|
|
181
|
+
return null;
|
|
182
|
+
groups.push(Number.parseInt(piece, 16));
|
|
183
|
+
}
|
|
184
|
+
return groups.length === 8 ? groups : null;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The IPv4 address an IPv6 address carries, when it carries one.
|
|
188
|
+
*
|
|
189
|
+
* Every transition mechanism embeds a v4 address somewhere, and every one of
|
|
190
|
+
* them is a way to reach a v4 destination while wearing a v6 spelling that no
|
|
191
|
+
* v4 range check looks at. The NAT64 well-known prefix is the sharpest: a
|
|
192
|
+
* DNS64 resolver answers an IPv4-only name with `64:ff9b::<the v4>`, so
|
|
193
|
+
* `64:ff9b::a9fe:a9fe` IS the metadata service on any network that runs one.
|
|
194
|
+
*/
|
|
195
|
+
function embeddedIpv4(g) {
|
|
196
|
+
const quad = (hi, lo) => `${(hi >>> 8) & 0xff}.${hi & 0xff}.${(lo >>> 8) & 0xff}.${lo & 0xff}`;
|
|
197
|
+
const zeros = (from, to) => g.slice(from, to).every((x) => x === 0);
|
|
198
|
+
const last = quad(g[6], g[7]);
|
|
199
|
+
if (zeros(0, 5) && g[5] === 0xffff)
|
|
200
|
+
return last; // ::ffff:0:0/96, IPv4-mapped
|
|
201
|
+
if (zeros(0, 4) && g[4] === 0xffff && g[5] === 0)
|
|
202
|
+
return last; // ::ffff:0:0:0/96, translated
|
|
203
|
+
if (g[0] === 0x64 && g[1] === 0xff9b)
|
|
204
|
+
return last; // 64:ff9b::/96 and 64:ff9b:1::/48, NAT64
|
|
205
|
+
if (g[0] === 0x2002)
|
|
206
|
+
return quad(g[1], g[2]); // 2002::/16, 6to4
|
|
207
|
+
// ::a.b.c.d, IPv4-compatible: deprecated, still routed by some stacks, and
|
|
208
|
+
// not to be confused with `::` or `::1`, which are handled before this.
|
|
209
|
+
if (zeros(0, 6))
|
|
210
|
+
return last;
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/** Ranges that are never a public RPC endpoint. */
|
|
214
|
+
export function isPrivateIp(address) {
|
|
215
|
+
const v4 = normalizeIpv4(address);
|
|
216
|
+
if (v4 !== null) {
|
|
217
|
+
const [a = 0, b = 0, c = 0, d = 0] = v4.split(".").map((n) => Number.parseInt(n, 10));
|
|
218
|
+
if (a === 0 || a === 10 || a === 127)
|
|
219
|
+
return true;
|
|
220
|
+
if (a === 169 && b === 254)
|
|
221
|
+
return true; // link-local, and the metadata service
|
|
222
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
223
|
+
return true;
|
|
224
|
+
if (a === 192 && b === 168)
|
|
225
|
+
return true;
|
|
226
|
+
if (a === 192 && b === 0 && c === 0)
|
|
227
|
+
return true;
|
|
228
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
229
|
+
return true; // carrier-grade NAT
|
|
230
|
+
if (a === 198 && (b === 18 || b === 19))
|
|
231
|
+
return true; // benchmarking
|
|
232
|
+
if (a >= 224)
|
|
233
|
+
return true; // multicast, reserved, broadcast
|
|
234
|
+
return a === 255 && b === 255 && c === 255 && d === 255;
|
|
235
|
+
}
|
|
236
|
+
const groups = parseIpv6(address);
|
|
237
|
+
if (groups === null)
|
|
238
|
+
return false;
|
|
239
|
+
if (groups.every((g) => g === 0))
|
|
240
|
+
return true; // ::, the unspecified address
|
|
241
|
+
if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1)
|
|
242
|
+
return true; // ::1
|
|
243
|
+
const carried = embeddedIpv4(groups);
|
|
244
|
+
if (carried !== null)
|
|
245
|
+
return isPrivateIp(carried);
|
|
246
|
+
const head = groups[0];
|
|
247
|
+
if ((head & 0xfe00) === 0xfc00)
|
|
248
|
+
return true; // fc00::/7, unique-local
|
|
249
|
+
if ((head & 0xffc0) === 0xfe80)
|
|
250
|
+
return true; // fe80::/10, link-local
|
|
251
|
+
return (head & 0xff00) === 0xff00; // ff00::/8, multicast
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Validate an RPC URL and resolve the address to pin the connection to.
|
|
255
|
+
*
|
|
256
|
+
* Throws `RpcEndpointError` for everything a caller could have got wrong, with
|
|
257
|
+
* the reason in the message, because every one of these is a refusal a model
|
|
258
|
+
* should read and act on rather than retry.
|
|
259
|
+
*/
|
|
260
|
+
export async function vetEndpoint(rawUrl) {
|
|
261
|
+
let url;
|
|
262
|
+
try {
|
|
263
|
+
url = new URL(rawUrl);
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
throw new RpcEndpointError(`"${rawUrl}" is not a URL`);
|
|
267
|
+
}
|
|
268
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
269
|
+
throw new RpcEndpointError(`refusing scheme "${url.protocol.replace(":", "")}" — an RPC endpoint is http(s); a websocket is a different transport this package does not speak`);
|
|
270
|
+
}
|
|
271
|
+
if (url.username !== "" || url.password !== "") {
|
|
272
|
+
// A provider key belongs in the path (Alchemy, Infura) or a header, not in
|
|
273
|
+
// userinfo, which lands in logs and in any error message quoting the URL.
|
|
274
|
+
throw new RpcEndpointError("refusing a URL with credentials in it — strip the user:password@ and put the provider key in the path");
|
|
275
|
+
}
|
|
276
|
+
const allowed = policy.allowedOrigins;
|
|
277
|
+
if (allowed !== undefined && !allowed.includes(url.origin)) {
|
|
278
|
+
throw new RpcEndpointError(`refusing "${url.origin}" — the operator's rpc allow-list is ${allowed.length === 0 ? "empty" : allowed.join(", ")}`);
|
|
279
|
+
}
|
|
280
|
+
const host = url.hostname.toLowerCase();
|
|
281
|
+
const allowPrivate = policy.allowPrivateHosts === true;
|
|
282
|
+
if (!allowPrivate && (host === "localhost" || host.endsWith(".localhost"))) {
|
|
283
|
+
throw new RpcEndpointError(deniedPrivate(url.hostname, "loopback"));
|
|
284
|
+
}
|
|
285
|
+
if (!allowPrivate && host.endsWith(".local")) {
|
|
286
|
+
throw new RpcEndpointError(deniedPrivate(url.hostname, "an mDNS name"));
|
|
287
|
+
}
|
|
288
|
+
const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
|
|
289
|
+
// Parsed, not sniffed for a colon: an address this cannot expand is not an
|
|
290
|
+
// address it can classify either, and treating it as a literal would skip the
|
|
291
|
+
// resolution that is the only other thing standing between a name and a
|
|
292
|
+
// private range.
|
|
293
|
+
const literal = normalizeIpv4(unbracketed) ?? (parseIpv6(unbracketed) === null ? null : unbracketed);
|
|
294
|
+
if (literal !== null) {
|
|
295
|
+
if (!allowPrivate && isPrivateIp(literal)) {
|
|
296
|
+
throw new RpcEndpointError(deniedPrivate(url.hostname, `the private address ${literal}`));
|
|
297
|
+
}
|
|
298
|
+
return { url, pinnedIp: literal };
|
|
299
|
+
}
|
|
300
|
+
if (allowPrivate) {
|
|
301
|
+
// The operator opened the private ranges, so a resolution has nothing left
|
|
302
|
+
// to decide — and resolving anyway would break the offline devnet case the
|
|
303
|
+
// flag exists for.
|
|
304
|
+
return { url, pinnedIp: "" };
|
|
305
|
+
}
|
|
306
|
+
let resolved;
|
|
307
|
+
try {
|
|
308
|
+
resolved = await dnsLookupFn(host);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
throw new RpcEndpointError(`cannot resolve "${url.hostname}": ${err instanceof Error ? err.message : String(err)}`);
|
|
312
|
+
}
|
|
313
|
+
if (isPrivateIp(resolved.address)) {
|
|
314
|
+
throw new RpcEndpointError(deniedPrivate(url.hostname, `the private address ${resolved.address}`));
|
|
315
|
+
}
|
|
316
|
+
return { url, pinnedIp: resolved.address };
|
|
317
|
+
}
|
|
318
|
+
function deniedPrivate(host, what) {
|
|
319
|
+
return `refusing to dial "${host}" — it is ${what}, and reaching one from a model-supplied URL is how an agent ends up reading a metadata service or an unauthenticated local port. An operator can permit it with setRpcEndpointPolicy({ allowPrivateHosts: true }) for a local devnet.`;
|
|
320
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One address's onchain history, in the row shape a reconciler already
|
|
3
|
+
* consumes.
|
|
4
|
+
*
|
|
5
|
+
* The row shape is NOT this package's. `@crewhaus/tool-money` owns
|
|
6
|
+
* `Transaction` — the shape `StatementParse` produces and
|
|
7
|
+
* `@crewhaus/tool-ledger`'s `LedgerReconcile` consumes — and it is imported
|
|
8
|
+
* here rather than re-declared, because a second declaration of it is a shape
|
|
9
|
+
* that drifts the first time a field is added, and an onchain history that
|
|
10
|
+
* cannot be reconciled against a bank statement is the whole point of the tool
|
|
11
|
+
* gone.
|
|
12
|
+
*
|
|
13
|
+
* What that costs, and what is done about it: `amountMinor` is a JS `number`,
|
|
14
|
+
* and a uint256 is not. Nothing here rounds to fit. An amount that does not
|
|
15
|
+
* survive the conversion exactly — dust below the ledger's smallest unit, or a
|
|
16
|
+
* magnitude past `Number.MAX_SAFE_INTEGER`, which 0.01 ETH in wei already is —
|
|
17
|
+
* is PARKED with the exact raw value and a named reason rather than emitted as
|
|
18
|
+
* a row that reconciles to the wrong number. An empty history and an
|
|
19
|
+
* unrepresentable one are different answers.
|
|
20
|
+
*
|
|
21
|
+
* Three sources, and only three, because a public endpoint has no index by
|
|
22
|
+
* address:
|
|
23
|
+
*
|
|
24
|
+
* 1. **Logged token transfers**, from `eth_getLogs` filtered on the Transfer
|
|
25
|
+
* topic with the address in the from or the to position. Paged by
|
|
26
|
+
* `scanLogs` — the same code `EvmEventScan` uses, so the silent-truncation
|
|
27
|
+
* proof, the reorg check and the refusal-rather-than-a-prefix behaviour
|
|
28
|
+
* are the tested ones rather than a second implementation of them.
|
|
29
|
+
* 2. **Native value and gas**, from hydrating each block in the range and
|
|
30
|
+
* picking out the transactions this address sent or received. One request
|
|
31
|
+
* per block, so the range is bounded and a range past the bound is a
|
|
32
|
+
* refusal, not a quiet token-only answer.
|
|
33
|
+
* 3. Nothing else. Internal native transfers need a trace, ERC-1155 puts its
|
|
34
|
+
* parties in different topic positions and its batch amounts in data where
|
|
35
|
+
* no filter reaches, and an explorer's address index is not a public RPC.
|
|
36
|
+
* All three are reported as excluded in the output, not only here.
|
|
37
|
+
*/
|
|
38
|
+
import type { Transaction } from "@crewhaus/tool-money";
|
|
39
|
+
import { type RpcClient } from "./rpc";
|
|
40
|
+
/**
|
|
41
|
+
* What a caller knows about one asset's units.
|
|
42
|
+
*
|
|
43
|
+
* `decimals` is never read off the chain. Resolving it costs an `eth_call` per
|
|
44
|
+
* token and a token contract is free to report whatever it likes — an airdrop
|
|
45
|
+
* whose `decimals()` returns 2 turns a dust transfer into a five-figure row.
|
|
46
|
+
* So it arrives from the caller or it stays `null`, and `null` means the raw
|
|
47
|
+
* base units are used as the minor units, which is exact.
|
|
48
|
+
*/
|
|
49
|
+
export type AssetUnits = {
|
|
50
|
+
readonly decimals: number | null;
|
|
51
|
+
/** The ledger's minor unit for this asset. Defaults to `decimals`, i.e. base units. */
|
|
52
|
+
readonly minorUnitDecimals: number | null;
|
|
53
|
+
readonly symbol: string | null;
|
|
54
|
+
};
|
|
55
|
+
export declare const UNKNOWN_UNITS: AssetUnits;
|
|
56
|
+
export type HistoryOptions = {
|
|
57
|
+
/** Exactly one address: a statement is one account's, and the row shape has no account column. */
|
|
58
|
+
readonly address: string;
|
|
59
|
+
readonly from: bigint;
|
|
60
|
+
readonly to: bigint;
|
|
61
|
+
readonly includeTokens: boolean;
|
|
62
|
+
readonly includeNative: boolean;
|
|
63
|
+
readonly includeFees: boolean;
|
|
64
|
+
/** Lowercased token address → units. Also the scan's address filter when `onlyKnownTokens`. */
|
|
65
|
+
readonly tokenUnits: ReadonlyMap<string, AssetUnits>;
|
|
66
|
+
readonly onlyKnownTokens: boolean;
|
|
67
|
+
readonly nativeUnits: AssetUnits;
|
|
68
|
+
/** Blocks per `eth_getLogs` request to start from; narrowed by `scanLogs` as the endpoint pushes back. */
|
|
69
|
+
readonly span: bigint;
|
|
70
|
+
readonly suspectAt: number;
|
|
71
|
+
readonly maxRows: number;
|
|
72
|
+
readonly maxHydratedBlocks: number;
|
|
73
|
+
readonly maxCalls: number;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Everything about a row that the `Transaction` shape has nowhere to put.
|
|
77
|
+
*
|
|
78
|
+
* Keyed by the row's `id`, so the exact uint256 is one lookup away and is never
|
|
79
|
+
* the thing that got rounded. A reconciler reads `rows`; an auditor reads this.
|
|
80
|
+
*/
|
|
81
|
+
export type RowDetail = {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly kind: "token" | "native" | "fee";
|
|
84
|
+
readonly standard: "erc20" | "erc721" | "native" | "fee";
|
|
85
|
+
readonly asset: string;
|
|
86
|
+
readonly symbol: string | null;
|
|
87
|
+
readonly assetDecimals: number | null;
|
|
88
|
+
readonly minorUnitDecimals: number | null;
|
|
89
|
+
/** The chain's own number, in base units, as a decimal string. Never a double. */
|
|
90
|
+
readonly rawAmount: string;
|
|
91
|
+
readonly tokenId: string | null;
|
|
92
|
+
readonly counterparty: string | null;
|
|
93
|
+
readonly txHash: string;
|
|
94
|
+
readonly blockNumber: string;
|
|
95
|
+
readonly blockHash: string;
|
|
96
|
+
readonly timestamp: string;
|
|
97
|
+
readonly timestampIso: string | null;
|
|
98
|
+
/**
|
|
99
|
+
* The transaction's own status, in the three values a receipt actually has.
|
|
100
|
+
* `unknown` is a pre-Byzantium receipt, which carries a state root instead
|
|
101
|
+
* and does not record whether the call reverted — collapsing that onto
|
|
102
|
+
* `false` would assert a success the chain never claimed.
|
|
103
|
+
*/
|
|
104
|
+
readonly status: "success" | "reverted" | "unknown";
|
|
105
|
+
readonly feeModel?: string;
|
|
106
|
+
};
|
|
107
|
+
/** A movement that exists on the chain and cannot be written as a row. Never dropped, never rounded. */
|
|
108
|
+
export type ParkedRow = {
|
|
109
|
+
readonly id: string;
|
|
110
|
+
readonly reason: "belowMinorUnit" | "exceedsSafeInteger" | "timestampOutOfRange" | "undecodable";
|
|
111
|
+
readonly why: string;
|
|
112
|
+
readonly asset: string;
|
|
113
|
+
readonly rawAmount: string | null;
|
|
114
|
+
readonly txHash: string;
|
|
115
|
+
readonly blockNumber: string;
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* Whether the blocks the endpoint served held every transaction this address
|
|
119
|
+
* SENT over the range — and, when that could not be established, why not.
|
|
120
|
+
*
|
|
121
|
+
* A hydrated block is one response that cannot be split, so the split-and-
|
|
122
|
+
* compare proof the log scan uses has nothing to work with here. The account's
|
|
123
|
+
* own nonce is the oracle instead: it goes up once per transaction sent, so its
|
|
124
|
+
* rise across the range is the number of transactions that must be in those
|
|
125
|
+
* blocks. Fewer is a refusal — every one missed is a fee row and possibly a
|
|
126
|
+
* payment. The count is never reported as proved when the nonce could not be
|
|
127
|
+
* read: a pruned endpoint answers a historical `eth_getTransactionCount` with
|
|
128
|
+
* an error, and "could not check" is not "checked".
|
|
129
|
+
*/
|
|
130
|
+
export type SentProof = {
|
|
131
|
+
readonly outcome: "proved";
|
|
132
|
+
readonly sent: number;
|
|
133
|
+
} | {
|
|
134
|
+
readonly outcome: "moreThanTheNonce";
|
|
135
|
+
readonly sent: number;
|
|
136
|
+
readonly nonceAccountsFor: number;
|
|
137
|
+
readonly why: string;
|
|
138
|
+
} | {
|
|
139
|
+
readonly outcome: "unproved";
|
|
140
|
+
readonly sent: number;
|
|
141
|
+
readonly why: string;
|
|
142
|
+
};
|
|
143
|
+
export type HistoryReport = {
|
|
144
|
+
readonly rows: ReadonlyArray<Transaction>;
|
|
145
|
+
readonly detail: ReadonlyArray<RowDetail>;
|
|
146
|
+
readonly unrepresentable: ReadonlyArray<ParkedRow>;
|
|
147
|
+
readonly logChunks: number;
|
|
148
|
+
readonly logCalls: number;
|
|
149
|
+
readonly silentTruncations: number;
|
|
150
|
+
readonly verifications: number;
|
|
151
|
+
readonly hydratedBlocks: number;
|
|
152
|
+
readonly receiptsRead: number;
|
|
153
|
+
/** Transfers where this address is both parties: they move nothing, so they are not rows. */
|
|
154
|
+
readonly selfTransfers: number;
|
|
155
|
+
/** Absent when no block was hydrated, because then nothing claims to have read what it sent. */
|
|
156
|
+
readonly sentProof?: SentProof;
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* Collect the history, or refuse.
|
|
160
|
+
*
|
|
161
|
+
* The range must already be resolved to numbers and pinned by the caller. A
|
|
162
|
+
* sync whose upper bound is still the string `latest` cannot hand back a cursor
|
|
163
|
+
* anybody can resume from, because the block it stopped at is not a block it
|
|
164
|
+
* can name.
|
|
165
|
+
*/
|
|
166
|
+
export declare function collectHistory(client: RpcClient, options: HistoryOptions): Promise<HistoryReport>;
|
|
167
|
+
type Scaled = {
|
|
168
|
+
readonly ok: true;
|
|
169
|
+
readonly value: number;
|
|
170
|
+
} | {
|
|
171
|
+
readonly ok: false;
|
|
172
|
+
readonly reason: "belowMinorUnit" | "exceedsSafeInteger";
|
|
173
|
+
readonly why: string;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* A base-unit amount as the ledger's minor units, exactly or not at all.
|
|
177
|
+
*
|
|
178
|
+
* Both failures are real and neither is rounding's business. Wei is eighteen
|
|
179
|
+
* decimals and `Number.MAX_SAFE_INTEGER` is about nine thousandths of one ETH,
|
|
180
|
+
* so a plain ETH payment does not fit in this field at all until the caller
|
|
181
|
+
* says what unit the ledger keeps ETH in — and 0.5 USDC does not fit a
|
|
182
|
+
* cent-denominated ledger however it is asked.
|
|
183
|
+
*/
|
|
184
|
+
export declare function toMinorUnits(raw: bigint, units: AssetUnits): Scaled;
|
|
185
|
+
/**
|
|
186
|
+
* The `YYYY-MM-DD` the row shape wants, or `null` when the instant cannot be
|
|
187
|
+
* written that way.
|
|
188
|
+
*
|
|
189
|
+
* `Date.prototype.toISOString` switches to the EXPANDED year format outside
|
|
190
|
+
* 0000–9999 — a block stamped in the year 33658 serialises as
|
|
191
|
+
* `+033658-…`, and slicing ten characters off that gives `+033658-0`, which is
|
|
192
|
+
* not a date and which `LedgerReconcile`'s schema rejects at the far end of the
|
|
193
|
+
* pipeline rather than here. So the slice is validated, not assumed.
|
|
194
|
+
*/
|
|
195
|
+
export declare function calendarDate(iso: string | null): string | null;
|
|
196
|
+
export {};
|