@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,113 @@
1
+ /**
2
+ * Block headers, and the search over their timestamps.
3
+ *
4
+ * The search is the reason this file is not three lines long. "Which block was
5
+ * the chain at on 1 March?" is asked by every reconciliation, every
6
+ * backtest and every "what did the contract hold at the end of the quarter",
7
+ * and the answer is used as a `blockTag` in later reads — so a wrong answer is
8
+ * not a wrong number on a screen, it is a whole report computed against the
9
+ * wrong state.
10
+ *
11
+ * The invariant is written down in one place, here, and the result carries the
12
+ * evidence for it:
13
+ *
14
+ * the returned block is the LAST block whose timestamp is <= the target,
15
+ * and the block after it has a timestamp > the target.
16
+ *
17
+ * Both halves are checked before the answer is returned. Half an invariant —
18
+ * "a block at or before the timestamp" — is satisfied by the genesis block,
19
+ * which is why it is not the contract.
20
+ */
21
+ import { type BlockTag } from "./quantity";
22
+ import { type RpcClient } from "./rpc";
23
+ export type BlockSelector = {
24
+ readonly kind: "number";
25
+ readonly value: bigint;
26
+ } | {
27
+ readonly kind: "tag";
28
+ readonly tag: BlockTag;
29
+ } | {
30
+ readonly kind: "hash";
31
+ readonly hash: string;
32
+ };
33
+ /**
34
+ * A block header, projected.
35
+ *
36
+ * Every quantity is a decimal STRING. `gasUsed` fits in a double today and
37
+ * `baseFeePerGas` does not always — and having two conventions in one object is
38
+ * how the wrong one gets copied into the next file.
39
+ */
40
+ export type BlockSummary = {
41
+ /** `null` only for the pending block, which has not been assigned one yet. */
42
+ readonly number: string | null;
43
+ readonly hash: string | null;
44
+ readonly parentHash: string | null;
45
+ readonly timestamp: string;
46
+ readonly timestampIso: string | null;
47
+ readonly gasLimit: string | null;
48
+ readonly gasUsed: string | null;
49
+ /**
50
+ * `null` on a pre-1559 block and on chains that never adopted it. Reported
51
+ * next to `feeMarket` so a caller reads "this chain has no base fee" rather
52
+ * than putting a null into a fee calculation.
53
+ */
54
+ readonly baseFeePerGas: string | null;
55
+ readonly feeMarket: "eip1559" | "legacy";
56
+ readonly miner: string | null;
57
+ readonly transactionCount: number | null;
58
+ readonly transactions?: ReadonlyArray<string>;
59
+ readonly pending: boolean;
60
+ };
61
+ export declare function selectorToRpc(selector: BlockSelector): {
62
+ readonly method: string;
63
+ readonly params: ReadonlyArray<unknown>;
64
+ };
65
+ export declare function describeSelector(selector: BlockSelector): string;
66
+ /**
67
+ * Fetch one header, or `null` when the chain has no such block.
68
+ *
69
+ * A node answers an unknown block with `result: null` and no error, which is
70
+ * the one JSON-RPC shape that most resembles success. Collapsing it into an
71
+ * exception would lose the distinction a caller needs: "block 99999999 does not
72
+ * exist yet" is an answer about the chain, while "the endpoint refused" is not.
73
+ */
74
+ export declare function fetchBlock(client: RpcClient, selector: BlockSelector): Promise<BlockSummary | null>;
75
+ export declare function projectBlock(raw: Record<string, unknown>): BlockSummary;
76
+ /** The head block number, read once so a scan or a search works against a fixed chain tip. */
77
+ export declare function headNumber(client: RpcClient): Promise<bigint>;
78
+ export type TimestampSearch = {
79
+ readonly outcome: "found";
80
+ /** The last block at or before the target. */
81
+ readonly at: BlockSummary;
82
+ /** The first block strictly after it, or `null` when the target is at or past the head. */
83
+ readonly next: BlockSummary | null;
84
+ readonly atHead: boolean;
85
+ readonly probes: number;
86
+ } | {
87
+ /** Every block in the bracket is later than the target — there is nothing at or before it. */
88
+ readonly outcome: "beforeRange";
89
+ readonly first: BlockSummary;
90
+ readonly probes: number;
91
+ };
92
+ /**
93
+ * Binary search for the last block at or before `target`.
94
+ *
95
+ * Deliberately NOT interpolated. Estimating a block from an average block time
96
+ * converges in fewer probes on a chain with a steady block time and badly on
97
+ * one that changed (Ethereum before and after the Merge is two different
98
+ * chains by this measure), and the failure mode of a bad estimate is extra
99
+ * probes at best and a wrong bracket at worst. Bisection over twenty million
100
+ * blocks is twenty-five probes, every one of which narrows a bracket that is
101
+ * provably still correct. When a caller already knows roughly where to look,
102
+ * `fromBlock`/`toBlock` cut the bracket and the probe count with it.
103
+ *
104
+ * Non-decreasing timestamps are the assumption the whole search rests on, and
105
+ * the assumption is CHECKED rather than trusted: every sample is compared with
106
+ * every other, and a pair that goes backwards is a refusal naming both blocks.
107
+ * A chain where that happens is a chain where "the block at 09:00" has more
108
+ * than one answer, and picking one silently is the lie.
109
+ */
110
+ export declare function searchBlockAtTimestamp(client: RpcClient, target: bigint, bracket: {
111
+ readonly low: bigint;
112
+ readonly high: bigint;
113
+ }, maxProbes?: number): Promise<TimestampSearch>;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Block headers, and the search over their timestamps.
3
+ *
4
+ * The search is the reason this file is not three lines long. "Which block was
5
+ * the chain at on 1 March?" is asked by every reconciliation, every
6
+ * backtest and every "what did the contract hold at the end of the quarter",
7
+ * and the answer is used as a `blockTag` in later reads — so a wrong answer is
8
+ * not a wrong number on a screen, it is a whole report computed against the
9
+ * wrong state.
10
+ *
11
+ * The invariant is written down in one place, here, and the result carries the
12
+ * evidence for it:
13
+ *
14
+ * the returned block is the LAST block whose timestamp is <= the target,
15
+ * and the block after it has a timestamp > the target.
16
+ *
17
+ * Both halves are checked before the answer is returned. Half an invariant —
18
+ * "a block at or before the timestamp" — is satisfied by the genesis block,
19
+ * which is why it is not the contract.
20
+ */
21
+ import { ChainReadError, field, hexToBigint, lowerHex, optionalHexToBigint, requireObject, toHexQuantity, unixToIso, } from "./quantity";
22
+ import { callOrThrow } from "./rpc";
23
+ export function selectorToRpc(selector) {
24
+ if (selector.kind === "hash") {
25
+ return { method: "eth_getBlockByHash", params: [selector.hash, false] };
26
+ }
27
+ const tag = selector.kind === "tag" ? selector.tag : toHexQuantity(selector.value);
28
+ return { method: "eth_getBlockByNumber", params: [tag, false] };
29
+ }
30
+ export function describeSelector(selector) {
31
+ if (selector.kind === "hash")
32
+ return `block ${selector.hash}`;
33
+ if (selector.kind === "tag")
34
+ return `block "${selector.tag}"`;
35
+ return `block ${selector.value}`;
36
+ }
37
+ /**
38
+ * Fetch one header, or `null` when the chain has no such block.
39
+ *
40
+ * A node answers an unknown block with `result: null` and no error, which is
41
+ * the one JSON-RPC shape that most resembles success. Collapsing it into an
42
+ * exception would lose the distinction a caller needs: "block 99999999 does not
43
+ * exist yet" is an answer about the chain, while "the endpoint refused" is not.
44
+ */
45
+ export async function fetchBlock(client, selector) {
46
+ const { method, params } = selectorToRpc(selector);
47
+ // Always the hash list, never the hydrated bodies. Nothing in this package
48
+ // reads a transaction out of a block — `EvmTransactionSummary` fetches the
49
+ // one it was asked about — and hydrating pulls a megabyte of transaction
50
+ // objects to throw all but their hashes away.
51
+ const raw = await callOrThrow(client, method, params, `reading ${describeSelector(selector)}`);
52
+ if (raw === null || raw === undefined)
53
+ return null;
54
+ return projectBlock(requireObject(raw, `the answer for ${describeSelector(selector)}`));
55
+ }
56
+ export function projectBlock(raw) {
57
+ const numberField = raw["number"];
58
+ const pending = numberField === null || numberField === undefined;
59
+ const base = optionalHexToBigint(raw["baseFeePerGas"], "baseFeePerGas");
60
+ const timestamp = hexToBigint(raw["timestamp"], "block timestamp");
61
+ const txs = raw["transactions"];
62
+ // A node asked for the hash list answers with strings, and one that hydrates
63
+ // anyway answers with objects. Reading only the first shape is how
64
+ // `includeTransactions` became a field that silently returned nothing: the
65
+ // hashes were there, in a form this did not recognise.
66
+ const hashes = Array.isArray(txs) ? hashesOf(txs) : undefined;
67
+ return {
68
+ number: pending ? null : hexToBigint(numberField, "block number").toString(),
69
+ hash: typeof raw["hash"] === "string" ? lowerHex(raw["hash"], "block hash") : null,
70
+ parentHash: typeof raw["parentHash"] === "string" ? lowerHex(raw["parentHash"], "parentHash") : null,
71
+ timestamp: timestamp.toString(),
72
+ timestampIso: unixToIso(timestamp),
73
+ gasLimit: optionalHexToBigint(raw["gasLimit"], "gasLimit")?.toString() ?? null,
74
+ gasUsed: optionalHexToBigint(raw["gasUsed"], "gasUsed")?.toString() ?? null,
75
+ baseFeePerGas: base === null ? null : base.toString(),
76
+ feeMarket: base === null ? "legacy" : "eip1559",
77
+ miner: typeof raw["miner"] === "string" ? lowerHex(raw["miner"], "miner") : null,
78
+ transactionCount: Array.isArray(txs) ? txs.length : null,
79
+ ...(hashes !== undefined ? { transactions: hashes } : {}),
80
+ pending,
81
+ };
82
+ }
83
+ /** The hashes out of a `transactions` array, in either of the two shapes a node sends. */
84
+ function hashesOf(txs) {
85
+ const out = [];
86
+ for (const entry of txs) {
87
+ if (typeof entry === "string") {
88
+ out.push(entry.toLowerCase());
89
+ continue;
90
+ }
91
+ const hash = typeof entry === "object" && entry !== null ? field(entry, "hash") : undefined;
92
+ if (typeof hash !== "string")
93
+ return undefined;
94
+ out.push(hash.toLowerCase());
95
+ }
96
+ return out;
97
+ }
98
+ /** The head block number, read once so a scan or a search works against a fixed chain tip. */
99
+ export async function headNumber(client) {
100
+ const raw = await callOrThrow(client, "eth_blockNumber", [], "reading the head block number");
101
+ return hexToBigint(raw, "eth_blockNumber");
102
+ }
103
+ /**
104
+ * Binary search for the last block at or before `target`.
105
+ *
106
+ * Deliberately NOT interpolated. Estimating a block from an average block time
107
+ * converges in fewer probes on a chain with a steady block time and badly on
108
+ * one that changed (Ethereum before and after the Merge is two different
109
+ * chains by this measure), and the failure mode of a bad estimate is extra
110
+ * probes at best and a wrong bracket at worst. Bisection over twenty million
111
+ * blocks is twenty-five probes, every one of which narrows a bracket that is
112
+ * provably still correct. When a caller already knows roughly where to look,
113
+ * `fromBlock`/`toBlock` cut the bracket and the probe count with it.
114
+ *
115
+ * Non-decreasing timestamps are the assumption the whole search rests on, and
116
+ * the assumption is CHECKED rather than trusted: every sample is compared with
117
+ * every other, and a pair that goes backwards is a refusal naming both blocks.
118
+ * A chain where that happens is a chain where "the block at 09:00" has more
119
+ * than one answer, and picking one silently is the lie.
120
+ */
121
+ export async function searchBlockAtTimestamp(client, target, bracket, maxProbes = 64) {
122
+ const samples = [];
123
+ let probes = 0;
124
+ const probe = async (number) => {
125
+ probes += 1;
126
+ if (probes > maxProbes) {
127
+ throw new ChainReadError(`the timestamp search used more than ${maxProbes} probes over blocks ${bracket.low}–${bracket.high} — the bracket is implausibly wide or the endpoint is answering inconsistently`);
128
+ }
129
+ const block = await fetchBlock(client, { kind: "number", value: number });
130
+ if (block === null) {
131
+ throw new ChainReadError(`block ${number} is inside the search bracket ${bracket.low}–${bracket.high} but the endpoint says it does not exist — the bracket is wrong, or this endpoint is not serving the whole chain`);
132
+ }
133
+ record(samples, { number, timestamp: BigInt(block.timestamp) });
134
+ return block;
135
+ };
136
+ let low = bracket.low;
137
+ let high = bracket.high;
138
+ if (low > high) {
139
+ throw new ChainReadError(`search bracket is inverted: ${low} > ${high}`);
140
+ }
141
+ const lowBlock = await probe(low);
142
+ if (BigInt(lowBlock.timestamp) > target) {
143
+ // Nothing at or before the target exists in this bracket. For the default
144
+ // bracket that means the timestamp predates genesis; returning block 0
145
+ // anyway — "the earliest block we have" — is the answer a caller would
146
+ // silently use to query state that did not exist.
147
+ return { outcome: "beforeRange", first: lowBlock, probes };
148
+ }
149
+ if (high === low) {
150
+ return { outcome: "found", at: lowBlock, next: null, atHead: true, probes };
151
+ }
152
+ const highBlock = await probe(high);
153
+ if (BigInt(highBlock.timestamp) <= target) {
154
+ // The target is at or past the tip of the bracket. The head is the honest
155
+ // answer and `next: null` says why there is nothing after it: this answer
156
+ // will change as the chain advances.
157
+ return { outcome: "found", at: highBlock, next: null, atHead: true, probes };
158
+ }
159
+ // Invariant from here: timestamp(low) <= target < timestamp(high).
160
+ let lowSummary = lowBlock;
161
+ let highSummary = highBlock;
162
+ while (high - low > 1n) {
163
+ const mid = low + (high - low) / 2n;
164
+ const midBlock = await probe(mid);
165
+ if (BigInt(midBlock.timestamp) <= target) {
166
+ low = mid;
167
+ lowSummary = midBlock;
168
+ }
169
+ else {
170
+ high = mid;
171
+ highSummary = midBlock;
172
+ }
173
+ }
174
+ return { outcome: "found", at: lowSummary, next: highSummary, atHead: false, probes };
175
+ }
176
+ /**
177
+ * Keep the samples, and refuse the moment two of them disagree about time's
178
+ * direction. Cheap: the search takes at most a few dozen samples, so the
179
+ * pairwise comparison is free next to one RPC round trip.
180
+ */
181
+ function record(samples, next) {
182
+ for (const seen of samples) {
183
+ const earlier = seen.number < next.number ? seen : next;
184
+ const later = seen.number < next.number ? next : seen;
185
+ if (earlier.number === later.number)
186
+ continue;
187
+ if (earlier.timestamp > later.timestamp) {
188
+ throw new ChainReadError(`this chain's block timestamps go backwards — block ${earlier.number} is stamped ${earlier.timestamp} but the later block ${later.number} is stamped ${later.timestamp}. A binary search over timestamps has no single right answer here, so this refuses rather than returning one of several.`);
189
+ }
190
+ }
191
+ samples.push(next);
192
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Time, injected.
3
+ *
4
+ * Two tools here need a clock: `EvmWaitForReceipt` sleeps between polls and
5
+ * stops at a deadline, and `EvmRpcHealth` reports how old the head block is.
6
+ * Both would otherwise be untestable without a real wait — and a test that
7
+ * waits is a test that is slow on a good day and flaky on a loaded CI box.
8
+ *
9
+ * With the seam, the deadline test costs nothing and asserts the thing that
10
+ * matters (how many polls happened and why it stopped) rather than how long it
11
+ * took. No test in this package asserts wall-clock time.
12
+ */
13
+ export type Clock = {
14
+ /** Milliseconds since the epoch. */
15
+ now(): number;
16
+ sleep(ms: number, signal?: AbortSignal): Promise<void>;
17
+ };
18
+ /** Test seam — `_setClock(undefined)` restores the real one. */
19
+ export declare function _setClock(next: Clock | undefined): void;
20
+ export declare function nowMs(): number;
21
+ export declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
22
+ /**
23
+ * A clock a test drives: `now` only moves when something sleeps.
24
+ *
25
+ * Exported from the package's fixtures rather than kept in one test file
26
+ * because three suites need it. Sleeping advances the virtual clock and
27
+ * returns immediately, so a fifteen-minute deadline costs microseconds and the
28
+ * poll count is exactly what the code decided rather than what the machine had
29
+ * time for.
30
+ */
31
+ export declare function virtualClock(startMs?: number): Clock & {
32
+ readonly sleeps: number[];
33
+ advance(ms: number): void;
34
+ };
@@ -0,0 +1,55 @@
1
+ const realClock = {
2
+ now: () => Date.now(),
3
+ sleep: (ms, signal) => new Promise((resolve, reject) => {
4
+ if (signal?.aborted === true) {
5
+ reject(new Error("aborted"));
6
+ return;
7
+ }
8
+ const handle = setTimeout(() => {
9
+ signal?.removeEventListener("abort", onAbort);
10
+ resolve();
11
+ }, ms);
12
+ const onAbort = () => {
13
+ clearTimeout(handle);
14
+ reject(new Error("aborted"));
15
+ };
16
+ signal?.addEventListener("abort", onAbort, { once: true });
17
+ }),
18
+ };
19
+ let clock = realClock;
20
+ /** Test seam — `_setClock(undefined)` restores the real one. */
21
+ export function _setClock(next) {
22
+ clock = next ?? realClock;
23
+ }
24
+ export function nowMs() {
25
+ return clock.now();
26
+ }
27
+ export function sleep(ms, signal) {
28
+ return clock.sleep(ms, signal);
29
+ }
30
+ /**
31
+ * A clock a test drives: `now` only moves when something sleeps.
32
+ *
33
+ * Exported from the package's fixtures rather than kept in one test file
34
+ * because three suites need it. Sleeping advances the virtual clock and
35
+ * returns immediately, so a fifteen-minute deadline costs microseconds and the
36
+ * poll count is exactly what the code decided rather than what the machine had
37
+ * time for.
38
+ */
39
+ export function virtualClock(startMs = 1_700_000_000_000) {
40
+ let current = startMs;
41
+ const sleeps = [];
42
+ return {
43
+ sleeps,
44
+ now: () => current,
45
+ advance(ms) {
46
+ current += ms;
47
+ },
48
+ async sleep(ms, signal) {
49
+ if (signal?.aborted === true)
50
+ throw new Error("aborted");
51
+ sleeps.push(ms);
52
+ current += ms;
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,91 @@
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 declare class RpcEndpointError extends CrewhausError {
27
+ readonly name = "RpcEndpointError";
28
+ constructor(message: string);
29
+ }
30
+ /** What the runtime may loosen at boot. Nothing here is reachable from a tool's input. */
31
+ export type RpcEndpointPolicy = {
32
+ /**
33
+ * Permit loopback and private-range endpoints. A local anvil or hardhat node
34
+ * at `http://127.0.0.1:8545` is the reason this exists, and it is the whole
35
+ * reason it is off by default: that address is also every unauthenticated
36
+ * service on the box the agent happens to be running on.
37
+ */
38
+ readonly allowPrivateHosts?: boolean;
39
+ /**
40
+ * When set, the ONLY origins that may be dialled, e.g.
41
+ * `["https://mainnet.base.org"]`. An operator that knows its endpoints should
42
+ * set this; it turns the guard from a deny-list into an allow-list.
43
+ */
44
+ readonly allowedOrigins?: ReadonlyArray<string>;
45
+ };
46
+ /** Bind the endpoint policy at boot. Generated daemons call this before registering the tools. */
47
+ export declare function setRpcEndpointPolicy(next: RpcEndpointPolicy): void;
48
+ /** Read the policy back — for `EvmRpcHealth`, which reports the posture it is operating under. */
49
+ export declare function getRpcEndpointPolicy(): RpcEndpointPolicy;
50
+ export type DnsLookupFn = (host: string) => Promise<{
51
+ readonly address: string;
52
+ readonly family: number;
53
+ }>;
54
+ export declare function _setDnsLookup(fn: DnsLookupFn | undefined): void;
55
+ /**
56
+ * Canonicalise an IPv4 literal.
57
+ *
58
+ * `inet_aton` forms are the classic allow-list bypass: `0177.0.0.1`,
59
+ * `0x7f.0.0.1`, `2130706433` and `127.1` are all 127.0.0.1, and a check that
60
+ * only understands dotted-decimal waves every one of them through. Returns
61
+ * `null` when the string is not an IPv4 literal at all.
62
+ */
63
+ export declare function normalizeIpv4(raw: string): string | null;
64
+ /**
65
+ * Expand an IPv6 literal to its eight 16-bit groups, or `null` when the string
66
+ * is not one.
67
+ *
68
+ * Classifying IPv6 by its TEXT is where the bypasses live, because one address
69
+ * has many spellings and the one a check was written against is rarely the one
70
+ * that arrives. `http://[::ffff:169.254.169.254]/` never reaches a guard in
71
+ * that form: the WHATWG URL parser re-serialises the embedded quad as hex
72
+ * pieces, so what the guard sees is `::ffff:a9fe:a9fe`. Normalising to numbers
73
+ * first means the prefix tests below are arithmetic, and spelling stops
74
+ * mattering.
75
+ */
76
+ export declare function parseIpv6(raw: string): ReadonlyArray<number> | null;
77
+ /** Ranges that are never a public RPC endpoint. */
78
+ export declare function isPrivateIp(address: string): boolean;
79
+ export type VettedEndpoint = {
80
+ readonly url: URL;
81
+ /** The address the socket must go to. Empty when there is nothing to pin. */
82
+ readonly pinnedIp: string;
83
+ };
84
+ /**
85
+ * Validate an RPC URL and resolve the address to pin the connection to.
86
+ *
87
+ * Throws `RpcEndpointError` for everything a caller could have got wrong, with
88
+ * the reason in the message, because every one of these is a refusal a model
89
+ * should read and act on rather than retry.
90
+ */
91
+ export declare function vetEndpoint(rawUrl: string): Promise<VettedEndpoint>;