@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.
- package/package.json +2 -2
- package/src/plugins/hub-actions/hub-actions.plugin.ts +73 -24
- package/src/plugins/ixo-chain/balances.ts +141 -0
- package/src/plugins/ixo-chain/blocksync.ts +11 -6
- package/src/plugins/ixo-chain/denoms.ts +84 -36
- package/src/plugins/ixo-chain/http.ts +300 -63
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +182 -301
- package/src/plugins/ixo-chain/kinds/chain/account.ts +516 -0
- package/src/plugins/ixo-chain/kinds/chain/claims.ts +287 -0
- package/src/plugins/ixo-chain/kinds/chain/economics.ts +237 -0
- package/src/plugins/ixo-chain/kinds/chain/governance.ts +455 -0
- package/src/plugins/ixo-chain/kinds/chain/grants.ts +682 -0
- package/src/plugins/ixo-chain/kinds/chain/index.ts +23 -0
- package/src/plugins/ixo-chain/kinds/chain/liquid-staking.ts +279 -0
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +286 -0
- package/src/plugins/ixo-chain/kinds/chain/staking.ts +220 -0
- package/src/plugins/ixo-chain/kinds/coins.ts +112 -0
- package/src/plugins/ixo-chain/kinds/core.ts +246 -0
- package/src/plugins/ixo-chain/kinds/indexed/agent-deposits.ts +124 -0
- package/src/plugins/ixo-chain/kinds/indexed/dao-proposals.ts +379 -0
- package/src/plugins/ixo-chain/kinds/indexed/daos.ts +319 -0
- package/src/plugins/ixo-chain/kinds/indexed/disputes.ts +159 -0
- package/src/plugins/ixo-chain/kinds/indexed/evaluations.ts +172 -0
- package/src/plugins/ixo-chain/kinds/indexed/index.ts +21 -0
- package/src/plugins/ixo-chain/kinds/indexed/liquid-stake-history.ts +160 -0
- package/src/plugins/ixo-chain/kinds/indexed/member-budgets.ts +175 -0
- package/src/plugins/ixo-chain/kinds/indexed/shared.ts +211 -0
- package/src/plugins/ixo-chain/kinds/indexed/token-history.ts +205 -0
- package/src/plugins/ixo-chain/kinds/registry.ts +43 -0
- package/src/plugins/ixo-chain/kinds/types.ts +156 -0
- package/src/plugins/ixo-chain/lookup.ts +470 -0
- package/src/plugins/ixo-chain/overview.ts +1060 -0
- package/src/plugins/ixo-chain/queries.ts +170 -138
- package/src/plugins/ixo-chain/rest.ts +129 -54
- 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
|
+
}
|