@ixo/oracle-runtime-workers 0.27.0 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,799 @@
1
+ /**
2
+ * What a `chain_lookup` answer looks like to the model: every Blocksync
3
+ * row cut down to the fields a question needs, every nested list capped,
4
+ * and message values summarised (a bank send as from/to/amount, a claim
5
+ * message as its claim and collection, anything else as a short excerpt) —
6
+ * never a raw value blob. Every string from the index is chain data
7
+ * written by anyone and is clipped: ids 120 characters, names and labels
8
+ * 200, memos 256, excerpts 240. Bank coins are `{ denom, amountBaseUnits }`
9
+ * (never `amount`, which `get_account_balances` uses for converted units);
10
+ * 1155 token amounts are whole units and stay `amount`.
11
+ */
12
+ import { z } from 'zod';
13
+ import type { Failure } from './http';
14
+ import type { ChainRequest } from './queries';
15
+
16
+ export type Coin = { denom: string; amountBaseUnits: string };
17
+
18
+ export type MessageSummary =
19
+ | { type: 'MsgSend'; from?: string; to?: string; amount: Coin[] }
20
+ | {
21
+ type: 'MsgTransferToken';
22
+ from?: string;
23
+ to?: string;
24
+ tokens: { id: string; amount: string }[];
25
+ }
26
+ | {
27
+ type: 'MsgSubmitClaim' | 'MsgEvaluateClaim';
28
+ claimId?: string;
29
+ collectionId?: string;
30
+ status?: string;
31
+ }
32
+ | { type: 'MsgExec'; messages: MessageSummary[]; count: number }
33
+ | { type: string; excerpt: string };
34
+
35
+ export const EXCERPT_CHARS = 240;
36
+ export const ID_CHARS = 120;
37
+ export const TEXT_CHARS = 200;
38
+ export const MEMO_CHARS = 256;
39
+ const LIST_CAP = 20;
40
+ const COIN_CAP = 10;
41
+ const EXEC_CAP = 5;
42
+
43
+ /** x/claims `EvaluationStatus`. */
44
+ const EVALUATION_STATUS = [
45
+ 'pending',
46
+ 'approved',
47
+ 'rejected',
48
+ 'disputed',
49
+ 'invalidated',
50
+ 'flagged',
51
+ ] as const;
52
+ /** x/claims `CollectionState`. */
53
+ const COLLECTION_STATE = ['open', 'paused', 'closed'] as const;
54
+
55
+ export const TOKEN_DECIMALS_NOTE =
56
+ 'IXO 1155 tokens have no decimals on chain: amounts are whole units.';
57
+ /** x/entity defines no status values: `status` is a number its creator gives meaning to. */
58
+ export const ENTITY_STATUS_NOTE =
59
+ 'Entity status is a number its creator defines; the chain gives it no fixed meaning.';
60
+ /** `entities` reads `ID_DESC`, and an entity id is a hash: the order says nothing about age. */
61
+ export const ENTITIES_ORDER = 'id (text)';
62
+
63
+ function clip(text: string, max: number): string {
64
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
65
+ }
66
+
67
+ export function isRecord(value: unknown): value is Record<string, unknown> {
68
+ return !!value && typeof value === 'object' && !Array.isArray(value);
69
+ }
70
+
71
+ /** A non-empty string field, clipped; undefined otherwise. */
72
+ function text(value: unknown, max = TEXT_CHARS): string | undefined {
73
+ return typeof value === 'string' && value ? clip(value, max) : undefined;
74
+ }
75
+
76
+ /** The first of `keys` present as a non-empty string (camelCase first, then snake_case). */
77
+ function field(
78
+ value: Record<string, unknown>,
79
+ ...keys: string[]
80
+ ): string | undefined {
81
+ for (const key of keys) {
82
+ const found = text(value[key]);
83
+ if (found) return found;
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ /** Drops undefined entries so absent fields cost nothing in the answer. */
89
+ function compact<T extends Record<string, unknown>>(value: T): T {
90
+ for (const key of Object.keys(value))
91
+ if (value[key] === undefined) delete value[key];
92
+ return value;
93
+ }
94
+
95
+ /** An amount (string or number) as clipped text; undefined otherwise. */
96
+ function amountText(value: unknown): string | undefined {
97
+ return typeof value === 'string' || typeof value === 'number'
98
+ ? clip(String(value), 80)
99
+ : undefined;
100
+ }
101
+
102
+ /** `[{ denom, amountBaseUnits }]` from bank coins, at most `max`; anything else is left out. */
103
+ function coins(value: unknown, max = COIN_CAP): Coin[] {
104
+ if (!Array.isArray(value)) return [];
105
+ const out: Coin[] = [];
106
+ for (const entry of value) {
107
+ if (out.length >= max) break;
108
+ if (!isRecord(entry)) continue;
109
+ const denom = text(entry.denom, ID_CHARS);
110
+ const amountBaseUnits = amountText(entry.amount);
111
+ if (denom && amountBaseUnits) out.push({ denom, amountBaseUnits });
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function evaluationStatus(value: unknown): string | undefined {
117
+ if (typeof value === 'number')
118
+ return EVALUATION_STATUS[value] ?? `unknown (${value})`;
119
+ if (typeof value === 'string' && value)
120
+ return clip(value.replace(/^EVALUATION_STATUS_/, '').toLowerCase(), 40);
121
+ return undefined;
122
+ }
123
+
124
+ function collectionState(value: unknown): string | undefined {
125
+ return typeof value === 'number'
126
+ ? (COLLECTION_STATE[value] ?? `unknown (${value})`)
127
+ : undefined;
128
+ }
129
+
130
+ const shortType = (typeUrl: string) =>
131
+ clip(typeUrl.slice(typeUrl.lastIndexOf('.') + 1) || typeUrl, 80);
132
+
133
+ function excerpt(value: unknown): string {
134
+ return clip(JSON.stringify(value) ?? '', EXCERPT_CHARS);
135
+ }
136
+
137
+ /**
138
+ * A message as the model reads it. An authz `MsgExec` is stored as the
139
+ * array of its inner `{ typeUrl, value }` messages; its first five are
140
+ * summarised (one level deep).
141
+ */
142
+ export function summarizeMessage(
143
+ typeUrl: string,
144
+ value: unknown,
145
+ depth = 0,
146
+ ): MessageSummary {
147
+ const type = shortType(typeUrl);
148
+ if (typeUrl === '/cosmos.authz.v1beta1.MsgExec' && Array.isArray(value)) {
149
+ const inner = value.filter(isRecord);
150
+ return {
151
+ type: 'MsgExec',
152
+ count: inner.length,
153
+ messages: inner
154
+ .slice(0, EXEC_CAP)
155
+ .map((message) =>
156
+ depth === 0 && typeof message.typeUrl === 'string'
157
+ ? summarizeMessage(message.typeUrl, message.value, depth + 1)
158
+ : { type: 'message', excerpt: excerpt(message) },
159
+ ),
160
+ };
161
+ }
162
+ if (!isRecord(value)) return { type, excerpt: excerpt(value) };
163
+ switch (typeUrl) {
164
+ case '/cosmos.bank.v1beta1.MsgSend': {
165
+ const summary: MessageSummary = {
166
+ type: 'MsgSend',
167
+ from: field(value, 'fromAddress', 'from_address'),
168
+ to: field(value, 'toAddress', 'to_address'),
169
+ amount: coins(value.amount),
170
+ };
171
+ return compact(summary);
172
+ }
173
+ case '/ixo.token.v1beta1.MsgTransferToken': {
174
+ const summary: MessageSummary = {
175
+ type: 'MsgTransferToken',
176
+ from: field(value, 'owner'),
177
+ to: field(value, 'recipient'),
178
+ tokens: Array.isArray(value.tokens)
179
+ ? value.tokens
180
+ .filter(isRecord)
181
+ .slice(0, COIN_CAP)
182
+ .flatMap((token) => {
183
+ const id = text(token.id, ID_CHARS);
184
+ const amount = amountText(token.amount);
185
+ return id && amount ? [{ id, amount }] : [];
186
+ })
187
+ : [],
188
+ };
189
+ return compact(summary);
190
+ }
191
+ case '/ixo.claims.v1beta1.MsgSubmitClaim':
192
+ case '/ixo.claims.v1beta1.MsgEvaluateClaim': {
193
+ const summary: MessageSummary = {
194
+ type:
195
+ typeUrl === '/ixo.claims.v1beta1.MsgSubmitClaim'
196
+ ? 'MsgSubmitClaim'
197
+ : 'MsgEvaluateClaim',
198
+ claimId: field(value, 'claimId', 'claim_id'),
199
+ collectionId: field(value, 'collectionId', 'collection_id'),
200
+ status:
201
+ typeUrl === '/ixo.claims.v1beta1.MsgEvaluateClaim'
202
+ ? evaluationStatus(value.status)
203
+ : undefined,
204
+ };
205
+ return compact(summary);
206
+ }
207
+ default:
208
+ return { type, excerpt: excerpt(value) };
209
+ }
210
+ }
211
+
212
+ /* ── per-kind shaping ──────────────────────────────────────────────────── */
213
+
214
+ /**
215
+ * A request's answer. `ownedBy` (by-id kinds) lists the accounts whose
216
+ * record it is — a claim's agent, a collection's admin, an entity's owner,
217
+ * a transaction's signers — so the caller can say whether it is the user's.
218
+ */
219
+ export type Shaped =
220
+ | { ok: true; data: unknown; ownedBy?: string[] }
221
+ | { ok: false; failure: Failure };
222
+
223
+ const unexpected: Failure = {
224
+ reason: 'unavailable',
225
+ detail: 'Blocksync answered this request in an unexpected shape.',
226
+ retryable: false,
227
+ };
228
+ const notFound = (what: string): Failure => ({
229
+ reason: 'not_found',
230
+ detail: `No ${what} with that id is indexed.`,
231
+ retryable: false,
232
+ });
233
+
234
+ const str = z.string();
235
+ const opt = z.string().nullish();
236
+ const num = z.union([z.string(), z.number()]).nullish();
237
+
238
+ const evaluationNode = z.looseObject({
239
+ status: z.number().nullish(),
240
+ evaluationDate: opt,
241
+ oracle: opt,
242
+ agentDid: opt,
243
+ agentAddress: opt,
244
+ reason: num,
245
+ amount: z.unknown().optional(),
246
+ });
247
+ const claimNode = z.looseObject({
248
+ claimId: str,
249
+ collectionId: str,
250
+ agentDid: opt,
251
+ agentAddress: opt,
252
+ submissionDate: opt,
253
+ paymentsStatus: z.unknown().optional(),
254
+ schemaType: opt,
255
+ useIntent: z.boolean().nullish(),
256
+ amount: z.unknown().optional(),
257
+ currentEvaluationId: num,
258
+ evaluation: evaluationNode.nullish(),
259
+ });
260
+ const collectionNode = z.looseObject({
261
+ id: str,
262
+ entity: opt,
263
+ admin: opt,
264
+ protocol: opt,
265
+ startDate: opt,
266
+ endDate: opt,
267
+ quota: num,
268
+ count: num,
269
+ evaluated: num,
270
+ approved: num,
271
+ rejected: num,
272
+ disputed: num,
273
+ state: z.number().nullish(),
274
+ payments: z.unknown().optional(),
275
+ });
276
+ const messageNode = z.looseObject({
277
+ typeUrl: str,
278
+ index: z.number().nullish(),
279
+ value: z.unknown().optional(),
280
+ });
281
+ const transactionNode = z.looseObject({
282
+ hash: str,
283
+ height: z.number(),
284
+ time: opt,
285
+ code: z.number(),
286
+ memo: opt,
287
+ fee: z.unknown().optional(),
288
+ transactionSignersByTransactionId: z
289
+ .object({ nodes: z.array(z.object({ signerAddress: str })) })
290
+ .nullish(),
291
+ messagesByTransactionHash: z
292
+ .object({ totalCount: z.number(), nodes: z.array(messageNode) })
293
+ .nullish(),
294
+ });
295
+ const connection = <T extends z.ZodType>(node: T) =>
296
+ z.looseObject({
297
+ totalCount: z.number().optional(),
298
+ pageInfo: z
299
+ .object({ hasNextPage: z.boolean(), endCursor: z.string().nullish() })
300
+ .optional(),
301
+ nodes: z.array(node),
302
+ });
303
+
304
+ const nonEmpty = <T>(list: T[]): T[] | undefined =>
305
+ list.length ? list : undefined;
306
+
307
+ function stringOf(value: string | number | null | undefined) {
308
+ return value === null || value === undefined ? undefined : String(value);
309
+ }
310
+
311
+ /** `paymentsStatus`: leg → status text (`PAID`, `NO_PAYMENT` …). */
312
+ function paymentLegs(value: unknown): Record<string, string> | undefined {
313
+ if (!isRecord(value)) return undefined;
314
+ return Object.fromEntries(
315
+ Object.entries(value)
316
+ .slice(0, 8)
317
+ .flatMap(([leg, status]) =>
318
+ typeof status === 'string' ? [[leg, clip(status, 40)]] : [],
319
+ ),
320
+ );
321
+ }
322
+
323
+ /** An id, address or date from the index, clipped to {@link ID_CHARS}. */
324
+ const idText = (value: unknown) => text(value, ID_CHARS);
325
+
326
+ function evaluationOf(node: z.infer<typeof evaluationNode>) {
327
+ return compact({
328
+ status: evaluationStatus(node.status),
329
+ evaluationDate: idText(node.evaluationDate),
330
+ oracle: idText(node.oracle),
331
+ agentDid: idText(node.agentDid),
332
+ agentAddress: idText(node.agentAddress),
333
+ reason: idText(stringOf(node.reason)),
334
+ amount: nonEmpty(coins(node.amount)),
335
+ });
336
+ }
337
+
338
+ function claimOf(node: z.infer<typeof claimNode>) {
339
+ return compact({
340
+ claimId: idText(node.claimId),
341
+ collectionId: idText(node.collectionId),
342
+ agentDid: idText(node.agentDid),
343
+ agentAddress: idText(node.agentAddress),
344
+ submissionDate: idText(node.submissionDate),
345
+ paymentsStatus: paymentLegs(node.paymentsStatus),
346
+ schemaType: text(node.schemaType),
347
+ useIntent: node.useIntent ?? undefined,
348
+ amount: nonEmpty(coins(node.amount)),
349
+ currentEvaluationId: idText(stringOf(node.currentEvaluationId)),
350
+ evaluation: node.evaluation ? evaluationOf(node.evaluation) : undefined,
351
+ });
352
+ }
353
+
354
+ function collectionOf(node: z.infer<typeof collectionNode>) {
355
+ return compact({
356
+ id: idText(node.id),
357
+ entity: idText(node.entity),
358
+ admin: idText(node.admin),
359
+ protocol: idText(node.protocol),
360
+ startDate: idText(node.startDate),
361
+ endDate: idText(node.endDate),
362
+ quota: idText(stringOf(node.quota)),
363
+ count: idText(stringOf(node.count)),
364
+ evaluated: idText(stringOf(node.evaluated)),
365
+ approved: idText(stringOf(node.approved)),
366
+ rejected: idText(stringOf(node.rejected)),
367
+ disputed: idText(stringOf(node.disputed)),
368
+ state: collectionState(node.state),
369
+ });
370
+ }
371
+
372
+ /** A list of `{ address, amount }` entries (cw20 base units, or 1155 whole units with their token ids). */
373
+ function contractPayments(value: unknown, withTokens: boolean) {
374
+ return nonEmpty(
375
+ listOf(value, (entry) =>
376
+ isRecord(entry)
377
+ ? compact({
378
+ address: idText(entry.address),
379
+ ...(withTokens
380
+ ? {
381
+ tokenIds: nonEmpty(
382
+ listOf(entry.token_id ?? entry.tokenId, (t) => idText(t)),
383
+ ),
384
+ amount: amountText(entry.amount),
385
+ }
386
+ : { amountBaseUnits: amountText(entry.amount) }),
387
+ })
388
+ : undefined,
389
+ ).slice(0, COIN_CAP),
390
+ );
391
+ }
392
+
393
+ /**
394
+ * A collection's `payments` (x/claims `Payments`, as the indexer stores it):
395
+ * per leg (submission, evaluation, approval, rejection) the paying account,
396
+ * bank coins and cw20 amounts in base units, cw1155 amounts in whole units,
397
+ * and whether it is an oracle payment. Both snake_case and camelCase keys
398
+ * are read.
399
+ */
400
+ function paymentsOf(value: unknown) {
401
+ if (!isRecord(value)) return undefined;
402
+ return Object.fromEntries(
403
+ Object.entries(value)
404
+ .slice(0, 8)
405
+ .flatMap(([leg, payment]) => {
406
+ if (!isRecord(payment)) return [];
407
+ const oracle = payment.is_oracle_payment ?? payment.isOraclePayment;
408
+ return [
409
+ [
410
+ clip(leg, 40),
411
+ compact({
412
+ account: idText(payment.account),
413
+ amount: nonEmpty(coins(payment.amount)),
414
+ cw20: contractPayments(
415
+ payment.cw20_payment ?? payment.cw20Payment,
416
+ false,
417
+ ),
418
+ cw1155: contractPayments(
419
+ payment.cw1155_payment ?? payment.cw1155Payment,
420
+ true,
421
+ ),
422
+ oraclePayment: oracle === true ? true : undefined,
423
+ }),
424
+ ],
425
+ ];
426
+ }),
427
+ );
428
+ }
429
+
430
+ /** A linked resource or service: what it is and where, never its body. */
431
+ function resourceOf(value: unknown) {
432
+ if (!isRecord(value)) return undefined;
433
+ return compact({
434
+ id: field(value, 'id'),
435
+ type: field(value, 'type'),
436
+ description: field(value, 'description'),
437
+ serviceEndpoint: field(value, 'serviceEndpoint'),
438
+ mediaType: field(value, 'mediaType'),
439
+ });
440
+ }
441
+
442
+ function listOf<T>(value: unknown, map: (item: unknown) => T | undefined): T[] {
443
+ if (!Array.isArray(value)) return [];
444
+ const out: T[] = [];
445
+ for (const item of value) {
446
+ if (out.length >= LIST_CAP) break;
447
+ const mapped = map(item);
448
+ if (mapped !== undefined) out.push(mapped);
449
+ }
450
+ return out;
451
+ }
452
+
453
+ function transactionOf(
454
+ node: z.infer<typeof transactionNode>,
455
+ messages: z.infer<typeof messageNode>[],
456
+ messageCount?: number,
457
+ ) {
458
+ return compact({
459
+ hash: idText(node.hash),
460
+ height: node.height,
461
+ time: idText(node.time),
462
+ code: node.code,
463
+ succeeded: node.code === 0,
464
+ memo: text(node.memo, MEMO_CHARS),
465
+ messages: messages.map((m) => summarizeMessage(m.typeUrl, m.value)),
466
+ messageCount:
467
+ messageCount !== undefined && messageCount > messages.length
468
+ ? messageCount
469
+ : undefined,
470
+ });
471
+ }
472
+
473
+ /** The accounts named, without empty values. */
474
+ const accounts = (...values: (string | null | undefined)[]): string[] =>
475
+ values.flatMap((value) => (value ? [value] : []));
476
+
477
+ /** The alias's data as the request kind's answer, or why it has none. */
478
+ export function shapeResult(request: ChainRequest, raw: unknown): Shaped {
479
+ switch (request.kind) {
480
+ case 'claims': {
481
+ const parsed = connection(claimNode).safeParse(raw);
482
+ if (!parsed.success) return { ok: false, failure: unexpected };
483
+ const { totalCount, pageInfo, nodes } = parsed.data;
484
+ return {
485
+ ok: true,
486
+ data: compact({
487
+ totalCount,
488
+ hasNextPage: pageInfo?.hasNextPage,
489
+ endCursor: pageInfo?.hasNextPage
490
+ ? (pageInfo.endCursor ?? undefined)
491
+ : undefined,
492
+ items: nodes.map(claimOf),
493
+ }),
494
+ };
495
+ }
496
+ case 'claim': {
497
+ if (raw === null) return { ok: false, failure: notFound('claim') };
498
+ const parsed = claimNode.safeParse(raw);
499
+ return parsed.success
500
+ ? {
501
+ ok: true,
502
+ data: claimOf(parsed.data),
503
+ ownedBy: accounts(parsed.data.agentAddress),
504
+ }
505
+ : { ok: false, failure: unexpected };
506
+ }
507
+ case 'claim_collection': {
508
+ if (raw === null)
509
+ return { ok: false, failure: notFound('claim collection') };
510
+ const parsed = collectionNode.safeParse(raw);
511
+ return parsed.success
512
+ ? {
513
+ ok: true,
514
+ data: compact({
515
+ ...collectionOf(parsed.data),
516
+ payments: paymentsOf(parsed.data.payments),
517
+ }),
518
+ ownedBy: accounts(parsed.data.admin),
519
+ }
520
+ : { ok: false, failure: unexpected };
521
+ }
522
+ case 'claim_collections': {
523
+ const parsed = connection(collectionNode).safeParse(raw);
524
+ if (!parsed.success) return { ok: false, failure: unexpected };
525
+ return {
526
+ ok: true,
527
+ data: compact({
528
+ totalCount: parsed.data.totalCount,
529
+ items: parsed.data.nodes.map(collectionOf),
530
+ }),
531
+ };
532
+ }
533
+ case 'entities': {
534
+ const parsed = connection(
535
+ z.looseObject({
536
+ id: str,
537
+ type: opt,
538
+ status: z.number().nullish(),
539
+ owner: opt,
540
+ metadata: z.unknown().optional(),
541
+ }),
542
+ ).safeParse(raw);
543
+ if (!parsed.success) return { ok: false, failure: unexpected };
544
+ const items = parsed.data.nodes.map((node) =>
545
+ compact({
546
+ id: idText(node.id),
547
+ type: text(node.type),
548
+ status: node.status ?? undefined,
549
+ owner: idText(node.owner),
550
+ created: isRecord(node.metadata)
551
+ ? text(node.metadata.created, 40)
552
+ : undefined,
553
+ }),
554
+ );
555
+ return {
556
+ ok: true,
557
+ data: compact({
558
+ totalCount: parsed.data.totalCount,
559
+ orderedBy: ENTITIES_ORDER,
560
+ statusNote: items.some((item) => item.status !== undefined)
561
+ ? ENTITY_STATUS_NOTE
562
+ : undefined,
563
+ items,
564
+ }),
565
+ };
566
+ }
567
+ case 'entity': {
568
+ if (raw === null) return { ok: false, failure: notFound('entity') };
569
+ const parsed = z
570
+ .looseObject({
571
+ id: str,
572
+ type: opt,
573
+ status: z.number().nullish(),
574
+ owner: opt,
575
+ accounts: z.unknown().optional(),
576
+ settings: z.unknown().optional(),
577
+ linkedResource: z.unknown().optional(),
578
+ linkedEntity: z.unknown().optional(),
579
+ controller: z.unknown().optional(),
580
+ service: z.unknown().optional(),
581
+ })
582
+ .safeParse(raw);
583
+ if (!parsed.success) return { ok: false, failure: unexpected };
584
+ const node = parsed.data;
585
+ return {
586
+ ok: true,
587
+ ownedBy: accounts(node.owner),
588
+ data: compact({
589
+ id: idText(node.id),
590
+ type: text(node.type),
591
+ status: node.status ?? undefined,
592
+ statusNote:
593
+ node.status === null || node.status === undefined
594
+ ? undefined
595
+ : ENTITY_STATUS_NOTE,
596
+ owner: idText(node.owner),
597
+ controller: listOf(node.controller, (c) => text(c)),
598
+ accounts: listOf(node.accounts, (a) =>
599
+ isRecord(a)
600
+ ? compact({
601
+ name: field(a, 'name'),
602
+ address: field(a, 'address'),
603
+ })
604
+ : undefined,
605
+ ),
606
+ linkedEntity: listOf(node.linkedEntity, (e) =>
607
+ isRecord(e)
608
+ ? compact({
609
+ id: field(e, 'id'),
610
+ type: field(e, 'type'),
611
+ relationship: field(e, 'relationship'),
612
+ })
613
+ : undefined,
614
+ ),
615
+ linkedResource: listOf(node.linkedResource, resourceOf),
616
+ service: listOf(node.service, resourceOf),
617
+ settings: isRecord(node.settings)
618
+ ? Object.entries(node.settings)
619
+ .slice(0, LIST_CAP)
620
+ .map(([key, value]) => ({
621
+ key: clip(key, 80),
622
+ ...resourceOf(value),
623
+ }))
624
+ : undefined,
625
+ }),
626
+ };
627
+ }
628
+ case 'did_document': {
629
+ if (raw === null) return { ok: false, failure: notFound('DID document') };
630
+ const parsed = z
631
+ .looseObject({
632
+ id: str,
633
+ controller: z.unknown().optional(),
634
+ verificationMethod: z.unknown().optional(),
635
+ linkedResource: z.unknown().optional(),
636
+ alsoKnownAs: opt,
637
+ })
638
+ .safeParse(raw);
639
+ if (!parsed.success) return { ok: false, failure: unexpected };
640
+ const node = parsed.data;
641
+ return {
642
+ ok: true,
643
+ data: compact({
644
+ id: idText(node.id),
645
+ controller: listOf(node.controller, (c) => text(c)),
646
+ verificationMethod: listOf(node.verificationMethod, (m) =>
647
+ isRecord(m)
648
+ ? compact({
649
+ id: field(m, 'id'),
650
+ type: field(m, 'type'),
651
+ controller: field(m, 'controller'),
652
+ blockchainAccountID: field(m, 'blockchainAccountID'),
653
+ publicKeyMultibase: field(m, 'publicKeyMultibase'),
654
+ publicKeyBase58: field(m, 'publicKeyBase58'),
655
+ })
656
+ : undefined,
657
+ ),
658
+ linkedResource: listOf(node.linkedResource, resourceOf),
659
+ alsoKnownAs: text(node.alsoKnownAs),
660
+ }),
661
+ };
662
+ }
663
+ case 'token_balances': {
664
+ const parsed = connection(
665
+ z.looseObject({
666
+ tokenId: str,
667
+ amount: z.union([z.string(), z.number()]),
668
+ retired: num,
669
+ token: z
670
+ .looseObject({
671
+ name: opt,
672
+ collection: opt,
673
+ tokenClassByName: z
674
+ .looseObject({ contractAddress: opt, class: opt })
675
+ .nullish(),
676
+ })
677
+ .nullish(),
678
+ }),
679
+ ).safeParse(raw);
680
+ if (!parsed.success) return { ok: false, failure: unexpected };
681
+ return {
682
+ ok: true,
683
+ data: compact({
684
+ totalCount: parsed.data.totalCount,
685
+ note: TOKEN_DECIMALS_NOTE,
686
+ items: parsed.data.nodes.map((node) =>
687
+ compact({
688
+ tokenId: idText(node.tokenId),
689
+ name: text(node.token?.name),
690
+ class: idText(node.token?.tokenClassByName?.class),
691
+ collection: idText(node.token?.collection),
692
+ amount: amountText(node.amount),
693
+ retired: idText(stringOf(node.retired)),
694
+ contractAddress: idText(
695
+ node.token?.tokenClassByName?.contractAddress,
696
+ ),
697
+ }),
698
+ ),
699
+ }),
700
+ };
701
+ }
702
+ case 'transactions': {
703
+ if (request.direction === 'received') {
704
+ const parsed = z
705
+ .looseObject({
706
+ totalCount: z.number().optional(),
707
+ nodes: z.array(
708
+ messageNode.extend({
709
+ transactionHash: str,
710
+ transactionByTransactionHash: transactionNode.nullish(),
711
+ }),
712
+ ),
713
+ })
714
+ .safeParse(raw);
715
+ if (!parsed.success) return { ok: false, failure: unexpected };
716
+ // One row per transaction, holding the messages that matched.
717
+ const rows = new Map<
718
+ string,
719
+ {
720
+ node: z.infer<typeof transactionNode>;
721
+ messages: z.infer<typeof messageNode>[];
722
+ }
723
+ >();
724
+ for (const message of parsed.data.nodes) {
725
+ const tx = message.transactionByTransactionHash;
726
+ if (!tx) continue;
727
+ const row = rows.get(message.transactionHash);
728
+ if (row) row.messages.push(message);
729
+ else
730
+ rows.set(message.transactionHash, {
731
+ node: tx,
732
+ messages: [message],
733
+ });
734
+ }
735
+ return {
736
+ ok: true,
737
+ data: compact({
738
+ totalCount: parsed.data.totalCount,
739
+ items: [...rows.values()].map((row) =>
740
+ transactionOf(row.node, row.messages),
741
+ ),
742
+ }),
743
+ };
744
+ }
745
+ const parsed = connection(
746
+ z.looseObject({
747
+ transactionHash: str,
748
+ transaction: transactionNode.nullish(),
749
+ }),
750
+ ).safeParse(raw);
751
+ if (!parsed.success) return { ok: false, failure: unexpected };
752
+ // A transaction can list the same signer twice (a signature and a
753
+ // fee grant): one row per transaction.
754
+ const seen = new Set<string>();
755
+ const items = parsed.data.nodes.flatMap((node) => {
756
+ const tx = node.transaction;
757
+ if (!tx || seen.has(tx.hash)) return [];
758
+ seen.add(tx.hash);
759
+ const messages = tx.messagesByTransactionHash;
760
+ return [transactionOf(tx, messages?.nodes ?? [], messages?.totalCount)];
761
+ });
762
+ return {
763
+ ok: true,
764
+ data: compact({ totalCount: parsed.data.totalCount, items }),
765
+ };
766
+ }
767
+ case 'transaction': {
768
+ const parsed = z
769
+ .looseObject({ nodes: z.array(transactionNode) })
770
+ .safeParse(raw);
771
+ if (!parsed.success) return { ok: false, failure: unexpected };
772
+ const tx = parsed.data.nodes[0];
773
+ if (!tx) return { ok: false, failure: notFound('transaction') };
774
+ const messages = tx.messagesByTransactionHash;
775
+ // A signer listed twice (a signature and a fee grant) is named once.
776
+ const signers = [
777
+ ...new Set(
778
+ (tx.transactionSignersByTransactionId?.nodes ?? []).map(
779
+ (signer) => signer.signerAddress,
780
+ ),
781
+ ),
782
+ ];
783
+ return {
784
+ ok: true,
785
+ ownedBy: signers,
786
+ data: compact({
787
+ ...transactionOf(tx, messages?.nodes ?? [], messages?.totalCount),
788
+ signers: nonEmpty(
789
+ signers.flatMap((signer) => {
790
+ const shown = idText(signer);
791
+ return shown ? [shown] : [];
792
+ }),
793
+ ),
794
+ fee: isRecord(tx.fee) ? nonEmpty(coins(tx.fee.amount)) : undefined,
795
+ }),
796
+ };
797
+ }
798
+ }
799
+ }