@ixo/oracle-runtime-workers 0.29.1 → 0.30.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.
Files changed (34) hide show
  1. package/package.json +3 -3
  2. package/src/plugins/ixo-chain/balances.ts +141 -0
  3. package/src/plugins/ixo-chain/blocksync.ts +11 -6
  4. package/src/plugins/ixo-chain/denoms.ts +84 -36
  5. package/src/plugins/ixo-chain/http.ts +300 -63
  6. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
  7. package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
  8. package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
  9. package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
  10. package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
  11. package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
  12. package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
  13. package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
  14. package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
  15. package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
  16. package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
  17. package/src/plugins/ixo-chain/kinds/core.ts +246 -0
  18. package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
  19. package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
  20. package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
  21. package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
  22. package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
  23. package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
  24. package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
  25. package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
  26. package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
  27. package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
  28. package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
  29. package/src/plugins/ixo-chain/kinds/types.ts +156 -0
  30. package/src/plugins/ixo-chain/lookup.ts +470 -0
  31. package/src/plugins/ixo-chain/overview.ts +1060 -0
  32. package/src/plugins/ixo-chain/queries.ts +170 -138
  33. package/src/plugins/ixo-chain/rest.ts +129 -54
  34. package/src/plugins/ixo-chain/summaries.ts +3 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.29.1",
3
+ "version": "0.30.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -66,8 +66,8 @@
66
66
  "protobufjs": "^7.6.5",
67
67
  "@ixo/topic-protocol": "1.0.0-rc.8",
68
68
  "@ixo/common": "1.7.1",
