@ixo/oracle-runtime-workers 0.27.0 → 0.28.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,189 @@
1
+ /**
2
+ * How a bank denom is shown. DenomMetadata is trusted for any denom: on
3
+ * the IXO chain it is written only by chain code (liquidstake pools, which
4
+ * governance creates; the IBC transfer module; genesis and upgrades), never
5
+ * by a token's issuer (the chain has no tokenfactory module). This is
6
+ * broader than the Auth Hub's rule (ixo-auth-worker
7
+ * `src/lib/actions/denom-display.ts`), which reads metadata only for
8
+ * `ibc/…`, so the Hub may still show a native token such as `upay` as
9
+ * unverified.
10
+ *
11
+ * - `uixo`: always IXO with 6 decimals, verified, no lookup.
12
+ * - any other denom with metadata whose `display` unit is listed in
13
+ * `denom_units` with an exponent of 0–18: verified, those decimals, the
14
+ * metadata `symbol` (or, when that is empty, the display unit
15
+ * upper-cased: `uatom` → `ATOM`) if it is plain ASCII.
16
+ * - anything else (no metadata, a display unit not listed — the shape IBC
17
+ * transfers register — or a malformed denom, which is never looked up):
18
+ * unverified, base units (0 decimals), the full denom as its symbol.
19
+ *
20
+ * A denom whose metadata could not be read (a transport failure, or no
21
+ * budget left to ask) is shown the same way with `metadataUnavailable`:
22
+ * it may still be verified.
23
+ */
24
+ import { z } from 'zod';
25
+ import { MIN_ATTEMPT_MS, throwIfAborted, type ChainCall } from './http';
26
+ import { restJson } from './rest';
27
+
28
+ export type DenomMetadataView = { symbol: string; decimals: number } | null;
29
+ export type TokenDisplay = {
30
+ symbol: string;
31
+ decimals: number;
32
+ verified: boolean;
33
+ metadataUnavailable?: true;
34
+ };
35
+
36
+ /** Symbols an unverified denom may never use (compared case-insensitively). */
37
+ const RESERVED_SYMBOLS = ['IXO', 'USDC', 'USDT', 'ATOM', 'OSMO'] as const;
38
+ /** The Cosmos SDK bank denom pattern: only such a denom is looked up. */
39
+ const DENOM = /^[a-zA-Z][a-zA-Z0-9/:._-]{2,127}$/;
40
+ // Plain ASCII only: no spaces or look-alike Unicode letters ("UЅDC").
41
+ const SAFE_SYMBOL = /^[A-Za-z0-9._-]{1,32}$/;
42
+
43
+ export function trustedDenomDisplay(
44
+ denom: string,
45
+ metadata: DenomMetadataView,
46
+ ): TokenDisplay {
47
+ if (denom === 'uixo') return { symbol: 'IXO', decimals: 6, verified: true };
48
+ const symbol =
49
+ metadata && SAFE_SYMBOL.test(metadata.symbol) ? metadata.symbol : null;
50
+ if (
51
+ metadata &&
52
+ symbol &&
53
+ DENOM.test(denom) &&
54
+ Number.isInteger(metadata.decimals) &&
55
+ metadata.decimals >= 0 &&
56
+ metadata.decimals <= 18
57
+ )
58
+ return { symbol, decimals: metadata.decimals, verified: true };
59
+ const reserved =
60
+ symbol !== null &&
61
+ (RESERVED_SYMBOLS as readonly string[]).includes(symbol.toUpperCase());
62
+ return {
63
+ symbol: symbol && !reserved ? symbol : denom,
64
+ decimals: 0,
65
+ verified: false,
66
+ };
67
+ }
68
+
69
+ /** Base units as a decimal string: `("9000000", 6)` → `"9"`, `("1234567", 6)` → `"1.234567"`. */
70
+ export function formatUnits(amount: string, decimals: number): string {
71
+ if (decimals === 0) return amount;
72
+ const padded = amount.padStart(decimals + 1, '0');
73
+ const whole = padded.slice(0, -decimals);
74
+ const fraction = padded.slice(-decimals).replace(/0+$/, '');
75
+ return fraction ? `${whole}.${fraction}` : whole;
76
+ }
77
+
78
+ /**
79
+ * Per isolate: found metadata is reused for 10 min, a definite miss (404,
80
+ * or no display unit) for 60 s, a failed read for 10 s — a failure is
81
+ * never kept as a miss, so it cannot leave a real token unverified.
82
+ */
83
+ export const METADATA_TTL_MS = 600_000;
84
+ export const METADATA_NOT_FOUND_TTL_MS = 60_000;
85
+ export const METADATA_FAILED_TTL_MS = 10_000;
86
+ const CACHE_LIMIT = 256;
87
+
88
+ /** A metadata read: its view (null for a miss), or why there is none. */
89
+ type Lookup = { metadata: DenomMetadataView } | 'failed' | 'aborted';
90
+
91
+ const metadataCache = new Map<string, { lookup: Lookup; expiresAt: number }>();
92
+ /** Reads in flight, shared by every call of the isolate that needs the same denom. */
93
+ const inflight = new Map<string, Promise<Lookup>>();
94
+
95
+ /** Tests only: forget every cached metadata answer. */
96
+ export function resetDenomMetadataCache(): void {
97
+ metadataCache.clear();
98
+ inflight.clear();
99
+ }
100
+
101
+ const metadataResponse = z.object({
102
+ metadata: z.object({
103
+ denom_units: z.array(
104
+ z.object({ denom: z.string(), exponent: z.number().int() }),
105
+ ),
106
+ display: z.string(),
107
+ symbol: z.string(),
108
+ }),
109
+ });
110
+
111
+ /**
112
+ * One DenomMetadata read. The by-query-string route is used because the
113
+ * path route cannot carry the slash in `ibc/…` (the gateway answers 501).
114
+ * A 404 is a miss; any other failure is `failed`; the launching turn's
115
+ * abort is `aborted` (never cached), so a call sharing the read is not
116
+ * failed by another turn's abort.
117
+ */
118
+ async function readMetadata(call: ChainCall, denom: string): Promise<Lookup> {
119
+ let read;
120
+ try {
121
+ read = await restJson(
122
+ call,
123
+ `/cosmos/bank/v1beta1/denoms_metadata_by_query_string?denom=${encodeURIComponent(denom)}`,
124
+ metadataResponse,
125
+ { label: 'denom metadata', maxBytes: 64 * 1024, notFoundOn404: true },
126
+ );
127
+ } catch (error) {
128
+ if (call.signal.aborted) return 'aborted';
129
+ throw error;
130
+ }
131
+ if (!read.ok)
132
+ return read.failure.reason === 'not_found' ? { metadata: null } : 'failed';
133
+ const { metadata } = read.value;
134
+ const unit = metadata.denom_units.find((u) => u.denom === metadata.display);
135
+ return {
136
+ metadata:
137
+ unit && unit.exponent >= 0 && unit.exponent <= 18
138
+ ? {
139
+ symbol: metadata.symbol || metadata.display.toUpperCase(),
140
+ decimals: unit.exponent,
141
+ }
142
+ : null,
143
+ };
144
+ }
145
+
146
+ function remember(key: string, lookup: Lookup): void {
147
+ if (lookup === 'aborted') return;
148
+ const ttl =
149
+ lookup === 'failed'
150
+ ? METADATA_FAILED_TTL_MS
151
+ : lookup.metadata
152
+ ? METADATA_TTL_MS
153
+ : METADATA_NOT_FOUND_TTL_MS;
154
+ if (metadataCache.size > CACHE_LIMIT) metadataCache.clear();
155
+ metadataCache.set(key, { lookup, expiresAt: Date.now() + ttl });
156
+ }
157
+
158
+ /** The cached lookup, the read in flight, or a new read; `failed` without a read once the budget is spent. */
159
+ function lookupOf(call: ChainCall, denom: string): Promise<Lookup> {
160
+ const key = `${call.restUrl}|${denom}`;
161
+ const hit = metadataCache.get(key);
162
+ if (hit && hit.expiresAt > Date.now()) return Promise.resolve(hit.lookup);
163
+ const running = inflight.get(key);
164
+ if (running) return running;
165
+ if (call.deadline - Date.now() < MIN_ATTEMPT_MS)
166
+ return Promise.resolve('failed');
167
+ const read = readMetadata(call, denom)
168
+ .then((lookup) => {
169
+ remember(key, lookup);
170
+ return lookup;
171
+ })
172
+ .finally(() => inflight.delete(key));
173
+ inflight.set(key, read);
174
+ return read;
175
+ }
176
+
177
+ /** The display of `denom`, reading its DenomMetadata for every denom but `uixo`. */
178
+ export async function denomDisplay(
179
+ call: ChainCall,
180
+ denom: string,
181
+ ): Promise<TokenDisplay> {
182
+ if (denom === 'uixo' || !DENOM.test(denom))
183
+ return trustedDenomDisplay(denom, null);
184
+ const lookup = await lookupOf(call, denom);
185
+ throwIfAborted(call.signal);
186
+ return lookup === 'failed' || lookup === 'aborted'
187
+ ? { ...trustedDenomDisplay(denom, null), metadataUnavailable: true }
188
+ : trustedDenomDisplay(denom, lookup.metadata);
189
+ }
@@ -0,0 +1,388 @@
1
+ /**
2
+ * The network edge shared by the chain REST and Blocksync reads: one bounded
3
+ * HTTP exchange per attempt and the retry policy around it. Upstream
4
+ * failures become a {@link Failure} the tools hand to the model; nothing
5
+ * here throws for them. Only the turn's own abort is rethrown.
6
+ */
7
+ import {
8
+ boundedBytes,
9
+ fetchWithoutRedirects,
10
+ } from '../../core/domain-context/transport';
11
+
12
+ export type Source = 'chain' | 'blocksync';
13
+
14
+ export type FailureReason =
15
+ | 'rate_limited'
16
+ | 'timeout'
17
+ | 'index_timeout'
18
+ | 'unavailable'
19
+ | 'not_found'
20
+ | 'too_large'
21
+ | 'invalid_input';
22
+
23
+ /** Why a read failed: a code, the plain sentence the model relays, and whether asking again can help. */
24
+ export interface Failure {
25
+ reason: FailureReason;
26
+ detail: string;
27
+ retryable: boolean;
28
+ }
29
+
30
+ /**
31
+ * The one retry waits a uniform 1–3 s when the upstream names no
32
+ * `Retry-After`, so isolates that failed together do not retry together.
33
+ */
34
+ export const RETRY_DELAY_MIN_MS = 1_000;
35
+ export const RETRY_DELAY_MAX_MS = 3_000;
36
+ /** Longest `Retry-After` honoured. */
37
+ export const MAX_RETRY_DELAY_MS = 5_000;
38
+ /** Whole-tool budget: every attempt and wait of one tool call fits inside it. */
39
+ export const TOOL_BUDGET_MS = 20_000;
40
+ /** A read, or its retry, is not started with less time than this left in the budget. */
41
+ export const MIN_ATTEMPT_MS = 1_000;
42
+ /** How long a 429 or 503 answers the isolate's other reads of that upstream without asking it. */
43
+ export const UNHEALTHY_MS = 10_000;
44
+
45
+ const SOURCE_NAME: Record<Source, string> = {
46
+ chain: 'The chain node',
47
+ blocksync: 'Blocksync',
48
+ };
49
+
50
+ function rateLimited(source: Source): Failure {
51
+ return {
52
+ reason: 'rate_limited',
53
+ detail: `${SOURCE_NAME[source]} is rate-limiting requests; try again in a few seconds.`,
54
+ retryable: true,
55
+ };
56
+ }
57
+
58
+ function timedOut(source: Source): Failure {
59
+ return {
60
+ reason: 'timeout',
61
+ detail:
62
+ source === 'blocksync'
63
+ ? 'Blocksync did not answer in time; narrow the question (fewer rows or requests).'
64
+ : 'The chain node did not answer in time; try again shortly.',
65
+ retryable: true,
66
+ };
67
+ }
68
+
69
+ export const tooLarge = (detail: string): Failure => ({
70
+ reason: 'too_large',
71
+ detail,
72
+ retryable: false,
73
+ });
74
+
75
+ export function unavailable(
76
+ source: Source,
77
+ why: string,
78
+ retryable: boolean,
79
+ ): Failure {
80
+ return {
81
+ reason: 'unavailable',
82
+ detail: `${SOURCE_NAME[source]} is unavailable right now (${why}).`,
83
+ retryable,
84
+ };
85
+ }
86
+
87
+ /** What one tool call's reads share: where they go, the budget and the turn's abort. */
88
+ export interface ChainCall {
89
+ network: 'mainnet' | 'testnet' | 'devnet';
90
+ restUrl: string;
91
+ blocksyncUrl: string;
92
+ /** Epoch ms by which every read of the call has finished. */
93
+ deadline: number;
94
+ signal: AbortSignal;
95
+ fetch: typeof fetch;
96
+ }
97
+
98
+ /**
99
+ * One attempt's outcome. `retry` asks for the policy's one retry: on a
100
+ * failure it is the failure's, on a value it means the answer was
101
+ * transient (a GraphQL "transaction aborted") and is kept if the retry
102
+ * cannot run or fails.
103
+ */
104
+ export type Attempt<T> =
105
+ | { ok: true; value: T; retry?: false }
106
+ | { ok: true; value: T; retry: true; note: string }
107
+ | {
108
+ ok: false;
109
+ failure: Failure;
110
+ retry: boolean;
111
+ retryAfterMs?: number;
112
+ /** A 429 or 503: the upstream is marked unhealthy for {@link UNHEALTHY_MS}. */
113
+ unhealthy?: true;
114
+ /** An expected answer rather than a fault (a 404 the caller asked for): not logged. */
115
+ quiet?: true;
116
+ /** Log text: status or error class, never a request body or an address. */
117
+ note: string;
118
+ };
119
+
120
+ export type Outcome<T> =
121
+ | { ok: true; value: T }
122
+ | { ok: false; failure: Failure };
123
+
124
+ /** The turn's abort reason as an error (a non-error reason becomes an `AbortError`). */
125
+ function abortReason(signal: AbortSignal): Error {
126
+ return signal.reason instanceof Error
127
+ ? signal.reason
128
+ : new DOMException('Aborted', 'AbortError');
129
+ }
130
+
131
+ /** Throws the turn's abort reason once the turn is aborted. */
132
+ export function throwIfAborted(signal: AbortSignal): void {
133
+ if (signal.aborted) throw abortReason(signal);
134
+ }
135
+
136
+ /** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
137
+ function sleep(ms: number, signal: AbortSignal): Promise<void> {
138
+ return new Promise((resolve, reject) => {
139
+ if (signal.aborted) {
140
+ reject(abortReason(signal));
141
+ return;
142
+ }
143
+ const onAbort = () => {
144
+ clearTimeout(timer);
145
+ reject(abortReason(signal));
146
+ };
147
+ const timer = setTimeout(() => {
148
+ signal.removeEventListener('abort', onAbort);
149
+ resolve();
150
+ }, ms);
151
+ signal.addEventListener('abort', onAbort, { once: true });
152
+ });
153
+ }
154
+
155
+ /** The wait before the one retry when the upstream named none: uniform in 1–3 s. */
156
+ export function retryDelayMs(): number {
157
+ return Math.round(
158
+ RETRY_DELAY_MIN_MS +
159
+ Math.random() * (RETRY_DELAY_MAX_MS - RETRY_DELAY_MIN_MS),
160
+ );
161
+ }
162
+
163
+ /**
164
+ * Upstreams that answered 429 or 503 within the last {@link UNHEALTHY_MS},
165
+ * keyed by base URL, with the failure that marked them. Per isolate: every
166
+ * call of the isolate answers with it instead of asking the upstream again.
167
+ */
168
+ const unhealthy = new Map<string, { until: number; failure: Failure }>();
169
+
170
+ /** Tests only: forget every unhealthy mark. */
171
+ export function resetUpstreamHealth(): void {
172
+ unhealthy.clear();
173
+ }
174
+
175
+ function heldBack(upstream: string): Failure | null {
176
+ const mark = unhealthy.get(upstream);
177
+ if (!mark) return null;
178
+ if (mark.until > Date.now()) return mark.failure;
179
+ unhealthy.delete(upstream);
180
+ return null;
181
+ }
182
+
183
+ /** An answer clears the upstream's mark; a 429 or 503 sets it. */
184
+ function recordHealth(upstream: string, attempt: Attempt<unknown>): void {
185
+ if (attempt.ok) unhealthy.delete(upstream);
186
+ else if (attempt.unhealthy)
187
+ unhealthy.set(upstream, {
188
+ until: Date.now() + UNHEALTHY_MS,
189
+ failure: attempt.failure,
190
+ });
191
+ }
192
+
193
+ /** One logical read: its log label and the base URL its health is kept under. */
194
+ export interface Read {
195
+ label: string;
196
+ upstream: string;
197
+ }
198
+
199
+ /**
200
+ * Runs `attempt`, and once more when it asks for a retry and the budget
201
+ * leaves room: after `Retry-After` (capped at 5 s) when the upstream sent
202
+ * one, else after a uniform 1–3 s. An upstream marked unhealthy is not
203
+ * asked at all; the call's own retry is. A transient answer is kept when
204
+ * its retry fails. Each failed attempt logs one line, except an expected
205
+ * (`quiet`) answer.
206
+ */
207
+ export async function withRetry<T>(
208
+ call: ChainCall,
209
+ read: Read,
210
+ attempt: () => Promise<Attempt<T>>,
211
+ ): Promise<Outcome<T>> {
212
+ const held = heldBack(read.upstream);
213
+ if (held) {
214
+ console.warn(
215
+ `[ixo-chain] ${read.label} not attempted: the upstream answered ${held.reason === 'rate_limited' ? '429' : '503'} moments ago`,
216
+ );
217
+ return { ok: false, failure: held };
218
+ }
219
+ const first = await attempt();
220
+ recordHealth(read.upstream, first);
221
+ if (first.ok && !first.retry) return { ok: true, value: first.value };
222
+ if (first.ok || !first.quiet)
223
+ console.warn(`[ixo-chain] ${read.label} attempt 1 failed: ${first.note}`);
224
+ const wait =
225
+ !first.ok && first.retryAfterMs !== undefined
226
+ ? Math.min(first.retryAfterMs, MAX_RETRY_DELAY_MS)
227
+ : retryDelayMs();
228
+ const giveUp = (): Outcome<T> =>
229
+ first.ok
230
+ ? { ok: true, value: first.value }
231
+ : { ok: false, failure: first.failure };
232
+ if (!first.retry || Date.now() + wait + MIN_ATTEMPT_MS > call.deadline)
233
+ return giveUp();
234
+ await sleep(wait, call.signal);
235
+ const second = await attempt();
236
+ recordHealth(read.upstream, second);
237
+ if (second.ok) {
238
+ if (second.retry)
239
+ console.warn(
240
+ `[ixo-chain] ${read.label} attempt 2 failed: ${second.note}`,
241
+ );
242
+ return { ok: true, value: second.value };
243
+ }
244
+ console.warn(`[ixo-chain] ${read.label} attempt 2 failed: ${second.note}`);
245
+ return first.ok
246
+ ? { ok: true, value: first.value }
247
+ : { ok: false, failure: second.failure };
248
+ }
249
+
250
+ /**
251
+ * `Retry-After` in ms: delta seconds or an HTTP date. Undefined when absent,
252
+ * unreadable, zero or in the past, so the default wait applies.
253
+ */
254
+ export function retryAfterMs(header: string | null): number | undefined {
255
+ if (!header) return undefined;
256
+ const value = header.trim();
257
+ const ms = /^\d+$/.test(value)
258
+ ? Number(value) * 1000
259
+ : Date.parse(value) - Date.now();
260
+ return Number.isFinite(ms) && ms > 0 ? ms : undefined;
261
+ }
262
+
263
+ const REDIRECTED = 'ixo-chain-redirect';
264
+
265
+ /**
266
+ * One bounded HTTP exchange: at most `timeoutMs` (and never past the call's
267
+ * deadline) including the body read, no redirects followed, the body cut
268
+ * off at `maxBytes`; the answer is the body text. Only a 2xx body is read.
269
+ * 429/502/503/504 and network errors ask for a retry; anything else is a
270
+ * failure the retry cannot fix. A 404 is `unavailable` (a wrong route or
271
+ * gateway) unless the caller reads a resource that may be absent
272
+ * (`notFoundOn404`): then it is a quiet `not_found`.
273
+ */
274
+ export async function exchange(
275
+ call: ChainCall,
276
+ source: Source,
277
+ url: string,
278
+ init: RequestInit,
279
+ opts: { timeoutMs: number; maxBytes: number; notFoundOn404?: boolean },
280
+ ): Promise<Attempt<string>> {
281
+ throwIfAborted(call.signal);
282
+ const budget = Math.min(opts.timeoutMs, call.deadline - Date.now());
283
+ if (budget <= 0)
284
+ return {
285
+ ok: false,
286
+ failure: timedOut(source),
287
+ retry: false,
288
+ note: 'no budget left',
289
+ };
290
+ const controller = new AbortController();
291
+ const timer = setTimeout(() => controller.abort(), budget);
292
+ const onAbort = () => controller.abort();
293
+ call.signal.addEventListener('abort', onAbort, { once: true });
294
+ try {
295
+ const response = await fetchWithoutRedirects(
296
+ call.fetch,
297
+ url,
298
+ { ...init, signal: controller.signal },
299
+ REDIRECTED,
300
+ );
301
+ const { status } = response;
302
+ if (!response.ok) {
303
+ await response.body?.cancel().catch(() => undefined);
304
+ if (status === 429)
305
+ return {
306
+ ok: false,
307
+ failure: rateLimited(source),
308
+ retry: true,
309
+ unhealthy: true,
310
+ note: 'HTTP 429',
311
+ ...optionalRetryAfter(response.headers.get('retry-after')),
312
+ };
313
+ if (status === 502 || status === 503 || status === 504)
314
+ return {
315
+ ok: false,
316
+ failure: unavailable(source, `HTTP ${status}`, true),
317
+ retry: true,
318
+ ...(status === 503 ? { unhealthy: true } : {}),
319
+ note: `HTTP ${status}`,
320
+ ...optionalRetryAfter(response.headers.get('retry-after')),
321
+ };
322
+ if (status === 404 && opts.notFoundOn404)
323
+ return {
324
+ ok: false,
325
+ failure: {
326
+ reason: 'not_found',
327
+ detail: 'Nothing was found for that request.',
328
+ retryable: false,
329
+ },
330
+ retry: false,
331
+ quiet: true,
332
+ note: 'HTTP 404',
333
+ };
334
+ // 501 says the route is not implemented: asking again cannot help.
335
+ return {
336
+ ok: false,
337
+ failure: unavailable(
338
+ source,
339
+ `HTTP ${status}`,
340
+ status >= 500 && status !== 501,
341
+ ),
342
+ retry: false,
343
+ note: `HTTP ${status}`,
344
+ };
345
+ }
346
+ const bytes = await boundedBytes(response, opts.maxBytes);
347
+ return { ok: true, value: new TextDecoder().decode(bytes) };
348
+ } catch (error) {
349
+ throwIfAborted(call.signal);
350
+ if (controller.signal.aborted)
351
+ return {
352
+ ok: false,
353
+ failure: timedOut(source),
354
+ retry: false,
355
+ note: `timeout after ${budget} ms`,
356
+ };
357
+ if (error instanceof Error && error.message === REDIRECTED)
358
+ return {
359
+ ok: false,
360
+ failure: unavailable(source, 'it redirected the request', false),
361
+ retry: false,
362
+ note: 'redirect refused',
363
+ };
364
+ if (error instanceof Error && error.message === 'document-too-large')
365
+ return {
366
+ ok: false,
367
+ failure: tooLarge(
368
+ 'The answer was too large to read; ask for fewer rows or a narrower filter.',
369
+ ),
370
+ retry: false,
371
+ note: `body over ${opts.maxBytes} bytes`,
372
+ };
373
+ return {
374
+ ok: false,
375
+ failure: unavailable(source, 'network error', true),
376
+ retry: true,
377
+ note: `network error (${error instanceof Error ? error.name : 'unknown'})`,
378
+ };
379
+ } finally {
380
+ clearTimeout(timer);
381
+ call.signal.removeEventListener('abort', onAbort);
382
+ }
383
+ }
384
+
385
+ function optionalRetryAfter(header: string | null): { retryAfterMs?: number } {
386
+ const ms = retryAfterMs(header);
387
+ return ms === undefined ? {} : { retryAfterMs: ms };
388
+ }
@@ -0,0 +1 @@
1
+ export { IxoChainPlugin } from './ixo-chain.plugin';