@ixo/oracle-runtime-workers 0.32.0 → 0.33.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.32.0",
3
+ "version": "0.33.1",
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.1",
38
38
  "@blocknote/server-util": "0.29.1",
39
39
  "@composio/core": "0.10.0",
40
40
  "@composio/langchain": "0.9.2",
@@ -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,48 @@ 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's capabilities read then steps down
119
+ * (`HUB_PROTOCOL_VERSIONS`: 7, then 6, then 5) to the highest version that
120
+ * Hub answers, and every other call uses that version (every Hub action
121
+ * the lower level has keeps working while Auth Hub is rolled out: the group
122
+ * steps at 6, the level-6 chain types too at 5); it asks for 7 again after
123
+ * `HUB_PROTOCOL_RECHECK_MS`. Another read refused at that version never
124
+ * lowers it (`atSettledVersion`). A Hub that refuses 5 too is told apart
125
+ * with `HUB_UPDATE_REQUIRED`.
116
126
  */
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. */
127
+ const HUB_PROTOCOL_VERSION = '7';
128
+ /** The lowest version this runtime works at: 7 without the group steps and the level-6 chain types. */
129
+ const HUB_PROTOCOL_LOWEST_VERSION = '5';
130
+ /** Every version this runtime speaks, highest first. */
131
+ const HUB_PROTOCOL_VERSIONS = [
132
+ HUB_PROTOCOL_VERSION,
133
+ '6',
134
+ HUB_PROTOCOL_LOWEST_VERSION,
135
+ ] as const;
136
+ type HubProtocolVersion = (typeof HUB_PROTOCOL_VERSIONS)[number];
137
+ /** How long a Hub that refused a version is spoken to at a lower one before the highest is tried again. */
124
138
  export const HUB_PROTOCOL_RECHECK_MS = 10 * 60_000;
125
139
 
126
140
  /**
127
141
  * What the model is told when Auth Hub refuses a read (capabilities,
128
142
  * options, status) with 400 `invalid_request`. A read carries nothing but
129
143
  * 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.
144
+ * checked), so that answer comes from a Hub that does not know the version
145
+ * declared. The owner object keeps every action it holds: polls and
146
+ * callbacks pick the outcomes up once the Hub is updated.
133
147
  */
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.`;
148
+ 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.`;
149
+ /**
150
+ * A read (options, status) Auth Hub refused at the version its
151
+ * capabilities read accepts: a fault on that route, not an older Hub.
152
+ */
153
+ export function hubReadRefused(version: string): string {
154
+ return `Auth Hub refused this read at protocol version ${version}, which it accepts for this oracle's other calls (invalid_request). Tell the person this is not available right now and try again later; changing the fields will not help.`;
155
+ }
156
+
135
157
  /** Every Hub call but a chain-transaction prepare. */
136
158
  const HUB_TIMEOUT_MS = 10_000;
137
159
  /**
@@ -155,7 +177,9 @@ function isUpdateRequired(error: unknown): boolean {
155
177
 
156
178
  export class HubActionClient {
157
179
  private readonly origin: string;
158
- /** Until when this Hub is spoken to at version 5 (it refused 6). */
180
+ /** The lower version this Hub answered after refusing a higher one. */
181
+ private fallbackVersion: HubProtocolVersion = HUB_PROTOCOL_VERSION;
182
+ /** Until when this Hub is spoken to at `fallbackVersion`. */
159
183
  private fallbackUntil = 0;
160
184
 
