@ixo/oracle-runtime-workers 0.32.0 → 0.33.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.
@@ -3,7 +3,10 @@ import {
3
3
  CHAIN_TX_COLLECTION_STATES,
4
4
  CHAIN_TX_DISPUTE_REASONS,
5
5
  CHAIN_TX_ENTITY_PERMISSIONS,
6
+ CHAIN_TX_GROUP_ACTION_TYPES,
7
+ CHAIN_TX_GROUP_VOTE_OPTIONS,
6
8
  CHAIN_TX_MAX_CLAIM_PERMISSION,
9
+ CHAIN_TX_MAX_GROUP_ACTIONS,
7
10
  CHAIN_TX_MAX_MESSAGES,
8
11
  CHAIN_TX_MAX_PAYMENT_COINS,
9
12
  CHAIN_TX_MAX_SECONDS,
@@ -12,6 +15,8 @@ import {
12
15
  CHAIN_TX_VOTE_OPTIONS,
13
16
  DENOM,
14
17
  sanitizePublishedText,
18
+ type ChainTxGroupActionType,
19
+ type ChainTxGroupMessageType,
15
20
  type ChainTxMessageType,
16
21
  } from '@ixo/action-protocol';
17
22
  import { z } from 'zod';
@@ -98,6 +103,13 @@ const SERVICE_REF =
98
103
  */
99
104
  const LINKED_ENTITY_ADDRESS =
100
105
  /^(?:\{id\}#)?ixo1(?:[023456789acdefghjklmnpqrstuvwxyz]{38}|[023456789acdefghjklmnpqrstuvwxyz]{58})$/;
106
+ /** A 32-byte contract address (a group and its proposal list): `ixo1` and 58 characters. */
107
+ const CONTRACT_ADDRESS = /^ixo1[023456789acdefghjklmnpqrstuvwxyz]{58}$/;
108
+ /**
109
+ * A group proposal's number: digits without leading zeros, at most
110
+ * 2^53 − 1 (Auth Hub writes it as a JSON number).
111
+ */
112
+ const GROUP_PROPOSAL_ID = /^[1-9][0-9]{0,15}$/;
101
113
 
102
114
  /**
103
115
  * Characters that would let text render as something else: C0 and C1
@@ -494,11 +506,12 @@ const IID_REMOVE_RULE =
494
506
  'Copy the id exactly as the lookup shows it, whatever its form (`#api`, `{id}#ixo1…`): Auth Hub checks it is there before it makes the card.';
495
507
 
496
508
  /**
497
- * The catalogue. Every `rules` text ends with how to say the step to the
498
- * owner: plain everyday words, never the message type or chain terms (the
499
- * review card in Auth Hub shows the exact values).
509
+ * The types the owner's own account does (every type but the four group
510
+ * steps). Every `rules` text ends with how to say the step to the owner:
511
+ * plain everyday words, never the message type or chain terms (the review
512
+ * card in Auth Hub shows the exact values).
500
513
  */
501
- export const CHAIN_TX_CATALOG = {
514
+ const OWNER_CATALOG = {
502
515
  'cosmos.bank.send': {
503
516
  summary:
504
517
  "send coins (IXO or another token the owner holds) from the owner's wallet.",
@@ -1158,14 +1171,213 @@ export const CHAIN_TX_CATALOG = {
1158
1171
  ),
1159
1172
  }),
1160
1173
  },
1174
+ } as const satisfies Record<
1175
+ Exclude<ChainTxMessageType, ChainTxGroupMessageType>,
1176
+ ChainTxCatalogEntry
1177
+ >;
1178
+
1179
+ // ---- Groups (DAO DAO groups with single-choice proposals) ----
1180
+
1181
+ const contractAddress = (what: string) =>
1182
+ z
1183
+ .string()
1184
+ .regex(
1185
+ CONTRACT_ADDRESS,
1186
+ 'A 32-byte ixo1… contract address (62 characters), copied exactly from chain_lookup daos or dao_proposals.',
1187
+ )
1188
+ .describe(what);
1189
+ const groupDao = contractAddress(
1190
+ 'The group: its own address (`dao` in chain_lookup daos or dao_proposals).',
1191
+ );
1192
+ const proposalModule = contractAddress(
1193
+ 'The group’s proposal list the step acts on (`proposalModule` in chain_lookup dao_proposals, or a `proposalLists` entry in daos); never the group’s own address.',
1194
+ );
1195
+ const groupProposalId = z
1196
+ .string()
1197
+ .regex(
1198
+ GROUP_PROPOSAL_ID,
1199
+ 'The proposal number: digits from 1, no leading zeros (the `id`, not the label such as A3).',
1200
+ )
1201
+ // zod runs every check: a value the pattern refused is not parsed here.
1202
+ .refine(
1203
+ (value) =>
1204
+ !GROUP_PROPOSAL_ID.test(value) ||
1205
+ BigInt(value) <= BigInt(Number.MAX_SAFE_INTEGER),
1206
+ { message: `At most ${Number.MAX_SAFE_INTEGER}.` },
1207
+ )
1208
+ .describe(
1209
+ 'The proposal’s number in its proposal list (`id` in chain_lookup dao_proposals, not the label).',
1210
+ );
1211
+ /** Text published on chain with a group step: plain and shown exactly as written. */
1212
+ const groupText = (max: number, what: string) =>
1213
+ plainText(max, what).refine(shownAsIs, { message: SHOWN_AS_IS });
1214
+ /** `dao` and `proposalModule` are two different contracts (the protocol names `proposalModule`). */
1215
+ function twoContracts(
1216
+ message: { dao: string; proposalModule: string },
1217
+ ctx: z.RefinementCtx,
1218
+ ): void {
1219
+ if (message.proposalModule === message.dao)
1220
+ ctx.addIssue({
1221
+ code: 'custom',
1222
+ path: ['proposalModule'],
1223
+ message:
1224
+ 'The proposal list, a different address than the group’s own (`dao`).',
1225
+ });
1226
+ }
1227
+
1228
+ /**
1229
+ * What a group proposal's action may be, by type: exactly the top-level
1230
+ * message of that type (same fields, same rules), with the group's own
1231
+ * account as the actor. A dates change names both dates: a date left out
1232
+ * would be read now and be out of date when the group carries it out.
1233
+ */
1234
+ const GROUP_ACTION_SCHEMAS = {
1235
+ 'cosmos.bank.send': OWNER_CATALOG['cosmos.bank.send'].schema,
1236
+ 'ixo.token.transfer': OWNER_CATALOG['ixo.token.transfer'].schema,
1237
+ 'ixo.token.retire': OWNER_CATALOG['ixo.token.retire'].schema,
1238
+ 'cosmos.staking.delegate': OWNER_CATALOG['cosmos.staking.delegate'].schema,
1239
+ 'cosmos.staking.undelegate':
1240
+ OWNER_CATALOG['cosmos.staking.undelegate'].schema,
1241
+ 'cosmos.staking.redelegate':
1242
+ OWNER_CATALOG['cosmos.staking.redelegate'].schema,
1243
+ 'cosmos.distribution.withdraw-rewards':
1244
+ OWNER_CATALOG['cosmos.distribution.withdraw-rewards'].schema,
1245
+ 'cosmos.gov.vote': OWNER_CATALOG['cosmos.gov.vote'].schema,
1246
+ 'ixo.claims.update-collection-state':
1247
+ OWNER_CATALOG['ixo.claims.update-collection-state'].schema,
1248
+ 'ixo.claims.update-collection-dates': z
1249
+ .strictObject({
1250
+ type: z.literal('ixo.claims.update-collection-dates'),
1251
+ collectionId,
1252
+ adminAddress,
1253
+ startDate: instant('When it opens (always given in a group proposal).'),
1254
+ endDate: instant(
1255
+ 'When it closes; null: no end date (always given in a group proposal).',
1256
+ ).nullable(),
1257
+ })
1258
+ .superRefine(endAfterStart),
1259
+ 'ixo.claims.update-collection-quota':
1260
+ OWNER_CATALOG['ixo.claims.update-collection-quota'].schema,
1261
+ 'ixo.claims.create-collection':
1262
+ OWNER_CATALOG['ixo.claims.create-collection'].schema,
1263
+ 'ixo.entity.create-account':
1264
+ OWNER_CATALOG['ixo.entity.create-account'].schema,
1265
+ 'ixo.entity.revoke-account-authz':
1266
+ OWNER_CATALOG['ixo.entity.revoke-account-authz'].schema,
1267
+ } as const satisfies Record<ChainTxGroupActionType, z.ZodObject>;
1268
+
1269
+ /** The action types a group proposal may hold, in the protocol's order. */
1270
+ export const GROUP_ACTION_TYPES: readonly ChainTxGroupActionType[] =
1271
+ CHAIN_TX_GROUP_ACTION_TYPES;
1272
+
1273
+ const [firstGroupAction, ...otherGroupActions] = GROUP_ACTION_TYPES.map(
1274
+ (type) => GROUP_ACTION_SCHEMAS[type],
1275
+ );
1276
+ if (!firstGroupAction)
1277
+ throw new Error('The protocol lists no group action types');
1278
+ /**
1279
+ * One action of a new group proposal: its `type` picks the schema. A type a
1280
+ * group cannot do is refused on `actions[j].type`, as the protocol does.
1281
+ */
1282
+ export const GROUP_ACTION_SCHEMA = z.discriminatedUnion(
1283
+ 'type',
1284
+ [firstGroupAction, ...otherGroupActions],
1285
+ {
1286
+ error: `A group proposal can only hold these actions: ${GROUP_ACTION_TYPES.join(', ')}.`,
1287
+ },
1288
+ );
1289
+
1290
+ /** Said once for the four group steps. */
1291
+ const GROUP_RULES =
1292
+ 'A group here is a DAO DAO group on the IXO network (people say group, club, cooperative or DAO). Find it and its proposals with chain_lookup dao_proposals (`dao`, `proposalModule`, `id`, `label`, title, status, when voting closes) or chain_lookup daos (the person’s groups and, per group, `proposalLists` for new proposals). Copy `dao`, `proposalModule` and `proposalId` exactly from there: `dao` is the group’s own address, `proposalModule` its proposal list (a different address), `proposalId` the number without the list’s letter. Auth Hub checks the group and its proposal on chain and refuses what the group does not allow. One group step per card: nothing else goes on the same card. Only single-choice proposals are supported (not multiple choice). The review card may ask the owner to confirm twice: in the groups in use today one admin account can change how the group works without a vote, and the card says so. If the owner asks why, explain that calmly in one sentence; it is how these groups are set up, not a problem with this card.';
1293
+
1294
+ const GROUP_CATALOG = {
1295
+ 'daodao.proposal.vote': {
1296
+ summary:
1297
+ 'vote yes, no or abstain on a proposal in a group (DAO) the owner belongs to.',
1298
+ rules: `${GROUP_RULES} Records the owner’s vote on one group proposal while its voting is open (\`votingEnded\` absent in dao_proposals). Before asking how they vote, tell the owner the proposal’s title and label and when voting closes, and whether they can change the vote later (\`allowRevoting\`); never vote without the owner saying how, in this conversation. \`vote\`: \`yes\`, \`no\` or \`abstain\`. \`rationale\` is optional: the owner’s reason, published with the vote, up to 300 characters, exactly as they said it (no links, no hidden characters, one line). The weight of the vote is what the owner had in the group when the proposal started: Auth Hub refuses the card when they had none, and when they already voted on a proposal that doesn’t allow changing a vote. A vote on a proposal whose result is already decided is still recorded until voting closes, but can’t change the result. Say it like: “Vote Yes on proposal A3, ‘Fix the borehole’, in Clean Water Group. This group doesn’t let you change your vote later.”`,
1299
+ schema: z
1300
+ .strictObject({
1301
+ type: z.literal('daodao.proposal.vote'),
1302
+ dao: groupDao,
1303
+ proposalModule,
1304
+ proposalId: groupProposalId,
1305
+ vote: z
1306
+ .literal([...CHAIN_TX_GROUP_VOTE_OPTIONS])
1307
+ .describe('yes, no or abstain.'),
1308
+ rationale: groupText(
1309
+ 300,
1310
+ 'Optional: the owner’s reason, published with the vote.',
1311
+ ).optional(),
1312
+ })
1313
+ .superRefine(twoContracts),
1314
+ },
1315
+ 'daodao.proposal.execute': {
1316
+ summary:
1317
+ 'carry out a group proposal that passed, so the group’s own account does what it says.',
1318
+ rules: `${GROUP_RULES} Carries out a proposal that passed (\`canRun\` in dao_proposals): the group’s own account then does the proposal’s actions, which the review card lists. On a proposal list the group switched off (\`listDisabled\`), a proposal with actions can’t be carried out. Only when the owner asks for it. Tell them what the proposal does (its title) before preparing. Some groups let only members carry proposals out. Auth Hub tries it first and refuses the card when the actions would fail now (in most groups a failed run marks the proposal failed for good), for example when the group doesn’t hold enough; tell the owner it can be tried again once that is fixed. It costs the owner only the network fee; a deposit paid with the proposal goes back to whoever proposed it unless the group keeps deposits (the review card says). After it goes through (hub_action_status), read the receipt’s \`groupActions\`: \`"failed"\` means the proposal’s actions did not happen and it can’t be tried again; never say it is done. \`"unknown"\`: check its status with chain_lookup dao_proposals before saying anything. Say it like: “Carry out proposal A3, ‘Pay Thandi’, in Clean Water Group: the group sends Thandi 500 IXO. This can’t be undone.”`,
1319
+ schema: z
1320
+ .strictObject({
1321
+ type: z.literal('daodao.proposal.execute'),
1322
+ dao: groupDao,
1323
+ proposalModule,
1324
+ proposalId: groupProposalId,
1325
+ })
1326
+ .superRefine(twoContracts),
1327
+ },
1328
+ 'daodao.proposal.close': {
1329
+ summary:
1330
+ 'close a group proposal that did not pass (its actions will never happen).',
1331
+ rules: `${GROUP_RULES} Closes a proposal that did not pass (\`canClose\` in dao_proposals): none of its actions will happen. Only when the owner asks for it. Closing does not give its deposit back to whoever proposed it: in most groups the deposit goes to the group (the review card says where it goes); say so. Say it like: “Close proposal A3 in Clean Water Group. It didn’t pass, so nothing in it will happen; its 1 IXO deposit goes to the group, not back to whoever proposed it.”`,
1332
+ schema: z
1333
+ .strictObject({
1334
+ type: z.literal('daodao.proposal.close'),
1335
+ dao: groupDao,
1336
+ proposalModule,
1337
+ proposalId: groupProposalId,
1338
+ })
1339
+ .superRefine(twoContracts),
1340
+ },
1341
+ 'daodao.proposal.create': {
1342
+ summary:
1343
+ 'ask a group (DAO) to vote on a new proposal: agreement only, or up to 4 things the group’s own account then does.',
1344
+ rules: `${GROUP_RULES} Asks the group to vote on a new proposal from the owner. Never propose without the owner asking for it and agreeing to the exact title, description and actions. \`proposalModule\` is one of the group’s \`proposalLists\` in chain_lookup daos (use one that is not \`disabled\` and whose \`deposit\` a card can pay; \`membersOnly: true\`: only members may propose). \`title\` (up to 100 characters) and \`description\` (up to 300) are published as written: plain text on one line, no links, no hidden characters. \`actions\` is what the group’s own account does if the proposal passes: \`[]\` asks for agreement only; otherwise up to ${CHAIN_TX_MAX_GROUP_ACTIONS} actions, each written exactly like a top-level message of its type (describe that type too), of these types only: ${GROUP_ACTION_TYPES.join(', ')}. In an action the group is the actor, not the owner: a coin send goes from the group’s own account (\`dao\`; check what it holds with chain_lookup balances for that address), a stake stakes the group’s IXO, a collection change needs a permission given to the group’s address, a new collection or an entity account needs an entity the group owns, and a collection dates change gives both \`startDate\` and \`endDate\` (null = no end). Amounts are display units as at top level (IXO by default; another token only one listed in hub_action_options \`transfer.tokens\`, for its decimals); the owner’s own balance is not used. The network checks each action only when the group carries the proposal out, possibly days later; if one fails then, none happen. When the group takes a deposit, it comes from the owner’s wallet: say how much, and that it may be lost if the proposal doesn’t pass or is never carried out (it comes back only as the group’s refund rule says, \`refundPolicy\` in daos). A proposal can’t be withdrawn once made. Once it went through (hub_action_status), the receipt names the new proposal’s number (\`proposalId\`); when it is null, find the new proposal with chain_lookup dao_proposals and never guess its number. Say it like: “Propose to Clean Water Group: ‘Pay Thandi for the borehole repair’. If it passes, the group sends her 500 IXO; proposing costs you a 1 IXO deposit, which you get back only if it passes and is carried out.”`,
1345
+ schema: z
1346
+ .strictObject({
1347
+ type: z.literal('daodao.proposal.create'),
1348
+ dao: groupDao,
1349
+ proposalModule,
1350
+ title: groupText(100, 'The proposal’s title, as published.'),
1351
+ description: groupText(
1352
+ 300,
1353
+ 'What the proposal asks, as published (one line).',
1354
+ ),
1355
+ actions: z
1356
+ .array(GROUP_ACTION_SCHEMA)
1357
+ .max(CHAIN_TX_MAX_GROUP_ACTIONS)
1358
+ .describe(
1359
+ `What the group’s own account does if it passes: [] for agreement only, or up to ${CHAIN_TX_MAX_GROUP_ACTIONS} actions, each a message of one of: ${GROUP_ACTION_TYPES.join(', ')}.`,
1360
+ ),
1361
+ })
1362
+ .superRefine(twoContracts),
1363
+ },
1364
+ } as const satisfies Record<ChainTxGroupMessageType, ChainTxCatalogEntry>;
1365
+
1366
+ /**
1367
+ * The catalogue: every message type the protocol knows, the owner's own
1368
+ * types first, then the four group steps (the protocol's order).
1369
+ */
1370
+ export const CHAIN_TX_CATALOG = {
1371
+ ...OWNER_CATALOG,
1372
+ ...GROUP_CATALOG,
1161
1373
  } as const satisfies Record<ChainTxMessageType, ChainTxCatalogEntry>;
1162
1374
 
1163
1375
  /** What every message type shares, returned once by describe_chain_messages. */
1164
1376
  export const CHAIN_TX_GENERAL_RULES = [
1165
- 'Addresses are 20-byte `ixo1…` account addresses (contract addresses are refused), validators `ixovaloper1…`, entities `did:ixo:entity:…`. Copy every id, address and number exactly from a lookup (chain_lookup, hub_action_options) or from the owner; never invent or guess one. Ids (`claimId`, `collectionId`, token `id`, proofs) use only letters, digits and `: . _ / -`.',
1377
+ 'Addresses are 20-byte `ixo1…` account addresses (contract addresses are refused), except a group’s own address and its proposal list in the group steps (32-byte `ixo1…` contract addresses); validators `ixovaloper1…`, entities `did:ixo:entity:…`. Copy every id, address and number exactly from a lookup (chain_lookup, hub_action_options) or from the owner; never invent or guess one. Ids (`claimId`, `collectionId`, token `id`, proofs) use only letters, digits and `: . _ / -`.',
1166
1378
  '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. Amounts: coin sends and stakes are what the owner says in the token’s units ("100" IXO); impact tokens are whole tokens; collection payments are base units (1 IXO = 1000000 uixo). Dates are RFC 3339 with Z or an offset, from the owner’s time zone.',
1167
- `Up to ${CHAIN_TX_MAX_MESSAGES} messages per transaction, run in the order given (put them in the order the owner said them, and keep one decision in one card); all succeed or none do. A step that needs something stored first (a dispute statement, a profile, a document) can only be prepared once it is stored.`,
1168
- 'How to talk about it: before preparing, say in one or two plain, everyday sentences what will happen and what it means for the owner (what leaves their wallet, what someone else will be able to do, how long it takes, whether it can be undone), the way each type’s “Say it like” does. Use names (of the validator, collection, entity or person) when a lookup gives them. Never say message type names or chain words such as authz, grant, MsgExec, bech32, valoper, uixo, base units, DID, IID, delegate or redelegate: say stake, unstake, move stake, permission, account, identity and IXO. Show an address only when the owner needs it to check who receives something. The review card in Auth Hub shows every exact value.',
1379
+ `Up to ${CHAIN_TX_MAX_MESSAGES} messages per transaction, run in the order given (put them in the order the owner said them, and keep one decision in one card); all succeed or none do. A group step (a vote, carrying out or closing a group proposal, a new group proposal) goes alone on its card. A step that needs something stored first (a dispute statement, a profile, a document) can only be prepared once it is stored.`,
1380
+ 'How to talk about it: before preparing, say in one or two plain, everyday sentences what will happen and what it means for the owner (what leaves their wallet, what someone else will be able to do, how long it takes, whether it can be undone), the way each type’s “Say it like” does. Use names (of the validator, collection, entity or person) when a lookup gives them. Never say message type names or chain words such as authz, grant, MsgExec, bech32, valoper, uixo, base units, DID, IID, delegate, redelegate, DAO, proposal module, contract or execute: say stake, unstake, move stake, permission, account, identity, IXO, group, proposal, vote, deposit and carry out. Show an address only when the owner needs it to check who receives something. The review card in Auth Hub shows every exact value.',
1169
1381
  ].join(' ');
1170
1382
 
1171
1383
  /** A catalogue message as its schema parses it. */
@@ -1,4 +1,5 @@
1
1
  import {
2
+ CHAIN_TX_GROUP_MESSAGE_TYPES,
2
3
  CHAIN_TX_MAX_MESSAGES,
3
4
  CHAIN_TX_MESSAGE_TYPES,
4
5
  operationJsonSchema,
@@ -30,6 +31,8 @@ import { toolNotRunMessage } from '../../core/tool-not-run';
30
31
  import {
31
32
  CHAIN_TX_CATALOG,
32
33
  CHAIN_TX_GENERAL_RULES,
34
+ GROUP_ACTION_SCHEMA,
35
+ GROUP_ACTION_TYPES,
33
36
  chainTxMessageInput,
34
37
  hasUnsafeText,
35
38
  } from './chain-tx-catalog';
@@ -100,22 +103,38 @@ const memoSchema = z
100
103
  /**
101
104
  * A catalogue field reduced to its basic type: no descriptions, patterns or
102
105
  * limits (those stay in describe_chain_messages and the handler's check).
106
+ * A union of message types (a new group proposal's `actions`) is given as
107
+ * `typeUnion` gives the messages themselves: fields only for the types in
108
+ * `described`.
103
109
  */
104
- export function fieldsOnly(schema: z.core.$ZodType): z.ZodType {
110
+ export function fieldsOnly(
111
+ schema: z.core.$ZodType,
112
+ described?: ReadonlySet<string>,
113
+ ): z.ZodType {
105
114
  if (schema instanceof z.ZodObject)
106
115
  return z.strictObject(
107
116
  Object.fromEntries(
108
117
  Object.entries(schema.shape).map(([key, field]) => [
109
118
  key,
110
- fieldsOnly(field),
119
+ fieldsOnly(field, described),
111
120
  ]),
112
121
  ),
113
122
  );
114
123
  if (schema instanceof z.ZodOptional)
115
- return fieldsOnly(schema.unwrap()).optional();
124
+ return fieldsOnly(schema.unwrap(), described).optional();
116
125
  if (schema instanceof z.ZodNullable)
117
- return fieldsOnly(schema.unwrap()).nullable();
118
- if (schema instanceof z.ZodArray) return z.array(fieldsOnly(schema.element));
126
+ return fieldsOnly(schema.unwrap(), described).nullable();
127
+ if (schema instanceof z.ZodArray)
128
+ return z.array(fieldsOnly(schema.element, described));
129
+ if (schema instanceof z.ZodDiscriminatedUnion)
130
+ return typeUnion(
131
+ schema.options.map((option) => {
132
+ if (!(option instanceof z.ZodObject))
133
+ throw new Error('A message union holds a non-object branch');
134
+ return [messageTypeOf(option), option] as const;
135
+ }),
136
+ described,
137
+ );
119
138
  if (schema instanceof z.ZodString) return z.string();
120
139
  if (schema instanceof z.ZodNumber) return z.number();
121
140
  if (schema instanceof z.ZodBoolean) return z.boolean();
@@ -125,42 +144,51 @@ export function fieldsOnly(schema: z.core.$ZodType): z.ZodType {
125
144
  );
126
145
  }
127
146
 
147
+ /** The `type` a catalogue message schema is for (its `type` literal). */
148
+ function messageTypeOf(schema: z.ZodObject): string {
149
+ const type = schema.shape.type;
150
+ const [value] = type instanceof z.ZodLiteral ? [...type.values] : [];
151
+ if (typeof value !== 'string')
152
+ throw new Error('A catalogue message schema has no `type` literal');
153
+ return value;
154
+ }
155
+
128
156
  /**
129
157
  * One message type as the model is given it: its field names and basic
130
158
  * types, so a provider that holds a tool call to its schema sends every
131
159
  * field. Loose, so a field the type does not have reaches the handler,
132
160
  * which names it.
133
161
  */
134
- function messageFieldsOnly(schema: z.ZodObject) {
162
+ function messageFieldsOnly(
163
+ schema: z.ZodObject,
164
+ described?: ReadonlySet<string>,
165
+ ) {
135
166
  return z.looseObject(
136
167
  Object.fromEntries(
137
168
  Object.entries(schema.shape).map(([key, field]) => [
138
169
  key,
139
- fieldsOnly(field),
170
+ fieldsOnly(field, described),
140
171
  ]),
141
172
  ),
142
173
  );
143
174
  }
144
175
 
145
176
  /**
146
- * The schema the model is given: per type its fields (messageFieldsOnly)
147
- * when the type was described in this session (`described`; absent: every
148
- * type), and one open branch naming every other type (`type` one of them,
149
- * any other field allowed). A type the model has not read about costs its
150
- * name only; the request handler refuses it until it is described, and
151
- * the next model call is given its fields (`chainTxFieldsMiddleware`).
152
- * Each message is checked by the handler against its full catalogue
153
- * schema.
177
+ * Messages as the model is given them: per type its fields
178
+ * (messageFieldsOnly) when the type was described in this session
179
+ * (`described`; absent: every type), and one open branch naming every
180
+ * other type (`type` one of them, any other field allowed). A union inside
181
+ * a message (a new group proposal's `actions`) takes `nested` instead.
154
182
  */
155
- export function chainTransactionSchemaFor(
156
- catalog: Readonly<Record<string, { schema: z.ZodObject }>>,
183
+ function typeUnion(
184
+ schemas: readonly (readonly [string, z.ZodObject])[],
157
185
  described?: ReadonlySet<string>,
186
+ nested: ReadonlySet<string> | undefined = described,
158
187
  ) {
159
- const entries = Object.entries(catalog);
160
- const withFields = entries
188
+ const withFields = schemas
161
189
  .filter(([type]) => !described || described.has(type))
162
- .map(([, entry]) => messageFieldsOnly(entry.schema));
163
- const [firstUnread, ...unread] = entries
190
+ .map(([, schema]) => messageFieldsOnly(schema, nested));
191
+ const [firstUnread, ...unread] = schemas
164
192
  .filter(([type]) => described && !described.has(type))
165
193
  .map(([type]) => type);
166
194
  const branches =
@@ -172,9 +200,36 @@ export function chainTransactionSchemaFor(
172
200
  ];
173
201
  const [first, ...rest] = branches;
174
202
  if (!first) throw new Error('The chain transaction catalogue is empty');
203
+ return z.discriminatedUnion('type', [first, ...rest]);
204
+ }
205
+
206
+ /**
207
+ * The schema the model is given: the messages as `typeUnion` gives them,
208
+ * a new group proposal's actions the same way. A type the model has not
209
+ * read about costs its name only; the request handler refuses it until it
210
+ * is described, and the next model call is given its fields
211
+ * (`chainTxFieldsMiddleware`). Without a session store (`described`
212
+ * absent) every message type carries its fields, but a new proposal's
213
+ * actions are named only: the 14 action types' fields would repeat their
214
+ * top-level branches (26 228 characters instead of 20 950); the
215
+ * handler names any field it misses. Each message is checked by the
216
+ * handler against its full catalogue schema.
217
+ */
218
+ export function chainTransactionSchemaFor(
219
+ catalog: Readonly<Record<string, { schema: z.ZodObject }>>,
220
+ described?: ReadonlySet<string>,
221
+ ) {
175
222
  return z.strictObject({
176
223
  messages: z
177
- .array(z.discriminatedUnion('type', [first, ...rest]))
224
+ .array(
225
+ typeUnion(
226
+ Object.entries(catalog).map(
227
+ ([type, entry]) => [type, entry.schema] as const,
228
+ ),
229
+ described,
230
+ described ?? new Set<string>(),
231
+ ),
232
+ )
178
233
  .min(1)
179
234
  .max(CHAIN_TX_MAX_MESSAGES),
180
235
  memo: memoSchema,
@@ -649,7 +704,10 @@ async function requestChainTransaction(
649
704
  const unread = [
650
705
  ...new Set(
651
706
  parsed.data.messages
652
- .map(chainTxTypeOf)
707
+ .flatMap((message) => [
708
+ chainTxTypeOf(message),
709
+ ...groupActionTypesOf(message),
710
+ ])
653
711
  .filter(
654
712
  (type): type is ChainTxMessageType =>
655
713
  type !== null && !described.includes(type),
@@ -719,6 +777,19 @@ function strictChainTxMessages(
719
777
  ): { messages: ChainTxMessageInput[] } | { issues: string[] } {
720
778
  const checked: ChainTxMessageInput[] = [];
721
779
  const issues: string[] = [];
780
+ // A group step is a chain of reads in Auth Hub and names a new
781
+ // proposal's number on its receipt: it goes alone on its card (the
782
+ // protocol refuses `messages` otherwise).
783
+ if (
784
+ messages.length > 1 &&
785
+ messages.some((message) => {
786
+ const type = chainTxTypeOf(message);
787
+ return type !== null && GROUP_STEP_TYPES.includes(type);
788
+ })
789
+ )
790
+ issues.push(
791
+ 'messages: a group step (a group vote, carrying out or closing a group proposal, a new group proposal) goes alone on its card; prepare the other messages on another card',
792
+ );
722
793
  messages.forEach((message, index) => {
723
794
  const type = chainTxTypeOf(message);
724
795
  if (!type) {
@@ -739,6 +810,31 @@ function chainTxTypeOf(message: unknown): ChainTxMessageType | null {
739
810
  return CHAIN_TX_MESSAGE_TYPES.find((known) => known === type) ?? null;
740
811
  }
741
812
 
813
+ const GROUP_STEP_TYPES: readonly ChainTxMessageType[] =
814
+ CHAIN_TX_GROUP_MESSAGE_TYPES;
815
+
816
+ /**
817
+ * The action types a new group proposal holds (each a type a group may
818
+ * do): their rules are read like a top-level message's before the card.
819
+ * Anything else is left to the message's own check.
820
+ */
821
+ function groupActionTypesOf(message: unknown): ChainTxMessageType[] {
822
+ if (
823
+ chainTxTypeOf(message) !== 'daodao.proposal.create' ||
824
+ typeof message !== 'object' ||
825
+ message === null ||
826
+ !('actions' in message) ||
827
+ !Array.isArray(message.actions)
828
+ )
829
+ return [];
830
+ return message.actions
831
+ .map(chainTxTypeOf)
832
+ .filter(
833
+ (type): type is ChainTxMessageType =>
834
+ type !== null && GROUP_ACTION_TYPES.some((allowed) => allowed === type),
835
+ );
836
+ }
837
+
742
838
  function chainTxShapeRefusal(
743
839
  issues: readonly string[],
744
840
  ): HubActionRequestResult {
@@ -784,10 +880,11 @@ function issuesOf(
784
880
 
785
881
  /**
786
882
  * The catalogue types this account may use: the Hub capabilities'
787
- * `chainTx.messageTypes`. None when chain transactions cannot be used
883
+ * `chainTx.messageTypes`, which follow the protocol level the Hub speaks
884
+ * with this runtime (level 5: the four 0.6.0 types; 6: 33; 7: all 37, the
885
+ * four group steps included). None when chain transactions cannot be used
788
886
  * here: not a channel turn, Hub actions off, chain transactions off for the
789
- * account, a Hub at protocol level 5 (the 0.6.0 types only, as it lists
790
- * them), or capabilities that cannot be read (the owner object answers its
887
+ * account, or capabilities that cannot be read (the owner object answers its
791
888
  * last good capabilities while a read fails, and does not retry a failed
792
889
  * read for 30 s, so this never holds a turn for long).
793
890
  */
@@ -815,6 +912,19 @@ async function availableChainTxTypes(
815
912
  );
816
913
  }
817
914
 
915
+ /**
916
+ * A new group proposal's action as describe_chain_messages lists it: the
917
+ * types it may be, each with its own fields (described on its own), so one
918
+ * description does not repeat fourteen others.
919
+ */
920
+ const GROUP_ACTION_FIELDS = {
921
+ type: 'object',
922
+ description:
923
+ 'One message of the types listed in `type`, with exactly that type’s fields (describe_chain_messages that type); the group’s own account is its actor.',
924
+ properties: { type: { type: 'string', enum: [...GROUP_ACTION_TYPES] } },
925
+ required: ['type'],
926
+ };
927
+
818
928
  /** Each type's fields as JSON Schema, built once. */
819
929
  const catalogueFields = new Map<ChainTxMessageType, unknown>();
820
930
  function fieldsOf(type: ChainTxMessageType): unknown {
@@ -822,6 +932,14 @@ function fieldsOf(type: ChainTxMessageType): unknown {
822
932
  if (fields === undefined) {
823
933
  const { properties, required } = z.toJSONSchema(
824
934
  CHAIN_TX_CATALOG[type].schema,
935
+ {
936
+ override: ({ zodSchema, jsonSchema }) => {
937
+ if (zodSchema !== GROUP_ACTION_SCHEMA) return;
938
+ for (const key of Object.keys(jsonSchema))
939
+ Reflect.deleteProperty(jsonSchema, key);
940
+ Object.assign(jsonSchema, GROUP_ACTION_FIELDS);
941
+ },
942
+ },
825
943
  );
826
944
  fields = { properties, required };
827
945
  catalogueFields.set(type, fields);
@@ -914,6 +1032,45 @@ export function modelOutcome(
914
1032
  export const CANCELLED_MEANING =
915
1033
  'Withdrawn before anything was sent: nothing happened on chain. The person may have cancelled it, or Auth Hub withdrew it because something it depended on changed after the review; do not say the person cancelled it. If they still want it, offer to prepare a fresh one.';
916
1034
 
1035
+ /**
1036
+ * A carried-out group proposal whose actions failed: the transaction
1037
+ * succeeds (code 0) even then, and the network marks the proposal failed
1038
+ * for good, so the receipt's `groupActions` is the only sign.
1039
+ */
1040
+ export const GROUP_ACTIONS_FAILED_MEANING =
1041
+ 'The transaction went through, but the proposal’s actions failed: nothing in the proposal happened, the network has marked it failed for good and it can’t be carried out again. The network fee was used unless a fee grant covered it. Never say it is done; tell the person plainly.';
1042
+
1043
+ /** A carried-out group proposal whose transaction did not show whether its actions ran. */
1044
+ export const GROUP_ACTIONS_UNKNOWN_MEANING =
1045
+ 'The transaction went through, but Auth Hub could not tell from it whether the proposal’s actions happened. Check the proposal’s status with chain_lookup dao_proposals (executed: they happened; execution_failed: they did not) before saying anything about it; never say it is done until then.';
1046
+
1047
+ /** A new group proposal whose number Auth Hub could not read from the transaction. */
1048
+ export const PROPOSAL_NUMBER_UNKNOWN_MEANING =
1049
+ 'The new proposal was made, but Auth Hub could not read its number from the transaction. Find the new proposal with chain_lookup dao_proposals (the group’s newest) before naming it; never guess its number.';
1050
+
1051
+ /**
1052
+ * What an outcome means beyond its fields, for the model: a withdrawn card,
1053
+ * a carried-out proposal whose actions failed or may have, a new proposal
1054
+ * without its number. Undefined for anything else.
1055
+ */
1056
+ export function outcomeMeaning(
1057
+ outcome: ActionOutcomeV1 | null,
1058
+ ): string | undefined {
1059
+ if (!outcome) return undefined;
1060
+ if (outcome.status === 'cancelled') return CANCELLED_MEANING;
1061
+ const receipt = outcome.receipt;
1062
+ if (receipt?.kind !== 'ixo.chain.tx.v1') return undefined;
1063
+ for (const step of receipt.steps) {
1064
+ if (step.type === 'daodao.proposal.execute') {
1065
+ if (step.groupActions === 'failed') return GROUP_ACTIONS_FAILED_MEANING;
1066
+ if (step.groupActions === 'unknown') return GROUP_ACTIONS_UNKNOWN_MEANING;
1067
+ }
1068
+ if (step.type === 'daodao.proposal.create' && step.proposalId === null)
1069
+ return PROPOSAL_NUMBER_UNKNOWN_MEANING;
1070
+ }
1071
+ return undefined;
1072
+ }
1073
+
917
1074
  async function hubActionStatus(
918
1075
  input: unknown,
919
1076
  ctx: RuntimeContext,
@@ -935,15 +1092,14 @@ async function hubActionStatus(
935
1092
  status: 'unavailable',
936
1093
  reason: 'No Hub action with this id was prepared for this owner.',
937
1094
  };
1095
+ const meaning = outcomeMeaning(read.outcome);
938
1096
  return {
939
1097
  status: 'known',
940
1098
  reviewStatus: read.reviewStatus,
941
1099
  operationStatus: read.operationStatus,
942
1100
  outcome: modelOutcome(read.outcome),
943
1101
  ...(read.summary ? { summary: read.summary } : {}),
944
- ...(read.outcome?.status === 'cancelled'
945
- ? { meaning: CANCELLED_MEANING }
946
- : {}),
1102
+ ...(meaning ? { meaning } : {}),
947
1103
  };
948
1104
  } catch (error) {
949
1105
  // An Auth Hub that must be updated first: plain words, not a tool error.
@@ -1012,6 +1168,7 @@ const manifest: PluginManifest = {
1012
1168
  'The user asks to file or evaluate a claim, or to send coins or impact tokens on chain.',
1013
1169
  'The user asks to stake, unstake or move IXO, claim staking rewards, vote on a network proposal or retire impact tokens.',
1014
1170
  'The user asks to reserve or dispute a claim, create or change a claim collection, give or remove a permission, create or update an entity, or change the links of an identity.',
1171
+ 'The user asks to vote on a proposal of a group (DAO) they belong to, carry out or close a group proposal, or put a new proposal to a group.',
1015
1172
  'The user asks to review an exact private resource or to sign a statement.',
1016
1173
  'The user asks to connect Gmail or Calendar, send a reviewed email or add an event.',
1017
1174
  'The user asks for document access or to save a private note.',
@@ -1043,7 +1200,7 @@ export class HubActionsPlugin extends OraclePlugin {
1043
1200
  /**
1044
1201
  * request_chain_transaction is built per turn: its schema names only the
1045
1202
  * message types this account may use (Auth Hub capabilities, cached by
1046
- * the owner object), so 33 types do not reach a model that may use 4.
1203
+ * the owner object), so 37 types do not reach a model that may use 4.
1047
1204
  */
1048
1205
  override async getRequestTools(ctx: RuntimeContext): Promise<PluginTool[]> {
1049
1206
  const [available, described] = await Promise.all([
@@ -1093,7 +1250,7 @@ export class HubActionsPlugin extends OraclePlugin {
1093
1250
  {
1094
1251
  name: 'hub_action_status',
1095
1252
  description:
1096
- 'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it.',
1253
+ 'Read the verified status of a Hub action already prepared for this owner. Never executes. A `cancelled` outcome was withdrawn before anything was sent, by the person or by Auth Hub (`meaning` says so): never say the person cancelled it. When `meaning` is present, it says what the outcome means for the person (for example a carried-out group proposal whose actions failed): follow it.',
1097
1254
  schema: statusSchema,
1098
1255
  effect: 'read',
1099
1256
  handler: hubActionStatus,