@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.
- package/README.md +270 -0
- package/dist/fixtures.d.ts +111 -0
- package/dist/fixtures.js +261 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +918 -0
- package/dist/lib/abi.d.ts +147 -0
- package/dist/lib/abi.js +245 -0
- package/dist/lib/batch.d.ts +21 -0
- package/dist/lib/batch.js +83 -0
- package/dist/lib/decimal.d.ts +103 -0
- package/dist/lib/decimal.js +246 -0
- package/dist/lib/oracle.d.ts +147 -0
- package/dist/lib/oracle.js +270 -0
- package/dist/lib/positions.d.ts +51 -0
- package/dist/lib/positions.js +301 -0
- package/dist/lib/quotes.d.ts +85 -0
- package/dist/lib/quotes.js +370 -0
- package/dist/lib/rpc.d.ts +171 -0
- package/dist/lib/rpc.js +600 -0
- package/package.json +48 -0
package/dist/lib/rpc.js
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The network posture for `@crewhaus/tool-defi`, and the two seams a test drives.
|
|
3
|
+
*
|
|
4
|
+
* Two kinds of byte leave this package: a JSON-RPC POST to a chain node, and a
|
|
5
|
+
* GET to one of two pinned public price providers (see `./quotes`). Both go
|
|
6
|
+
* through `defiFetch`, which `_setFetch` replaces, so no test in this package
|
|
7
|
+
* resolves a name or opens a socket.
|
|
8
|
+
*
|
|
9
|
+
* **No caller ever supplies a URL.** An RPC endpoint comes from the operator's
|
|
10
|
+
* `defi` tool_config block, keyed by chain id; a price provider's origin is a
|
|
11
|
+
* constant in `./quotes`. That is the same stance `@crewhaus/tool-registry`
|
|
12
|
+
* takes and it is what lets this file be short: there is no model-chosen host
|
|
13
|
+
* to defend against, so there is no allow-list to widen and no SSRF gate to
|
|
14
|
+
* get subtly wrong. It also means an operator's own node on `127.0.0.1:8545`
|
|
15
|
+
* is a first-class endpoint rather than something a private-address rule has
|
|
16
|
+
* to be argued out of.
|
|
17
|
+
*
|
|
18
|
+
* **Nothing here writes.** The method allow-list is `eth_call`,
|
|
19
|
+
* `eth_getBalance`, `eth_blockNumber` and `eth_getBlockByNumber`, checked
|
|
20
|
+
* against `@crewhaus/chain-adapter-base`'s shared read-only chokepoint first so
|
|
21
|
+
* this package cannot drift from the runtime's own definition of a read. There
|
|
22
|
+
* is no signing path, no `eth_sendRawTransaction` and no `eth_sendTransaction`,
|
|
23
|
+
* and no schema in this package has a field to pass a key to.
|
|
24
|
+
*/
|
|
25
|
+
import { assertReadOnlyMethod } from "@crewhaus/chain-adapter-base";
|
|
26
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
27
|
+
/** A refusal this package chose: a missing endpoint, a bad shape, a write attempt. */
|
|
28
|
+
export class DefiError extends CrewhausError {
|
|
29
|
+
name = "DefiError";
|
|
30
|
+
constructor(message) {
|
|
31
|
+
super("tool", message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const EMPTY_CONFIG = Object.freeze({
|
|
35
|
+
endpoints: new Map(),
|
|
36
|
+
multicall: new Map(),
|
|
37
|
+
feeds: new Map(),
|
|
38
|
+
});
|
|
39
|
+
let bootConfig = EMPTY_CONFIG;
|
|
40
|
+
function stringField(row, key, what) {
|
|
41
|
+
const value = row[key];
|
|
42
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
43
|
+
throw new DefiError(`${what}: "${key}" must be a non-empty string`);
|
|
44
|
+
}
|
|
45
|
+
return value.trim();
|
|
46
|
+
}
|
|
47
|
+
export function buildDefiConfig(input) {
|
|
48
|
+
const endpoints = new Map();
|
|
49
|
+
for (const [chainId, url] of Object.entries(input.rpc ?? {})) {
|
|
50
|
+
if (typeof url !== "string")
|
|
51
|
+
throw new DefiError(`rpc["${chainId}"] must be a URL string`);
|
|
52
|
+
endpoints.set(String(chainId), assertEndpoint(url, chainId));
|
|
53
|
+
}
|
|
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());
|
|
60
|
+
}
|
|
61
|
+
const feeds = new Map();
|
|
62
|
+
for (const [name, row] of Object.entries(input.feeds ?? {})) {
|
|
63
|
+
if (typeof row !== "object" || row === null || Array.isArray(row)) {
|
|
64
|
+
throw new DefiError(`feeds["${name}"] must be an object`);
|
|
65
|
+
}
|
|
66
|
+
const kind = stringField(row, "kind", `feeds["${name}"]`);
|
|
67
|
+
if (kind !== "chainlink" && kind !== "pyth") {
|
|
68
|
+
throw new DefiError(`feeds["${name}"].kind must be "chainlink" or "pyth", got "${kind}"`);
|
|
69
|
+
}
|
|
70
|
+
const priceId = row["price_id"];
|
|
71
|
+
const heartbeat = row["heartbeat_seconds"];
|
|
72
|
+
if (kind === "pyth" && typeof priceId !== "string") {
|
|
73
|
+
// A Pyth contract serves every feed; without the id there is nothing to
|
|
74
|
+
// ask it for, and defaulting to one would read some other asset's price.
|
|
75
|
+
throw new DefiError(`feeds["${name}"]: a pyth feed needs "price_id"`);
|
|
76
|
+
}
|
|
77
|
+
feeds.set(name, {
|
|
78
|
+
chainId: stringField(row, "chain_id", `feeds["${name}"]`),
|
|
79
|
+
kind,
|
|
80
|
+
address: stringField(row, "address", `feeds["${name}"]`),
|
|
81
|
+
...(typeof priceId === "string" ? { priceId } : {}),
|
|
82
|
+
...(typeof heartbeat === "number" && Number.isFinite(heartbeat) && heartbeat > 0
|
|
83
|
+
? { heartbeatSeconds: heartbeat }
|
|
84
|
+
: {}),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return { endpoints, multicall, feeds };
|
|
88
|
+
}
|
|
89
|
+
/** Replace the process-global config. Generated daemons call this at boot. */
|
|
90
|
+
export function registerDefiConfig(input) {
|
|
91
|
+
bootConfig = buildDefiConfig(input);
|
|
92
|
+
}
|
|
93
|
+
export function getDefiConfig() {
|
|
94
|
+
return bootConfig;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The config ONE call runs under: the serving candidate's `tool_config.defi`
|
|
98
|
+
* block when it declares one, else the boot registration. A non-object
|
|
99
|
+
* override is ignored rather than merged — an endpoint table only ever comes
|
|
100
|
+
* from a spec block or from boot.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveDefiConfig(override) {
|
|
103
|
+
if (typeof override === "object" && override !== null && !Array.isArray(override)) {
|
|
104
|
+
return buildDefiConfig(override);
|
|
105
|
+
}
|
|
106
|
+
return bootConfig;
|
|
107
|
+
}
|
|
108
|
+
/** Test-only — back to an empty, fail-closed table. */
|
|
109
|
+
export function _resetDefiConfig() {
|
|
110
|
+
bootConfig = EMPTY_CONFIG;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Validate an endpoint at CONFIG time, not at first request.
|
|
114
|
+
*
|
|
115
|
+
* A typo in a spec block should fail when the spec is loaded, where somebody
|
|
116
|
+
* is looking at the spec, rather than three tool calls into a run.
|
|
117
|
+
*/
|
|
118
|
+
function assertEndpoint(raw, chainId) {
|
|
119
|
+
let url;
|
|
120
|
+
try {
|
|
121
|
+
url = new URL(raw.trim());
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
throw new DefiError(`rpc["${chainId}"] is not an absolute URL`);
|
|
125
|
+
}
|
|
126
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
127
|
+
throw new DefiError(`rpc["${chainId}"] uses the "${url.protocol.replace(":", "")}" scheme; JSON-RPC over http(s) only`);
|
|
128
|
+
}
|
|
129
|
+
if (url.username !== "" || url.password !== "") {
|
|
130
|
+
throw new DefiError(`rpc["${chainId}"] carries userinfo (user:password@host) — a credential in a URL ends up in logs`);
|
|
131
|
+
}
|
|
132
|
+
return url.toString();
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The only form an endpoint takes in a message or a result: scheme, host and
|
|
136
|
+
* port, never the path.
|
|
137
|
+
*
|
|
138
|
+
* Provider RPC URLs carry the API key in the PATH — `/v2/<key>` at one vendor,
|
|
139
|
+
* `/<key>` at another. Printing the endpoint in a refusal, a provenance field
|
|
140
|
+
* or a caveat would put that key in a model's context and then in a transcript.
|
|
141
|
+
*/
|
|
142
|
+
export function endpointLabel(raw) {
|
|
143
|
+
try {
|
|
144
|
+
const url = new URL(raw);
|
|
145
|
+
return `${url.protocol}//${url.host}`;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return "the configured endpoint";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Take the endpoint back out of a message this package did not write.
|
|
153
|
+
*
|
|
154
|
+
* Every failure here is assembled from `endpointLabel` — and then has somebody
|
|
155
|
+
* else's string concatenated onto it: the dialler's error, the node's own
|
|
156
|
+
* JSON-RPC message, an HTTP status line. Those are the one place the key can
|
|
157
|
+
* still get out, because a fetch implementation that reports "unable to
|
|
158
|
+
* connect to <url>" is quoting a URL whose PATH is the credential. One
|
|
159
|
+
* borrowed string undoes the labelling everywhere else, and it lands in a
|
|
160
|
+
* model's context and from there in a transcript.
|
|
161
|
+
*
|
|
162
|
+
* The path and the query are redacted as well as the whole URL, so a message
|
|
163
|
+
* that quotes only the tail is covered too.
|
|
164
|
+
*/
|
|
165
|
+
export function withoutEndpoint(message, endpoint) {
|
|
166
|
+
let url;
|
|
167
|
+
try {
|
|
168
|
+
url = new URL(endpoint);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return message;
|
|
172
|
+
}
|
|
173
|
+
const label = `${url.protocol}//${url.host}`;
|
|
174
|
+
let out = message.split(endpoint).join(label);
|
|
175
|
+
if (url.toString() !== endpoint)
|
|
176
|
+
out = out.split(url.toString()).join(label);
|
|
177
|
+
const tail = `${url.pathname}${url.search}`;
|
|
178
|
+
if (tail.length > 1)
|
|
179
|
+
out = out.split(tail).join("/<redacted>");
|
|
180
|
+
if (url.pathname.length > 1)
|
|
181
|
+
out = out.split(url.pathname).join("/<redacted>");
|
|
182
|
+
if (url.search.length > 1)
|
|
183
|
+
out = out.split(url.search).join("?<redacted>");
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
export function requireEndpoint(config, chainId) {
|
|
187
|
+
const endpoint = config.endpoints.get(chainId);
|
|
188
|
+
if (endpoint === undefined) {
|
|
189
|
+
const known = [...config.endpoints.keys()].sort();
|
|
190
|
+
throw new DefiError(`no RPC endpoint is configured for chain "${chainId}" — add it to the defi tool_config block under rpc${known.length === 0 ? " (the table is empty)" : `; configured chains: ${known.join(", ")}`}`);
|
|
191
|
+
}
|
|
192
|
+
return endpoint;
|
|
193
|
+
}
|
|
194
|
+
const realFetch = (req) => globalThis.fetch(req);
|
|
195
|
+
let defiFetch = realFetch;
|
|
196
|
+
/**
|
|
197
|
+
* Test-only injection point, the convention every networked package here
|
|
198
|
+
* follows. `undefined` restores the real dialler; a suite that sets it must
|
|
199
|
+
* restore it, or the next file in the same bun process inherits the stub.
|
|
200
|
+
*/
|
|
201
|
+
export function _setFetch(fn) {
|
|
202
|
+
defiFetch = fn ?? realFetch;
|
|
203
|
+
}
|
|
204
|
+
const realClock = () => Math.floor(Date.now() / 1000);
|
|
205
|
+
let clock = realClock;
|
|
206
|
+
/**
|
|
207
|
+
* Test-only clock. An oracle's age is the difference between two instants, and
|
|
208
|
+
* a test that took the second one from the wall clock would be asserting how
|
|
209
|
+
* fast the runner is. Every age in this package comes from here.
|
|
210
|
+
*/
|
|
211
|
+
export function _setClock(fn) {
|
|
212
|
+
clock = fn ?? realClock;
|
|
213
|
+
}
|
|
214
|
+
export function nowSeconds() {
|
|
215
|
+
return clock();
|
|
216
|
+
}
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// the request path
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
221
|
+
export const MAX_TIMEOUT_MS = 120_000;
|
|
222
|
+
/** 16 MB. An `eth_getLogs` answer is not read here; an `eth_call` result is small. */
|
|
223
|
+
export const MAX_RESPONSE_BYTES = 16 * 1024 * 1024;
|
|
224
|
+
const USER_AGENT = "crewhaus-tool-defi (+https://github.com/crewhaus/factory)";
|
|
225
|
+
/**
|
|
226
|
+
* The JSON-RPC methods this package may issue, narrower than the runtime's
|
|
227
|
+
* read-only set because this package reads state and prices and nothing else.
|
|
228
|
+
*/
|
|
229
|
+
export const DEFI_RPC_METHODS = new Set([
|
|
230
|
+
"eth_call",
|
|
231
|
+
"eth_getBalance",
|
|
232
|
+
"eth_blockNumber",
|
|
233
|
+
"eth_getBlockByNumber",
|
|
234
|
+
]);
|
|
235
|
+
/**
|
|
236
|
+
* Issue one JSON-RPC call.
|
|
237
|
+
*
|
|
238
|
+
* Failures are RETURNED rather than thrown, because the caller with the most
|
|
239
|
+
* at stake — `PortfolioValuation`, reading fifty assets — wants the asset that
|
|
240
|
+
* failed named in its unpriced bucket rather than losing the other forty-nine.
|
|
241
|
+
* A method outside the allow-list is the exception: that is a defect in this
|
|
242
|
+
* package and it throws.
|
|
243
|
+
*/
|
|
244
|
+
export async function rpcCall(endpoint, method, params, options = {}) {
|
|
245
|
+
// Two gates, the outer one shared with the runtime so this package cannot
|
|
246
|
+
// quietly diverge from what the rest of CrewHaus calls a read.
|
|
247
|
+
assertReadOnlyMethod(options.chainId ?? "defi", method);
|
|
248
|
+
if (!DEFI_RPC_METHODS.has(method)) {
|
|
249
|
+
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
|
+
}
|
|
251
|
+
const label = endpointLabel(endpoint);
|
|
252
|
+
const deadline = startDeadline(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal);
|
|
253
|
+
try {
|
|
254
|
+
const body = JSON.stringify({ jsonrpc: "2.0", id: 1, method, params });
|
|
255
|
+
let res;
|
|
256
|
+
try {
|
|
257
|
+
res = await defiFetch(new Request(endpoint, {
|
|
258
|
+
method: "POST",
|
|
259
|
+
redirect: "manual",
|
|
260
|
+
headers: {
|
|
261
|
+
"content-type": "application/json",
|
|
262
|
+
accept: "application/json",
|
|
263
|
+
"user-agent": USER_AGENT,
|
|
264
|
+
},
|
|
265
|
+
body,
|
|
266
|
+
signal: deadline.signal,
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
const error = err;
|
|
271
|
+
if (error?.name === "AbortError" || deadline.signal.aborted) {
|
|
272
|
+
return {
|
|
273
|
+
ok: false,
|
|
274
|
+
kind: "transport",
|
|
275
|
+
message: `the deadline elapsed before ${label} answered ${method}`,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
kind: "transport",
|
|
281
|
+
message: `could not reach ${label}: ${withoutEndpoint(error?.message ?? String(err), endpoint)}`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
if (res.status >= 300 && res.status < 400) {
|
|
285
|
+
await discard(res);
|
|
286
|
+
// Not followed. A JSON-RPC endpoint that redirects is a proxy nobody
|
|
287
|
+
// configured, and a POST body forwarded to it goes somewhere the
|
|
288
|
+
// operator did not name.
|
|
289
|
+
return {
|
|
290
|
+
ok: false,
|
|
291
|
+
kind: "refused",
|
|
292
|
+
status: res.status,
|
|
293
|
+
message: `${label} answered ${res.status} with a redirect; a JSON-RPC POST is not followed to another host`,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
if (res.status === 429) {
|
|
297
|
+
await discard(res);
|
|
298
|
+
const retryAfter = res.headers.get("retry-after");
|
|
299
|
+
return {
|
|
300
|
+
ok: false,
|
|
301
|
+
kind: "rateLimited",
|
|
302
|
+
status: 429,
|
|
303
|
+
message: `${label} rate-limited this read${retryAfter === null ? "" : `; it asked for ${retryAfter}s`} — this package does not retry, so the caller decides when to ask again`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
if (!res.ok) {
|
|
307
|
+
await discard(res);
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
kind: "status",
|
|
311
|
+
status: res.status,
|
|
312
|
+
message: withoutEndpoint(`${label} answered ${res.status} ${res.statusText}`.trimEnd(), endpoint),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const text = await readCapped(res, MAX_RESPONSE_BYTES);
|
|
316
|
+
if (text === null) {
|
|
317
|
+
return {
|
|
318
|
+
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`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
let parsed;
|
|
324
|
+
try {
|
|
325
|
+
parsed = JSON.parse(text);
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
return {
|
|
329
|
+
ok: false,
|
|
330
|
+
kind: "malformed",
|
|
331
|
+
message: `${label} answered ${method} with bytes that are not JSON: ${withoutEndpoint(err.message, endpoint)}`,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
kind: "malformed",
|
|
338
|
+
message: `${label} answered ${method} with a non-object`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const envelope = parsed;
|
|
342
|
+
if (envelope.error !== undefined && envelope.error !== null) {
|
|
343
|
+
const code = typeof envelope.error.code === "number" ? envelope.error.code : undefined;
|
|
344
|
+
const message = typeof envelope.error.message === "string" ? envelope.error.message : "no message";
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
kind: "rpcError",
|
|
348
|
+
message: `${label} refused ${method}: ${withoutEndpoint(message, endpoint)}`,
|
|
349
|
+
...(code === undefined ? {} : { code }),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
if (!("result" in envelope)) {
|
|
353
|
+
// A node that answered 200 with neither result nor error. Reading that
|
|
354
|
+
// as `undefined` downstream would decode to an empty answer, which the
|
|
355
|
+
// word decoders turn into zeros.
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
kind: "malformed",
|
|
359
|
+
message: `${label} answered ${method} with neither a result nor an error`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return { ok: true, value: envelope.result };
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
deadline.cancel();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* GET a JSON document, through the same seam and the same cap.
|
|
370
|
+
*
|
|
371
|
+
* The URL is never a caller's: `./quotes` builds it from one of two origin
|
|
372
|
+
* constants and re-checks the origin before dialling. Redirects are NOT
|
|
373
|
+
* followed — a price provider that 302s somewhere else is not a price
|
|
374
|
+
* provider, and following it is how an answer arrives from a host nobody
|
|
375
|
+
* configured.
|
|
376
|
+
*/
|
|
377
|
+
export async function getJson(url, options = {}) {
|
|
378
|
+
const label = endpointLabel(url);
|
|
379
|
+
const deadline = startDeadline(options.timeoutMs ?? DEFAULT_TIMEOUT_MS, options.signal);
|
|
380
|
+
try {
|
|
381
|
+
let res;
|
|
382
|
+
try {
|
|
383
|
+
res = await defiFetch(new Request(url, {
|
|
384
|
+
method: "GET",
|
|
385
|
+
redirect: "manual",
|
|
386
|
+
headers: { accept: options.accept ?? "application/json", "user-agent": USER_AGENT },
|
|
387
|
+
signal: deadline.signal,
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
const error = err;
|
|
392
|
+
if (error?.name === "AbortError" || deadline.signal.aborted) {
|
|
393
|
+
return {
|
|
394
|
+
ok: false,
|
|
395
|
+
kind: "transport",
|
|
396
|
+
message: `the deadline elapsed before ${label} answered`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
ok: false,
|
|
401
|
+
kind: "transport",
|
|
402
|
+
message: `could not reach ${label}: ${withoutEndpoint(error?.message ?? String(err), url)}`,
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
if (res.status >= 300 && res.status < 400) {
|
|
406
|
+
await discard(res);
|
|
407
|
+
return {
|
|
408
|
+
ok: false,
|
|
409
|
+
kind: "refused",
|
|
410
|
+
status: res.status,
|
|
411
|
+
message: `${label} answered ${res.status} with a redirect; a price read is not followed to another host`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
if (res.status === 429) {
|
|
415
|
+
await discard(res);
|
|
416
|
+
const retryAfter = res.headers.get("retry-after");
|
|
417
|
+
return {
|
|
418
|
+
ok: false,
|
|
419
|
+
kind: "rateLimited",
|
|
420
|
+
status: 429,
|
|
421
|
+
message: `${label} rate-limited this read${retryAfter === null ? "" : `; it asked for ${retryAfter}s`} — this package does not retry`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (!res.ok) {
|
|
425
|
+
await discard(res);
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
kind: "status",
|
|
429
|
+
status: res.status,
|
|
430
|
+
message: withoutEndpoint(`${label} answered ${res.status} ${res.statusText}`.trimEnd(), url),
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
const text = await readCapped(res, MAX_RESPONSE_BYTES);
|
|
434
|
+
if (text === null) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
kind: "malformed",
|
|
438
|
+
message: `${label} sent more than ${MAX_RESPONSE_BYTES} bytes`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
try {
|
|
442
|
+
return { ok: true, value: JSON.parse(text) };
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
return {
|
|
446
|
+
ok: false,
|
|
447
|
+
kind: "malformed",
|
|
448
|
+
message: `${label} answered bytes that are not JSON: ${withoutEndpoint(err.message, url)}`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
finally {
|
|
453
|
+
deadline.cancel();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
/** `eth_call` at a named block, returning the raw 0x hex. */
|
|
457
|
+
export async function ethCall(endpoint, to, data, blockTag, options = {}) {
|
|
458
|
+
const outcome = await rpcCall(endpoint, "eth_call", [{ to, data }, blockTag], options);
|
|
459
|
+
if (!outcome.ok)
|
|
460
|
+
return outcome;
|
|
461
|
+
if (typeof outcome.value !== "string") {
|
|
462
|
+
return { ok: false, kind: "malformed", message: "eth_call returned something other than hex" };
|
|
463
|
+
}
|
|
464
|
+
return { ok: true, value: outcome.value };
|
|
465
|
+
}
|
|
466
|
+
/** The native-coin balance of an address, in wei, at a named block. */
|
|
467
|
+
export async function ethGetBalance(endpoint, address, blockTag, options = {}) {
|
|
468
|
+
const outcome = await rpcCall(endpoint, "eth_getBalance", [address, blockTag], options);
|
|
469
|
+
if (!outcome.ok)
|
|
470
|
+
return outcome;
|
|
471
|
+
return toBigintOutcome(outcome.value, "eth_getBalance");
|
|
472
|
+
}
|
|
473
|
+
/** The head block number. */
|
|
474
|
+
export async function ethBlockNumber(endpoint, options = {}) {
|
|
475
|
+
const outcome = await rpcCall(endpoint, "eth_blockNumber", [], options);
|
|
476
|
+
if (!outcome.ok)
|
|
477
|
+
return outcome;
|
|
478
|
+
return toBigintOutcome(outcome.value, "eth_blockNumber");
|
|
479
|
+
}
|
|
480
|
+
function toBigintOutcome(value, method) {
|
|
481
|
+
if (typeof value !== "string" || !/^0x[0-9a-fA-F]+$/.test(value)) {
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
kind: "malformed",
|
|
485
|
+
message: `${method} returned "${String(value)}", which is not a quantity`,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
return { ok: true, value: BigInt(value) };
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Turn a block number into the tag every read in one answer shares.
|
|
492
|
+
*
|
|
493
|
+
* A valuation assembled from reads at different heights is a number that never
|
|
494
|
+
* existed: balances from one block, a price from the next. Every tool here
|
|
495
|
+
* resolves ONE tag up front and passes it down.
|
|
496
|
+
*/
|
|
497
|
+
export function blockTagOf(blockNumber) {
|
|
498
|
+
return blockNumber === undefined ? "latest" : `0x${blockNumber.toString(16)}`;
|
|
499
|
+
}
|
|
500
|
+
async function discard(res) {
|
|
501
|
+
try {
|
|
502
|
+
await res.body?.cancel();
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
// already closed — nothing to release
|
|
506
|
+
}
|
|
507
|
+
}
|
|
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;
|
|
552
|
+
}
|
|
553
|
+
return new TextDecoder("utf-8", { fatal: false }).decode(merged);
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* A deadline that also honours the runtime's own cancellation. Every call
|
|
557
|
+
* opens one and cancels it in a `finally` — a timer left running holds the
|
|
558
|
+
* process open past the answer.
|
|
559
|
+
*/
|
|
560
|
+
export function startDeadline(ms, outer) {
|
|
561
|
+
const controller = new AbortController();
|
|
562
|
+
const timer = setTimeout(() => controller.abort(new Error(`deadline of ${ms}ms elapsed`)), ms);
|
|
563
|
+
const onOuter = () => controller.abort(outer?.reason);
|
|
564
|
+
if (outer !== undefined) {
|
|
565
|
+
if (outer.aborted)
|
|
566
|
+
controller.abort(outer.reason);
|
|
567
|
+
else
|
|
568
|
+
outer.addEventListener("abort", onOuter, { once: true });
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
signal: controller.signal,
|
|
572
|
+
cancel: () => {
|
|
573
|
+
clearTimeout(timer);
|
|
574
|
+
outer?.removeEventListener("abort", onOuter);
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Run `work` over `items` with at most `limit` in flight, preserving input
|
|
580
|
+
* order in the result. Order is preserved because the output is a TABLE, and a
|
|
581
|
+
* row order that depends on which read answered first is a diff that changes
|
|
582
|
+
* between two identical runs.
|
|
583
|
+
*/
|
|
584
|
+
export async function mapPool(items, limit, work) {
|
|
585
|
+
const out = new Array(items.length);
|
|
586
|
+
let next = 0;
|
|
587
|
+
const width = Math.max(1, Math.min(limit, items.length));
|
|
588
|
+
const runners = Array.from({ length: width }, async () => {
|
|
589
|
+
while (true) {
|
|
590
|
+
const index = next++;
|
|
591
|
+
if (index >= items.length)
|
|
592
|
+
return;
|
|
593
|
+
out[index] = await work(items[index], index);
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
await Promise.all(runners);
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
/** Compact JSON — every byte returned is a byte in somebody's context window. */
|
|
600
|
+
export const json = (value) => JSON.stringify(value);
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-defi",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic valuation: oracle reads, price quotes and portfolio valuation with provenance and an explicit unpriced bucket",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"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",
|
|
24
|
+
"zod": "^3.23.8"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
28
|
+
},
|
|
29
|
+
"license": "Apache-2.0",
|
|
30
|
+
"author": {
|
|
31
|
+
"name": "Max Meier",
|
|
32
|
+
"email": "max@crewhaus.ai",
|
|
33
|
+
"url": "https://crewhaus.ai"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
38
|
+
"directory": "packages/tool-defi"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-defi#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
48
|
+
}
|