@ixo/oracle-runtime-workers 0.26.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.
Files changed (36) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/channels/auth.ts +2 -1
  4. package/src/channels/contract.ts +104 -0
  5. package/src/channels/replies.ts +58 -0
  6. package/src/channels/routing.ts +222 -0
  7. package/src/channels/turns.ts +1252 -16
  8. package/src/core/domain-context/turn-context.ts +1 -1
  9. package/src/core/env.ts +22 -4
  10. package/src/core/main-agent.ts +31 -5
  11. package/src/core/prompt-composer.ts +14 -0
  12. package/src/core/sub-agent-fallback.ts +3 -2
  13. package/src/core/user-identity.ts +67 -0
  14. package/src/do/contracts.ts +19 -0
  15. package/src/do/room-mirror.ts +8 -1
  16. package/src/do/run-coordinator.ts +27 -0
  17. package/src/do/transcript.ts +14 -11
  18. package/src/do/user-oracle-do.ts +269 -63
  19. package/src/memory/session-history-indexer.ts +442 -103
  20. package/src/memory/user-context-fetcher.ts +13 -0
  21. package/src/memory/user-context-invalidation.ts +2 -2
  22. package/src/plugins/action-proposals/evidence.ts +7 -8
  23. package/src/plugins/hub-actions/hub-actions.plugin.ts +6 -2
  24. package/src/plugins/index.ts +6 -2
  25. package/src/plugins/ixo-chain/blocksync.ts +214 -0
  26. package/src/plugins/ixo-chain/denoms.ts +189 -0
  27. package/src/plugins/ixo-chain/http.ts +388 -0
  28. package/src/plugins/ixo-chain/index.ts +1 -0
  29. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +543 -0
  30. package/src/plugins/ixo-chain/queries.ts +401 -0
  31. package/src/plugins/ixo-chain/rest.ts +79 -0
  32. package/src/plugins/ixo-chain/summaries.ts +799 -0
  33. package/src/plugins/tasks/middleware.ts +4 -9
  34. package/src/shell/app.ts +70 -0
  35. package/src/sqlite/serialization.ts +20 -0
  36. package/src/sqlite/sessions-store.ts +43 -0
