@ixo/oracle-runtime-workers 0.29.0 → 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 (35) hide show
  1. package/package.json +2 -2
  2. package/src/plugins/hub-actions/hub-actions.plugin.ts +73 -24
  3. package/src/plugins/ixo-chain/balances.ts +141 -0
  4. package/src/plugins/ixo-chain/blocksync.ts +11 -6
  5. package/src/plugins/ixo-chain/denoms.ts +84 -36
  6. package/src/plugins/ixo-chain/http.ts +300 -63
  7. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
  8. package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
  9. package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
  10. package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
  11. package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
  12. package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
  13. package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
  14. package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
  15. package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
  16. package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
  17. package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
  18. package/src/plugins/ixo-chain/kinds/core.ts +246 -0
  19. package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
  20. package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
  21. package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
  22. package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
  23. package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
  24. package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
  25. package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
  26. package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
  27. package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
  28. package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
  29. package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
  30. package/src/plugins/ixo-chain/kinds/types.ts +156 -0
  31. package/src/plugins/ixo-chain/lookup.ts +470 -0
  32. package/src/plugins/ixo-chain/overview.ts +1060 -0
  33. package/src/plugins/ixo-chain/queries.ts +170 -138
  34. package/src/plugins/ixo-chain/rest.ts +129 -54
  35. package/src/plugins/ixo-chain/summaries.ts +3 -2
