@crewhaus/tool-defi 0.7.0 → 0.8.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.
@@ -1,4 +1,4 @@
1
- import { MAX_CONTRACT_TEXT_CHARS, SELECTORS, asSigned, callNoArgs, callWithBytes32, decodeString, decodeWords, normalizeAddress, normalizeBytes32, sanitizeContractText, } from "./abi";
1
+ import { MAX_CONTRACT_TEXT_CHARS, SELECTORS, asSigned, asUnsigned, callNoArgs, callWithBytes32, decodeString, decodeWords, isCanonicalInt, normalizeAddress, normalizeBytes32, sanitizeContractText, } from "./abi";
2
2
  /**
3
3
  * Reading a price feed onchain, and saying what its own freshness signal says.
4
4
  *
@@ -198,7 +198,10 @@ export async function readPyth(request, options = {}) {
198
198
  // Every member is static, so the struct is four words inline — no offset.
199
199
  const [priceWord, confWord, expoWord, publishWord] = decodeWords(data, 4, "getPriceUnsafe(bytes32)");
200
200
  const rawPrice = asSigned(priceWord, 64);
201
- const confidence = asSigned(confWord, 64);
201
+ // `conf` is a uint64. Read signed, a confidence from 2^63 up came back
202
+ // negative, so its ratio to the price was negative and never beyond any
203
+ // bound — the widest band a feed can state passed as the tightest.
204
+ const confidence = asUnsigned(confWord, 64);
202
205
  // `expo` is an int32 and is almost always NEGATIVE: -8 means the integer is
203
206
  // scaled by 1e-8. Read unsigned it is about 4.29e9, and the price comes back
204
207
  // as a number with four billion zeros after it.
@@ -210,6 +213,14 @@ export async function readPyth(request, options = {}) {
210
213
  const value = fixed(rawPrice, -exponent);
211
214
  const confidenceValue = fixed(confidence, -exponent);
212
215
  const notes = [];
216
+ const dirty = [
217
+ isCanonicalInt(priceWord, 64, true) ? null : "price (int64)",
218
+ isCanonicalInt(confWord, 64, false) ? null : "conf (uint64)",
219
+ isCanonicalInt(expoWord, 32, true) ? null : "expo (int32)",
220
+ ].filter((field) => field !== null);
221
+ if (dirty.length > 0) {
222
+ notes.push(`the answer's ${dirty.join(", ")} ${dirty.length === 1 ? "word is" : "words are"} not padded the way the ABI encodes ${dirty.length === 1 ? "that type" : "those types"}, so only the low bits were read — a real Pyth contract does not answer like that, and a different contract answering the same selector does`);
223
+ }
213
224
  let confidenceToPriceBps = 0;
214
225
  if (isPositive(value)) {
215
226
  confidenceToPriceBps = ratioBps(confidenceValue, value);
@@ -109,8 +109,11 @@ async function frankfurterLeg(base, quote, at, options) {
109
109
  const url = new URL(`${at ?? "latest"}?from=${base}&to=${quote}`, `${PROVIDER_ORIGINS.frankfurter}/`);
110
110
  assertKnownOrigin(url);
111
111
  const outcome = await getJson(url.toString(), options);
112
- if (!outcome.ok)
113
- return { ok: false, reason: `frankfurter: ${outcome.message}` };
112
+ if (!outcome.ok) {
113
+ return outcome.kind === "budget"
114
+ ? { ok: false, reason: `frankfurter was not asked for ${base}/${quote}: ${outcome.message}` }
115
+ : { ok: false, reason: `frankfurter: ${outcome.message}` };
116
+ }
114
117
  const body = outcome.value;
115
118
  if (typeof body !== "object" || body === null) {
116
119
  return { ok: false, reason: "frankfurter answered something other than an object" };
@@ -177,6 +180,10 @@ async function coinbaseLeg(base, quote, at, options) {
177
180
  return { ok: false, reason: "a pair of one asset with itself is not a quote" };
178
181
  }
179
182
  const direct = await coinbaseSpot(base, quote, options);
183
+ if (!direct.ok && direct.notAsked === true) {
184
+ // Nothing was asked, so nothing is known about what coinbase publishes.
185
+ return { ok: false, reason: `coinbase was not asked for ${base}-${quote}: ${direct.reason}` };
186
+ }
180
187
  if (direct.ok) {
181
188
  return {
182
189
  ok: true,
@@ -199,7 +206,9 @@ async function coinbaseLeg(base, quote, at, options) {
199
206
  if (!inverse.ok) {
200
207
  return {
201
208
  ok: false,
202
- reason: `coinbase publishes neither ${base}-${quote} (${direct.reason}) nor its inverse`,
209
+ reason: inverse.notAsked === true
210
+ ? `coinbase does not publish ${base}-${quote} (${direct.reason}), and its inverse ${quote}-${base} was not asked: ${inverse.reason}`
211
+ : `coinbase publishes neither ${base}-${quote} (${direct.reason}) nor its inverse`,
203
212
  };
204
213
  }
205
214
  if (!isPositive(inverse.price)) {
@@ -230,8 +239,11 @@ async function coinbaseSpot(base, quote, options) {
230
239
  const url = new URL(`/v2/prices/${base}-${quote}/spot`, PROVIDER_ORIGINS.coinbase);
231
240
  assertKnownOrigin(url);
232
241
  const outcome = await getJson(url.toString(), options);
233
- if (!outcome.ok)
234
- return { ok: false, reason: outcome.message };
242
+ if (!outcome.ok) {
243
+ return outcome.kind === "budget"
244
+ ? { ok: false, reason: outcome.message, notAsked: true }
245
+ : { ok: false, reason: outcome.message };
246
+ }
235
247
  const body = outcome.value;
236
248
  const data = body?.data;
237
249
  if (typeof data !== "object" || data === null) {
package/dist/lib/rpc.d.ts CHANGED
@@ -96,7 +96,12 @@ export declare const MAX_RESPONSE_BYTES: number;
96
96
  */
