@crewhaus/tool-chainread 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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 {};