@ixo/oracle-runtime-workers 0.28.2 → 0.29.1

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.
@@ -1,11 +1,17 @@
1
1
  import {
2
+ CHAIN_TX_MAX_MESSAGES,
3
+ CHAIN_TX_MAX_TOKEN_LINES,
4
+ CHAIN_TX_MESSAGE_TYPES,
5
+ DENOM,
2
6
  operationJsonSchema,
3
7
  parseOperation,
4
8
  type ActionReferenceV1,
9
+ type ChainTxMessageType,
5
10
  } from '@ixo/action-protocol';
6
11
  import { z } from 'zod';
7
12
  import { OraclePlugin } from '../../plugin-api/oracle-plugin';
8
13
  import type {
14
+ ChainTxMessageInput,
9
15
  PluginManifest,
10
16
  PluginTool,
11
17
  RuntimeContext,
@@ -16,38 +22,339 @@ const configSchema = z.object({
16
22
  HUB_ACTIONS_ENABLED: z.enum(['true', 'false']).default('false'),
17
23
  });
18
24
 
19
- const operationSchema = z.fromJSONSchema({
25
+ /**
26
+ * Kinds the generic tool never prepares: a chain transaction
27
+ * (`ixo.chain.tx.v1`, coin sends included) is built by the runtime from
28
+ * `request_chain_transaction`'s messages, and the older bank send
29
+ * (`ixo.bank.send.v1`) is no longer prepared by this runtime (Auth Hub
30
+ * still serves it for older runtimes). They are left out of the generic
31
+ * tool's schema (its handler refuses them anyway), which also keeps their
32
+ * branches (about 3.7 KB of the JSON Schema the model is sent) out of every
33
+ * model request.
34
+ */
35
+ const RUNTIME_BUILT_KINDS: readonly string[] = [
36
+ 'ixo.bank.send.v1',
37
+ 'ixo.chain.tx.v1',
38
+ ];
39
+ function branchKind(branch: Record<string, unknown>): unknown {
40
+ const properties = branch.properties;
41
+ if (!properties || typeof properties !== 'object') return undefined;
42
+ const kind = (properties as Record<string, unknown>).kind;
43
+ return kind && typeof kind === 'object'
44
+ ? (kind as Record<string, unknown>).const
45
+ : undefined;
46
+ }
47
+ export const requestOperationJsonSchema = {
20
48
  ...operationJsonSchema,
21
- $schema: 'https://json-schema.org/draft/2020-12/schema',
22
- });
49
+ $schema: 'https://json-schema.org/draft/2020-12/schema' as const,
50
+ oneOf: operationJsonSchema.oneOf.filter(
51
+ (branch) => !RUNTIME_BUILT_KINDS.includes(String(branchKind(branch))),
52
+ ),
53
+ };
54
+ const operationSchema = z.fromJSONSchema(requestOperationJsonSchema);
23
55
  const requestSchema = z.strictObject({ operation: operationSchema });
24
56
  const statusSchema = z.strictObject({
25
57
  actionId: z.string().regex(/^[A-Za-z0-9_-]{32}$/),
26
58
  });
27
59
  const discoverSchema = z.strictObject({});
28
- const transferSchema = z.strictObject({
29
- toAddress: z
60
+
61
+ /**
62
+ * The messages of one chain transaction (`ixo.chain.tx.v1`), as the model
63
+ * writes them. The shapes are the protocol's minus the owner's identity
64
+ * (a claim's `agentDid`, an evaluation's `agentAddress`), which the runtime
65
+ * fills, as it fills the chain id, network and signer; a bank step names a
66
+ * display amount, which the owner object converts to base units from the
67
+ * owner's token list. The protocol parses the whole operation again before
68
+ * it reaches the Hub. The rules match Auth Hub's (ids, address length, DID
69
+ * escapes, memo characters), so a message the Hub would refuse is refused
70
+ * here, before any Hub call.
71
+ */
72
+ /** A 20-byte bech32 account address: `ixo1` and 38 characters (a 32-byte contract address is longer). */
73
+ const IXO_ADDRESS = /^ixo1[023456789acdefghjklmnpqrstuvwxyz]{38}$/;
74
+ const BASE_UNITS = /^[1-9][0-9]{0,77}$/;
75
+ /** A DID; `%` only as a `%xx` escape. */
76
+ const ANY_DID = /^did:[a-z0-9]+:(?:[A-Za-z0-9._:-]|%[0-9A-Fa-f]{2})+$/;
77
+ /** Claim, collection, token and proof ids: no spaces, quotes or markup. */
78
+ const ID_CHARS = /^[A-Za-z0-9:._/-]+$/;
79
+ const ID_RULE = 'Only letters, digits and : . _ / - (copied exactly).';
80
+ /**
81
+ * Characters that would let text render as something else: C0 and C1
82
+ * controls, zero-width and direction marks (U+200B–U+200F), line and
83
+ * paragraph separators (U+2028–U+2029), bidi embeddings, overrides and
84
+ * isolates (U+202A–U+202E, U+2066–U+2069), the Arabic letter mark (U+061C)
85
+ * and the byte order mark (U+FEFF). Auth Hub refuses the same set.
86
+ */
87
+ export function hasUnsafeText(value: string): boolean {
88
+ for (const char of value) {
89
+ const code = char.codePointAt(0) ?? 0;
90
+ if (
91
+ code <= 0x1f ||
92
+ (code >= 0x7f && code <= 0x9f) ||
93
+ code === 0x061c ||
94
+ (code >= 0x200b && code <= 0x200f) ||
95
+ (code >= 0x2028 && code <= 0x202e) ||
96
+ (code >= 0x2066 && code <= 0x2069) ||
97
+ code === 0xfeff
98
+ )
99
+ return true;
100
+ }
101
+ return false;
102
+ }
103
+ const MEMO_BYTES = 256;
104
+ const address = (what: string) =>
105
+ z
30
106
  .string()
31
- .min(1)
32
- .max(100)
33
- .describe('The recipient address the user gave, exactly.'),
34
- denom: z
107
+ .regex(
108
+ IXO_ADDRESS,
109
+ 'A 20-byte ixo1… account address (42 characters); contract addresses are refused.',
110
+ )
111
+ .describe(`${what} (an ixo1… account address).`);
112
+ const id = (max: number) => z.string().min(1).max(max).regex(ID_CHARS, ID_RULE);
113
+ const baseUnits = (what: string) =>
114
+ z
35
115
  .string()
36
- .min(1)
37
- .max(128)
38
- .describe(
39
- 'The token, copied exactly from a `denom` in hub_action_options `transfer.tokens` (never guessed).',
116
+ .regex(BASE_UNITS, 'A whole number of base units above zero.')
117
+ .describe(what);
118
+ const claimId = id(256).describe('The claim id, exactly as stored.');
119
+ const collectionId = z
120
+ .string()
121
+ .regex(/^(0|[1-9][0-9]{0,19})$/, 'Digits only, no leading zeros.')
122
+ .describe('The claim collection id (digits).');
123
+
124
+ /** A display amount: a plain decimal in the token's units, as the user said it. */
125
+ const DISPLAY_AMOUNT = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
126
+
127
+ /**
128
+ * One chain-transaction message type: the one-line `summary` the request
129
+ * tool's description lists, the full `rules` describe_chain_messages
130
+ * returns (with the fields of `schema`), and the strict `schema` the
131
+ * request handler checks each message against. Adding a message type is
132
+ * one entry here (once `@ixo/action-protocol` and Auth Hub support it).
133
+ */
134
+ interface ChainTxCatalogEntry {
135
+ summary: string;
136
+ rules: string;
137
+ schema: z.ZodObject;
138
+ }
139
+
140
+ export const CHAIN_TX_CATALOG = {
141
+ 'cosmos.bank.send': {
142
+ summary:
143
+ "send coins (IXO or another token the owner holds) from the owner's wallet.",
144
+ rules: [
145
+ "Sends coins from the owner's wallet to `toAddress`, alone (a single coin send) or as a step of a batch.",
146
+ '`amount` is the amount as the user said it, in the token’s display units ("1.5" for 1.5 IXO): a plain decimal, never base units. The runtime looks the token up in the owner’s token list (hub_action_options `transfer.tokens`), converts the amount with the listed `decimals`, and refuses a token not in the list, more digits after the point than the token has, zero, and more than the listed balance (the bank steps of one token together).',
147
+ '`denom` is optional and defaults to uixo (IXO, 6 decimals). For another token copy its `denom` exactly from `transfer.tokens` (never guess an `ibc/…` hash); name the full denom to the owner for anything but IXO, and say when the token is unverified (it lists 0 decimals: its amount is a whole number of base units).',
148
+ ].join(' '),
149
+ schema: z.strictObject({
150
+ type: z.literal('cosmos.bank.send'),
151
+ toAddress: address('The recipient'),
152
+ amount: z
153
+ .string()
154
+ .max(80)
155
+ .regex(
156
+ DISPLAY_AMOUNT,
157
+ 'A plain decimal amount in the token’s display units, such as 1.5 (never base units).',
158
+ )
159
+ .describe(
160
+ 'The amount as the user said it, in the token’s display units (1.5 = 1.5 IXO).',
161
+ ),
162
+ denom: z
163
+ .string()
164
+ .regex(DENOM, 'A denom exactly as hub_action_options lists it.')
165
+ .optional()
166
+ .describe(
167
+ 'The token’s denom, copied exactly from hub_action_options `transfer.tokens`; leave out for uixo (IXO).',
168
+ ),
169
+ }),
170
+ },
171
+ 'ixo.claims.submit': {
172
+ summary:
173
+ 'file a claim the owner has already created with a claim collection.',
174
+ rules:
175
+ 'Files a claim the owner has already created with a claim collection. `claimId` is the stored claim’s id, `collectionId` the collection, `adminAddress` the collection admin’s address. The runtime adds the owner’s DID as the filer. The collection admin must have authorized the owner to submit claims. Collections with member budgets cannot be filed into here (no member address is sent). Set `useIntent` only when the collection works with intents.',
176
+ schema: z.strictObject({
177
+ type: z.literal('ixo.claims.submit'),
178
+ collectionId,
179
+ claimId,
180
+ adminAddress: address("The collection admin's address"),
181
+ useIntent: z
182
+ .boolean()
183
+ .optional()
184
+ .describe('true only when the collection pays claims through intents.'),
185
+ }),
186
+ },
187
+ 'ixo.claims.evaluate': {
188
+ summary:
189
+ 'record the owner’s decision, as evaluator, on someone else’s claim (approve, reject, dispute, invalidate).',
190
+ rules:
191
+ 'Records the owner’s decision, as evaluator, on someone else’s claim (`claimId` in `collectionId`). `status` 1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate. It is recorded as the owner’s (the runtime adds their DID and address, never the claimant’s); `oracle` is the evaluator DID recorded on the evaluation (normally the owner’s DID, or the oracle entity DID the collection admin authorized); `adminAddress` is the collection admin’s address; `reason` (a number) and `verificationProof` are optional. The collection admin must have authorized the owner as an evaluator. An approval can pay the claimant.',
192
+ schema: z.strictObject({
193
+ type: z.literal('ixo.claims.evaluate'),
194
+ claimId,
195
+ collectionId,
196
+ oracle: z
197
+ .string()
198
+ .max(200)
199
+ .regex(ANY_DID, 'A DID (did:<method>:…; % only as %xx).')
200
+ .describe('The evaluator DID recorded on the evaluation (a did:…).'),
201
+ adminAddress: address("The collection admin's address"),
202
+ status: z
203
+ .literal([1, 2, 3, 4])
204
+ .describe(
205
+ '1 approve, 2 reject, 3 dispute (the chain may refuse it as deprecated), 4 invalidate.',
206
+ ),
207
+ reason: z
208
+ .number()
209
+ .int()
210
+ .min(0)
211
+ .max(4_294_967_295)
212
+ .optional()
213
+ .describe("The evaluator's reason code, when there is one."),
214
+ verificationProof: id(300)
215
+ .optional()
216
+ .describe(
217
+ 'The evaluation proof (a credential CID), when there is one.',
218
+ ),
219
+ }),
220
+ },
221
+ 'ixo.token.transfer': {
222
+ summary:
223
+ "move IXO impact tokens (1155-style token ids, whole amounts) from the owner's wallet.",
224
+ rules: `Moves IXO impact tokens (1155-style: each line a token \`id\` of 32 hex characters and a whole \`amount\`, 1 to ${CHAIN_TX_MAX_TOKEN_LINES} lines) from the owner’s wallet to \`recipient\`. Impact tokens have no decimals: an amount is a whole number of tokens. All tokens in one message must come from one token contract. These are not the IXO coin: coins go through \`cosmos.bank.send\`.`,
225
+ schema: z.strictObject({
226
+ type: z.literal('ixo.token.transfer'),
227
+ recipient: address('The recipient'),
228
+ tokens: z
229
+ .array(
230
+ z.strictObject({
231
+ id: id(128).describe(
232
+ 'The impact token id (32 hex characters), exactly as listed.',
233
+ ),
234
+ amount: baseUnits('A whole number of tokens above zero.'),
235
+ }),
236
+ )
237
+ .min(1)
238
+ .max(CHAIN_TX_MAX_TOKEN_LINES),
239
+ }),
240
+ },
241
+ } as const satisfies Record<ChainTxMessageType, ChainTxCatalogEntry>;
242
+
243
+ /** What every message type shares, returned once by describe_chain_messages. */
244
+ export const CHAIN_TX_GENERAL_RULES =
245
+ 'Addresses are 20-byte `ixo1…` account addresses (contract addresses are refused). Ids (`claimId`, `collectionId`, token `id`, `verificationProof`) use only letters, digits and `: . _ / -`; copy them exactly and never invent them. Give only the fields listed for a type: the runtime adds the chain, the network, the signer and the owner’s own DID and address where a message needs them, and Auth Hub sets the fee. Up to 8 messages per transaction, run in order; all succeed or none do.';
246
+
247
+ const memoSchema = z
248
+ .string()
249
+ .max(MEMO_BYTES)
250
+ .refine((memo) => new TextEncoder().encode(memo).length <= MEMO_BYTES, {
251
+ message: `At most ${MEMO_BYTES} bytes.`,
252
+ })
253
+ .refine((memo) => !hasUnsafeText(memo), {
254
+ message: 'No control, invisible or text-direction characters.',
255
+ })
256
+ .optional()
257
+ .describe('An optional note shown to the owner and kept on chain.');
258
+
259
+ /**
260
+ * A catalogue field reduced to its basic type: no descriptions, patterns or
261
+ * limits (those stay in describe_chain_messages and the handler's check).
262
+ */
263
+ export function fieldsOnly(schema: z.core.$ZodType): z.ZodType {
264
+ if (schema instanceof z.ZodObject)
265
+ return z.strictObject(
266
+ Object.fromEntries(
267
+ Object.entries(schema.shape).map(([key, field]) => [
268
+ key,
269
+ fieldsOnly(field),
270
+ ]),
271
+ ),
272
+ );
273
+ if (schema instanceof z.ZodOptional)
274
+ return fieldsOnly(schema.unwrap()).optional();
275
+ if (schema instanceof z.ZodArray) return z.array(fieldsOnly(schema.element));
276
+ if (schema instanceof z.ZodString) return z.string();
277
+ if (schema instanceof z.ZodNumber) return z.number();
278
+ if (schema instanceof z.ZodBoolean) return z.boolean();
279
+ if (schema instanceof z.ZodLiteral) return z.literal([...schema.values]);
280
+ throw new Error(
281
+ `No model-facing form for a ${schema._zod.def.type} field in the chain transaction catalogue`,
282
+ );
283
+ }
284
+
285
+ /**
286
+ * One message type as the model is given it: its field names and basic
287
+ * types, so a provider that holds a tool call to its schema sends every
288
+ * field. Loose, so a field the type does not have reaches the handler,
289
+ * which names it.
290
+ */
291
+ function messageFieldsOnly(schema: z.ZodObject) {
292
+ return z.looseObject(
293
+ Object.fromEntries(
294
+ Object.entries(schema.shape).map(([key, field]) => [
295
+ key,
296
+ fieldsOnly(field),
297
+ ]),
40
298
  ),
41
- amount: z
42
- .string()
43
- .regex(/^(0|[1-9][0-9]*)(\.[0-9]+)?$/)
44
- .max(80)
45
- .describe(
46
- "A decimal amount in the token's listed units (its `decimals`); for an unverified token (decimals 0) a whole number of base units.",
299
+ );
300
+ }
301
+
302
+ /**
303
+ * The schema the model is given: per type, its fields (messageFieldsOnly),
304
+ * so a type adds its short field list and the rules are fetched with
305
+ * describe_chain_messages when needed. Each message is then checked by the
306
+ * handler against its full catalogue schema.
307
+ */
308
+ export function chainTransactionSchemaFor(
309
+ catalog: Readonly<Record<string, { schema: z.ZodObject }>>,
310
+ ) {
311
+ const [first, ...rest] = Object.values(catalog).map((entry) =>
312
+ messageFieldsOnly(entry.schema),
313
+ );
314
+ if (!first) throw new Error('The chain transaction catalogue is empty');
315
+ return z.strictObject({
316
+ messages: z
317
+ .array(z.discriminatedUnion('type', [first, ...rest]))
318
+ .min(1)
319
+ .max(CHAIN_TX_MAX_MESSAGES),
320
+ memo: memoSchema,
321
+ });
322
+ }
323
+ const chainTransactionSchema = chainTransactionSchemaFor(CHAIN_TX_CATALOG);
324
+
325
+ /**
326
+ * The request tool's description: what it does, the discovery step, one
327
+ * line per catalogue type (its summary only) and the batch rules. A new
328
+ * type adds one line.
329
+ */
330
+ export function chainTransactionDescription(
331
+ catalog: Readonly<Record<string, { summary: string }>>,
332
+ ): string {
333
+ return [
334
+ 'Prepare one chain transaction for the owner (the person you are talking to) to review and sign in Auth Hub; it never signs or executes. A single coin send is one `cosmos.bank.send` message. Before the first use in a conversation, call describe_chain_messages for the types you need and give exactly the fields it lists. Message types:',
335
+ ...Object.entries(catalog).map(
336
+ ([type, entry]) => `- \`${type}\`: ${entry.summary}`,
47
337
  ),
48
- memo: z.string().max(256).optional(),
338
+ 'Up to 8 messages run in order as one transaction: all succeed or none do. Never invent ids or addresses (ask the owner or use other tools); coin amounts are display units ("1.5"), never base units. When the result is unavailable, tell the owner its reason in plain words; when it says retrying or changing the fields will not help, do not retry.',
339
+ ].join('\n');
340
+ }
341
+ export const CHAIN_TRANSACTION_TOOL_DESCRIPTION =
342
+ chainTransactionDescription(CHAIN_TX_CATALOG);
343
+
344
+ const describeChainMessagesSchema = z.strictObject({
345
+ types: z
346
+ .array(z.enum(CHAIN_TX_MESSAGE_TYPES))
347
+ .min(1)
348
+ .max(CHAIN_TX_MAX_MESSAGES)
349
+ .refine((types) => new Set(types).size === types.length, {
350
+ message: 'Name each type once.',
351
+ })
352
+ .describe('The message types to describe, each once.'),
49
353
  });
50
354
 
355
+ export const DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION =
356
+ 'Read the full rules of chain-transaction message types before using them in request_chain_transaction: every field with its meaning, format and limits, what the runtime fills, and chain caveats. Ask only for the types you need. Also returns `availableTypes`: the types this account may use.';
357
+
51
358
  export type HubActionRequestResult =
52
359
  | {
53
360
  status: 'prepared';
@@ -109,6 +416,32 @@ async function requestHubAction(
109
416
  input: unknown,
110
417
  ctx: RuntimeContext,
111
418
  ): Promise<HubActionRequestResult> {
419
+ // Named before the schema check: the schema has no branch for these kinds.
420
+ const kind =
421
+ input &&
422
+ typeof input === 'object' &&
423
+ 'operation' in input &&
424
+ input.operation &&
425
+ typeof input.operation === 'object' &&
426
+ 'kind' in input.operation
427
+ ? input.operation.kind
428
+ : undefined;
429
+ if (kind === 'ixo.bank.send.v1')
430
+ // A coin send is a `cosmos.bank.send` step of a chain transaction,
431
+ // built by the runtime from a listed token and a display amount: the
432
+ // model never supplies base units, a denom it chose or a fee.
433
+ return {
434
+ status: 'unavailable',
435
+ reason:
436
+ 'Use request_chain_transaction with one cosmos.bank.send message (a token from hub_action_options and the amount in its display units) for a coin send.',
437
+ };
438
+ if (kind === 'ixo.chain.tx.v1')
439
+ // The runtime adds the chain, network, signer and owner identity itself.
440
+ return {
441
+ status: 'unavailable',
442
+ reason:
443
+ 'Use request_chain_transaction with only the messages for a chain transaction.',
444
+ };
112
445
  const parsed = requestSchema.safeParse(input);
113
446
  if (!parsed.success)
114
447
  return { status: 'unavailable', reason: 'An operation is required.' };
@@ -131,15 +464,6 @@ async function requestHubAction(
131
464
  let operation;
132
465
  try {
133
466
  operation = parseOperation(parsed.data.operation);
134
- if (operation.kind === 'ixo.bank.send.v1')
135
- // A transfer is built by the runtime from a listed token and a
136
- // display amount (request_hub_transfer): the model never supplies
137
- // base units, a denom it chose or a fee.
138
- return {
139
- status: 'unavailable',
140
- reason:
141
- 'Use request_hub_transfer with a token from hub_action_options for a transfer.',
142
- };
143
467
  } catch (error) {
144
468
  // The protocol's own words ("Invalid fromAddress"): the model can fix
145
469
  // the field instead of guessing.
@@ -172,17 +496,14 @@ async function requestHubAction(
172
496
  return preparedResult(reference, ctx);
173
497
  }
174
498
 
175
- async function requestHubTransfer(
499
+ async function requestChainTransaction(
176
500
  input: unknown,
177
501
  ctx: RuntimeContext,
178
502
  ): Promise<HubActionRequestResult> {
179
- const parsed = transferSchema.safeParse(input);
180
- if (!parsed.success)
181
- return {
182
- status: 'unavailable',
183
- reason:
184
- 'A recipient, a listed token denom and a plain decimal amount are required.',
185
- };
503
+ const parsed = chainTransactionSchema.safeParse(input);
504
+ if (!parsed.success) return chainTxShapeRefusal(issuesOf(parsed.error));
505
+ const checked = strictChainTxMessages(parsed.data.messages);
506
+ if ('issues' in checked) return chainTxShapeRefusal(checked.issues);
186
507
  if (ctx.config.HUB_ACTIONS_ENABLED !== 'true' || !ctx.hubActions)
187
508
  return {
188
509
  status: 'unavailable',
@@ -200,7 +521,7 @@ async function requestHubTransfer(
200
521
  reason: 'A verified channel turn is required.',
201
522
  };
202
523
  try {
203
- const reference = await ctx.hubActions.transfer({
524
+ const reference = await ctx.hubActions.chainTransaction({
204
525
  ownerDid: ctx.user.did,
205
526
  sessionId: ctx.session.id,
206
527
  runId: ctx.session.runId,
@@ -209,21 +530,161 @@ async function requestHubTransfer(
209
530
  bindingId: channel.bindingId,
210
531
  bindingRevision: channel.bindingRevision,
211
532
  requestKey: await hubRequestKey(ctx.session.runId, ctx.toolCallId),
212
- toAddress: parsed.data.toAddress,
213
- denom: parsed.data.denom,
214
- amount: parsed.data.amount,
533
+ messages: checked.messages,
215
534
  memo: parsed.data.memo ?? '',
216
535
  });
217
536
  return preparedResult(reference, ctx);
218
537
  } catch (error) {
219
- // The owner object turns the protocol refusing the operation built
220
- // from the model's fields into a refusal; anything else is an error.
538
+ // The runtime refusing (turn origin, capabilities, a token or amount,
539
+ // the protocol on a field) or the Hub refusing with its reason: words
540
+ // the model relays.
221
541
  const refused = refusalOf(error);
222
542
  if (refused) return refused;
223
543
  throw error;
224
544
  }
225
545
  }
226
546
 
547
+ /**
548
+ * Each message against its type's catalogue schema (strict: a field the
549
+ * type does not have, the owner's identity included, is refused). The
550
+ * model-facing schema only names the fields and their basic types, so this
551
+ * is where a message's formats and limits are checked, on a model turn as on
552
+ * a direct call.
553
+ */
554
+ function strictChainTxMessages(
555
+ messages: readonly unknown[],
556
+ ): { messages: ChainTxMessageInput[] } | { issues: string[] } {
557
+ const checked: ChainTxMessageInput[] = [];
558
+ const issues: string[] = [];
559
+ messages.forEach((message, index) => {
560
+ const type = chainTxTypeOf(message);
561
+ if (!type) {
562
+ issues.push(`messages[${index}].type: not a known message type`);
563
+ return;
564
+ }
565
+ const result = CHAIN_TX_CATALOG[type].schema.safeParse(message);
566
+ if (result.success) checked.push(result.data);
567
+ else issues.push(...issuesOf(result.error, ['messages', index]));
568
+ });
569
+ return issues.length ? { issues } : { messages: checked };
570
+ }
571
+
572
+ function chainTxTypeOf(message: unknown): ChainTxMessageType | null {
573
+ if (typeof message !== 'object' || message === null || !('type' in message))
574
+ return null;
575
+ const { type } = message;
576
+ return CHAIN_TX_MESSAGE_TYPES.find((known) => known === type) ?? null;
577
+ }
578
+
579
+ function chainTxShapeRefusal(
580
+ issues: readonly string[],
581
+ ): HubActionRequestResult {
582
+ return {
583
+ status: 'unavailable',
584
+ reason: `The messages do not match a supported message shape (check ${[...new Set(issues)].slice(0, 5).join('; ')}).`,
585
+ };
586
+ }
587
+
588
+ /** `messages[1].toAddress`, from a zod path. */
589
+ function pathText(path: readonly PropertyKey[]): string {
590
+ return path.reduce<string>(
591
+ (text, key) =>
592
+ typeof key === 'number'
593
+ ? `${text}[${key}]`
594
+ : text
595
+ ? `${text}.${String(key)}`
596
+ : String(key),
597
+ '',
598
+ );
599
+ }
600
+
601
+ /**
602
+ * Each refused field as `path: what is wrong` (`messages[1].toAddress: A
603
+ * 20-byte ixo1… account address …`); a field the shape does not have is
604
+ * named on its own path.
605
+ */
606
+ function issuesOf(
607
+ error: z.ZodError,
608
+ prefix: readonly PropertyKey[] = [],
609
+ ): string[] {
610
+ return error.issues.flatMap((issue) =>
611
+ issue.code === 'unrecognized_keys'
612
+ ? issue.keys.map(
613
+ (key) =>
614
+ `${pathText([...prefix, ...issue.path, key])}: not a field here (describe_chain_messages lists each type's fields)`,
615
+ )
616
+ : [
617
+ `${pathText([...prefix, ...issue.path]) || 'arguments'}: ${issue.message.replace(/\.$/, '')}`,
618
+ ],
619
+ );
620
+ }
621
+
622
+ /**
623
+ * The catalogue types this account may use: the Hub capabilities'
624
+ * `chainTx.messageTypes` (none when chain transactions are off for the
625
+ * account), or the whole catalogue when the capabilities cannot be read
626
+ * (not a channel turn, or the Hub did not answer).
627
+ */
628
+ async function availableChainTxTypes(
629
+ ctx: RuntimeContext,
630
+ ): Promise<readonly ChainTxMessageType[]> {
631
+ if (
632
+ ctx.config.HUB_ACTIONS_ENABLED !== 'true' ||
633
+ ctx.session.client !== 'channel' ||
634
+ !ctx.session.channel ||
635
+ !ctx.hubActions
636
+ )
637
+ return CHAIN_TX_MESSAGE_TYPES;
638
+ let chainTx;
639
+ try {
640
+ chainTx = (await ctx.hubActions.capabilities()).chainTx;
641
+ } catch (error) {
642
+ ctx.logger.warn(
643
+ `[hub-actions] capabilities unavailable for describe_chain_messages: ${error instanceof Error ? error.message : String(error)}`,
644
+ );
645
+ return CHAIN_TX_MESSAGE_TYPES;
646
+ }
647
+ return CHAIN_TX_MESSAGE_TYPES.filter((type) =>
648
+ chainTx?.messageTypes.includes(type),
649
+ );
650
+ }
651
+
652
+ /** Each type's fields as JSON Schema, built once. */
653
+ const catalogueFields = new Map<ChainTxMessageType, unknown>();
654
+ function fieldsOf(type: ChainTxMessageType): unknown {
655
+ let fields = catalogueFields.get(type);
656
+ if (fields === undefined) {
657
+ const { properties, required } = z.toJSONSchema(
658
+ CHAIN_TX_CATALOG[type].schema,
659
+ );
660
+ fields = { properties, required };
661
+ catalogueFields.set(type, fields);
662
+ }
663
+ return fields;
664
+ }
665
+
666
+ async function describeChainMessages(
667
+ input: unknown,
668
+ ctx: RuntimeContext,
669
+ ): Promise<unknown> {
670
+ const parsed = describeChainMessagesSchema.safeParse(input);
671
+ if (!parsed.success)
672
+ return {
673
+ status: 'unavailable',
674
+ reason: `Name 1 to ${CHAIN_TX_MAX_MESSAGES} different message types, each one of: ${CHAIN_TX_MESSAGE_TYPES.join(', ')}.`,
675
+ };
676
+ return {
677
+ status: 'known',
678
+ general: CHAIN_TX_GENERAL_RULES,
679
+ messages: parsed.data.types.map((type) => ({
680
+ type,
681
+ rules: CHAIN_TX_CATALOG[type].rules,
682
+ fields: fieldsOf(type),
683
+ })),
684
+ availableTypes: await availableChainTxTypes(ctx),
685
+ };
686
+ }
687
+
227
688
  /** A typed runtime refusal as a result the model can relay, else null. */
228
689
  function refusalOf(error: unknown): HubActionRequestResult | null {
229
690
  if (
@@ -301,7 +762,8 @@ const manifest: PluginManifest = {
301
762
  summary:
302
763
  'Prepare a registered operation for owner review and read its verified status.',
303
764
  whenToUse: [
304
- 'The user requests a registered form submission or a transfer.',
765
+ 'The user requests a registered form submission or a coin send.',
766
+ 'The user asks to file or evaluate a claim, or to send coins or impact tokens on chain.',
305
767
  'The user asks to review an exact private resource or to sign a statement.',
306
768
  'The user asks to connect Gmail or Calendar, send a reviewed email or add an event.',
307
769
  'The user asks for document access or to save a private note.',
@@ -351,18 +813,24 @@ export class HubActionsPlugin extends OraclePlugin {
351
813
  {
352
814
  name: 'request_hub_action',
353
815
  description:
354
- 'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again.',
816
+ 'Prepare a supported operation for the authenticated channel owner to review in Auth Hub. Never approves, signs, or executes. For email, propose one plain-text message to one recipient using a confirmed Gmail send connection. Supply only the operation candidate; the runtime supplies identity and turn binding. Coin sends and other chain transactions have their own tool (request_chain_transaction). Never prepare a replacement email after an unknown send without the user checking Gmail’s Sent folder and asking again.',
355
817
  schema: requestSchema,
356
818
  effect: 'write',
357
819
  handler: requestHubAction,
358
820
  },
359
821
  {
360
- name: 'request_hub_transfer',
361
- description:
362
- 'Prepare a bank transfer of one of the owner’s listed tokens for review in Auth Hub. Never signs or executes. Read hub_action_options first and copy the token `denom` exactly; give the amount as the user said it in that token’s units (no base-unit math, no fee: Auth Hub sets the fee). Name the full denom for anything but IXO, and say when a token is unverified.',
363
- schema: transferSchema,
822
+ name: 'describe_chain_messages',
823
+ description: DESCRIBE_CHAIN_MESSAGES_TOOL_DESCRIPTION,
824
+ schema: describeChainMessagesSchema,
825
+ effect: 'read',
826
+ handler: describeChainMessages,
827
+ },
828
+ {
829
+ name: 'request_chain_transaction',
830
+ description: CHAIN_TRANSACTION_TOOL_DESCRIPTION,
831
+ schema: chainTransactionSchema,
364
832
  effect: 'write',
365
- handler: requestHubTransfer,
833
+ handler: requestChainTransaction,
366
834
  },
367
835
  {
368
836
  name: 'hub_action_status',