@@ -0,0 +1,401 @@
1
+ /**
2
+ * The `chain_lookup` requests and the one Blocksync document they become.
3
+ *
4
+ * Every field name, argument and enum below is checked against the
5
+ * ixo-blocksync-api schema (`public/graphql/schema.graphql`; see
6
+ * docs/chain-tools.md for the commit). Ids are checked against their kind's
7
+ * pattern and then written into the document as GraphQL literals, never
8
+ * passed as variables. Every filter is on an indexed column; none is a
9
+ * `Message.value` containment: on an account address those run into
10
+ * Blocksync's 8 s statement timeout on mainnet even beside the `typeUrl`
11
+ * clause that makes them an index lookup. Should one ever be added, it must
12
+ * carry `typeUrl: { notEqualTo: "/ibc.core.client.v1.MsgUpdateClient" }`
13
+ * beside it (the GIN index is partial).
14
+ */
15
+ import { IxoAddressSchema } from '@ixo/ixo-transaction';
16
+ import { z } from 'zod';
17
+ import { ixoAddressOf, type UserIdentity } from '../../core/user-identity';
18
+ import type { AliasedField } from './blocksync';
19
+
20
+ const MAX_REQUESTS = 8;
21
+ const MAX_FIRST = 20;
22
+ const DEFAULT_FIRST = 10;
23
+ /**
24
+ * Rows a document of several requests reads in all: each list request is
25
+ * capped at `max(5, floor(40 / requests))`.
26
+ */
27
+ const SHARED_ROWS = 40;
28
+ const MIN_SHARED_FIRST = 5;
29
+ /** Messages read per transaction in a list (the count of all is kept), and for one transaction. */
30
+ const LIST_TX_MESSAGES = 5;
31
+ const ONE_TX_MESSAGES = 50;
32
+ /** Signers read for one transaction. */
33
+ const TX_SIGNERS = 10;
34
+
35
+ const ENTITY_DID = /^did:ixo:entity:[0-9a-f]{32}$/;
36
+ const IXO_DID = /^did:ixo:[A-Za-z0-9:._-]{1,120}$/;
37
+ const CLAIM_ID = /^[a-z0-9]{20,100}$/i;
38
+ const COLLECTION_ID = /^\d{1,12}$/;
39
+ const TX_HASH = /^[A-F0-9]{64}$/i;
40
+ const CURSOR = /^[A-Za-z0-9+/=_-]{1,200}$/;
41
+ const ENTITY_TYPE = /^[A-Za-z0-9/_.:-]{1,64}$/;
42
+
43
+ // The account-field format and `first`'s meaning are stated once, in the
44
+ // tool description; each field here only names its role. Lengths are not
45
+ // capped here: every string is checked against its kind's bounded pattern
46
+ // before it reaches the document.
47
+ const first = z.number().int().min(1).max(MAX_FIRST).optional();
48
+ const account = (role: string) => z.string().optional().describe(role);
49
+
50
+ export const chainRequestSchema = z.discriminatedUnion('kind', [
51
+ z.strictObject({
52
+ kind: z.literal('claims'),
53
+ agent: account('Claim submitter.'),
54
+ collectionId: z.string().optional(),
55
+ first,
56
+ after: z.string().optional().describe('Previous page endCursor.'),
57
+ }),
58
+ z.strictObject({
59
+ kind: z.literal('claim'),
60
+ claimId: z.string().describe('The claim id (a CID, e.g. bafkrei…).'),
61
+ }),
62
+ z.strictObject({
63
+ kind: z.literal('claim_collection'),
64
+ id: z.string().describe('Collection id, e.g. "8250".'),
65
+ }),
66
+ z.strictObject({
67
+ kind: z.literal('claim_collections'),
68
+ entity: z.string().optional().describe('Its did:ixo:entity:… DID.'),
69
+ admin: account('Collection admin.'),
70
+ first,
71
+ }),
72
+ z.strictObject({
73
+ kind: z.literal('entities'),
74
+ owner: account('Entity owner.'),
75
+ type: z.string().optional().describe('e.g. "dao".'),
76
+ first,
77
+ }),
78
+ z.strictObject({
79
+ kind: z.literal('entity'),
80
+ id: z.string().describe('did:ixo:entity:…'),
81
+ }),
82
+ z.strictObject({
83
+ kind: z.literal('did_document'),
84
+ id: z
85
+ .string()
86
+ .optional()
87
+ .describe("Any did:ixo:… DID; default: the user's."),
88
+ }),
89
+ z.strictObject({
90
+ kind: z.literal('token_balances'),
91
+ address: account('Token holder.'),
92
+ first,
93
+ }),
94
+ z.strictObject({
95
+ kind: z.literal('transactions'),
96
+ address: account('Signer or recipient.'),
97
+ direction: z
98
+ .enum(['signed', 'received'])
99
+ .optional()
100
+ .describe('Default: signed.'),
101
+ first,
102
+ }),
103
+ z.strictObject({
104
+ kind: z.literal('transaction'),
105
+ hash: z.string().describe('64-hex transaction hash.'),
106
+ }),
107
+ ]);
108
+
109
+ export type ChainRequest = z.infer<typeof chainRequestSchema>;
110
+ export type ChainRequestKind = ChainRequest['kind'];
111
+ export type Subject = 'user' | 'other';
112
+
113
+ export const chainLookupSchema = z.strictObject({
114
+ requests: z.array(chainRequestSchema).min(1).max(MAX_REQUESTS),
115
+ });
116
+
117
+ /**
118
+ * One request as a document field, or why it cannot be asked. `subject` is
119
+ * known before the read for a request by account; `first` is the rows a
120
+ * list request reads.
121
+ */
122
+ export type FieldPlan =
123
+ | ({
124
+ ok: true;
125
+ request: ChainRequest;
126
+ kind: ChainRequestKind;
127
+ subject?: Subject;
128
+ first?: number;
129
+ } & AliasedField)
130
+ | {
131
+ ok: false;
132
+ request: ChainRequest;
133
+ kind: ChainRequestKind;
134
+ detail: string;
135
+ };
136
+
137
+ type Json = string | number | boolean | null | Json[] | { [key: string]: Json };
138
+
139
+ /** A JSON value as a GraphQL input literal: keys bare, strings quoted as JSON quotes them. */
140
+ export function literal(value: Json): string {
141
+ if (Array.isArray(value)) return `[${value.map(literal).join(', ')}]`;
142
+ if (value && typeof value === 'object')
143
+ return `{ ${Object.entries(value)
144
+ .map(([key, inner]) => {
145
+ if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(key))
146
+ throw new Error(`not a GraphQL name: ${key}`);
147
+ return `${key}: ${literal(inner)}`;
148
+ })
149
+ .join(', ')} }`;
150
+ return JSON.stringify(value);
151
+ }
152
+
153
+ /**
154
+ * The rows a list request reads: its `first` (1–20 by the schema, default
155
+ * 10), capped at `max(5, floor(40 / count))` when the document holds
156
+ * `count > 1` requests.
157
+ */
158
+ export function firstOf(requested: number | undefined, count: number): number {
159
+ const cap =
160
+ count > 1
161
+ ? Math.max(MIN_SHARED_FIRST, Math.floor(SHARED_ROWS / count))
162
+ : MAX_FIRST;
163
+ return Math.min(requested ?? DEFAULT_FIRST, cap, MAX_FIRST);
164
+ }
165
+
166
+ /**
167
+ * The account an account field names: the user's own address when the
168
+ * field is omitted, else an `ixo1…` address or the address a
169
+ * `did:ixo:ixo1…` DID names.
170
+ */
171
+ export function accountOf(
172
+ input: string | undefined,
173
+ identity: UserIdentity,
174
+ role: string,
175
+ ):
176
+ | { ok: true; address: string; subject: Subject }
177
+ | { ok: false; detail: string } {
178
+ if (input === undefined)
179
+ return identity.address
180
+ ? { ok: true, address: identity.address, subject: 'user' }
181
+ : { ok: false, detail: 'The user has no IXO account address; give one.' };
182
+ const address = IxoAddressSchema.safeParse(input).success
183
+ ? input
184
+ : ixoAddressOf(input);
185
+ if (!address)
186
+ return {
187
+ ok: false,
188
+ detail: `${role} must be an ixo1… address or a did:ixo:ixo1… DID.`,
189
+ };
190
+ return {
191
+ ok: true,
192
+ address,
193
+ subject: address === identity.address ? 'user' : 'other',
194
+ };
195
+ }
196
+
197
+ const CLAIM_FIELDS =
198
+ 'claimId collectionId agentDid agentAddress submissionDate paymentsStatus schemaType useIntent amount currentEvaluationId';
199
+ const COLLECTION_FIELDS =
200
+ 'id entity admin protocol startDate endDate quota count evaluated approved rejected disputed state';
201
+ const MESSAGE_FIELDS = 'typeUrl index value';
202
+ const TRANSACTION_FIELDS = 'hash height time code memo';
203
+
204
+ const messagesOf = (n: number) =>
205
+ `messagesByTransactionHash(first: ${n}, orderBy: INDEX_ASC) { totalCount nodes { ${MESSAGE_FIELDS} } }`;
206
+
207
+ /** `filter: { a: { equalTo: x }, … }` for the given scalar equalities. */
208
+ function equalities(pairs: [string, string | undefined][]): string {
209
+ return literal(
210
+ Object.fromEntries(
211
+ pairs.flatMap(([key, value]) =>
212
+ value === undefined ? [] : [[key, { equalTo: value }]],
213
+ ),
214
+ ),
215
+ );
216
+ }
217
+
218
+ /**
219
+ * One request as an aliased field of the lookup document; `count` is the
220
+ * number of requests the document holds.
221
+ */
222
+ export function requestField(
223
+ alias: string,
224
+ request: ChainRequest,
225
+ identity: UserIdentity,
226
+ count = 1,
227
+ ): FieldPlan {
228
+ const plan = fieldOf(alias, request, identity, count);
229
+ return 'detail' in plan
230
+ ? { ok: false, request, kind: request.kind, detail: plan.detail }
231
+ : { ok: true, request, kind: request.kind, alias, ...plan };
232
+ }
233
+
234
+ const invalid = (detail: string) => ({ detail });
235
+
236
+ function fieldOf(
237
+ alias: string,
238
+ request: ChainRequest,
239
+ identity: UserIdentity,
240
+ count: number,
241
+ ): { field: string; subject?: Subject; first?: number } | { detail: string } {
242
+ switch (request.kind) {
243
+ case 'claims': {
244
+ const agent = accountOf(request.agent, identity, 'agent');
245
+ if (!agent.ok) return invalid(agent.detail);
246
+ if (
247
+ request.collectionId !== undefined &&
248
+ !COLLECTION_ID.test(request.collectionId)
249
+ )
250
+ return invalid('collectionId must be a number such as "8250".');
251
+ if (request.after !== undefined && !CURSOR.test(request.after))
252
+ return invalid('after must be an endCursor from an earlier page.');
253
+ // Claim."agentAddress" is indexed and "agentDid" is not: a did:ixo
254
+ // account's claims are read by the address its DID names.
255
+ const filter = equalities([
256
+ ['agentAddress', agent.address],
257
+ ['collectionId', request.collectionId],
258
+ ]);
259
+ const after = request.after ? `, after: ${literal(request.after)}` : '';
260
+ const n = firstOf(request.first, count);
261
+ return {
262
+ subject: agent.subject,
263
+ first: n,
264
+ field: `${alias}: claims(filter: ${filter}, first: ${n}${after}, orderBy: SUBMISSION_DATE_DESC) { totalCount pageInfo { hasNextPage endCursor } nodes { ${CLAIM_FIELDS} evaluation { status evaluationDate } } }`,
265
+ };
266
+ }
267
+ case 'claim':
268
+ if (!CLAIM_ID.test(request.claimId))
269
+ return invalid(
270
+ 'claimId must be a claim id (a CID such as "bafkrei…").',
271
+ );
272
+ return {
273
+ field: `${alias}: claim(claimId: ${literal(request.claimId)}) { ${CLAIM_FIELDS} evaluation { status evaluationDate oracle agentDid agentAddress reason amount } }`,
274
+ };
275
+ case 'claim_collection':
276
+ if (!COLLECTION_ID.test(request.id))
277
+ return invalid('id must be a collection number such as "8250".');
278
+ return {
279
+ field: `${alias}: claimCollection(id: ${literal(request.id)}) { ${COLLECTION_FIELDS} payments }`,
280
+ };
281
+ case 'claim_collections': {
282
+ if (request.entity !== undefined && !ENTITY_DID.test(request.entity))
283
+ return invalid('entity must be a did:ixo:entity:… DID.');
284
+ // With only an entity named, every collection of that entity;
285
+ // otherwise the admin (the user by default).
286
+ const admin =
287
+ request.entity !== undefined && request.admin === undefined
288
+ ? undefined
289
+ : accountOf(request.admin, identity, 'admin');
290
+ if (admin && !admin.ok) return invalid(admin.detail);
291
+ const filter = equalities([
292
+ ['entity', request.entity],
293
+ ['admin', admin?.address],
294
+ ]);
295
+ const n = firstOf(request.first, count);
296
+ // Newest start date first (the id is text: "9" sorts above "10"). A
297
+ // collection without a start date sorts first, as PostgreSQL puts
298
+ // NULLs first in a descending order; x/claims reads the start date on
299
+ // every claim submission, so a collection that takes claims has one.
300
+ return {
301
+ ...(admin ? { subject: admin.subject } : {}),
302
+ first: n,
303
+ field: `${alias}: claimCollections(filter: ${filter}, first: ${n}, orderBy: START_DATE_DESC) { totalCount nodes { ${COLLECTION_FIELDS} } }`,
304
+ };
305
+ }
306
+ case 'entities': {
307
+ const owner = accountOf(request.owner, identity, 'owner');
308
+ if (!owner.ok) return invalid(owner.detail);
309
+ if (request.type !== undefined && !ENTITY_TYPE.test(request.type))
310
+ return invalid('type must be an entity type such as "dao".');
311
+ const filter = equalities([
312
+ ['owner', owner.address],
313
+ ['type', request.type],
314
+ ]);
315
+ const n = firstOf(request.first, count);
316
+ return {
317
+ subject: owner.subject,
318
+ first: n,
319
+ field: `${alias}: entities(filter: ${filter}, first: ${n}, orderBy: ID_DESC) { totalCount nodes { id type status owner metadata } }`,
320
+ };
321
+ }
322
+ case 'entity':
323
+ if (!ENTITY_DID.test(request.id))
324
+ return invalid('id must be a did:ixo:entity:… DID.');
325
+ return {
326
+ field: `${alias}: entity(id: ${literal(request.id)}) { id type status owner accounts settings linkedResource linkedEntity controller service }`,
327
+ };
328
+ case 'did_document': {
329
+ const id = request.id ?? identity.did;
330
+ if (!IXO_DID.test(id))
331
+ return invalid(
332
+ request.id === undefined
333
+ ? "The user's DID is not a did:ixo DID; give one."
334
+ : 'id must be a did:ixo:… DID.',
335
+ );
336
+ return {
337
+ subject: id === identity.did ? 'user' : 'other',
338
+ field: `${alias}: iid(id: ${literal(id)}) { id controller verificationMethod linkedResource alsoKnownAs }`,
339
+ };
340
+ }
341
+ case 'token_balances': {
342
+ const holder = accountOf(request.address, identity, 'address');
343
+ if (!holder.ok) return invalid(holder.detail);
344
+ const n = firstOf(request.first, count);
345
+ return {
346
+ subject: holder.subject,
347
+ first: n,
348
+ field: `${alias}: tokenBalances(filter: ${equalities([['address', holder.address]])}, first: ${n}, orderBy: AMOUNT_DESC) { totalCount nodes { tokenId amount retired token { name collection tokenClassByName { contractAddress class } } } }`,
349
+ };
350
+ }
351
+ case 'transactions': {
352
+ const holder = accountOf(request.address, identity, 'address');
353
+ if (!holder.ok) return invalid(holder.detail);
354
+ const n = firstOf(request.first, count);
355
+ if (request.direction === 'received')
356
+ // `Message.to` (indexed) is the recipient field of the message, or
357
+ // of its first inner message for an authz MsgExec: toAddress,
358
+ // receiver, recipient, recipientAddress or recipientDid. The same
359
+ // filter the Portal's wallet history polls with.
360
+ return {
361
+ subject: holder.subject,
362
+ first: n,
363
+ field: `${alias}: messages(filter: ${equalities([['to', holder.address]])}, first: ${n}, orderBy: ID_DESC) { totalCount nodes { ${MESSAGE_FIELDS} transactionHash transactionByTransactionHash { ${TRANSACTION_FIELDS} } } }`,
364
+ };
365
+ return {
366
+ subject: holder.subject,
367
+ first: n,
368
+ field: `${alias}: transactionSigners(filter: ${equalities([['signerAddress', holder.address]])}, first: ${n}, orderBy: TRANSACTION_ID_DESC) { totalCount nodes { transactionHash transaction { ${TRANSACTION_FIELDS} ${messagesOf(LIST_TX_MESSAGES)} } } }`,
369
+ };
370
+ }
371
+ case 'transaction':
372
+ if (!TX_HASH.test(request.hash))
373
+ return invalid('hash must be a 64-character hex transaction hash.');
374
+ return {
375
+ field: `${alias}: transactions(filter: ${equalities([['hash', request.hash.toUpperCase()]])}, first: 1) { nodes { ${TRANSACTION_FIELDS} fee transactionSignersByTransactionId(first: ${TX_SIGNERS}) { nodes { signerAddress } } ${messagesOf(ONE_TX_MESSAGES)} } }`,
376
+ };
377
+ }
378
+ }
379
+
380
+ /** The alias of the request at `index`. */
381
+ const aliasOf = (index: number) => `r${index}`;
382
+
383
+ /**
384
+ * Every askable request as an aliased document field (aliases `r0…r7`, by
385
+ * position) and each request's plan. `fields` is empty when no request can
386
+ * be asked.
387
+ */
388
+ export function buildDocument(
389
+ requests: readonly ChainRequest[],
390
+ identity: UserIdentity,
391
+ ): { fields: AliasedField[]; plans: FieldPlan[] } {
392
+ const plans = requests.map((request, index) =>
393
+ requestField(aliasOf(index), request, identity, requests.length),
394
+ );
395
+ return {
396
+ fields: plans.flatMap((plan) =>
397
+ plan.ok ? [{ alias: plan.alias, field: plan.field }] : [],
398
+ ),
399
+ plans,
400
+ };
401
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Chain REST (the node's LCD gateway) reads: one GET, bounded and parsed,
3
+ * under the shared retry policy.
4
+ */
5
+ import type { z } from 'zod';
6
+ import {
7
+ exchange,
8
+ unavailable,
9
+ withRetry,
10
+ type ChainCall,
11
+ type Outcome,
12
+ } from './http';
13
+
14
+ export const REST_MAX_BYTES = 512 * 1024;
15
+ /** Per-attempt wall bound for one REST read. */
16
+ export const REST_ATTEMPT_TIMEOUT_MS = 8_000;
17
+
18
+ /**
19
+ * `GET ${restUrl}${path}` parsed by `schema`. `label` names the read in the
20
+ * log line (never the path: it carries an address). A body that is not
21
+ * JSON or not the expected shape is `unavailable` and is not retried. A 404
22
+ * is `unavailable` unless `notFoundOn404` (a resource that may be absent).
23
+ */
24
+ export function restJson<S extends z.ZodType>(
25
+ call: ChainCall,
26
+ path: string,
27
+ schema: S,
28
+ opts: {
29
+ label: string;
30
+ timeoutMs?: number;
31
+ maxBytes?: number;
32
+ notFoundOn404?: boolean;
33
+ },
34
+ ): Promise<Outcome<z.infer<S>>> {
35
+ const base = call.restUrl.replace(/\/+$/, '');
36
+ const url = `${base}${path}`;
37
+ return withRetry(call, { label: opts.label, upstream: base }, async () => {
38
+ const answer = await exchange(
39
+ call,
40
+ 'chain',
41
+ url,
42
+ { method: 'GET', headers: { accept: 'application/json' } },
43
+ {
44
+ timeoutMs: opts.timeoutMs ?? REST_ATTEMPT_TIMEOUT_MS,
45
+ maxBytes: opts.maxBytes ?? REST_MAX_BYTES,
46
+ notFoundOn404: opts.notFoundOn404 ?? false,
47
+ },
48
+ );
49
+ if (!answer.ok) return answer;
50
+ let body: unknown;
51
+ try {
52
+ body = JSON.parse(answer.value);
53
+ } catch {
54
+ return {
55
+ ok: false,
56
+ failure: unavailable(
57
+ 'chain',
58
+ 'it answered something that is not JSON',
59
+ false,
60
+ ),
61
+ retry: false,
62
+ note: 'malformed JSON',
63
+ };
64
+ }
65
+ const parsed = schema.safeParse(body);
66
+ if (!parsed.success)
67
+ return {
68
+ ok: false,
69
+ failure: unavailable(
70
+ 'chain',
71
+ 'it answered in an unexpected shape',
72
+ false,
73
+ ),
74
+ retry: false,
75
+ note: 'unexpected shape',
76
+ };
77
+ return { ok: true, value: parsed.data };
78
+ });
79
+ }