@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.
- package/package.json +4 -4
- package/src/delivery/plan.ts +1 -1
- package/src/do/user-oracle-do.ts +42 -71
- package/src/hub-actions/client.ts +52 -21
- package/src/hub-actions/request.ts +102 -0
- package/src/hub-actions/transfer.ts +167 -18
- package/src/plugin-api/types.ts +35 -7
- package/src/plugins/hub-actions/hub-actions.plugin.ts +518 -50
|
@@ -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
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
.
|
|
37
|
-
.
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
499
|
+
async function requestChainTransaction(
|
|
176
500
|
input: unknown,
|
|
177
501
|
ctx: RuntimeContext,
|
|
178
502
|
): Promise<HubActionRequestResult> {
|
|
179
|
-
const parsed =
|
|
180
|
-
if (!parsed.success)
|
|
181
|
-
|
|
182
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
220
|
-
//
|
|
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
|
|
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: '
|
|
361
|
-
description:
|
|
362
|
-
|
|
363
|
-
|
|
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:
|
|
833
|
+
handler: requestChainTransaction,
|
|
366
834
|
},
|
|
367
835
|
{
|
|
368
836
|
name: 'hub_action_status',
|