@@ -0,0 +1,1060 @@
1
+ /**
2
+ * `get_account_overview`: one call for "tell me about my account". Each
3
+ * section is answered by existing kinds (balances by the read
4
+ * `get_account_balances` makes), all asked in ONE `runRequests` call: the
5
+ * Blocksync-backed parts as one GraphQL document, every chain REST read
6
+ * and denom lookup under the call's limits, inside the shared 20 s
7
+ * budget. Parts that need the same data read it once: the DAO section's
8
+ * DAOs and open proposals share one membership read. Each section answers
9
+ * on its own: its counts and top rows, with the request that reads the
10
+ * rest, or why it failed. The answer is cut to {@link MAX_RESULT_CHARS},
11
+ * top rows first, and an answer without any failure is kept per isolate
12
+ * for {@link OVERVIEW_TTL_MS}.
13
+ */
14
+ import { z } from 'zod';
15
+ import type { UserIdentity } from '../../core/user-identity';
16
+ import { balancesKind } from './balances';
17
+ import {
18
+ hasBudget,
19
+ throwIfAborted,
20
+ tooLarge,
21
+ untilAborted,
22
+ type ChainCall,
23
+ type Failure,
24
+ type Outcome,
25
+ } from './http';
26
+ import { claimsPage } from './kinds/core';
27
+ import { daosWithOpenProposals } from './kinds/indexed/dao-proposals';
28
+ import { kindByName } from './kinds/registry';
29
+ import type { KindContext, KindDef, KindRestOptions } from './kinds/types';
30
+ import {
31
+ MAX_RESULT_CHARS,
32
+ runRequests,
33
+ type KindRequest,
34
+ type LookupOptions,
35
+ type LookupResult,
36
+ } from './lookup';
37
+ import { accountOf, type Subject } from './queries';
38
+ import type { Absent } from './rest';
39
+ import { isRecord } from './summaries';
40
+
41
+ const sectionSchema = z.enum([
42
+ 'balances',
43
+ 'fee_grants',
44
+ 'authz',
45
+ 'smart_account',
46
+ 'account',
47
+ 'staking',
48
+ 'liquid_staking',
49
+ 'claims',
50
+ 'governance',
51
+ 'daos',
52
+ ]);
53
+ export type Section = z.infer<typeof sectionSchema>;
54
+ /** Every section, in the order the answer lists them. */
55
+ export const SECTIONS = sectionSchema.options;
56
+
57
+ // `address` is bounded by `accountOf`'s patterns, not by a length cap here.
58
+ export const overviewSchema = z.strictObject({
59
+ address: z.string().optional().describe('ixo1… or did:ixo:ixo1…'),
60
+ sections: z.array(sectionSchema).min(1).max(SECTIONS.length).optional(),
61
+ });
62
+
63
+ /** Rows a section shows, and rows each of its list parts reads. */
64
+ const TOP_ROWS = 3;
65
+ /** An answer is reused for 20 s by every call of the isolate that asks the same. */
66
+ export const OVERVIEW_TTL_MS = 20_000;
67
+ export const OVERVIEW_CACHE_LIMIT = 200;
68
+
69
+ /* ── sections ─────────────────────────────────────────────────────────── */
70
+
71
+ /** A count of rows: a number, or `"N+"` when there are more than the N read. */
72
+ type Count = number | string;
73
+ const countOf = (rows: number, more: boolean): Count =>
74
+ more ? `${rows}+` : rows;
75
+
76
+ /** What one part's answer holds: its rows, how many there are, and whether more exist than were read. */
77
+ interface Rows {
78
+ rows: unknown[];
79
+ /** Left out of the section's counts when the rows are one record. */
80
+ count?: Count;
81
+ more: boolean;
82
+ }
83
+
84
+ /** What a section adds besides its rows' counts. */
85
+ interface Extra {
86
+ counts?: Record<string, Count>;
87
+ totals?: Record<string, unknown>;
88
+ }
89
+
90
+ interface Part {
91
+ /** The name of the part's rows: a key of `counts`, and each top row's `list` in a section of several parts. */
92
+ list: string;
93
+ /** The kind `more` and a failure name. */
94
+ kind: KindDef;
95
+ /** The kind's arguments besides the account. */
96
+ args: Record<string, unknown>;
97
+ /**
98
+ * The read that answers the part when it is not `kind` itself, with its
99
+ * arguments besides the account and the part's data in its answer. Parts
100
+ * naming the same read share one request.
101
+ */
102
+ via?: {
103
+ read: KindDef;
104
+ args: Record<string, unknown>;
105
+ pick?(data: unknown): unknown;
106
+ };
107
+ /** The kind's argument naming the account. */
108
+ account: 'address' | 'agent' | 'voter';
109
+ /** Rows the part reads (list kinds): the section's top rows. */
110
+ first?: number;
111
+ /** The rows and counts in the kind's answer; null when it is not the shape the kind answers. */
112
+ read(data: unknown): Rows | null;
113
+ extra?(data: unknown): Extra;
114
+ /** The tool that reads the rest, when it is not `chain_lookup` with this kind. */
115
+ tool?: 'get_account_balances';
116
+ }
117
+
118
+ function kind(name: string): KindDef {
119
+ const found = kindByName(name);
120
+ if (!found) throw new Error(`ixo-chain: the overview needs kind "${name}"`);
121
+ return found;
122
+ }
123
+
124
+ /** `read` over `schema`: null when `data` is not that shape. */
125
+ function shaped<S extends z.ZodType>(
126
+ schema: S,
127
+ read: (value: z.infer<S>) => Rows,
128
+ ): (data: unknown) => Rows | null {
129
+ return (data) => {
130
+ const parsed = schema.safeParse(data);
131
+ return parsed.success ? read(parsed.data) : null;
132
+ };
133
+ }
134
+
135
+ const rows = z.array(z.unknown());
136
+ /** `data[key]` of a combined answer. */
137
+ const pick = (key: string) => (data: unknown) =>
138
+ isRecord(data) ? data[key] : undefined;
139
+ /** The fee and authz grant kinds' answer (no total: `more` says there are more rows). */
140
+ const grants = shaped(
141
+ z.looseObject({ more: z.boolean(), grants: rows }),
142
+ (value) => ({
143
+ rows: value.grants,
144
+ count: countOf(value.grants.length, value.more),
145
+ more: value.more,
146
+ }),
147
+ );
148
+ /** An indexed list kind's answer: `items` and `more`. */
149
+ const items = shaped(
150
+ z.looseObject({ items: rows, more: z.boolean().optional() }),
151
+ (value) => ({
152
+ rows: value.items,
153
+ count: countOf(value.items.length, value.more === true),
154
+ more: value.more === true,
155
+ }),
156
+ );
157
+ /** Rows that say their account has not voted on a proposal still in its voting time. */
158
+ const notVoted = (list: unknown[]) =>
159
+ list.filter(
160
+ (row) => isRecord(row) && row.notVoted === true && row.votingEnded !== true,
161
+ ).length;
162
+
163
+ const fee = kind('fee_grants');
164
+ const authz = kind('authz_grants');
165
+ const liquidStaking = kind('liquid_staking');
166
+ const daoProposals = kind('dao_proposals');
167
+
168
+ const PARTS: Record<Section, readonly Part[]> = {
169
+ balances: [
170
+ {
171
+ list: 'tokens',
172
+ kind: balancesKind,
173
+ args: {},
174
+ account: 'address',
175
+ tool: 'get_account_balances',
176
+ read: shaped(
177
+ z.looseObject({ tokens: rows, moreDenoms: z.boolean() }),
178
+ (value) => ({
179
+ rows: value.tokens,
180
+ count: countOf(value.tokens.length, value.moreDenoms),
181
+ more: value.moreDenoms,
182
+ }),
183
+ ),
184
+ },
185
+ ],
186
+ // Fee grants given are left out: the chain finds them by scanning every
187
+ // fee grant (see `NOT_READ`).
188
+ fee_grants: [
189
+ {
190
+ list: 'received',
191
+ kind: fee,
192
+ args: { direction: 'received' },
193
+ account: 'address',
194
+ first: TOP_ROWS,
195
+ read: grants,
196
+ },
197
+ ],
198
+ authz: [
199
+ {
200
+ list: 'received',
201
+ kind: authz,
202
+ args: { direction: 'received' },
203
+ account: 'address',
204
+ first: TOP_ROWS,
205
+ read: grants,
206
+ },
207
+ {
208
+ list: 'given',
209
+ kind: authz,
210
+ args: { direction: 'given' },
211
+ account: 'address',
212
+ first: TOP_ROWS,
213
+ read: grants,
214
+ },
215
+ ],
216
+ smart_account: [
217
+ {
218
+ list: 'authenticators',
219
+ kind: kind('smart_account'),
220
+ args: {},
221
+ account: 'address',
222
+ read: shaped(
223
+ z.looseObject({
224
+ authenticators: rows,
225
+ moreAuthenticators: z.number().optional(),
226
+ }),
227
+ (value) => ({
228
+ rows: value.authenticators,
229
+ count: value.authenticators.length + (value.moreAuthenticators ?? 0),
230
+ more: (value.moreAuthenticators ?? 0) > 0,
231
+ }),
232
+ ),
233
+ },
234
+ ],
235
+ account: [
236
+ {
237
+ list: 'account',
238
+ kind: kind('account'),
239
+ args: {},
240
+ account: 'address',
241
+ // One record: the row, without the address the overview names.
242
+ read: shaped(
243
+ z.looseObject({ address: z.string(), onChain: z.boolean() }),
244
+ (value) => {
245
+ const record: Record<string, unknown> = { ...value };
246
+ delete record.address;
247
+ return { rows: [record], more: false };
248
+ },
249
+ ),
250
+ },
251
+ ],
252
+ staking: [
253
+ {
254
+ list: 'delegations',
255
+ kind: kind('staking'),
256
+ args: {},
257
+ account: 'address',
258
+ read: shaped(
259
+ z.looseObject({
260
+ delegations: rows,
261
+ moreDelegations: z.boolean().optional(),
262
+ }),
263
+ (value) => ({
264
+ rows: value.delegations,
265
+ count: countOf(value.delegations.length, !!value.moreDelegations),
266
+ more: !!value.moreDelegations,
267
+ }),
268
+ ),
269
+ extra(data) {
270
+ // `rewards` is missing when its read failed (`rewardsUnavailable`).
271
+ const parsed = z
272
+ .looseObject({
273
+ unbonding: rows,
274
+ moreUnbonding: z.boolean().optional(),
275
+ rewards: z.looseObject({ total: z.unknown() }).optional(),
276
+ rewardsUnavailable: z.literal(true).optional(),
277
+ totals: z.looseObject({
278
+ delegated: z.unknown(),
279
+ unbonding: z.unknown(),
280
+ }),
281
+ })
282
+ .safeParse(data);
283
+ if (!parsed.success) return {};
284
+ const { unbonding, moreUnbonding, rewards, totals } = parsed.data;
285
+ return {
286
+ counts: { unbonding: countOf(unbonding.length, !!moreUnbonding) },
287
+ totals: {
288
+ delegated: totals.delegated,
289
+ unbonding: totals.unbonding,
290
+ ...(rewards
291
+ ? { rewards: rewards.total }
292
+ : { rewardsUnavailable: true }),
293
+ },
294
+ };
295
+ },
296
+ },
297
+ ],
298
+ liquid_staking: [
299
+ {
300
+ list: 'pools',
301
+ kind: liquidStaking,
302
+ args: {},
303
+ account: 'address',
304
+ read: shaped(
305
+ z.looseObject({ pools: rows, morePools: z.boolean().optional() }),
306
+ (value) => ({
307
+ rows: value.pools,
308
+ count: countOf(value.pools.length, !!value.morePools),
309
+ more: !!value.morePools,
310
+ }),
311
+ ),
312
+ extra(data) {
313
+ const parsed = z
314
+ .looseObject({
315
+ pools: z.array(
316
+ z.looseObject({
317
+ yourBalance: z
318
+ .looseObject({ amountBaseUnits: z.string() })
319
+ .nullish(),
320
+ }),
321
+ ),
322
+ })
323
+ .safeParse(data);
324
+ if (!parsed.success) return {};
325
+ return {
326
+ counts: {
327
+ holding: parsed.data.pools.filter(
328
+ (pool) => (pool.yourBalance?.amountBaseUnits ?? '0') !== '0',
329
+ ).length,
330
+ },
331
+ };
332
+ },
333
+ },
334
+ ],
335
+ claims: [
336
+ {
337
+ list: 'claims',
338
+ kind: kind('claims'),
339
+ args: {},
340
+ account: 'agent',
341
+ first: TOP_ROWS,
342
+ // Without `totalCount`: a full count of the account's claims.
343
+ via: { read: claimsPage, args: {} },
344
+ read: shaped(
345
+ z.looseObject({ hasNextPage: z.boolean().optional(), items: rows }),
346
+ (value) => ({
347
+ rows: value.items,
348
+ count: countOf(value.items.length, value.hasNextPage === true),
349
+ more: value.hasNextPage === true,
350
+ }),
351
+ ),
352
+ },
353
+ {
354
+ list: 'deposits',
355
+ kind: kind('agent_deposits'),
356
+ args: {},
357
+ account: 'address',
358
+ first: TOP_ROWS,
359
+ read: items,
360
+ },
361
+ {
362
+ list: 'budgets',
363
+ kind: kind('member_budgets'),
364
+ args: {},
365
+ account: 'address',
366
+ first: TOP_ROWS,
367
+ read: items,
368
+ },
369
+ ],
370
+ governance: [
371
+ {
372
+ list: 'voting',
373
+ kind: kind('gov_proposals'),
374
+ args: { status: 'voting' },
375
+ account: 'voter',
376
+ first: TOP_ROWS,
377
+ read: shaped(
378
+ z.looseObject({ proposals: rows, more: z.boolean() }),
379
+ (value) => ({
380
+ rows: value.proposals,
381
+ count: countOf(value.proposals.length, value.more),
382
+ more: value.more,
383
+ }),
384
+ ),
385
+ extra(data) {
386
+ const parsed = z
387
+ .looseObject({ proposals: rows, more: z.boolean() })
388
+ .safeParse(data);
389
+ return parsed.success
390
+ ? {
391
+ counts: {
392
+ notVoted: countOf(
393
+ notVoted(parsed.data.proposals),
394
+ parsed.data.more,
395
+ ),
396
+ },
397
+ }
398
+ : {};
399
+ },
400
+ },
401
+ ],
402
+ // Both parts from one membership read.
403
+ daos: [
404
+ {
405
+ list: 'daos',
406
+ kind: kind('daos'),
407
+ args: {},
408
+ account: 'address',
409
+ first: TOP_ROWS,
410
+ via: { read: daosWithOpenProposals, args: {}, pick: pick('daos') },
411
+ read: items,
412
+ },
413
+ {
414
+ list: 'openProposals',
415
+ kind: daoProposals,
416
+ args: { status: 'open' },
417
+ account: 'address',
418
+ first: TOP_ROWS,
419
+ via: {
420
+ read: daosWithOpenProposals,
421
+ args: {},
422
+ pick: pick('proposals'),
423
+ },
424
+ read: items,
425
+ extra(data) {
426
+ const parsed = z
427
+ .looseObject({ items: rows, more: z.boolean().optional() })
428
+ .safeParse(data);
429
+ return parsed.success
430
+ ? {
431
+ counts: {
432
+ notVoted: countOf(
433
+ notVoted(parsed.data.items),
434
+ parsed.data.more === true,
435
+ ),
436
+ },
437
+ }
438
+ : {};
439
+ },
440
+ },
441
+ ],
442
+ };
443
+
444
+ /**
445
+ * What a section does not read by default, as the requests that read it:
446
+ * fee grants given, which the chain answers by scanning every fee grant
447
+ * (Cosmos SDK 0.50 keys them by grantee).
448
+ */
449
+ const NOT_READ: Partial<
450
+ Record<Section, readonly { kind: KindDef; args: Record<string, unknown> }[]>
451
+ > = {
452
+ fee_grants: [{ kind: fee, args: { direction: 'given' } }],
453
+ };
454
+
455
+ /* ── balances read once ───────────────────────────────────────────────── */
456
+
457
+ const BALANCES_PAGE =
458
+ /^\/cosmos\/bank\/v1beta1\/balances\/(ixo1[02-9ac-hj-np-z]+)\?pagination\.limit=\d+$/;
459
+ const BY_DENOM =
460
+ /^\/cosmos\/bank\/v1beta1\/balances\/(ixo1[02-9ac-hj-np-z]+)\/by_denom\?denom=([^&]+)$/;
461
+ const balancePage = z.object({
462
+ balances: z.array(z.object({ denom: z.string(), amount: z.string() })),
463
+ pagination: z.object({ next_key: z.string().nullish() }).nullish(),
464
+ });
465
+ /** One account's page of bank balances, and whether it is the whole list. */
466
+ type BalancePage = {
467
+ address: string;
468
+ amounts: ReadonlyMap<string, string>;
469
+ complete: boolean;
470
+ };
471
+
472
+ /**
473
+ * `ctx.rest` that first asks `known` for a GET's body (undefined, or a
474
+ * body its schema refuses: read as usual) and shows `seen` every answer
475
+ * it reads.
476
+ */
477
+ function restFirst(
478
+ ctx: KindContext,
479
+ known: (path: string) => Promise<{ body: unknown } | undefined>,
480
+ seen: (path: string, outcome: Outcome<unknown> | Absent) => void,
481
+ ): KindContext['rest'] {
482
+ function rest<S extends z.ZodType>(
483
+ path: string,
484
+ schema: S,
485
+ opts: KindRestOptions & { noneOn?: undefined },
486
+ ): Promise<Outcome<z.infer<S>>>;
487
+ function rest<S extends z.ZodType>(
488
+ path: string,
489
+ schema: S,
490
+ opts: KindRestOptions & { noneOn: readonly number[] },
491
+ ): Promise<Outcome<z.infer<S>> | Absent>;
492
+ async function rest<S extends z.ZodType>(
493
+ path: string,
494
+ schema: S,
495
+ opts: KindRestOptions,
496
+ ): Promise<Outcome<z.infer<S>> | Absent> {
497
+ const { noneOn } = opts;
498
+ if (noneOn !== undefined) {
499
+ const outcome = await ctx.rest(path, schema, { ...opts, noneOn });
500
+ seen(path, outcome);
501
+ return outcome;
502
+ }
503
+ const answer = await known(path);
504
+ if (answer) {
505
+ const parsed = schema.safeParse(answer.body);
506
+ if (parsed.success) return { ok: true, value: parsed.data };
507
+ }
508
+ const outcome = await ctx.rest(path, schema, { ...opts, noneOn });
509
+ seen(path, outcome);
510
+ return outcome;
511
+ }
512
+ return rest;
513
+ }
514
+
515
+ /**
516
+ * The balances and liquid staking parts reading the account's balances
517
+ * once: each pool's token balance (`/cosmos/bank/v1beta1/balances/{address}
518
+ * /by_denom`, one read per pool in `liquid_staking`) is taken from the
519
+ * page the balances part reads. A denom missing from a complete page (no
520
+ * `next_key`) is held at zero. When the page is not complete, the
521
+ * balances read failed or it never ran, the denom is read as usual.
522
+ */
523
+ function balancesOnce(
524
+ balances: KindDef,
525
+ liquid: KindDef,
526
+ ): { balances: KindDef; liquid: KindDef } {
527
+ let settle: (page: BalancePage | null) => void = () => undefined;
528
+ const page = new Promise<BalancePage | null>((resolve) => {
529
+ settle = resolve;
530
+ });
531
+ const none = async () => undefined;
532
+ const fromPage = async (
533
+ path: string,
534
+ ): Promise<{ body: unknown } | undefined> => {
535
+ const match = BY_DENOM.exec(path);
536
+ if (!match) return undefined;
537
+ const known = await page;
538
+ if (!known?.complete || known.address !== match[1]) return undefined;
539
+ let denom: string;
540
+ try {
541
+ denom = decodeURIComponent(match[2] ?? '');
542
+ } catch {
543
+ return undefined;
544
+ }
545
+ const amount = known.amounts.get(denom) ?? '0';
546
+ return { body: { balance: { denom, amount } } };
547
+ };
548
+ const keep = (path: string, outcome: Outcome<unknown> | Absent) => {
549
+ const address = BALANCES_PAGE.exec(path)?.[1];
550
+ if (!address) return;
551
+ const parsed =
552
+ 'value' in outcome ? balancePage.safeParse(outcome.value) : null;
553
+ settle(
554
+ parsed?.success
555
+ ? {
556
+ address,
557
+ amounts: new Map(
558
+ parsed.data.balances.map((coin) => [coin.denom, coin.amount]),
559
+ ),
560
+ complete: !parsed.data.pagination?.next_key,
561
+ }
562
+ : null,
563
+ );
564
+ };
565
+ return {
566
+ balances: {
567
+ ...balances,
568
+ plan(args, ctx) {
569
+ // The page settles on every path, so no pool waits for it forever.
570
+ let plan: ReturnType<KindDef['plan']>;
571
+ try {
572
+ plan = balances.plan(args, {
573
+ ...ctx,
574
+ rest: restFirst(ctx, none, keep),
575
+ });
576
+ } catch (error) {
577
+ settle(null);
578
+ throw error;
579
+ }
580
+ if ('invalid' in plan || plan.source !== 'chain') {
581
+ settle(null);
582
+ return plan;
583
+ }
584
+ const { run } = plan;
585
+ return {
586
+ source: 'chain',
587
+ async run() {
588
+ try {
589
+ return await run();
590
+ } finally {
591
+ settle(null);
592
+ }
593
+ },
594
+ };
595
+ },
596
+ },
597
+ liquid: {
598
+ ...liquid,
599
+ plan: (args, ctx) =>
600
+ liquid.plan(args, {
601
+ ...ctx,
602
+ rest: restFirst(ctx, fromPage, () => undefined),
603
+ }),
604
+ },
605
+ };
606
+ }
607
+
608
+ /* ── answers ──────────────────────────────────────────────────────────── */
609
+
610
+ /** The request that reads the rest of a section. */
611
+ type More =
612
+ | { kind: string; args: Record<string, unknown> }
613
+ | { tool: 'get_account_balances'; args: Record<string, unknown> };
614
+
615
+ type PartFailure = { kind: string } & Failure;
616
+
617
+ export type SectionAnswer =
618
+ | {
619
+ ok: true;
620
+ summary: {
621
+ counts: Record<string, Count>;
622
+ totals?: Record<string, unknown>;
623
+ top: unknown[];
624
+ };
625
+ more?: More;
626
+ /** The requests for what the section does not read by default. */
627
+ notRead?: More[];
628
+ /** Parts of the section that failed; the summary is from the others. */
629
+ failed?: PartFailure[];
630
+ /** Top rows were left out to keep the answer small. */
631
+ truncated?: true;
632
+ }
633
+ | ({ ok: false } & Failure);
634
+
635
+ export type OverviewAnswer = {
636
+ ok: true;
637
+ network: ChainCall['network'];
638
+ fetchedAt: string;
639
+ address: string;
640
+ subject: Subject;
641
+ sections: Partial<Record<Section, SectionAnswer>>;
642
+ };
643
+
644
+ export type OverviewFailure = {
645
+ ok: false;
646
+ network: ChainCall['network'];
647
+ } & Failure;
648
+
649
+ const SHAPE_FAILED: Failure = {
650
+ reason: 'unavailable',
651
+ detail: 'This section failed inside the oracle; asking again will not help.',
652
+ retryable: false,
653
+ };
654
+ const LEFT_OUT = tooLarge(
655
+ 'Left out to keep the answer small; ask for this section on its own.',
656
+ );
657
+
658
+ /** A part that answered, with its rows. */
659
+ interface Answered {
660
+ part: Part;
661
+ rows: Rows;
662
+ extra: Extra;
663
+ }
664
+
665
+ /** A section before its top rows are chosen: what answered and what failed. */
666
+ interface Gathered {
667
+ answered: Answered[];
668
+ failed: PartFailure[];
669
+ /** Whether top rows carry the `list` they come from (a section of several parts). */
670
+ tagged: boolean;
671
+ }
672
+
673
+ /** `parts` with the answer of each part's request (`results`, in part order). */
674
+ function gather(
675
+ parts: readonly Part[],
676
+ results: readonly (LookupResult | undefined)[],
677
+ ): Gathered {
678
+ const answered: Answered[] = [];
679
+ const failed: PartFailure[] = [];
680
+ parts.forEach((part, index) => {
681
+ const result = results[index];
682
+ if (!result) return;
683
+ const name = part.kind.name;
684
+ if (!result.ok) {
685
+ failed.push({
686
+ kind: name,
687
+ reason: result.reason,
688
+ detail: result.detail,
689
+ retryable: result.retryable,
690
+ });
691
+ return;
692
+ }
693
+ const data = part.via?.pick ? part.via.pick(result.data) : result.data;
694
+ const read = part.read(data);
695
+ if (!read) {
696
+ // A kind answering another shape than it documents is a bug here.
697
+ // eslint-disable-next-line no-console -- console IS the logger on Workers
698
+ console.error(`[ixo-chain] overview could not read the ${name} answer`);
699
+ failed.push({ kind: name, ...SHAPE_FAILED });
700
+ return;
701
+ }
702
+ answered.push({ part, rows: read, extra: part.extra?.(data) ?? {} });
703
+ });
704
+ return { answered, failed, tagged: parts.length > 1 };
705
+ }
706
+
707
+ /**
708
+ * A section with at most `limit` top rows, taken from its parts in turn
709
+ * (first row of each, then the second…), and the request for the rest of
710
+ * the first part with rows not shown.
711
+ */
712
+ function render(
713
+ gathered: Gathered,
714
+ limit: number,
715
+ moreArgs: (part: Part) => Record<string, unknown>,
716
+ ): SectionAnswer {
717
+ const { answered, failed, tagged } = gathered;
718
+ const first = failed[0];
719
+ if (!answered.length)
720
+ return first
721
+ ? {
722
+ ok: false,
723
+ reason: first.reason,
724
+ detail: first.detail,
725
+ retryable: first.retryable,
726
+ }
727
+ : { ok: false, ...SHAPE_FAILED };
728
+ const top: unknown[] = [];
729
+ const shown = answered.map(() => 0);
730
+ for (let row = 0; top.length < limit; row += 1) {
731
+ let added = false;
732
+ answered.forEach((entry, index) => {
733
+ const value = entry.rows.rows[row];
734
+ if (top.length >= limit || value === undefined) return;
735
+ top.push(
736
+ tagged && isRecord(value) ? { list: entry.part.list, ...value } : value,
737
+ );
738
+ shown[index] = (shown[index] ?? 0) + 1;
739
+ added = true;
740
+ });
741
+ if (!added) break;
742
+ }
743
+ const counts: Record<string, Count> = {};
744
+ let totals: Record<string, unknown> | undefined;
745
+ for (const { part, rows, extra } of answered) {
746
+ if (rows.count !== undefined) counts[part.list] = rows.count;
747
+ Object.assign(counts, extra.counts);
748
+ if (extra.totals) totals = extra.totals;
749
+ }
750
+ const rest = answered.find(
751
+ (entry, index) =>
752
+ entry.rows.more || entry.rows.rows.length > (shown[index] ?? 0),
753
+ );
754
+ const more: More | undefined = !rest
755
+ ? undefined
756
+ : rest.part.tool
757
+ ? { tool: rest.part.tool, args: moreArgs(rest.part) }
758
+ : { kind: rest.part.kind.name, args: moreArgs(rest.part) };
759
+ return {
760
+ ok: true,
761
+ summary: { counts, ...(totals ? { totals } : {}), top },
762
+ ...(more ? { more } : {}),
763
+ ...(failed.length ? { failed } : {}),
764
+ };
765
+ }
766
+
767
+ const size = (value: unknown) => JSON.stringify(value).length;
768
+
769
+ /**
770
+ * Reads every section's parts in one `runRequests` call and cuts the
771
+ * answer to {@link MAX_RESULT_CHARS}: one top row at a time from the
772
+ * largest section that still has some, then, if it is still too large,
773
+ * whole sections (largest first) as `too_large`.
774
+ */
775
+ async function readOverview(
776
+ call: ChainCall,
777
+ identity: UserIdentity,
778
+ account: { address: string; subject: Subject },
779
+ sections: readonly Section[],
780
+ options: LookupOptions,
781
+ ): Promise<OverviewAnswer> {
782
+ const planned = sections.flatMap((section) =>
783
+ PARTS[section].map((part) => ({ section, part })),
784
+ );
785
+ // With both sections asked, the liquid staking holdings come from the
786
+ // balances read.
787
+ const once =
788
+ sections.includes('balances') && sections.includes('liquid_staking')
789
+ ? balancesOnce(balancesKind, liquidStaking)
790
+ : null;
791
+ const readOf = (part: Part): KindDef => {
792
+ const read = part.via?.read ?? part.kind;
793
+ if (once && read === balancesKind) return once.balances;
794
+ if (once && read === liquidStaking) return once.liquid;
795
+ return read;
796
+ };
797
+ // One request per part, except that parts reading through the same
798
+ // `via` share its request.
799
+ const requests: KindRequest[] = [];
800
+ const shared = new Map<KindDef, number>();
801
+ const requestOf = planned.map(({ part }) => {
802
+ const known = part.via ? shared.get(part.via.read) : undefined;
803
+ if (known !== undefined) return known;
804
+ const index =
805
+ requests.push({
806
+ kind: readOf(part),
807
+ args: {
808
+ ...(part.via?.args ?? part.args),
809
+ [part.account]: account.address,
810
+ },
811
+ ...(part.first !== undefined ? { first: part.first } : {}),
812
+ }) - 1;
813
+ if (part.via) shared.set(part.via.read, index);
814
+ return index;
815
+ });
816
+ const answers = await runRequests(call, identity, requests, options);
817
+ // The account is named in `more` only when it is not the user's (the
818
+ // kinds' default).
819
+ const moreArgs = (part: Part) =>
820
+ account.subject === 'user'
821
+ ? { ...part.args }
822
+ : { ...part.args, [part.account]: account.address };
823
+ const state = sections.map((section) => {
824
+ const results = planned.flatMap((entry, index) =>
825
+ entry.section === section ? [answers[requestOf[index] ?? -1]] : [],
826
+ );
827
+ return {
828
+ section,
829
+ gathered: gather(PARTS[section], results),
830
+ limit: TOP_ROWS,
831
+ };
832
+ });
833
+ const answer: OverviewAnswer = {
834
+ ok: true,
835
+ network: call.network,
836
+ fetchedAt: new Date().toISOString(),
837
+ address: account.address,
838
+ subject: account.subject,
839
+ sections: {},
840
+ };
841
+ // What a section leaves out, named for every answer it gives (a cut
842
+ // below keeps it).
843
+ const notRead = (section: Section): More[] | undefined =>
844
+ NOT_READ[section]?.map((entry) => ({
845
+ kind: entry.kind.name,
846
+ args:
847
+ account.subject === 'user'
848
+ ? { ...entry.args }
849
+ : { ...entry.args, address: account.address },
850
+ }));
851
+ const show = (entry: (typeof state)[number], cut: boolean) => {
852
+ const shown = render(entry.gathered, entry.limit, moreArgs);
853
+ const unread = notRead(entry.section);
854
+ answer.sections[entry.section] = shown.ok
855
+ ? {
856
+ ...shown,
857
+ ...(unread ? { notRead: unread } : {}),
858
+ ...(cut ? { truncated: true } : {}),
859
+ }
860
+ : shown;
861
+ };
862
+ for (const entry of state) show(entry, false);
863
+ while (size(answer) > MAX_RESULT_CHARS) {
864
+ let largest: (typeof state)[number] | undefined;
865
+ let largestSize = -1;
866
+ for (const entry of state) {
867
+ const shown = answer.sections[entry.section];
868
+ if (!shown?.ok || !shown.summary.top.length) continue;
869
+ const bytes = size(shown);
870
+ if (bytes > largestSize) {
871
+ largest = entry;
872
+ largestSize = bytes;
873
+ }
874
+ }
875
+ if (!largest) break;
876
+ largest.limit -= 1;
877
+ show(largest, true);
878
+ }
879
+ while (size(answer) > MAX_RESULT_CHARS) {
880
+ let largest: Section | undefined;
881
+ let largestSize = -1;
882
+ for (const section of sections) {
883
+ const shown = answer.sections[section];
884
+ if (!shown?.ok) continue;
885
+ const bytes = size(shown);
886
+ if (bytes > largestSize) {
887
+ largest = section;
888
+ largestSize = bytes;
889
+ }
890
+ }
891
+ if (!largest) break;
892
+ answer.sections[largest] = { ok: false, ...LEFT_OUT };
893
+ }
894
+ return answer;
895
+ }
896
+
897
+ /* ── cache ────────────────────────────────────────────────────────────── */
898
+
899
+ /**
900
+ * Whether `value` holds a read that failed inside an answer: every kind
901
+ * marks one `<what>Unavailable: true` (a coin's `metadataUnavailable`,
902
+ * staking's `rewardsUnavailable`, a pool's `stateUnavailable`, a
903
+ * proposal's `tallyUnavailable` or `voteUnavailable`, an account's
904
+ * `contractInfoUnavailable`).
905
+ */
906
+ function hasUnreadPart(value: unknown): boolean {
907
+ if (Array.isArray(value)) return value.some(hasUnreadPart);
908
+ if (!isRecord(value)) return false;
909
+ return Object.entries(value).some(([key, inner]) =>
910
+ key.endsWith('Unavailable') ? inner === true : hasUnreadPart(inner),
911
+ );
912
+ }
913
+
914
+ /**
915
+ * Whether an answer may be kept: every section answered, no part failed
916
+ * and no read inside a part failed. Anything less is read again by the
917
+ * next call, so a passing fault is never served for 20 s.
918
+ */
919
+ function complete(answer: OverviewAnswer): boolean {
920
+ return (
921
+ Object.values(answer.sections).every(
922
+ (section) => section.ok && !section.failed,
923
+ ) && !hasUnreadPart(answer.sections)
924
+ );
925
+ }
926
+
927
+ /** Whether a section or part ran out of time: of the call that read it, not necessarily of another. */
928
+ function timedOut(answer: OverviewAnswer): boolean {
929
+ return Object.values(answer.sections).some((section) =>
930
+ section.ok
931
+ ? (section.failed ?? []).some((part) => part.reason === 'timeout')
932
+ : section.reason === 'timeout',
933
+ );
934
+ }
935
+
936
+ /**
937
+ * Overview answers per isolate: each complete one kept for
938
+ * {@link OVERVIEW_TTL_MS}, at most {@link OVERVIEW_CACHE_LIMIT} (the
939
+ * oldest dropped first). A read in flight is shared by every call that
940
+ * asks the same; a joining call waits on its own turn's abort only and
941
+ * throws its own abort reason. It reads again itself when the shared read
942
+ * ended in the starting turn's abort, or when that read ran out of time
943
+ * and the joining call still has budget. Every call gets its own copy.
944
+ */
945
+ class OverviewCache {
946
+ private readonly answers = new Map<
947
+ string,
948
+ { answer: OverviewAnswer; expiresAt: number }
949
+ >();
950
+ private readonly inflight = new Map<string, Promise<OverviewAnswer>>();
951
+
952
+ async get(
953
+ key: string,
954
+ call: ChainCall,
955
+ read: () => Promise<OverviewAnswer>,
956
+ ): Promise<OverviewAnswer> {
957
+ const hit = this.answers.get(key);
958
+ if (hit && hit.expiresAt > Date.now()) return structuredClone(hit.answer);
959
+ if (hit) this.answers.delete(key);
960
+ const running = this.inflight.get(key);
961
+ if (!running) return this.start(key, read);
962
+ let shared: OverviewAnswer;
963
+ try {
964
+ shared = await untilAborted(running, call.signal);
965
+ } catch {
966
+ // This turn's abort, or the starting turn's (the only error a read
967
+ // throws): only this turn's is thrown; after the other's this call
968
+ // reads on its own.
969
+ throwIfAborted(call.signal);
970
+ return this.start(key, read);
971
+ }
972
+ if (timedOut(shared) && hasBudget(call)) return this.start(key, read);
973
+ return structuredClone(shared);
974
+ }
975
+
976
+ clear(): void {
977
+ this.answers.clear();
978
+ this.inflight.clear();
979
+ }
980
+
981
+ private async start(
982
+ key: string,
983
+ read: () => Promise<OverviewAnswer>,
984
+ ): Promise<OverviewAnswer> {
985
+ const pending = read();
986
+ this.inflight.set(key, pending);
987
+ try {
988
+ const answer = await pending;
989
+ if (complete(answer)) this.remember(key, answer);
990
+ return answer;
991
+ } finally {
992
+ if (this.inflight.get(key) === pending) this.inflight.delete(key);
993
+ }
994
+ }
995
+
996
+ private remember(key: string, answer: OverviewAnswer): void {
997
+ this.answers.delete(key);
998
+ if (this.answers.size >= OVERVIEW_CACHE_LIMIT) {
999
+ const oldest = this.answers.keys().next();
1000
+ if (!oldest.done) this.answers.delete(oldest.value);
1001
+ }
1002
+ this.answers.set(key, {
1003
+ answer: structuredClone(answer),
1004
+ expiresAt: Date.now() + OVERVIEW_TTL_MS,
1005
+ });
1006
+ }
1007
+ }
1008
+
1009
+ const cache = new OverviewCache();
1010
+
1011
+ /** Tests only: forget every kept overview. */
1012
+ export function resetOverviewCache(): void {
1013
+ cache.clear();
1014
+ }
1015
+
1016
+ /**
1017
+ * The overview of `input.address` (default: the user's account), the
1018
+ * sections asked (default: all) in their fixed order. Only the turn's
1019
+ * abort is thrown; a user with no IXO address and none given, or an
1020
+ * address that is none, is `invalid_input`.
1021
+ */
1022
+ export async function accountOverview(
1023
+ call: ChainCall,
1024
+ identity: UserIdentity,
1025
+ input: { address?: string; sections?: readonly Section[] },
1026
+ options: LookupOptions,
1027
+ ): Promise<OverviewAnswer | OverviewFailure> {
1028
+ const account = accountOf(input.address, identity, 'address');
1029
+ if (!account.ok)
1030
+ return {
1031
+ ok: false,
1032
+ network: call.network,
1033
+ reason: 'invalid_input',
1034
+ detail: account.detail,
1035
+ retryable: false,
1036
+ };
1037
+ const asked = input.sections;
1038
+ const sections = SECTIONS.filter(
1039
+ (section) => !asked || asked.includes(section),
1040
+ );
1041
+ // Everything an answer depends on: where it is read from, whose it is
1042
+ // (`subject` differs per user) and what it holds.
1043
+ const key = [
1044
+ call.network,
1045
+ call.restUrl,
1046
+ call.blocksyncUrl,
1047
+ account.address,
1048
+ account.subject,
1049
+ sections.join(','),
1050
+ ].join('|');
1051
+ return cache.get(key, call, () =>
1052
+ readOverview(
1053
+ call,
1054
+ identity,
1055
+ { address: account.address, subject: account.subject },
1056
+ sections,
1057
+ options,
1058
+ ),
1059
+ );
1060
+ }