69
- "@ixo/ucan": "2.3.0",
70
- "@ixo/ixo-transaction": "0.2.0"
69
+ "@ixo/ixo-transaction": "0.2.0",
70
+ "@ixo/ucan": "2.3.0"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -0,0 +1,141 @@
1
+ /**
2
+ * An account's bank balances as `get_account_balances` shows them: one
3
+ * REST read of one page, each denom through its display, `uixo` first,
4
+ * then verified tokens, then by amount. The tool reads them with its own
5
+ * readers; `get_account_overview` reads them as an unregistered kind
6
+ * inside its one `runRequests` call.
7
+ */
8
+ import { z } from 'zod';
9
+ import { formatUnits, type IbcTrace, type TokenDisplay } from './denoms';
10
+ import type { Outcome } from './http';
11
+ import { defineKind } from './kinds/types';
12
+ import { accountOf } from './queries';
13
+
14
+ /** Bank balances read per account (one page). */
15
+ export const BALANCE_PAGE = 50;
16
+ /** Tokens resolved at once; each runs its metadata, trace and channel reads in turn. */
17
+ const METADATA_CONCURRENCY = 4;
18
+
19
+ const balancesResponse = z.object({
20
+ balances: z.array(z.object({ denom: z.string(), amount: z.string() })),
21
+ pagination: z.object({ next_key: z.string().nullish() }).nullish(),
22
+ });
23
+
24
+ export type BalanceToken = {
25
+ denom: string;
26
+ symbol: string;
27
+ decimals: number;
28
+ verified: boolean;
29
+ amountBaseUnits: string;
30
+ amount: string;
31
+ origin?: string;
32
+ trace?: IbcTrace;
33
+ metadataUnavailable?: true;
34
+ };
35
+
36
+ export interface Balances {
37
+ tokens: BalanceToken[];
38
+ /** The account holds more denoms than one page. */
39
+ moreDenoms: boolean;
40
+ }
41
+
42
+ /** `items` mapped with at most `limit` running at once, in order. */
43
+ async function mapLimit<T, R>(
44
+ items: readonly T[],
45
+ limit: number,
46
+ map: (item: T) => Promise<R>,
47
+ ): Promise<R[]> {
48
+ const out: R[] = [];
49
+ for (let index = 0; index < items.length; index += limit)
50
+ out.push(
51
+ ...(await Promise.all(items.slice(index, index + limit).map(map))),
52
+ );
53
+ return out;
54
+ }
55
+
56
+ /** How the balances are read: one REST GET and each denom's display. */
57
+ export interface BalanceReaders {
58
+ rest<S extends z.ZodType>(
59
+ path: string,
60
+ schema: S,
61
+ opts: { label: string },
62
+ ): Promise<Outcome<z.infer<S>>>;
63
+ display(denom: string): Promise<TokenDisplay>;
64
+ }
65
+
66
+ /**
67
+ * The bank balances of `address` (an `ixo1` address of bech32 characters,
68
+ * safe as a path segment), each token shown through `readers.display`.
69
+ */
70
+ export async function readBalances(
71
+ readers: BalanceReaders,
72
+ address: string,
73
+ ): Promise<Outcome<Balances>> {
74
+ const read = await readers.rest(
75
+ `/cosmos/bank/v1beta1/balances/${address}?pagination.limit=${BALANCE_PAGE}`,
76
+ balancesResponse,
77
+ { label: 'balances' },
78
+ );
79
+ if (!read.ok) return read;
80
+ const coins = read.value.balances.filter((coin) =>
81
+ /^(0|[1-9][0-9]{0,39})$/.test(coin.amount),
82
+ );
83
+ const tokens: BalanceToken[] = await mapLimit(
84
+ coins,
85
+ METADATA_CONCURRENCY,
86
+ async (coin) => {
87
+ const display = await readers.display(coin.denom);
88
+ return {
89
+ denom: coin.denom,
90
+ symbol: display.symbol,
91
+ decimals: display.decimals,
92
+ verified: display.verified,
93
+ amountBaseUnits: coin.amount,
94
+ amount: formatUnits(coin.amount, display.decimals),
95
+ ...(display.origin !== undefined ? { origin: display.origin } : {}),
96
+ ...(display.trace !== undefined ? { trace: display.trace } : {}),
97
+ ...(display.metadataUnavailable ? { metadataUnavailable: true } : {}),
98
+ };
99
+ },
100
+ );
101
+ const rank = (token: BalanceToken) =>
102
+ token.denom === 'uixo' ? 0 : token.verified ? 1 : 2;
103
+ tokens.sort(
104
+ (a, b) => rank(a) - rank(b) || Number(b.amount) - Number(a.amount),
105
+ );
106
+ return {
107
+ ok: true,
108
+ value: { tokens, moreDenoms: !!read.value.pagination?.next_key },
109
+ };
110
+ }
111
+
112
+ /**
113
+ * The balances as a kind, for `get_account_overview` only (it is not in
114
+ * the `chain_lookup` catalogue): `{ tokens, moreDenoms }` with the
115
+ * account's `subject`. Its denoms are looked up through the request's
116
+ * `display`, so at most `MAX_DISPLAY_DENOMS` of them.
117
+ */
118
+ export const balancesKind = defineKind({
119
+ name: 'balances',
120
+ summary:
121
+ 'The bank balances of an account, as get_account_balances reads them.',
122
+ explain:
123
+ 'Each token: denom, symbol, decimals, verified, amountBaseUnits and `amount` in display units; uixo first, then verified tokens, then by amount.',
124
+ source: 'chain',
125
+ list: false,
126
+ args: z.strictObject({ address: z.string().optional() }),
127
+ example: {},
128
+ plan(input, ctx) {
129
+ const account = accountOf(input.address, ctx.identity, 'address');
130
+ if (!account.ok) return { invalid: account.detail };
131
+ return {
132
+ source: 'chain',
133
+ async run() {
134
+ const read = await readBalances(ctx, account.address);
135
+ return read.ok
136
+ ? { ok: true, subject: account.subject, data: read.value }
137
+ : { ok: false, ...read.failure };
138
+ },
139
+ };
140
+ },
141
+ });
@@ -5,11 +5,11 @@
5
5
  *
6
6
  * PostGraphile answers field by field: one alias can be null with an error
7
7
  * naming it while the others are fine, so errors are returned beside the