97
97
  export declare const DEFI_RPC_METHODS: ReadonlySet<string>;
98
98
  export type TransportFailure = {
99
- readonly kind: "transport" | "status" | "rateLimited" | "malformed" | "rpcError" | "refused";
99
+ /**
100
+ * `budget`: not sent, because the call's price-provider requests are spent
101
+ * or this round's share of them is — nothing was asked, so nothing is
102
+ * known about what the provider publishes.
103
+ */
104
+ readonly kind: "transport" | "status" | "rateLimited" | "malformed" | "rpcError" | "refused" | "budget";
100
105
  readonly message: string;
101
106
  readonly status?: number;
102
107
  /** The JSON-RPC error code, when the node answered with one. */
@@ -109,11 +114,52 @@ export type RpcOutcome<T> = {
109
114
  readonly ok: false;
110
115
  } & TransportFailure);
111
116
  export type RpcOptions = {
117
+ /**
118
+ * The WHOLE call's signal: a tool opens one deadline per call
119
+ * ({@link startDeadline}) and hands its signal down, so every request the
120
+ * call makes shares one budget of time. Once it has aborted, nothing more
121
+ * is dialled.
122
+ */
112
123
  readonly signal?: AbortSignal;
113
124
  readonly timeoutMs?: number;
114
125
  /** Only for the refusal message when a method is off the allow-list. */
115
126
  readonly chainId?: string;
127
+ /** The call's price-provider budget and answers, for {@link getJson}. */
128
+ readonly providers?: ProviderLedger;
116
129
  };
