@crewhaus/tool-defi 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,370 @@
1
+ /**
2
+ * Offchain prices from two unauthenticated public providers, with the
3
+ * provenance that makes a number checkable.
4
+ *
5
+ * Two origins, both constants, neither of which takes a credential:
6
+ *
7
+ * - **Frankfurter** republishes the ECB's daily euro reference rates. One
8
+ * fixing per TARGET business day at about 16:00 CET — so a weekend, a
9
+ * Christmas Day, or "today before the fixing" all resolve BACK to an
10
+ * earlier date, and the response says which. That returned date is the
11
+ * `asOf`, and it is the whole reason a reconciliation run reproduces.
12
+ * - **Coinbase's** `/v2/prices/{pair}/spot`, for crypto and for fiat pairs
13
+ * the ECB does not publish.
14
+ *
15
+ * **Coinbase's spot response carries no timestamp.** Not a stale one, none at
16
+ * all: `{ "data": { "base", "currency", "amount" } }` is the whole document.
17
+ * The survey sketch that proposed this tool assumed every provider had a
18
+ * timestamp field to stamp `asOf` from; this one does not, and the honest
19
+ * answer is `asOf: null` with `asOfSource: "none"` rather than the time the
20
+ * response arrived. Stamping arrival time would make every reading look fresh
21
+ * by construction, which is the exact failure the sketch was trying to avoid.
22
+ *
23
+ * It also means **a historical crypto quote is refused here**, rather than
24
+ * served from a spot endpoint that echoes no date: a price that cannot be
25
+ * distinguished from today's must not be labelled as last Tuesday's. The
26
+ * refusal names the alternatives — a Chainlink round read at a pinned block,
27
+ * or pricing the asset in a fiat pair the ECB does publish.
28
+ *
29
+ * Every price carries where it came from and how it was derived: a direct
30
+ * quote, an inversion of the pair the provider actually publishes, or a cross
31
+ * through an intermediate asset — and a cross names the intermediate and
32
+ * carries both legs' provenance with it.
33
+ */
34
+ import { KNOWN_CURRENCIES } from "@crewhaus/tool-math";
35
+ import { divide, fixedFromJson, fromInteger, isPositive, multiply, toDecimalString, } from "./decimal";
36
+ import { DefiError, getJson } from "./rpc";
37
+ /** The two origins this package reads prices from. There is no third, and no override. */
38
+ export const PROVIDER_ORIGINS = Object.freeze({
39
+ frankfurter: "https://api.frankfurter.app",
40
+ coinbase: "https://api.coinbase.com",
41
+ });
42
+ export const PROVIDER_NAMES = Object.freeze([
43
+ "frankfurter",
44
+ "coinbase",
45
+ ]);
46
+ const ALLOWED_ORIGINS = new Set(Object.values(PROVIDER_ORIGINS));
47
+ /** An asset symbol, as a provider spells it. Deliberately narrow: it goes into a path. */
48
+ const SYMBOL = /^[A-Za-z0-9]{2,12}$/;
49
+ /** ISO 8601 calendar date, which is the only `at` either provider understands. */
50
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
51
+ /**
52
+ * Inversion and cross division are carried to this many places.
53
+ *
54
+ * 1/3 has no exact decimal expansion, so an inverted quote is rounded exactly
55
+ * once, here, with the mode named in the result. Eighteen places is more than
56
+ * any provider publishes and matches the widest token decimalisation, so the
57
+ * rounding is below the noise of the input rather than above it.
58
+ */
59
+ export const INVERSION_SCALE = 18;
60
+ export function normalizeSymbol(raw, what) {
61
+ const text = raw.trim().toUpperCase();
62
+ if (!SYMBOL.test(text)) {
63
+ throw new DefiError(`${what}: "${raw}" is not an asset symbol (2 to 12 letters or digits)`);
64
+ }
65
+ return text;
66
+ }
67
+ function normalizeAt(raw) {
68
+ if (raw === undefined)
69
+ return undefined;
70
+ const text = raw.trim();
71
+ if (!ISO_DATE.test(text)) {
72
+ throw new DefiError(`at: "${raw}" is not an ISO calendar date (YYYY-MM-DD) — these providers publish by date, not by instant`);
73
+ }
74
+ return text;
75
+ }
76
+ /**
77
+ * Whether a code is a currency this monorepo knows.
78
+ *
79
+ * The table is `@crewhaus/tool-math`'s, through its public surface, rather than
80
+ * a second ISO 4217 list in this package that would drift from it.
81
+ */
82
+ export function isFiat(code) {
83
+ return KNOWN_CURRENCIES.includes(code);
84
+ }
85
+ /** Which providers to try, and in what order, for a pair nobody named providers for. */
86
+ export function defaultProviders(base, quote) {
87
+ // The ECB fixing is the reference rate a finance team reconciles against, so
88
+ // it goes first when it can answer at all; it publishes fiat only.
89
+ return isFiat(base) && isFiat(quote) ? ["frankfurter", "coinbase"] : ["coinbase"];
90
+ }
91
+ function assertKnownOrigin(url) {
92
+ if (!ALLOWED_ORIGINS.has(url.origin)) {
93
+ throw new DefiError(`refusing to dial "${url.origin}" — this package reads prices from ${[...ALLOWED_ORIGINS].join(" and ")} only`);
94
+ }
95
+ }
96
+ const FRANKFURTER_CONVENTION = "ECB daily euro reference rate, fixed at about 16:00 CET on a TARGET business day; a request for a non-business day resolves back to the previous fixing and the date field says which one";
97
+ const COINBASE_CONVENTION = "Coinbase's current spot price for the pair; the endpoint publishes no timestamp, so this price has no asOf of its own";
98
+ /** Ask Frankfurter for `base -> quote`, directly. It has no inverse problem: from/to are parameters. */
99
+ async function frankfurterLeg(base, quote, at, options) {
100
+ if (!isFiat(base) || !isFiat(quote)) {
101
+ return {
102
+ ok: false,
103
+ reason: `frankfurter publishes ECB reference rates for currencies; ${isFiat(base) ? quote : base} is not one`,
104
+ };
105
+ }
106
+ if (base === quote) {
107
+ return { ok: false, reason: "a pair of one currency with itself is not a quote" };
108
+ }
109
+ const url = new URL(`${at ?? "latest"}?from=${base}&to=${quote}`, `${PROVIDER_ORIGINS.frankfurter}/`);
110
+ assertKnownOrigin(url);
111
+ const outcome = await getJson(url.toString(), options);
112
+ if (!outcome.ok)
113
+ return { ok: false, reason: `frankfurter: ${outcome.message}` };
114
+ const body = outcome.value;
115
+ if (typeof body !== "object" || body === null) {
116
+ return { ok: false, reason: "frankfurter answered something other than an object" };
117
+ }
118
+ const rates = body.rates;
119
+ if (typeof rates !== "object" || rates === null) {
120
+ return { ok: false, reason: "frankfurter's answer carried no rates object" };
121
+ }
122
+ const raw = rates[quote];
123
+ if (raw === undefined) {
124
+ return { ok: false, reason: `frankfurter does not publish a rate for ${base}/${quote}` };
125
+ }
126
+ const date = typeof body.date === "string" ? body.date : null;
127
+ if (date === null) {
128
+ // Without the fixing date there is no provenance, and an ECB rate without
129
+ // its date is exactly the number a reconciliation cannot reproduce.
130
+ return {
131
+ ok: false,
132
+ reason: "frankfurter's answer carried no date, so the fixing it used is unknown",
133
+ };
134
+ }
135
+ let price;
136
+ let literal;
137
+ try {
138
+ const parsed = fixedFromJson(raw, `the ${base}/${quote} rate`);
139
+ price = parsed.value;
140
+ literal = parsed.kind;
141
+ }
142
+ catch (err) {
143
+ return { ok: false, reason: `frankfurter: ${err.message}` };
144
+ }
145
+ if (!isPositive(price)) {
146
+ return {
147
+ ok: false,
148
+ reason: `frankfurter answered ${toDecimalString(price)} for ${base}/${quote}`,
149
+ };
150
+ }
151
+ return {
152
+ ok: true,
153
+ value: price,
154
+ leg: {
155
+ source: "frankfurter",
156
+ endpoint: PROVIDER_ORIGINS.frankfurter,
157
+ pair: `${base}/${quote}`,
158
+ derivation: "direct",
159
+ price: toDecimalString(price),
160
+ asOf: date,
161
+ asOfSource: "frankfurter.date",
162
+ convention: FRANKFURTER_CONVENTION,
163
+ literal,
164
+ requestedAt: at ?? null,
165
+ },
166
+ };
167
+ }
168
+ /** Ask Coinbase for a spot price, trying the inverse pair when the direct one is not published. */
169
+ async function coinbaseLeg(base, quote, at, options) {
170
+ if (at !== undefined) {
171
+ return {
172
+ ok: false,
173
+ reason: "coinbase's spot endpoint publishes no date and echoes none back, so a historical answer from it cannot be told apart from today's — read a Chainlink round at a pinned block with OraclePriceRead, or quote the asset against a currency the ECB publishes",
174
+ };
175
+ }
176
+ if (base === quote) {
177
+ return { ok: false, reason: "a pair of one asset with itself is not a quote" };
178
+ }
179
+ const direct = await coinbaseSpot(base, quote, options);
180
+ if (direct.ok) {
181
+ return {
182
+ ok: true,
183
+ value: direct.price,
184
+ leg: {
185
+ source: "coinbase",
186
+ endpoint: PROVIDER_ORIGINS.coinbase,
187
+ pair: `${base}-${quote}`,
188
+ derivation: "direct",
189
+ price: toDecimalString(direct.price),
190
+ asOf: null,
191
+ asOfSource: "none",
192
+ convention: COINBASE_CONVENTION,
193
+ literal: direct.literal,
194
+ requestedAt: null,
195
+ },
196
+ };
197
+ }
198
+ const inverse = await coinbaseSpot(quote, base, options);
199
+ if (!inverse.ok) {
200
+ return {
201
+ ok: false,
202
+ reason: `coinbase publishes neither ${base}-${quote} (${direct.reason}) nor its inverse`,
203
+ };
204
+ }
205
+ if (!isPositive(inverse.price)) {
206
+ return {
207
+ ok: false,
208
+ reason: `coinbase answered ${toDecimalString(inverse.price)} for ${quote}-${base}`,
209
+ };
210
+ }
211
+ const inverted = divide(fromInteger(1n), inverse.price, INVERSION_SCALE, "halfEven");
212
+ return {
213
+ ok: true,
214
+ value: inverted,
215
+ leg: {
216
+ source: "coinbase",
217
+ endpoint: PROVIDER_ORIGINS.coinbase,
218
+ pair: `${quote}-${base}`,
219
+ derivation: "inverted",
220
+ price: toDecimalString(inverted),
221
+ asOf: null,
222
+ asOfSource: "none",
223
+ convention: `${COINBASE_CONVENTION}; this figure is 1 divided by the published ${quote}-${base} price of ${toDecimalString(inverse.price)}, rounded half-even to ${INVERSION_SCALE} places`,
224
+ literal: inverse.literal,
225
+ requestedAt: null,
226
+ },
227
+ };
228
+ }
229
+ async function coinbaseSpot(base, quote, options) {
230
+ const url = new URL(`/v2/prices/${base}-${quote}/spot`, PROVIDER_ORIGINS.coinbase);
231
+ assertKnownOrigin(url);
232
+ const outcome = await getJson(url.toString(), options);
233
+ if (!outcome.ok)
234
+ return { ok: false, reason: outcome.message };
235
+ const body = outcome.value;
236
+ const data = body?.data;
237
+ if (typeof data !== "object" || data === null) {
238
+ return { ok: false, reason: "coinbase's answer carried no data object" };
239
+ }
240
+ if (typeof data.currency === "string" && data.currency.toUpperCase() !== quote) {
241
+ // A quote currency that is not the one asked for is a wrong answer, not a
242
+ // near miss: the number would be right for some other pair.
243
+ return {
244
+ ok: false,
245
+ reason: `coinbase answered in ${data.currency} for a ${base}-${quote} request`,
246
+ };
247
+ }
248
+ try {
249
+ const parsed = fixedFromJson(data.amount, `the ${base}-${quote} spot price`);
250
+ if (!isPositive(parsed.value)) {
251
+ return {
252
+ ok: false,
253
+ reason: `coinbase answered ${toDecimalString(parsed.value)} for ${base}-${quote}`,
254
+ };
255
+ }
256
+ return { ok: true, price: parsed.value, literal: parsed.kind };
257
+ }
258
+ catch (err) {
259
+ return { ok: false, reason: err.message };
260
+ }
261
+ }
262
+ async function tryProviders(providers, base, quote, at, options, attempts) {
263
+ for (const provider of providers) {
264
+ const outcome = provider === "frankfurter"
265
+ ? await frankfurterLeg(base, quote, at, options)
266
+ : await coinbaseLeg(base, quote, at, options);
267
+ if (outcome.ok)
268
+ return outcome;
269
+ attempts.push(`${provider} could not quote ${base}/${quote}: ${outcome.reason}`);
270
+ }
271
+ return { ok: false, reason: attempts.join("; ") };
272
+ }
273
+ /** The intermediates a cross tries when the caller names none. */
274
+ export const DEFAULT_CROSS_VIA = Object.freeze(["USD", "EUR"]);
275
+ /**
276
+ * Price one asset in another, direct if a provider publishes it, inverted if
277
+ * one publishes the other direction, crossed through an intermediate if
278
+ * neither — and say which of the three happened, every time.
279
+ */
280
+ export async function quotePrice(request, options = {}) {
281
+ const base = normalizeSymbol(request.base, "base");
282
+ const quote = normalizeSymbol(request.quote, "quote");
283
+ const at = normalizeAt(request.at);
284
+ if (base === quote) {
285
+ throw new DefiError(`base and quote are both "${base}" — a pair of an asset with itself has no price`);
286
+ }
287
+ const providers = request.providers ?? defaultProviders(base, quote);
288
+ if (providers.length === 0)
289
+ throw new DefiError("providers was empty, so there is nothing to ask");
290
+ const attempts = [];
291
+ const direct = await tryProviders(providers, base, quote, at, options, attempts);
292
+ if (direct.ok) {
293
+ return {
294
+ value: direct.value,
295
+ quote: {
296
+ base,
297
+ quote,
298
+ price: toDecimalString(direct.value),
299
+ derivation: direct.leg.derivation,
300
+ via: null,
301
+ legs: [direct.leg],
302
+ asOf: direct.leg.asOf,
303
+ notes: direct.leg.asOf === null ? [untimestampedNote(direct.leg.source)] : [],
304
+ },
305
+ };
306
+ }
307
+ if (request.allowCross === false) {
308
+ throw new DefiError(`no configured provider publishes ${base}/${quote} and crossing was not allowed: ${attempts.join("; ")}`);
309
+ }
310
+ const candidates = request.via === undefined ? DEFAULT_CROSS_VIA : [normalizeSymbol(request.via, "via")];
311
+ for (const raw of candidates) {
312
+ const via = normalizeSymbol(raw, "via");
313
+ if (via === base || via === quote)
314
+ continue;
315
+ // An explicit provider order applies to the LEGS too. A caller who named
316
+ // one provider did not ask for a price composed out of a different one.
317
+ const firstProviders = request.providers ?? defaultProviders(base, via);
318
+ const secondProviders = request.providers ?? defaultProviders(via, quote);
319
+ const first = await tryProviders(firstProviders, base, via, at, options, attempts);
320
+ if (!first.ok)
321
+ continue;
322
+ const second = await tryProviders(secondProviders, via, quote, at, options, attempts);
323
+ if (!second.ok)
324
+ continue;
325
+ // Exact: the legs' scales add, and nothing is rounded until a caller asks
326
+ // for a number of places.
327
+ const value = multiply(first.value, second.value);
328
+ const legs = [first.leg, second.leg];
329
+ const asOf = compositeAsOf(legs);
330
+ const notes = [
331
+ `crossed through ${via}: ${base}/${via} from ${first.leg.source}, ${via}/${quote} from ${second.leg.source}`,
332
+ ];
333
+ if (asOf === null)
334
+ notes.push(compositeUntimestampedNote(legs));
335
+ else if (legs.some((leg) => leg.asOf !== asOf)) {
336
+ notes.push(`the legs carry different dates (${legs.map((l) => l.asOf ?? "none").join(", ")}); the older one is reported`);
337
+ }
338
+ return {
339
+ value,
340
+ quote: {
341
+ base,
342
+ quote,
343
+ price: toDecimalString(value),
344
+ derivation: "cross",
345
+ via,
346
+ legs,
347
+ asOf,
348
+ notes,
349
+ },
350
+ };
351
+ }
352
+ throw new DefiError(`could not price ${base}/${quote} directly or through ${candidates.join(" or ")}: ${attempts.join("; ")}`);
353
+ }
354
+ function untimestampedNote(source) {
355
+ return `${source} publishes no timestamp with this price, so asOf is null rather than the time the response arrived`;
356
+ }
357
+ function compositeUntimestampedNote(legs) {
358
+ const without = legs.filter((leg) => leg.asOf === null).map((leg) => leg.source);
359
+ return `no asOf: ${[...new Set(without)].join(" and ")} publishes none, and a composite price is only as dated as its least dated leg`;
360
+ }
361
+ function compositeAsOf(legs) {
362
+ let oldest = null;
363
+ for (const leg of legs) {
364
+ if (leg.asOf === null)
365
+ return null;
366
+ if (oldest === null || leg.asOf < oldest)
367
+ oldest = leg.asOf;
368
+ }
369
+ return oldest;
370
+ }
@@ -0,0 +1,171 @@
1
+ import { CrewhausError } from "@crewhaus/errors";
2
+ /** A refusal this package chose: a missing endpoint, a bad shape, a write attempt. */
3
+ export declare class DefiError extends CrewhausError {
4
+ readonly name = "DefiError";
5
+ constructor(message: string);
6
+ }
7
+ /** A pinned oracle feed, so a caller can name "eth-usd" instead of an address. */
8
+ export type FeedPin = {
9
+ readonly chainId: string;
10
+ readonly kind: "chainlink" | "pyth";
11
+ readonly address: string;
12
+ /** Pyth's per-feed id. Required for a Pyth pin, meaningless for Chainlink. */
13
+ readonly priceId?: string;
14
+ /**
15
+ * The feed's published heartbeat, in seconds. Optional because a heartbeat
16
+ * is a property of the feed's deployment and is not readable from the
17
+ * aggregator — a tool that guessed one would be inventing the threshold it
18
+ * then judges against.
19
+ */
20
+ readonly heartbeatSeconds?: number;
21
+ };
22
+ export type DefiConfig = {
23
+ /** chain id -> JSON-RPC endpoint. */
24
+ readonly endpoints: ReadonlyMap<string, string>;
25
+ /** chain id -> Multicall3 deployment, when it is not at the canonical address. */
26
+ readonly multicall: ReadonlyMap<string, string>;
27
+ /** feed name -> pin. */
28
+ readonly feeds: ReadonlyMap<string, FeedPin>;
29
+ };
30
+ export type DefiConfigInput = {
31
+ readonly rpc?: Record<string, string>;
32
+ readonly multicall3?: Record<string, string>;
33
+ readonly feeds?: Record<string, Record<string, unknown>>;
34
+ };
35
+ export declare function buildDefiConfig(input: DefiConfigInput): DefiConfig;
36
+ /** Replace the process-global config. Generated daemons call this at boot. */
37
+ export declare function registerDefiConfig(input: DefiConfigInput): void;
38
+ export declare function getDefiConfig(): DefiConfig;
39
+ /**
40
+ * The config ONE call runs under: the serving candidate's `tool_config.defi`
41
+ * block when it declares one, else the boot registration. A non-object
42
+ * override is ignored rather than merged — an endpoint table only ever comes
43
+ * from a spec block or from boot.
44
+ */
45
+ export declare function resolveDefiConfig(override: unknown): DefiConfig;
46
+ /** Test-only — back to an empty, fail-closed table. */
47
+ export declare function _resetDefiConfig(): void;
48
+ /**
49
+ * The only form an endpoint takes in a message or a result: scheme, host and
50
+ * port, never the path.
51
+ *
52
+ * Provider RPC URLs carry the API key in the PATH — `/v2/<key>` at one vendor,
53
+ * `/<key>` at another. Printing the endpoint in a refusal, a provenance field
54
+ * or a caveat would put that key in a model's context and then in a transcript.
55
+ */
56
+ export declare function endpointLabel(raw: string): string;
57
+ /**
58
+ * Take the endpoint back out of a message this package did not write.
59
+ *
60
+ * Every failure here is assembled from `endpointLabel` — and then has somebody
61
+ * else's string concatenated onto it: the dialler's error, the node's own
62
+ * JSON-RPC message, an HTTP status line. Those are the one place the key can
63
+ * still get out, because a fetch implementation that reports "unable to
64
+ * connect to <url>" is quoting a URL whose PATH is the credential. One
65
+ * borrowed string undoes the labelling everywhere else, and it lands in a
66
+ * model's context and from there in a transcript.
67
+ *
68
+ * The path and the query are redacted as well as the whole URL, so a message
69
+ * that quotes only the tail is covered too.
70
+ */
71
+ export declare function withoutEndpoint(message: string, endpoint: string): string;
72
+ export declare function requireEndpoint(config: DefiConfig, chainId: string): string;
73
+ export type DefiFetch = (req: Request) => Promise<Response>;
74
+ /**
75
+ * Test-only injection point, the convention every networked package here
76
+ * follows. `undefined` restores the real dialler; a suite that sets it must
77
+ * restore it, or the next file in the same bun process inherits the stub.
78
+ */
79
+ export declare function _setFetch(fn: DefiFetch | undefined): void;
80
+ /** Epoch SECONDS, to match every onchain timestamp this package compares against. */
81
+ export type Clock = () => number;
82
+ /**
83
+ * Test-only clock. An oracle's age is the difference between two instants, and
84
+ * a test that took the second one from the wall clock would be asserting how
85
+ * fast the runner is. Every age in this package comes from here.
86
+ */
87
+ export declare function _setClock(fn: Clock | undefined): void;
88
+ export declare function nowSeconds(): number;
89
+ export declare const DEFAULT_TIMEOUT_MS = 20000;
90
+ export declare const MAX_TIMEOUT_MS = 120000;
91
+ /** 16 MB. An `eth_getLogs` answer is not read here; an `eth_call` result is small. */
92
+ export declare const MAX_RESPONSE_BYTES: number;
93
+ /**
94
+ * The JSON-RPC methods this package may issue, narrower than the runtime's
95
+ * read-only set because this package reads state and prices and nothing else.
96
+ */
97
+ export declare const DEFI_RPC_METHODS: ReadonlySet<string>;
98
+ export type TransportFailure = {
99
+ readonly kind: "transport" | "status" | "rateLimited" | "malformed" | "rpcError" | "refused";
100
+ readonly message: string;
101
+ readonly status?: number;
102
+ /** The JSON-RPC error code, when the node answered with one. */
103
+ readonly code?: number;
104
+ };
105
+ export type RpcOutcome<T> = {
106
+ readonly ok: true;
107
+ readonly value: T;
108
+ } | ({
109
+ readonly ok: false;
110
+ } & TransportFailure);
111
+ export type RpcOptions = {
112
+ readonly signal?: AbortSignal;
113
+ readonly timeoutMs?: number;
114
+ /** Only for the refusal message when a method is off the allow-list. */
115
+ readonly chainId?: string;
116
+ };
117
+ /**
118
+ * Issue one JSON-RPC call.
119
+ *
120
+ * Failures are RETURNED rather than thrown, because the caller with the most
121
+ * at stake — `PortfolioValuation`, reading fifty assets — wants the asset that
122
+ * failed named in its unpriced bucket rather than losing the other forty-nine.
123
+ * A method outside the allow-list is the exception: that is a defect in this
124
+ * package and it throws.
125
+ */
126
+ export declare function rpcCall<T = unknown>(endpoint: string, method: string, params: ReadonlyArray<unknown>, options?: RpcOptions): Promise<RpcOutcome<T>>;
127
+ /**
128
+ * GET a JSON document, through the same seam and the same cap.
129
+ *
130
+ * The URL is never a caller's: `./quotes` builds it from one of two origin
131
+ * constants and re-checks the origin before dialling. Redirects are NOT
132
+ * followed — a price provider that 302s somewhere else is not a price
133
+ * provider, and following it is how an answer arrives from a host nobody
134
+ * configured.
135
+ */
136
+ export declare function getJson(url: string, options?: RpcOptions & {
137
+ readonly accept?: string;
138
+ }): Promise<RpcOutcome<unknown>>;
139
+ /** `eth_call` at a named block, returning the raw 0x hex. */
140
+ export declare function ethCall(endpoint: string, to: string, data: string, blockTag: string, options?: RpcOptions): Promise<RpcOutcome<string>>;
141
+ /** The native-coin balance of an address, in wei, at a named block. */
142
+ export declare function ethGetBalance(endpoint: string, address: string, blockTag: string, options?: RpcOptions): Promise<RpcOutcome<bigint>>;
143
+ /** The head block number. */
144
+ export declare function ethBlockNumber(endpoint: string, options?: RpcOptions): Promise<RpcOutcome<bigint>>;
145
+ /**
146
+ * Turn a block number into the tag every read in one answer shares.
147
+ *
148
+ * A valuation assembled from reads at different heights is a number that never
149
+ * existed: balances from one block, a price from the next. Every tool here
150
+ * resolves ONE tag up front and passes it down.
151
+ */
152
+ export declare function blockTagOf(blockNumber: bigint | undefined): string;
153
+ export type Deadline = {
154
+ readonly signal: AbortSignal;
155
+ cancel(): void;
156
+ };
157
+ /**
158
+ * A deadline that also honours the runtime's own cancellation. Every call
159
+ * opens one and cancels it in a `finally` — a timer left running holds the
160
+ * process open past the answer.
161
+ */
162
+ export declare function startDeadline(ms: number, outer?: AbortSignal): Deadline;
163
+ /**
164
+ * Run `work` over `items` with at most `limit` in flight, preserving input
165
+ * order in the result. Order is preserved because the output is a TABLE, and a
166
+ * row order that depends on which read answered first is a diff that changes
167
+ * between two identical runs.
168
+ */
169
+ export declare function mapPool<T, R>(items: readonly T[], limit: number, work: (item: T, index: number) => Promise<R>): Promise<R[]>;
170
+ /** Compact JSON — every byte returned is a byte in somebody's context window. */
171
+ export declare const json: (value: unknown) => string;