@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,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>;
|