130
+ /**
131
+ * What one tool call may ask the public price providers, and what it already
132
+ * has.
133
+ *
134
+ * `PortfolioValuation` prices up to 256 holdings, and one quote can take up to
135
+ * fifteen requests (direct, inverted, then crossed through two intermediates).
136
+ * Unbounded, one call could send thousands of requests to somebody else's
137
+ * public endpoint — and the same leg (USD/JPY, for every holding crossed
138
+ * through USD) was fetched again for every holding. The ledger caps the
139
+ * requests and answers a URL already fetched in this call from its answer.
140
+ */
141
+ export type ProviderLedger = {
142
+ readonly limit: number;
143
+ made: number;
144
+ /**
145
+ * How many NEW requests the attempt in progress may still make, when a
146
+ * caller shares the budget out in rounds (PortfolioValuation: one per
147
+ * holding per round). Undefined: no per-attempt limit. An answer already
148
+ * fetched in the call is free — until the attempt is `halted`.
149
+ */
150
+ allowance?: number;
151
+ /**
152
+ * Set once the attempt in progress has been refused a request: every
153
+ * later request of that attempt is refused too, cached or not, so a route
154
+ * it prefers less cannot answer from the cache while a route it prefers
155
+ * more was not asked.
156
+ */
157
+ halted?: boolean;
158
+ /** How many requests were refused for budget, so a caller can say so. */
159
+ refused: number;
160
+ readonly answers: Map<string, Promise<RpcOutcome<unknown>>>;
161
+ };
162
+ export declare function newProviderLedger(limit: number): ProviderLedger;
117
163
  /**
118
164
  * Issue one JSON-RPC call.
119
165
  *
@@ -152,8 +198,15 @@ export declare function ethBlockNumber(endpoint: string, options?: RpcOptions):
152
198
  export declare function blockTagOf(blockNumber: bigint | undefined): string;
153
199
  export type Deadline = {
154
200
  readonly signal: AbortSignal;
201
+ readonly ms: number;
155
202
  cancel(): void;
156
203
  };
204
+ /** The abort reason of a deadline that ran out, as opposed to a cancellation. */
205
+ export declare class DeadlineElapsed extends Error {
206
+ readonly ms: number;
207
+ readonly name = "DeadlineElapsed";
208
+ constructor(ms: number);
209
+ }
157
210
  /**
158
211
  * A deadline that also honours the runtime's own cancellation. Every call
159
212
  * opens one and cancels it in a `finally` — a timer left running holds the
package/dist/lib/rpc.js CHANGED
@@ -24,6 +24,8 @@
24
24
  */
25
25
  import { assertReadOnlyMethod } from "@crewhaus/chain-adapter-base";
26
26
  import { CrewhausError } from "@crewhaus/errors";
27
+ import { parseMulticallMap } from "@crewhaus/tool-onchain";
28
+ import { fetchRaw, readResponseBounded } from "@crewhaus/tool-safety/streams";
27
29
  /** A refusal this package chose: a missing endpoint, a bad shape, a write attempt. */