161
185
  constructor(private readonly transport: HubActionTransport) {
@@ -179,37 +203,87 @@ export class HubActionClient {
179
203
  /** The protocol version the next call declares. */
180
204
  get protocolVersion(): HubProtocolVersion {
181
205
  return Date.now() < this.fallbackUntil
182
- ? HUB_PROTOCOL_FALLBACK_VERSION
206
+ ? this.fallbackVersion
183
207
  : HUB_PROTOCOL_VERSION;
184
208
  }
185
209
 
186
210
  /**
187
- * A read at the current version; when Auth Hub refuses version 6 as
188
- * unknown, the same read at version 5, remembered for
189
- * `HUB_PROTOCOL_RECHECK_MS`. Only reads do this: a refused read changed
190
- * nothing, while a prepare's 400 can be a real refusal of its operation.
211
+ * The capabilities read at the current version; when Auth Hub refuses it
212
+ * as unknown, the same read at each lower version in turn
213
+ * (`HUB_PROTOCOL_VERSIONS`), and the version it answered is remembered for
214
+ * `HUB_PROTOCOL_RECHECK_MS`. Only the capabilities read decides the
215
+ * version: a refused read changed nothing, while a prepare's 400 can be a
216
+ * real refusal of its operation, and another route refusing a version
217
+ * (an options route bug once refused 7 and 6) must never downgrade the
218
+ * operations. A Hub that refuses the lowest version too answers
219
+ * `HUB_UPDATE_REQUIRED`.
191
220
  */
192
221
  private async negotiated<T>(
193
222
  read: (version: HubProtocolVersion) => Promise<T>,
223
+ ): Promise<T> {
224
+ const current = this.protocolVersion;
225
+ const versions = HUB_PROTOCOL_VERSIONS.slice(
226
+ HUB_PROTOCOL_VERSIONS.indexOf(current),
227
+ );
228
+ for (const [step, version] of versions.entries()) {
229
+ try {
230
+ const answer = await read(version);
231
+ if (step > 0) {
232
+ this.fallbackVersion = version;
233
+ this.fallbackUntil = Date.now() + HUB_PROTOCOL_RECHECK_MS;
234
+ }
235
+ return answer;
236
+ } catch (error) {
237
+ if (step === versions.length - 1 || !isUpdateRequired(error))
238
+ throw error;
239
+ }
240
+ }
241
+ // Unreachable: the loop returns or throws at its last version.
242
+ throw new Error('No Hub protocol version to read at');
243
+ }
244
+
245
+ /**
246
+ * Another read (options, status) at the version the capabilities read
247
+ * settled on. When Auth Hub refuses it as unknown, the capabilities read
248
+ * settles the version again (a Hub rolled back since); only when that
249
+ * changes the version is the read tried once more, at the new version.
250
+ * Otherwise this read fails on its own (`hubReadRefused`) and the version
251
+ * the operations use stays as it is. `timeoutMs` bounds that capabilities
252
+ * read as it bounds this one.
253
+ */
254
+ private async atSettledVersion<T>(
255
+ read: (version: HubProtocolVersion) => Promise<T>,
256
+ timeoutMs = HUB_TIMEOUT_MS,
194
257
  ): Promise<T> {
195
258
  const version = this.protocolVersion;
196
259
  try {
197
260
  return await read(version);
198
261
  } 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;
262
+ if (!isUpdateRequired(error)) throw error;
204
263
  }
264
+ await this.capabilities(timeoutMs);
265
+ const settled = this.protocolVersion;
266
+ if (settled === version)
267
+ throw new HubActionRefused(
268
+ 'refused',
269
+ hubReadRefused(version),
270
+ undefined,
271
+ 400,
272
+ );
273
+ return read(settled);
205
274
  }
206
275
 
207
- /** `subject`: what a refused prepare is called in the model's words. */
276
+ /**
277
+ * `subject`: what a refused prepare is called in the model's words;
278
+ * `steps`: a chain transaction's message types, for the words of a refusal
279
+ * that depends on them.
280
+ */
208
281
  private async send(
209
282
  path: string,
210
283
  body?: unknown,
211
284
  timeoutMs = HUB_TIMEOUT_MS,
212
285
  subject?: HubPrepareSubject,
286
+ steps?: readonly ChainTxMessageType[],
213
287
  ): Promise<unknown> {
214
288
  const request = new Request(new URL(path, this.origin), {
215
289
  method: body === undefined ? 'GET' : 'POST',
@@ -243,6 +317,7 @@ export class HubActionClient {
243
317
  response.status,
244
318
  await response.json().catch(() => null),
245
319
  subject,
320
+ steps,
246
321
  );
247
322
  if (refusal) throw refusal;
248
323
  }
@@ -281,6 +356,9 @@ export class HubActionClient {
281
356
  ? HUB_CHAIN_TX_PREPARE_TIMEOUT_MS
282
357
  : HUB_TIMEOUT_MS,
283
358
  prepareSubjectOf(operation.kind),
359
+ operation.kind === 'ixo.chain.tx.v1'
360
+ ? operation.messages.map((message) => message.type)
361
+ : [],
284
362
  );
285
363
  if (!response || typeof response !== 'object' || !('reference' in response))
286
364
  throw new Error('Hub action response has no reference');
@@ -309,7 +387,7 @@ export class HubActionClient {
309
387
  // Options version 3 (asked at the protocol version; version 4 answers
310
388
  // the same): version 2 plus the owner's bank tokens
311
389
  // (`transfer.tokens`), which any-token transfers are chosen from.
312
- const options = await this.negotiated(async (version) => {
390
+ const options = await this.atSettledVersion(async (version) => {
313
391
  const query = new URLSearchParams({
314
392
  bindingId: input.bindingId,
315
393
  bindingRevision: String(input.bindingRevision),
@@ -357,12 +435,14 @@ export class HubActionClient {
357
435
  ): Promise<HubActionStatus> {
358
436
  if (!/^[A-Za-z0-9_-]{32}$/.test(id))
359
437
  throw new Error('Invalid Hub action id');
360
- const raw = await this.negotiated((version) =>
361
- this.send(
362
- `/api/internal/actions/operations/${id}?version=${version}`,
363
- undefined,
364
- timeoutMs,
365
- ),
438
+ const raw = await this.atSettledVersion(
439
+ (version) =>
440
+ this.send(
441
+ `/api/internal/actions/operations/${id}?version=${version}`,
442
+ undefined,
443
+ timeoutMs,
444
+ ),
445
+ timeoutMs,
366
446
  );
367
447
  if (!isRecord(raw)) throw new Error('Invalid Hub action status');
368
448
  const value = raw;
@@ -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
  );