@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.
- package/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +27 -4
- package/dist/index.d.ts +21 -0
- package/dist/index.js +283 -120
- package/dist/lib/abi.d.ts +27 -7
- package/dist/lib/abi.js +35 -7
- package/dist/lib/oracle.js +13 -2
- package/dist/lib/quotes.js +17 -5
- package/dist/lib/rpc.d.ts +54 -1
- package/dist/lib/rpc.js +129 -61
- package/package.json +14 -9
- package/dist/fixtures.d.ts +0 -111
- package/dist/fixtures.js +0 -261
package/dist/lib/oracle.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/lib/quotes.js
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
|
316
|
-
if (
|
|
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:
|
|
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
|
|
434
|
-
if (
|
|
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:
|
|
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
|
-
/**
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
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.
|
|
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.
|
|
19
|
-
"@crewhaus/errors": "0.
|
|
20
|
-
"@crewhaus/tool-builder": "0.
|
|
21
|
-
"@crewhaus/tool-catalog": "0.
|
|
22
|
-
"@crewhaus/tool-math": "0.
|
|
23
|
-
"@crewhaus/tool-onchain": "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
|
}
|
package/dist/fixtures.d.ts
DELETED
|
@@ -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;
|