@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.32.0",
3
+ "version": "0.33.0",
4
4
  "description": "QiForge oracle runtime for Cloudflare Workers — Durable Object per user (SQLite in WASM over DO storage), Matrix E2EE gateway Durable Object, Hono HTTP/SSE shell.",
5
5
  "private": false,
6
6
  "publishConfig": {
@@ -34,7 +34,7 @@
34
34
  "!src/plugins/pod-creator/test-fixtures.ts"
35
35
  ],
36
36
  "dependencies": {
37
- "@ixo/action-protocol": "0.8.0",
37
+ "@ixo/action-protocol": "0.9.0",
38
38
  "@blocknote/server-util": "0.29.1",
39
39
  "@composio/core": "0.10.0",
40
40
  "@composio/langchain": "0.9.2",
@@ -68,8 +68,8 @@
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
70
  "@ixo/common": "1.7.1",
71
- "@ixo/ucan": "2.3.0",
72
- "@ixo/ixo-transaction": "0.2.0"
71
+ "@ixo/ixo-transaction": "0.2.0",
72
+ "@ixo/ucan": "2.3.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -55,6 +55,7 @@ import { chainTxOperation, prepareHubAction } from '../hub-actions/request';
55
55
  import {
56
56
  bankReceiptSummary,
57
57
  chainTxAmountsInBaseUnits,
58
+ chainTxNeedsTokenList,
58
59
  } from '../hub-actions/transfer';
59
60
  import {
60
61
  HUB_CHECK_AFTER_EXPIRY_MS,
@@ -3652,8 +3653,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3652
3653
  * steps an amount of IXO: with at least one of either, the owner's
3653
3654
  * options are read once, fresh (the balances must be current), and each
3654
3655
  * amount is converted to base units and checked against the listed
3655
- * balance (`chainTxAmountsInBaseUnits`). No fee is sent: the Hub
3656
- * simulates gas and sets it.
3656
+ * balance (`chainTxAmountsInBaseUnits`). A new group proposal's coin
3657
+ * send of another token than IXO reads them too, for the token's
3658
+ * decimals only. No fee is sent: the Hub simulates gas and sets it.
3657
3659
  */
3658
3660
  private async chainTransactionHubAction(input: {
3659
3661
  ownerDid: string;
@@ -3684,11 +3686,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3684
3686
  const chainId = chainIdsFromConfig(chainIdConfigSchema.parse(this.env))[
3685
3687
  HUB_NETWORK_CHAIN[capabilities.network]
3686
3688
  ];
3687
- const tokens = input.messages.some(
3688
- (message) =>
3689
- message.type === 'cosmos.bank.send' ||
3690
- message.type === 'cosmos.staking.delegate',
3691
- )
3689
+ const tokens = chainTxNeedsTokenList(input.messages)
3692
3690
  ? ((await this.hubActionOptions(input, true)).transfer?.tokens ?? null)
3693
3691
  : null;
3694
3692
  const operation = chainTxOperation({
@@ -41,11 +41,11 @@ export const CHAIN_TX_REFUSAL_WORDS = {
41
41
  },
42
42
  proposal_not_found: {
43
43
  means: 'there is no proposal with that number',
44
- next: 'Find the open proposals with chain_lookup gov_proposals.',
44
+ next: 'Find the proposal again: chain_lookup gov_proposals for a network proposal, dao_proposals for a group proposal (copy its `id`, `proposalModule` and `dao`).',
45
45
  },
46
46
  proposal_not_open: {
47
47
  means:
48
- 'voting on this proposal is not open (it has closed, or not started yet)',
48
+ 'voting on this proposal is not open (it has closed, or a network proposal has not started yet)',
49
49
  next: 'Tell them; a vote cannot be recorded now.',
50
50
  },
51
51
  invalid_vote_option: {
@@ -168,6 +168,90 @@ export const CHAIN_TX_REFUSAL_WORDS = {
168
168
  means: 'one of the identifiers is not a valid ID on the network',
169
169
  next: 'Copy it again exactly from a lookup.',
170
170
  },
171
+ group_not_supported: {
172
+ means:
173
+ 'this group runs a version these cards don’t support yet, or the proposal ends at a block number rather than a time',
174
+ next: 'Do not retry; tell them to use the group’s own app for this.',
175
+ },
176
+ not_in_group: {
177
+ means: 'that proposal list is not one of this group’s',
178
+ next: 'Copy `dao` and `proposalModule` again together from chain_lookup dao_proposals or daos.',
179
+ },
180
+ group_paused: {
181
+ means:
182
+ 'the group is paused, so a proposal with actions can’t be carried out right now',
183
+ next: 'Do not retry now; tell them it can be carried out once the group is no longer paused.',
184
+ },
185
+ no_voting_power: {
186
+ means: 'they had no vote in this group when the proposal started',
187
+ next: 'Do not retry; tell them only members with a vote when it started can vote on it.',
188
+ },
189
+ already_voted: {
190
+ means:
191
+ 'they have already voted on this proposal, and this group doesn’t allow changing a vote',
192
+ next: 'Do not retry; tell them their earlier vote stands.',
193
+ },
194
+ same_vote: {
195
+ means: 'they have already voted this way on this proposal',
196
+ next: 'Nothing needs to change; tell them.',
197
+ },
198
+ not_a_member: {
199
+ means: 'this group lets only its members do this',
200
+ next: 'Do not retry; tell them a member of the group has to do it.',
201
+ },
202
+ proposal_not_passed: {
203
+ means:
204
+ 'this proposal can’t be carried out: it hasn’t passed (yet, or at all), or it was already carried out',
205
+ next: 'Check its status with chain_lookup dao_proposals and tell them.',
206
+ },
207
+ proposal_not_rejected: {
208
+ means: 'only a proposal that did not pass can be closed',
209
+ next: 'Check its status with chain_lookup dao_proposals and tell them.',
210
+ },
211
+ group_actions_would_fail: {
212
+ means:
213
+ 'if carried out now, what the proposal makes the group do would fail (in most groups that would mark the proposal failed for good)',
214
+ next: 'Do not retry now; tell them it can be carried out once that is fixed (for example once the group holds enough).',
215
+ },
216
+ deposit_not_supported: {
217
+ means:
218
+ 'this group takes its proposal deposit in a token these cards can’t pay',
219
+ next: 'Do not retry; tell them to make the proposal in the group’s own app.',
220
+ },
221
+ proposal_too_large: {
222
+ means: 'the proposal is larger than this group allows',
223
+ next: 'Ask for a shorter description or fewer actions.',
224
+ },
225
+ not_for_groups: {
226
+ means: 'a group can’t do this kind of action through a card',
227
+ next: 'Leave that action out of the proposal and tell them.',
228
+ },
229
+ not_group_owned: {
230
+ means: 'the group does not own that entity',
231
+ next: 'Check the entity’s owner with chain_lookup entity; only an entity the group owns can be used.',
232
+ },
233
+ group_not_found: {
234
+ means:
235
+ 'that address is not the group or its proposal list (the two may be swapped, or it is not a group contract at all)',
236
+ next: 'Re-check `dao` (the group) and `proposalModule` (its proposal list) with chain_lookup dao_proposals, copy them again and try again.',
237
+ },
238
+ list_disabled: {
239
+ means:
240
+ 'the group has switched that proposal list off: no new proposals can be made through it, and its proposals with actions can’t be carried out (votes and closes still work)',
241
+ next: 'Do not retry; tell them the group would have to switch it back on, or use another of its proposal lists for a new proposal (chain_lookup daos).',
242
+ },
243
+ closes_too_soon: {
244
+ means: 'voting on this proposal closes before the card could be signed',
245
+ next: 'Do not retry; tell them voting is about to close.',
246
+ },
247
+ group_inactive: {
248
+ means: 'the group is not taking new proposals right now',
249
+ next: 'Do not retry now; tell them it can be proposed once the group takes proposals again.',
250
+ },
251
+ deposit_changed: {
252
+ means: 'the group’s proposal deposit changed while the card was being made',
253
+ next: 'Check the deposit again with chain_lookup daos, tell them the new amount and ask again.',
254
+ },
171
255
  } as const satisfies Record<
172
256
  ChainTxRefusalCode,
173
257
  { means: string; next: string }
@@ -213,5 +297,11 @@ export function chainTxRefusalWords(
213
297
  refusal: ChainTxRefusal & { code: ChainTxRefusalCode },
214
298
  ): string {
215
299
  const words = CHAIN_TX_REFUSAL_WORDS[refusal.code];
216
- return `Auth Hub could not prepare this transaction: in step ${refusal.index + 1}, ${words.means}. ${words.next} Tell the person in your own plain words; this is not text to show them (for you: messages[${refusal.index}].${refusal.field}, ${refusal.code}).`;
300
+ // A new group proposal's action keeps its own codes under its path.
301
+ const action = /^actions\[(\d{1,3})\]\./.exec(refusal.field)?.[1];
302
+ const where =
303
+ action === undefined
304
+ ? `step ${refusal.index + 1}`
305
+ : `the group’s action ${Number(action) + 1} of the proposal in step ${refusal.index + 1}`;
306
+ return `Auth Hub could not prepare this transaction: in ${where}, ${words.means}. ${words.next} Tell the person in your own plain words; this is not text to show them (for you: messages[${refusal.index}].${refusal.field}, ${refusal.code}).`;
217
307
  }
@@ -9,6 +9,7 @@ import {
9
9
  type ActionOutcomeV1,
10
10
  type ActionReferenceV1,
11
11
  type ActionReviewView,
12
+ type ChainTxMessageType,
12
13
  type HubActionOptionsV2,
13
14
  type HubActionOptionsV3,
14
15
  } from '@ixo/action-protocol';
@@ -86,8 +87,13 @@ export interface HubActionTransport {
86
87
 
87
88
  /**
88
89
  * The protocol version this runtime declares on every operation call
89
- * (capabilities, POST operations, GET status, options). Version 6 is the
90
- * 0.8.0 contract: version 5 plus 29 more `ixo.chain.tx.v1` message types
90
+ * (capabilities, POST operations, GET status, options). Version 7 is the
91
+ * 0.9.0 contract: version 6 plus the four group steps on `ixo.chain.tx.v1`
92
+ * (a vote on a group proposal, carrying out a passed one, closing a failed
93
+ * one, a new proposal), which a 0.8.0 parser cannot read in capabilities,
94
+ * review steps or receipts, so the Hub lists and accepts them only at
95
+ * level 7; options answer as at version 3. Version 6 is the 0.8.0
96
+ * contract: version 5 plus 29 more `ixo.chain.tx.v1` message types
91
97
  * (staking, voting, retiring impact tokens, claim collections, entities and
92
98
  * identity-record links), which a 0.7.0 parser cannot read in capabilities,
93
99
  * review steps or receipts, so the Hub lists and accepts them only at level
@@ -106,32 +112,38 @@ export interface HubActionTransport {
106
112
  * the 0.4.1 shapes. The envelopes stay tagged `ixo.auth-outcome/2` and
107
113
  * `ixo.auth-capabilities/2`, which `@ixo/action-protocol` parses.
108
114
  *
109
- * An Auth Hub older than version 6 checks `?version=` against its closed
110
- * list (1 to 5) and answers every call that declares 6 with 400
111
- * `invalid_request`, before anything else. This runtime then speaks
112
- * version 5 to it (every Hub action but the level-6 chain types keeps
113
- * working while Auth Hub is rolled out) and asks for 6 again after
114
- * `HUB_PROTOCOL_RECHECK_MS`; a Hub that refuses 5 too is told apart with
115
- * `HUB_UPDATE_REQUIRED`.
115
+ * An Auth Hub older than this runtime's version checks `?version=` against
116
+ * its closed list (1 to 6 on 0.8.0, 1 to 5 on 0.7.0) and answers every call
117
+ * that declares a version it does not know with 400 `invalid_request`,
118
+ * before anything else. This runtime then steps down
119
+ * (`HUB_PROTOCOL_VERSIONS`: 7, then 6, then 5) to the highest version that
120
+ * Hub answers (every Hub action the lower level has keeps working while
121
+ * Auth Hub is rolled out: the group steps at 6, the level-6 chain types too
122
+ * at 5) and asks for 7 again after `HUB_PROTOCOL_RECHECK_MS`; a Hub that
123
+ * refuses 5 too is told apart with `HUB_UPDATE_REQUIRED`.
116
124
  */
117
- const HUB_PROTOCOL_VERSION = '6';
118
- /** The lowest version this runtime works at: 6 without the level-6 chain types. */
119
- const HUB_PROTOCOL_FALLBACK_VERSION = '5';
120
- type HubProtocolVersion =
121
- | typeof HUB_PROTOCOL_VERSION
122
- | typeof HUB_PROTOCOL_FALLBACK_VERSION;
123
- /** How long a Hub that refused version 6 is spoken to at version 5 before 6 is tried again. */
125
+ const HUB_PROTOCOL_VERSION = '7';
126
+ /** The lowest version this runtime works at: 7 without the group steps and the level-6 chain types. */
127
+ const HUB_PROTOCOL_LOWEST_VERSION = '5';
128
+ /** Every version this runtime speaks, highest first. */
129
+ const HUB_PROTOCOL_VERSIONS = [
130
+ HUB_PROTOCOL_VERSION,
131
+ '6',
132
+ HUB_PROTOCOL_LOWEST_VERSION,
133
+ ] as const;
134
+ type HubProtocolVersion = (typeof HUB_PROTOCOL_VERSIONS)[number];
135
+ /** How long a Hub that refused a version is spoken to at a lower one before the highest is tried again. */
124
136
  export const HUB_PROTOCOL_RECHECK_MS = 10 * 60_000;
125
137
 
126
138
  /**
127
139
  * What the model is told when Auth Hub refuses a read (capabilities,
128
140
  * options, status) with 400 `invalid_request`. A read carries nothing but
129
141
  * the protocol version (and, for options, the binding the runtime itself
130
- * checked), so that answer comes from a Hub that does not know version 6.
131
- * The owner object keeps every action it holds: polls and callbacks pick
132
- * the outcomes up once the Hub is updated.
142
+ * checked), so that answer comes from a Hub that does not know the version
143
+ * declared. The owner object keeps every action it holds: polls and
144
+ * callbacks pick the outcomes up once the Hub is updated.
133
145
  */
134
- export const HUB_UPDATE_REQUIRED = `Auth Hub must be updated before this oracle can use Hub actions: it does not accept protocol version ${HUB_PROTOCOL_FALLBACK_VERSION}, the lowest this runtime works with (invalid_request). Tell the person this is not available right now; retrying or changing the fields will not help.`;
146
+ export const HUB_UPDATE_REQUIRED = `Auth Hub must be updated before this oracle can use Hub actions: it does not accept protocol version ${HUB_PROTOCOL_LOWEST_VERSION}, the lowest this runtime works with (invalid_request). Tell the person this is not available right now; retrying or changing the fields will not help.`;
135
147
  /** Every Hub call but a chain-transaction prepare. */
136
148
  const HUB_TIMEOUT_MS = 10_000;
137
149
  /**
@@ -155,7 +167,9 @@ function isUpdateRequired(error: unknown): boolean {
155
167
 
156
168
  export class HubActionClient {
157
169
  private readonly origin: string;
158
- /** Until when this Hub is spoken to at version 5 (it refused 6). */
170
+ /** The lower version this Hub answered after refusing a higher one. */
171
+ private fallbackVersion: HubProtocolVersion = HUB_PROTOCOL_VERSION;
172
+ /** Until when this Hub is spoken to at `fallbackVersion`. */
159
173
  private fallbackUntil = 0;
160
174
 
161
175
  constructor(private readonly transport: HubActionTransport) {
@@ -179,37 +193,53 @@ export class HubActionClient {
179
193
  /** The protocol version the next call declares. */
180
194
  get protocolVersion(): HubProtocolVersion {
181
195
  return Date.now() < this.fallbackUntil
182
- ? HUB_PROTOCOL_FALLBACK_VERSION
196
+ ? this.fallbackVersion
183
197
  : HUB_PROTOCOL_VERSION;
184
198
  }
185
199
 
186
200
  /**
187
- * A read at the current version; when Auth Hub refuses version 6 as
188
- * unknown, the same read at version 5, remembered for
201
+ * A read at the current version; when Auth Hub refuses it as unknown,
202
+ * the same read at each lower version in turn (`HUB_PROTOCOL_VERSIONS`),
203
+ * and the version it answered is remembered for
189
204
  * `HUB_PROTOCOL_RECHECK_MS`. Only reads do this: a refused read changed
190
205
  * nothing, while a prepare's 400 can be a real refusal of its operation.
206
+ * A Hub that refuses the lowest version too answers `HUB_UPDATE_REQUIRED`.
191
207
  */
192
208
  private async negotiated<T>(
193
209
  read: (version: HubProtocolVersion) => Promise<T>,
194
210
  ): Promise<T> {
195
- const version = this.protocolVersion;
196
- try {
197
- return await read(version);
198
- } catch (error) {
199
- if (version !== HUB_PROTOCOL_VERSION || !isUpdateRequired(error))
200
- throw error;
201
- const answer = await read(HUB_PROTOCOL_FALLBACK_VERSION);
202
- this.fallbackUntil = Date.now() + HUB_PROTOCOL_RECHECK_MS;
203
- return answer;
211
+ const current = this.protocolVersion;
212
+ const versions = HUB_PROTOCOL_VERSIONS.slice(
213
+ HUB_PROTOCOL_VERSIONS.indexOf(current),
214
+ );
215
+ for (const [step, version] of versions.entries()) {
216
+ try {
217
+ const answer = await read(version);
218
+ if (step > 0) {
219
+ this.fallbackVersion = version;
220
+ this.fallbackUntil = Date.now() + HUB_PROTOCOL_RECHECK_MS;
221
+ }
222
+ return answer;
223
+ } catch (error) {
224
+ if (step === versions.length - 1 || !isUpdateRequired(error))
225
+ throw error;
226
+ }
204
227
  }
228
+ // Unreachable: the loop returns or throws at its last version.
229
+ throw new Error('No Hub protocol version to read at');
205
230
  }
206
231
 
207
- /** `subject`: what a refused prepare is called in the model's words. */
232
+ /**
233
+ * `subject`: what a refused prepare is called in the model's words;
234
+ * `steps`: a chain transaction's message types, for the words of a refusal
235
+ * that depends on them.
236
+ */
208
237
  private async send(
209
238
  path: string,
210
239
  body?: unknown,
211
240
  timeoutMs = HUB_TIMEOUT_MS,
212
241
  subject?: HubPrepareSubject,
242
+ steps?: readonly ChainTxMessageType[],
213
243
  ): Promise<unknown> {
214
244
  const request = new Request(new URL(path, this.origin), {
215
245
  method: body === undefined ? 'GET' : 'POST',
@@ -243,6 +273,7 @@ export class HubActionClient {
243
273
  response.status,
244
274
  await response.json().catch(() => null),
245
275
  subject,
276
+ steps,
246
277
  );
247
278
  if (refusal) throw refusal;
248
279
  }
@@ -281,6 +312,9 @@ export class HubActionClient {
281
312
  ? HUB_CHAIN_TX_PREPARE_TIMEOUT_MS
282
313
  : HUB_TIMEOUT_MS,
283
314
  prepareSubjectOf(operation.kind),
315
+ operation.kind === 'ixo.chain.tx.v1'
316
+ ? operation.messages.map((message) => message.type)
317
+ : [],
284
318
  );
285
319
  if (!response || typeof response !== 'object' || !('reference' in response))
286
320
  throw new Error('Hub action response has no reference');
@@ -1,11 +1,18 @@
1
1
  import {
2
+ CHAIN_TX_GROUP_MESSAGE_TYPES,
2
3
  parseChainTxRefusal,
3
4
  type ActionOperationV1,
4
5
  type ActionReceiptV1,
6
+ type ChainTxGroupAction,
7
+ type ChainTxMessageType,
5
8
  type OptionsToken,
6
9
  } from '@ixo/action-protocol';
7
10
  import { chainTxRefusalWords, wordableRefusal } from './chain-tx-refusals';
8
- import type { ChainTxMessageInput } from '../plugin-api/types';
11
+ import type {
12
+ ChainTxGroupActionInput,
13
+ ChainTxMessageInput,
14
+ ChainTxStakeInput,
15
+ } from '../plugin-api/types';
9
16
  import { quoteHubText } from './hub-text';
10
17
  import type { ChainTxHostMessage } from './request';
11
18
  import { HubActionRefused } from './store';
@@ -92,6 +99,84 @@ const IXO_TOKEN: TransferToken = {
92
99
  verified: true,
93
100
  };
94
101
 
102
+ /** `toBaseUnits`, its refusal naming the message's amount (`messages[0].amount`). */
103
+ function converted(at: string, amount: string, decimals: number): bigint {
104
+ try {
105
+ return toBaseUnits(amount, decimals);
106
+ } catch (error) {
107
+ if (error instanceof HubActionRefused)
108
+ throw new HubActionRefused('refused', `${at}.amount: ${error.message}`);
109
+ throw error;
110
+ }
111
+ }
112
+
113
+ const isStakeInput = (
114
+ message: ChainTxMessageInput | ChainTxGroupActionInput,
115
+ ): message is ChainTxStakeInput =>
116
+ message.type === 'cosmos.staking.delegate' ||
117
+ message.type === 'cosmos.staking.undelegate' ||
118
+ message.type === 'cosmos.staking.redelegate';
119
+
120
+ /**
121
+ * Whether converting `messages` needs the owner's token list: a bank or
122
+ * stake step (its amount and the owner's balance), or a new group
123
+ * proposal's coin send of another token than IXO (its decimals).
124
+ */
125
+ export function chainTxNeedsTokenList(
126
+ messages: readonly ChainTxMessageInput[],
127
+ ): boolean {
128
+ return messages.some(
129
+ (message) =>
130
+ message.type === 'cosmos.bank.send' ||
131
+ message.type === 'cosmos.staking.delegate' ||
132
+ (message.type === 'daodao.proposal.create' &&
133
+ message.actions.some(
134
+ (action) =>
135
+ action.type === 'cosmos.bank.send' &&
136
+ (action.denom ?? IXO_TOKEN.denom) !== IXO_TOKEN.denom,
137
+ )),
138
+ );
139
+ }
140
+
141
+ /**
142
+ * One action of a new group proposal with its amount in base units: a
143
+ * stake's IXO with 6 decimals, a coin send's with the decimals of its token
144
+ * (IXO without a list; another token from the owner's list, which is the
145
+ * only place the runtime learns a token's decimals). No balance is
146
+ * checked: the group's own account pays when it carries the proposal out,
147
+ * and the network checks it then.
148
+ */
149
+ function groupActionInBaseUnits(
150
+ action: ChainTxGroupActionInput,
151
+ at: string,
152
+ tokens: readonly OptionsToken[] | null,
153
+ ): ChainTxGroupAction {
154
+ if (isStakeInput(action)) {
155
+ const { amount, ...rest } = action;
156
+ return {
157
+ ...rest,
158
+ amountBaseUnits: converted(at, amount, IXO_TOKEN.decimals).toString(),
159
+ };
160
+ }
161
+ if (action.type !== 'cosmos.bank.send') return action;
162
+ const denom = action.denom ?? IXO_TOKEN.denom;
163
+ const token =
164
+ denom === IXO_TOKEN.denom
165
+ ? IXO_TOKEN
166
+ : tokens?.find((item) => item.denom === denom);
167
+ if (!token)
168
+ throw new HubActionRefused(
169
+ 'refused',
170
+ `${at}.denom: ${denom} is not in the owner's token list, so its decimals are unknown here; a group proposal can send IXO, or a token exactly as hub_action_options lists it.`,
171
+ );
172
+ return {
173
+ type: 'cosmos.bank.send',
174
+ toAddress: action.toAddress,
175
+ amountBaseUnits: converted(at, action.amount, token.decimals).toString(),
176
+ denom: token.denom,
177
+ };
178
+ }
179
+
95
180
  /**
96
181
  * A chain transaction's amounts in base units; messages without one pass
97
182
  * unchanged. Each bank step names a display `amount` of a token from the
@@ -103,10 +188,13 @@ const IXO_TOKEN: TransferToken = {
103
188
  * move stake) names a display `amount` of IXO, converted with IXO's 6
104
189
  * decimals; what a stake takes from the wallet counts against the listed
105
190
  * IXO balance together with the IXO bank steps, when the list has IXO
106
- * (Auth Hub checks every balance again before the card is made). `tokens`
107
- * is the owner's list (hub_action_options `transfer.tokens`), null when the
108
- * Hub listed none; the caller reads it only when there is a bank or stake
109
- * step.
191
+ * (Auth Hub checks every balance again before the card is made). A new
192
+ * group proposal's bank and stake actions convert the same way
193
+ * (`groupActionInBaseUnits`) and are never checked against the owner's
194
+ * balance: the group's own account pays them when the group carries the
195
+ * proposal out. `tokens` is the owner's list (hub_action_options
196
+ * `transfer.tokens`), null when the Hub listed none; the caller reads it
197
+ * only when `chainTxNeedsTokenList`.
110
198
  */
111
199
  export function chainTxAmountsInBaseUnits(
112
200
  messages: readonly ChainTxMessageInput[],
@@ -128,17 +216,15 @@ export function chainTxAmountsInBaseUnits(
128
216
  `${at}.amount: the owner holds ${describeAmount(token.balanceBaseUnits, token)}, less than ${total === amount ? what : 'what the steps of this transaction take of it together'}.`,
129
217
  );
130
218
  };
131
- const converted = (at: string, amount: string, decimals: number) => {
132
- try {
133
- return toBaseUnits(amount, decimals);
134
- } catch (error) {
135
- if (error instanceof HubActionRefused)
136
- throw new HubActionRefused('refused', `${at}.amount: ${error.message}`);
137
- throw error;
138
- }
139
- };
140
219
  return messages.map((message, index): ChainTxHostMessage => {
141
220
  const at = `messages[${index}]`;
221
+ if (message.type === 'daodao.proposal.create')
222
+ return {
223
+ ...message,
224
+ actions: message.actions.map((action, j) =>
225
+ groupActionInBaseUnits(action, `${at}.actions[${j}]`, tokens),
226
+ ),
227
+ };
142
228
  if (
143
229
  message.type === 'cosmos.staking.delegate' ||
144
230
  message.type === 'cosmos.staking.undelegate' ||
@@ -231,10 +317,26 @@ export function prepareSubjectOf(
231
317
  : 'operation';
232
318
  }
233
319
 
320
+ /**
321
+ * Why the chain's authorization check refused a chain transaction, by its
322
+ * steps: a group step (the group may let only members carry proposals out
323
+ * or propose), a claim step (the collection admin's authorization), or
324
+ * neither.
325
+ */
326
+ function notAuthorizedWhy(steps: readonly ChainTxMessageType[]): string {
327
+ if (steps.some((type) => GROUP_STEP_TYPES.includes(type)))
328
+ return ' (for a group step, the group may let only its members do this)';
329
+ if (steps.some((type) => type.startsWith('ixo.claims.')))
330
+ return ' (for claims, the collection admin has not granted the authorization)';
331
+ return '';
332
+ }
333
+ const GROUP_STEP_TYPES: readonly ChainTxMessageType[] =
334
+ CHAIN_TX_GROUP_MESSAGE_TYPES;
335
+
234
336
  /** What the model is told when Auth Hub refuses a prepare (400). */
235
337
  const HUB_PREPARE_REFUSALS: Record<
236
338
  string,
237
- (subject: HubPrepareSubject) => string
339
+ (subject: HubPrepareSubject, steps: readonly ChainTxMessageType[]) => string
238
340
  > = {
239
341
  simulation_failed: (subject) =>
240
342
  `Auth Hub simulated this ${subject} and it would fail on chain`,
@@ -247,10 +349,11 @@ const HUB_PREPARE_REFUSALS: Record<
247
349
  // it or tell the owner what was wrong.
248
350
  operation_not_registered: (subject) =>
249
351
  `Auth Hub did not accept this ${subject} as written`,
250
- // The chain's authorization module refused the simulation: for claims,
251
- // the collection admin has not authorized this account.
252
- not_authorized: (subject) =>
253
- `The account is not authorized on chain for this ${subject} (for claims, the collection admin has not granted the authorization)`,
352
+ // The chain's authorization check refused the simulation: for claims,
353
+ // the collection admin has not authorized this account; for a group step,
354
+ // the group lets only its members do it.
355
+ not_authorized: (subject, steps) =>
356
+ `The account is not authorized on chain for this ${subject}${notAuthorizedWhy(steps)}`,
254
357
  };
255
358
  /** Appended to every refusal no retry or change of arguments can lift. */
256
359
  const NOT_FIXABLE = 'retrying or changing the fields will not help';
@@ -342,6 +445,7 @@ export function hubPrepareRefusal(
342
445
  status: number,
343
446
  body: unknown,
344
447
  subject: HubPrepareSubject = 'transfer',
448
+ steps: readonly ChainTxMessageType[] = [],
345
449
  ): HubActionRefused | null {
346
450
  const parsed = hubErrorBody(body);
347
451
  const unfixable = unfixableRefusal(status, parsed, subject);
@@ -376,7 +480,7 @@ export function hubPrepareRefusal(
376
480
  if (words)
377
481
  return new HubActionRefused(
378
482
  'refused',
379
- `${words(subject)}.${reason ? ` ${reason}.` : ''}`,
483
+ `${words(subject, steps)}.${reason ? ` ${reason}.` : ''}`,
380
484
  undefined,
381
485
  status,
382
486
  );
@@ -29,6 +29,7 @@ import type {
29
29
  ActionReferenceV1,
30
30
  ActionOutcomeV1,
31
31
  ActionCapabilitiesV1,
32
+ ChainTxGroupAction,
32
33
  ChainTxMessage,
33
34
  } from '@ixo/action-protocol';
34
35
 
@@ -58,32 +59,65 @@ export type ChainTxStakeType =
58
59
  | 'cosmos.staking.undelegate'
59
60
  | 'cosmos.staking.redelegate';
60
61
 
62
+ /** A bank step as the model writes it: a display amount of a listed token. */
63
+ export type ChainTxBankInput = {
64
+ type: 'cosmos.bank.send';
65
+ toAddress: string;
66
+ /** A plain decimal in the token's display units ("1.5"). */
67
+ amount: string;
68
+ denom?: string;
69
+ };
70
+
71
+ /** A stake step as the model writes it: a display amount of IXO. */
72
+ export type ChainTxStakeInput = DistributiveOmit<
73
+ Extract<ChainTxMessage, { type: ChainTxStakeType }>,
74
+ 'amountBaseUnits'
75
+ > & {
76
+ /** A plain decimal in IXO ("100" or "0.5"). */
77
+ amount: string;
78
+ };
79
+
80
+ /**
81
+ * One action of a new group proposal (`daodao.proposal.create` `actions`)
82
+ * as the model writes it: the protocol's action, with a bank or stake
83
+ * action's amount in display units as at top level. The group's own
84
+ * account is its actor, so none of them names the owner.
85
+ */
86
+ export type ChainTxGroupActionInput =
87
+ | ChainTxBankInput
88
+ | ChainTxStakeInput
89
+ | Exclude<
90
+ ChainTxGroupAction,
91
+ { type: 'cosmos.bank.send' | ChainTxStakeType }
92
+ >;
93
+
61
94
  /**
62
95
  * A chain-transaction message as the model writes it: the protocol's
63
96
  * message without the owner's identity (`ChainTxOwnerField`), which the
64
97
  * host fills. A bank step names a display `amount` of a token from the
65
98
  * owner's list (`denom`, absent = uixo); a stake step a display `amount` of
66
99
  * IXO. The host converts both to the protocol's `amountBaseUnits` (the
67
- * listed decimals; 6 for IXO). Every other amount (impact tokens, a
68
- * collection's payments) is already in the protocol's units.
100
+ * listed decimals; 6 for IXO), at top level and inside a new group
101
+ * proposal's actions. Every other amount (impact tokens, a collection's
102
+ * payments) is already in the protocol's units.
69
103
  */
70
104
  export type ChainTxMessageInput =
71
- | {
72
- type: 'cosmos.bank.send';
73
- toAddress: string;
74
- /** A plain decimal in the token's display units ("1.5"). */
75
- amount: string;
76
- denom?: string;
77
- }
78
- | (DistributiveOmit<
79
- Extract<ChainTxMessage, { type: ChainTxStakeType }>,
80
- 'amountBaseUnits'
81
- > & {
82
- /** A plain decimal in IXO ("100" or "0.5"). */
83
- amount: string;
84
- })
105
+ | ChainTxBankInput
106
+ | ChainTxStakeInput
107
+ | (Omit<
108
+ Extract<ChainTxMessage, { type: 'daodao.proposal.create' }>,
109
+ 'actions'
110
+ > & { actions: ChainTxGroupActionInput[] })
85
111
  | DistributiveOmit<
86
- Exclude<ChainTxMessage, { type: 'cosmos.bank.send' | ChainTxStakeType }>,
112
+ Exclude<
113
+ ChainTxMessage,
114
+ {
115
+ type:
116
+ | 'cosmos.bank.send'
117
+ | ChainTxStakeType
118
+ | 'daodao.proposal.create';
119
+ }
120
+ >,
87
121
  ChainTxOwnerField
88
122
  >;
89
123
 
@@ -869,7 +903,10 @@ export interface RuntimeContext<TConfig = MergedConfig> {
869
903
  * digits than its decimals, zero or more than the listed balance. A
870
904
  * stake step's IXO `amount` is converted with 6 decimals; what a stake
871
905
  * takes from the wallet counts against the listed IXO balance with the
872
- * IXO bank steps, when the list has IXO.
906
+ * IXO bank steps, when the list has IXO. A new group proposal's bank and
907
+ * stake actions are converted the same way but never checked against
908
+ * the owner's balance: the group's own account pays them when the group
909
+ * carries the proposal out.
873
910
  */
874
911
  chainTransaction(input: {
875
912
  ownerDid: string;