28
30
  export class DefiError extends CrewhausError {
29
31
  name = "DefiError";
@@ -51,12 +53,17 @@ export function buildDefiConfig(input) {
51
53
  throw new DefiError(`rpc["${chainId}"] must be a URL string`);
52
54
  endpoints.set(String(chainId), assertEndpoint(url, chainId));
53
55
  }
54
- const multicall = new Map();
55
- for (const [chainId, address] of Object.entries(input.multicall3 ?? {})) {
56
- if (typeof address !== "string") {
57
- throw new DefiError(`multicall3["${chainId}"] must be an address string`);
58
- }
59
- multicall.set(String(chainId), address.trim().toLowerCase());
56
+ // Checked like any address a coder writes: shape, and the EIP-55 checksum
57
+ // when it carries one. The deployment answers every read in a batch.
58
+ let multicall;
59
+ try {
60
+ multicall = new Map([...parseMulticallMap(input.multicall3, "multicall3")].map(([id, a]) => [
61
+ id,
62
+ a.toLowerCase(),
63
+ ]));
64
+ }
65
+ catch (err) {
66
+ throw new DefiError(err.message);
60
67
  }
61
68
  const feeds = new Map();
62
69
  for (const [name, row] of Object.entries(input.feeds ?? {})) {
@@ -116,6 +123,11 @@ export function _resetDefiConfig() {
116
123
  * is looking at the spec, rather than three tool calls into a run.
117
124
  */
118
125
  function assertEndpoint(raw, chainId) {
126
+ if (/^\$[A-Z_][A-Z0-9_]*$/.test(raw.trim())) {
127
+ // A bundle and `crewhaus run` read a `$VAR` value from the environment
128
+ // before this sees it; one arriving here was handed over unresolved.
129
+ throw new DefiError(`rpc["${chainId}"] is ${raw.trim()}, an environment reference nothing resolved — a compiled bundle and crewhaus run read it from the environment at start; a direct registerDefiConfig caller passes the URL itself`);
130
+ }
119
131
  let url;
120
132
  try {
121
133
  url = new URL(raw.trim());
@@ -191,7 +203,14 @@ export function requireEndpoint(config, chainId) {
191
203
  }
192
204
  return endpoint;
193
205
  }
194
- const realFetch = (req) => globalThis.fetch(req);
206
+ /**
207
+ * The body is kept RAW (`fetchRaw`, Bun's `decompress: false`): otherwise Bun
208
+ * inflates a gzip, deflate, br or zstd reply in native code before any
209
+ * reader sees a byte, so a 260 KB gzip of zeros from an RPC or price
210
+ * endpoint cost about 1 GB of RSS before the cap fired (C093). `readCapped`
211
+ * decodes it, under the cap.
212
+ */
213
+ const realFetch = (req) => fetchRaw(req);
195
214
  let defiFetch = realFetch;
196
215
  /**
197
216
  * Test-only injection point, the convention every networked package here
@@ -232,6 +251,17 @@ export const DEFI_RPC_METHODS = new Set([
232
251
  "eth_blockNumber",
233
252
  "eth_getBlockByNumber",
234
253
  ]);
254
+ export function newProviderLedger(limit) {
255
+ return { limit, made: 0, refused: 0, answers: new Map() };
256
+ }
257
+ /** Why nothing was dialled: the call's signal had already fired. */
258
+ function notAsked(signal, label, what) {
259
+ const cancelled = !(signal.reason instanceof DeadlineElapsed);
260
+ return {
261
+ kind: "transport",
262
+ message: `the deadline elapsed before ${label} was asked ${what}: ${cancelled ? "the call was cancelled" : `the call's ${signal.reason.ms}ms were spent`}`,
263
+ };
264
+ }
235
265
  /**
236
266
  * Issue one JSON-RPC call.
237
267
  *
@@ -249,6 +279,9 @@ export async function rpcCall(endpoint, method, params, options = {}) {
249
279
  throw new DefiError(`refusing to issue "${method}" — @crewhaus/tool-defi reads prices and balances, and its method set is ${[...DEFI_RPC_METHODS].sort().join(", ")}`);
250
280
  }
251
281
  const label = endpointLabel(endpoint);
282
+ if (options.signal?.aborted === true) {
283
+ return { ok: false, ...notAsked(options.signal, label, method) };
284
+ }
252
285
  const deadline = startDeadline(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal);
253
286
  try {
254
287
  const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
@@ -312,14 +345,19 @@ export async function rpcCall(endpoint, method, params, options = {}) {
312
345
  message: withoutEndpoint(`${label} answered ${res.status} ${res.statusText}`.trimEnd(), endpoint),
313
346
  };
314
347
  }
315
- const text = await readCapped(res, MAX_RESPONSE_BYTES);
316
- if (text === null) {
348
+ const read = await readCapped(res, MAX_RESPONSE_BYTES, deadline.signal);
349
+ if (!read.ok) {
317
350
  return {
318
351
  ok: false,
319
- kind: "malformed",
320
- message: `${label} sent more than ${MAX_RESPONSE_BYTES} bytes for ${method} — refusing to answer from a prefix of it`,
352
+ kind: read.why === "aborted" ? "transport" : "malformed",
353
+ message: read.why === "too-large"
354
+ ? `${label} sent more than ${MAX_RESPONSE_BYTES} bytes for ${method} — refusing to answer from a prefix of it`
355
+ : read.why === "aborted"
356
+ ? `the deadline elapsed before ${label} finished answering ${method}`
357
+ : `${label} answered ${method} with a body that could not be decoded (${read.why})`,
321
358
  };
322
359
  }
360
+ const text = read.text;
323
361
  let parsed;
324
362
  try {
325
363
  parsed = JSON.parse(text);
@@ -376,6 +414,47 @@ export async function rpcCall(endpoint, method, params, options = {}) {
376
414
  */
377
415
  export async function getJson(url, options = {}) {
378
416
  const label = endpointLabel(url);
417
+ if (options.signal?.aborted === true) {
418
+ return { ok: false, ...notAsked(options.signal, label, "for a price") };
419
+ }
420
+ const ledger = options.providers;
421
+ if (ledger === undefined)
422
+ return dialJson(url, label, options);
423
+ const key = `${options.accept ?? "application/json"} ${url}`;
424
+ const roundOver = () => {
425
+ ledger.halted = true;
426
+ ledger.refused++;
427
+ return {
428
+ ok: false,
429
+ kind: "budget",
430
+ message: `${label} was not asked: this call's ${ledger.limit} price-provider requests go out one per holding per round, and too few were left for another round of every holding still unpriced — price fewer distinct assets per call`,
431
+ };
432
+ };
433
+ if (ledger.halted === true)
434
+ return roundOver();
435
+ const known = ledger.answers.get(key);
436
+ if (known !== undefined)
437
+ return known;
438
+ if (ledger.made >= ledger.limit) {
439
+ ledger.halted = true;
440
+ ledger.refused++;
441
+ return {
442
+ ok: false,
443
+ kind: "budget",
444
+ message: `this call has already sent the ${ledger.limit} price-provider requests it may send, so ${label} was not asked — price fewer distinct assets per call`,
445
+ };
446
+ }
447
+ if (ledger.allowance !== undefined) {
448
+ if (ledger.allowance <= 0)
449
+ return roundOver();
450
+ ledger.allowance--;
451
+ }
452
+ ledger.made++;
453
+ const answer = dialJson(url, label, options);
454
+ ledger.answers.set(key, answer);
455
+ return answer;
456
+ }
457
+ async function dialJson(url, label, options) {
379
458
  const deadline = startDeadline(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal);
380
459
  try {
381
460
  let res;
@@ -430,14 +509,19 @@ export async function getJson(url, options = {}) {
430
509
  message: withoutEndpoint(`${label} answered ${res.status} ${res.statusText}`.trimEnd(), url),
431
510
  };
432
511
  }
433
- const text = await readCapped(res, MAX_RESPONSE_BYTES);
434
- if (text === null) {
512
+ const read = await readCapped(res, MAX_RESPONSE_BYTES, deadline.signal);
513
+ if (!read.ok) {
435
514
  return {
436
515
  ok: false,
437
- kind: "malformed",
438
- message: `${label} sent more than ${MAX_RESPONSE_BYTES} bytes`,
516
+ kind: read.why === "aborted" ? "transport" : "malformed",
517
+ message: read.why === "too-large"
518
+ ? `${label} sent more than ${MAX_RESPONSE_BYTES} bytes`
519
+ : read.why === "aborted"
520
+ ? `the deadline elapsed before ${label} finished answering`
521
+ : `${label} answered with a body that could not be decoded (${read.why})`,
439
522
  };
440
523
  }
524
+ const text = read.text;
441
525
  try {
442
526
  return { ok: true, value: JSON.parse(text) };
443
527
  }
@@ -505,52 +589,35 @@ async function discard(res) {
505
589
  // already closed — nothing to release
506
590
  }
507
591
  }
508
- /** Read a body with a hard byte cap, cancelling the stream once it is passed. */
509
- async function readCapped(res, maxBytes) {
510
- if (res.body === null) {
511
- const text = await res.text();
512
- return new TextEncoder().encode(text).byteLength > maxBytes ? null : text;
513
- }
514
- const reader = res.body.getReader();
515
- const chunks = [];
516
- let total = 0;
517
- let over = false;
518
- try {
519
- while (true) {
520
- const { done, value } = await reader.read();
521
- if (done || value === undefined)
522
- break;
523
- if (total + value.byteLength > maxBytes) {
524
- over = true;
525
- try {
526
- await reader.cancel();
527
- }
528
- catch {
529
- // already aborting
530
- }
531
- break;
532
- }
533
- chunks.push(value);
534
- total += value.byteLength;
535
- }
536
- }
537
- finally {
538
- try {
539
- reader.releaseLock();
540
- }
541
- catch {
542
- // already released
543
- }
544
- }
545
- if (over)
546
- return null;
547
- const merged = new Uint8Array(total);
548
- let offset = 0;
549
- for (const chunk of chunks) {
550
- merged.set(chunk, offset);
551
- offset += chunk.byteLength;
592
+ /**
593
+ * Read a body with a hard cap on its DECODED size: the body arrives raw (see
594
+ * `realFetch`), a gzip, deflate, br or zstd body is decoded here in small
595
+ * steps, and the decoder stops at the cap. A body past it is refused, never
596
+ * parsed from a prefix.
597
+ */
598
+ async function readCapped(res, maxBytes, signal) {
599
+ const read = await readResponseBounded(res, { maxBytes, signal });
600
+ if (read.ok)
601
+ return read.truncated ? { ok: false, why: "too-large" } : { ok: true, text: read.text };
602
+ if (read.code === "aborted" || read.code === "stalled")
603
+ return { ok: false, why: "aborted" };
604
+ return {
605
+ ok: false,
606
+ why: read.code === "unsupported-encoding"
607
+ ? "a stack of content-encodings this package cannot decode within its cap"
608
+ : read.code === "read-error"
609
+ ? "the connection closed before the body ended"
610
+ : "it is labelled as compressed but is not",
611
+ };
612
+ }
613
+ /** The abort reason of a deadline that ran out, as opposed to a cancellation. */
614
+ export class DeadlineElapsed extends Error {
615
+ ms;
616
+ name = "DeadlineElapsed";
617
+ constructor(ms) {
618
+ super(`deadline of ${ms}ms elapsed`);
619
+ this.ms = ms;
552
620
  }
553
- return new TextDecoder("utf-8", { fatal: false }).decode(merged);
554
621
  }
555
622
  /**
556
623
  * A deadline that also honours the runtime's own cancellation. Every call
@@ -559,7 +626,7 @@ async function readCapped(res, maxBytes) {
559
626
  */
560
627
  export function startDeadline(ms, outer) {
561
628
  const controller = new AbortController();
562
- const timer = setTimeout(() => controller.abort(new Error(`deadline of ${ms}ms elapsed`)), ms);
629
+ const timer = setTimeout(() => controller.abort(new DeadlineElapsed(ms)), ms);
563
630
  const onOuter = () => controller.abort(outer?.reason);
564
631
  if (outer !== undefined) {
565
632
  if (outer.aborted)
@@ -569,6 +636,7 @@ export function startDeadline(ms, outer) {
569
636
  }
570
637
  return {
571
638
  signal: controller.signal,
639
+ ms,
572
640
  cancel: () => {
573
641
  clearTimeout(timer);
574
642
  outer?.removeEventListener("abort", onOuter);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-defi",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic valuation: oracle reads, price quotes and portfolio valuation with provenance and an explicit unpriced bucket",
6
6
  "main": "dist/index.js",
@@ -8,19 +8,21 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/chain-adapter-base": "0.7.0",
19
- "@crewhaus/errors": "0.7.0",
20
- "@crewhaus/tool-builder": "0.7.0",
21
- "@crewhaus/tool-catalog": "0.7.0",
22
- "@crewhaus/tool-math": "0.7.0",
23
- "@crewhaus/tool-onchain": "0.7.0",
19
+ "@crewhaus/chain-adapter-base": "0.8.0",
20
+ "@crewhaus/errors": "0.8.0",
21
+ "@crewhaus/tool-builder": "0.8.0",
22
+ "@crewhaus/tool-catalog": "0.8.0",
23
+ "@crewhaus/tool-math": "0.8.0",
24
+ "@crewhaus/tool-onchain": "0.8.0",
25
+ "@crewhaus/tool-safety": "0.8.0",
24
26
  "zod": "^3.23.8"
25
27
  },
26
28
  "devDependencies": {
@@ -44,5 +46,8 @@
44
46
  "publishConfig": {
45
47
  "access": "public"
46
48
  },
47
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
49
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
50
+ "engines": {
51
+ "bun": ">=1.3.14"
52
+ }
48
53
  }
@@ -1,111 +0,0 @@
1
- import type { DefiFetch } from "./lib/rpc";
2
- /** ABI-encode a tuple of values, as a contract's return data (no selector). */
3
- export declare function encodeReturn(types: ReadonlyArray<string>, args: ReadonlyArray<unknown>): Promise<string>;
4
- /** Mainnet-shaped addresses. Lowercase: this package shape-checks, it does not checksum. */
5
- export declare const ADDR: Readonly<{
6
- wallet: "0x1111111111111111111111111111111111111111";
7
- chainlinkEthUsd: "0x5f4ec3df9cbd43714fe2740f5e3616155c5b8419";
8
- pyth: "0x4305fb66699c3b2702d4d05cf36551390a4c69c6";
9
- aavePool: "0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2";
10
- comet: "0xc3d688b66703497daa19211eedff47f25384cdc3";
11
- vault4626: "0x83f20f44975d03b1b09e64809b757c47f942beea";
12
- usdc: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
13
- weth: "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2";
14
- multicall3: "0xca11bde05977b3631167028862be2a173976ca11";
15
- spam: "0xdead000000000000000000000000000000000001";
16
- }>;
17
- export declare const SELECTOR_TEXT: Readonly<{
18
- decimals: "0x313ce567";
19
- description: "0x7284e416";
20
- latestRoundData: "0xfeaf968c";
21
- balanceOf: "0x70a08231";
22
- getPriceUnsafe: "0x96834ad3";
23
- getUserAccountData: "0xbf92857c";
24
- convertToAssets: "0x07a2d13a";
25
- asset: "0x38d52e0f";
26
- borrowBalanceOf: "0x374c49b4";
27
- baseToken: "0xc55dae63";
28
- collateralBalanceOf: "0x5c2549ee";
29
- isLiquidatable: "0x042e02cf";
30
- aggregate3: "0x82ad56cb";
31
- }>;
32
- /** A Pyth price id. Any 32 bytes will do; this is the shape, not the value. */
33
- export declare const ETH_USD_PRICE_ID: string;
34
- /** A sub-call's answer: return data, or a revert. */
35
- export type CallAnswer = {
36
- readonly data: string;
37
- } | {
38
- readonly revert: string;
39
- };
40
- export type ChainState = {
41
- blockNumber: bigint;
42
- /** `${to}|${calldata}` -> answer. Exact calldata, so a wrong argument misses. */
43
- readonly calls: Map<string, CallAnswer>;
44
- /** address -> wei. */
45
- readonly balances: Map<string, bigint>;
46
- /** Where Multicall3 is, when this chain has it. */
47
- multicall3?: string;
48
- };
49
- export declare function newChain(blockNumber?: bigint): ChainState;
50
- export declare function route(chain: ChainState, to: string, calldata: string, answer: CallAnswer): void;
51
- /** `latestRoundData()`, `decimals()` and `description()` for one Chainlink feed. */
52
- export declare function chainlinkFeed(chain: ChainState, address: string, feed: {
53
- roundId: bigint;
54
- answer: bigint;
55
- startedAt: bigint;
56
- updatedAt: bigint;
57
- answeredInRound: bigint;
58
- decimals: number;
59
- description?: string | null;
60
- }): Promise<void>;
61
- /** One Pyth price, under `getPriceUnsafe(priceId)`. */
62
- export declare function pythFeed(chain: ChainState, address: string, priceId: string, price: {
63
- price: bigint;
64
- confidence: bigint;
65
- exponent: number;
66
- publishTime: bigint;
67
- }): Promise<void>;
68
- /** An ERC-20's `balanceOf(wallet)` and `decimals()`. */
69
- export declare function erc20(chain: ChainState, token: string, wallet: string, balance: bigint, decimals: number | null): Promise<void>;
70
- /** Aave v3 `getUserAccountData(account)`. */
71
- export declare function aaveAccount(chain: ChainState, pool: string, account: string, data: {
72
- collateralBase: bigint;
73
- debtBase: bigint;
74
- availableBorrowsBase: bigint;
75
- liquidationThresholdBps: bigint;
76
- ltvBps: bigint;
77
- healthFactorWad: bigint;
78
- }): Promise<void>;
79
- export type Recorded = {
80
- method: string;
81
- url: string;
82
- rpcMethod?: string;
83
- body?: unknown;
84
- };
85
- export type ServeOptions = {
86
- readonly chain?: ChainState;
87
- /** Absolute URL -> body, or a status to answer with. */
88
- readonly http?: Record<string, unknown | {
89
- status: number;
90
- headers?: Record<string, string>;
91
- }>;
92
- /** Called before anything is served, so a test can fail one request. */
93
- readonly intercept?: (req: Request) => Response | undefined;
94
- };
95
- /**
96
- * Build the fetch stub. It records every request so a test can assert what was
97
- * NOT asked for, which is how the "a cross only happens when a direct quote
98
- * failed" claims are checked.
99
- */
100
- export declare function serve(options: ServeOptions): {
101
- fetch: DefiFetch;
102
- recorded: Recorded[];
103
- };
104
- /** Frankfurter's shape. The rate is a bare JSON NUMBER, which is the point. */
105
- export declare function frankfurter(base: string, quote: string, rate: number, date: string): unknown;
106
- /** Coinbase's shape. The amount is a STRING, and there is no timestamp anywhere. */
107
- export declare function coinbaseSpot(base: string, currency: string, amount: string): unknown;
108
- export declare const FRANKFURTER = "https://api.frankfurter.app";
109
- export declare const COINBASE = "https://api.coinbase.com";
110
- export declare const frankfurterUrl: (base: string, quote: string, at?: string) => string;
111
- export declare const coinbaseUrl: (base: string, quote: string) => string;