8
- * data, never thrown. "current transaction is aborted" and "Connection
9
- * terminated unexpectedly" say nothing about the question: the aliases
10
- * they name are asked again once, in a document of only those aliases,
11
- * and the answers merged; a statement timeout is never asked again (it
12
- * would only time out again).
8
+ * data, never thrown. A transient database error ({@link TRANSIENT_ERROR}:
9
+ * an aborted transaction, a dropped connection, no free connection) says
10
+ * nothing about the question: the aliases it names are asked again once,
11
+ * in a document of only those aliases, and the answers merged; a
12
+ * statement timeout is never asked again (it would only time out again).
13
13
  */
14
14
  import { z } from 'zod';
15
15
  import {
@@ -28,8 +28,13 @@ export const BLOCKSYNC_MAX_BYTES = 1024 * 1024;
28
28
  */
29
29
  export const BLOCKSYNC_ATTEMPT_TIMEOUT_MS = 11_000;
30
30
 
31
+ /**
32
+ * Database errors that say nothing about the question: an aborted
33
+ * transaction, a dropped connection, and the server's connection pool or
34
+ * PostgreSQL itself out of connections.
35
+ */
31
36
  export const TRANSIENT_ERROR =
32
- /current transaction is aborted|Connection terminated unexpectedly/;
37
+ /current transaction is aborted|Connection terminated unexpectedly|timeout exceeded when trying to connect|too many clients|remaining connection slots are reserved/;
33
38
  export const STATEMENT_TIMEOUT = /statement timeout/i;
34
39
 
35
40
  export interface GraphqlError {
@@ -28,11 +28,17 @@
28
28
  * as its symbol.
29
29
  *
30
30
  * A denom whose metadata could not be read (a transport failure, or no
31
- * budget left to ask) is shown the same way with `metadataUnavailable`:
32
- * it may still be verified. Its trace is not read then.
31
+ * budget left to ask) or was not asked (a `chain_lookup` request past its
32
+ * denom lookups) is shown the same way with `metadataUnavailable`: it may
33
+ * still be verified. Its trace is not read then.
33
34
  */
34
35
  import { z } from 'zod';
35
- import { MIN_ATTEMPT_MS, throwIfAborted, type ChainCall } from './http';
36
+ import {
37
+ hasBudget,
38
+ throwIfAborted,
39
+ untilAborted,
40
+ type ChainCall,
41
+ } from './http';
36
42
  import { restJson } from './rest';
37
43
 
38
44
  export type DenomMetadataView = { symbol: string; decimals: number } | null;
@@ -229,16 +235,26 @@ export const METADATA_NOT_FOUND_TTL_MS = 60_000;
229
235
  export const METADATA_FAILED_TTL_MS = 10_000;
230
236
  /** A denom trace or a channel's chain id never changes: found ones are reused for 1 h (misses and failures as for metadata). */
231
237
  export const IBC_TRACE_TTL_MS = 3_600_000;
232
- const CACHE_LIMIT = 256;
238
+ /** Answers kept per cache; past it the oldest written is dropped. */
239
+ export const DENOM_CACHE_LIMIT = 256;
233
240
 
234
- /** One read's answer: the value (null for a miss), or why there is none. */
235
- type Lookup<T> = { value: T | null } | 'failed' | 'aborted';
241
+ /**
242
+ * One read's answer: the value (null for a miss), or why there is none:
243
+ * `failed` (the upstream failed), `timeout` (it did not answer in time, or
244
+ * the launching call's budget ran out), `aborted` (the launching turn was
245
+ * aborted).
246
+ */
247
+ type Lookup<T> = { value: T | null } | 'failed' | 'timeout' | 'aborted';
236
248
 
237
249
  /**
238
250
  * Answers per `${restUrl}|${key}`, kept per isolate: a found value for
239
- * `foundTtlMs`, a miss for 60 s, a failure for 10 s, an abort never. A
251
+ * `foundTtlMs`, a miss for 60 s, a failure for 10 s, a timeout or an abort
252
+ * never; at most {@link DENOM_CACHE_LIMIT}, the oldest dropped first. A
240
253
  * read in flight is shared by every call of the isolate that needs the
241
- * same key; none is started with less than 1 s of the budget left.
254
+ * same key; none is started with less than 1 s of the budget left. A call
255
+ * that joined a read which then timed out or was aborted by its launching
256
+ * turn reads again itself while its own budget allows; it waits on its own
257
+ * turn's abort, never another's.
242
258
  */
243
259
  class ReadCache<T> {
244
260
  private readonly answers = new Map<
@@ -249,18 +265,22 @@ class ReadCache<T> {
249
265
 
250
266
  constructor(private readonly foundTtlMs: number) {}
251
267
 
252
- get(
268
+ async get(
253
269
  call: ChainCall,
254
270
  key: string,
255
271
  read: () => Promise<Lookup<T>>,
256
272
  ): Promise<Lookup<T>> {
257
273
  const id = `${call.restUrl}|${key}`;
258
274
  const hit = this.answers.get(id);
259
- if (hit && hit.expiresAt > Date.now()) return Promise.resolve(hit.lookup);
275
+ if (hit && hit.expiresAt > Date.now()) return hit.lookup;
260
276
  const running = this.inflight.get(id);
261
- if (running) return running;
262
- if (call.deadline - Date.now() < MIN_ATTEMPT_MS)
263
- return Promise.resolve('failed');
277
+ if (running) {
278
+ const lookup = await untilAborted(running, call.signal);
279
+ return (lookup === 'timeout' || lookup === 'aborted') && hasBudget(call)
280
+ ? this.get(call, key, read)
281
+ : lookup;
282
+ }
283
+ if (!hasBudget(call)) return 'timeout';
264
284
  const pending = read()
265
285
  .then((lookup) => {
266
286
  this.remember(id, lookup);
@@ -277,14 +297,18 @@ class ReadCache<T> {
277
297
  }
278
298
 
279
299
  private remember(id: string, lookup: Lookup<T>): void {
280
- if (lookup === 'aborted') return;
300
+ if (lookup === 'aborted' || lookup === 'timeout') return;
281
301
  const ttl =
282
302
  lookup === 'failed'
283
303
  ? METADATA_FAILED_TTL_MS
284
304
  : lookup.value
285
305
  ? this.foundTtlMs
286
306
  : METADATA_NOT_FOUND_TTL_MS;
287
- if (this.answers.size > CACHE_LIMIT) this.answers.clear();
307
+ this.answers.delete(id);
308
+ if (this.answers.size >= DENOM_CACHE_LIMIT) {
309
+ const oldest = this.answers.keys().next();
310
+ if (!oldest.done) this.answers.delete(oldest.value);
311
+ }
288
312
  this.answers.set(id, { lookup, expiresAt: Date.now() + ttl });
289
313
  }
290
314
  }
@@ -306,8 +330,9 @@ export function resetDenomMetadataCache(): void {
306
330
 
307
331
  /**
308
332
  * One bounded REST read of a resource that may be absent: a 404 is a
309
- * miss, any other failure `failed`, the launching turn's abort `aborted`
310
- * (so a call sharing the read is not failed by another turn's abort).
333
+ * miss, a timeout `timeout`, any other failure `failed`, the launching
334
+ * turn's abort `aborted` (so a call sharing the read is not failed by
335
+ * another turn's abort).
311
336
  */
312
337
  async function readRest<S extends z.ZodType, T>(
313
338
  call: ChainCall,
@@ -328,7 +353,11 @@ async function readRest<S extends z.ZodType, T>(
328
353
  throw error;
329
354
  }
330
355
  if (!read.ok)
331
- return read.failure.reason === 'not_found' ? { value: null } : 'failed';
356
+ return read.failure.reason === 'not_found'
357
+ ? { value: null }
358
+ : read.failure.reason === 'timeout'
359
+ ? 'timeout'
360
+ : 'failed';
332
361
  return { value: pick(read.value) };
333
362
  }
334
363
 
@@ -441,8 +470,7 @@ async function tracedDisplay(
441
470
  const hash = denom.slice('ibc/'.length);
442
471
  const trace = await traceCache.get(call, hash, () => readTrace(call, hash));
443
472
  throwIfAborted(call.signal);
444
- if (trace === 'failed' || trace === 'aborted' || !trace.value)
445
- return fallback;
473
+ if (typeof trace !== 'object' || !trace.value) return fallback;
446
474
  const { baseDenom, path, channel } = trace.value;
447
475
  const chainId = await chainIdCache.get(call, channel, () =>
448
476
  readChainId(call, channel),
@@ -452,13 +480,43 @@ async function tracedDisplay(
452
480
  ...fallback,
453
481
  trace: {
454
482
  baseDenom,
455
- claimedChain:
456
- chainId === 'failed' || chainId === 'aborted' ? null : chainId.value,
483
+ claimedChain: typeof chainId === 'object' ? chainId.value : null,
457
484
  path,
458
485
  },
459
486
  };
460
487
  }
461
488
 
489
+ /**
490
+ * The display of `denom` when it takes no lookup: `uixo`, a denom that is
491
+ * not a bank denom (never looked up) and an `ibc/…` denom in `ibcDenoms`.
492
+ * Null for any other denom: its display needs its DenomMetadata.
493
+ */
494
+ export function knownDisplay(
495
+ denom: string,
496
+ ibcDenoms: IbcDenomTable,
497
+ ): TokenDisplay | null {
498
+ if (denom === 'uixo' || !DENOM.test(denom))
499
+ return trustedDenomDisplay(denom, null);
500
+ const known = IBC_DENOM.test(denom) ? ibcDenoms.get(denom) : undefined;
501
+ return known
502
+ ? {
503
+ symbol: known.symbol,
504
+ decimals: known.decimals,
505
+ verified: true,
506
+ ...(known.origin ? { origin: known.origin } : {}),
507
+ }
508
+ : null;
509
+ }
510
+
511
+ /**
512
+ * A denom shown without its metadata (the read failed, or was not made):
513
+ * unverified, in base units, under its full denom, `metadataUnavailable`
514
+ * because it may still be verified.
515
+ */
516
+ export function unreadDisplay(denom: string): TokenDisplay {
517
+ return { ...trustedDenomDisplay(denom, null), metadataUnavailable: true };
518
+ }
519
+
462
520
  /**
463
521
  * The display of `denom`: `uixo` and an `ibc/…` denom in `ibcDenoms`
464
522
  * without a lookup; any other denom through its DenomMetadata, then (an
@@ -469,25 +527,15 @@ export async function denomDisplay(
469
527
  denom: string,
470
528
  ibcDenoms: IbcDenomTable,
471
529
  ): Promise<TokenDisplay> {
472
- if (denom === 'uixo' || !DENOM.test(denom))
473
- return trustedDenomDisplay(denom, null);
474
- const ibc = IBC_DENOM.test(denom);
475
- const known = ibc ? ibcDenoms.get(denom) : undefined;
476
- if (known)
477
- return {
478
- symbol: known.symbol,
479
- decimals: known.decimals,
480
- verified: true,
481
- ...(known.origin ? { origin: known.origin } : {}),
482
- };
530
+ const known = knownDisplay(denom, ibcDenoms);
531
+ if (known) return known;
483
532
  const lookup = await metadataCache.get(call, denom, () =>
484
533
  readMetadata(call, denom),
485
534
  );
486
535
  throwIfAborted(call.signal);
487
- if (lookup === 'failed' || lookup === 'aborted')
488
- return { ...trustedDenomDisplay(denom, null), metadataUnavailable: true };
536
+ if (typeof lookup !== 'object') return unreadDisplay(denom);
489
537
  const display = trustedDenomDisplay(denom, lookup.value);
490
- return display.verified || !ibc
538
+ return display.verified || !IBC_DENOM.test(denom)
491
539
  ? display
492
540
  : tracedDisplay(call, denom, display);
493
541
  }