@ixo/oracle-runtime-workers 0.31.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.
@@ -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,18 @@ 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 5 is the
90
- * 0.7.0 contract: version 4 plus the application card (`ixo.bid.submit.v1`)
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
97
+ * (staking, voting, retiring impact tokens, claim collections, entities and
98
+ * identity-record links), which a 0.7.0 parser cannot read in capabilities,
99
+ * review steps or receipts, so the Hub lists and accepts them only at level
100
+ * 6 (`chainTxMessageTypesForLevel`); options answer as at version 3.
101
+ * Version 5 is the 0.7.0 contract: version 4 plus the application card (`ixo.bid.submit.v1`)
91
102
  * and the claim card (`ixo.claim.submit.v1`) with their receipts
92
103
  * (`ixo.bid.submission.v1`, `ixo.claim.submission.v1`,
93
104
  * `ixo.submission.failed.v1`), which a 0.6.0 parser cannot read, so the Hub
@@ -101,22 +112,38 @@ export interface HubActionTransport {
101
112
  * the 0.4.1 shapes. The envelopes stay tagged `ixo.auth-outcome/2` and
102
113
  * `ixo.auth-capabilities/2`, which `@ixo/action-protocol` parses.
103
114
  *
104
- * An Auth Hub older than version 5 checks `?version=` against its closed
105
- * list (1 to 4) and answers every call that declares 5 with 400
106
- * `invalid_request`, before anything else: see `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`.
107
124
  */
108
- const HUB_PROTOCOL_VERSION = '5';
109
- const HUB_PROTOCOL_QUERY = `version=${HUB_PROTOCOL_VERSION}`;
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. */
136
+ export const HUB_PROTOCOL_RECHECK_MS = 10 * 60_000;
110
137
 
111
138
  /**
112
139
  * What the model is told when Auth Hub refuses a read (capabilities,
113
140
  * options, status) with 400 `invalid_request`. A read carries nothing but
114
141
  * the protocol version (and, for options, the binding the runtime itself
115
- * checked), so that answer comes from a Hub that does not know version 5.
116
- * The owner object keeps every action it holds: polls and callbacks pick
117
- * 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.
118
145
  */
119
- 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_VERSION}, which this runtime needs (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.`;
120
147
  /** Every Hub call but a chain-transaction prepare. */
121
148
  const HUB_TIMEOUT_MS = 10_000;
122
149
  /**
@@ -131,8 +158,19 @@ function isRecord(value: unknown): value is Record<string, unknown> {
131
158
  return value !== null && typeof value === 'object' && !Array.isArray(value);
132
159
  }
133
160
 
161
+ /** A read Auth Hub refused because it does not know the declared version. */
162
+ function isUpdateRequired(error: unknown): boolean {
163
+ return (
164
+ error instanceof HubActionRefused && error.message === HUB_UPDATE_REQUIRED
165
+ );
166
+ }
167
+
134
168
  export class HubActionClient {
135
169
  private readonly origin: string;
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`. */
173
+ private fallbackUntil = 0;
136
174
 
137
175
  constructor(private readonly transport: HubActionTransport) {
138
176
  const url = new URL(transport.origin);
@@ -152,12 +190,56 @@ export class HubActionClient {
152
190
  this.origin = url.origin;
153
191
  }
154
192
 
155
- /** `subject`: what a refused prepare is called in the model's words. */
193
+ /** The protocol version the next call declares. */
194
+ get protocolVersion(): HubProtocolVersion {
195
+ return Date.now() < this.fallbackUntil
196
+ ? this.fallbackVersion
197
+ : HUB_PROTOCOL_VERSION;
198
+ }
199
+
200
+ /**
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
204
+ * `HUB_PROTOCOL_RECHECK_MS`. Only reads do this: a refused read changed
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`.
207
+ */
208
+ private async negotiated<T>(
209
+ read: (version: HubProtocolVersion) => Promise<T>,
210
+ ): Promise<T> {
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
+ }
227
+ }
228
+ // Unreachable: the loop returns or throws at its last version.
229
+ throw new Error('No Hub protocol version to read at');
230
+ }
231
+
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
+ */
156
237
  private async send(
157
238
  path: string,
158
239
  body?: unknown,
159
240
  timeoutMs = HUB_TIMEOUT_MS,
160
241
  subject?: HubPrepareSubject,
242
+ steps?: readonly ChainTxMessageType[],
161
243
  ): Promise<unknown> {
162
244
  const request = new Request(new URL(path, this.origin), {
163
245
  method: body === undefined ? 'GET' : 'POST',
@@ -191,6 +273,7 @@ export class HubActionClient {
191
273
  response.status,
192
274
  await response.json().catch(() => null),
193
275
  subject,
276
+ steps,
194
277
  );
195
278
  if (refusal) throw refusal;
196
279
  }
@@ -221,23 +304,34 @@ export class HubActionClient {
221
304
  // amounts) without it, and stores the version per action for the
222
305
  // outcomes it pushes.
223
306
  const operation = parseOperation(input.operation);
307
+ // The version the capabilities read before every prepare settled on.
224
308
  const response = await this.send(
225
- `/api/internal/actions/operations?${HUB_PROTOCOL_QUERY}`,
309
+ `/api/internal/actions/operations?version=${this.protocolVersion}`,
226
310
  { ...input, operation },
227
311
  operation.kind === 'ixo.chain.tx.v1'
228
312
  ? HUB_CHAIN_TX_PREPARE_TIMEOUT_MS
229
313
  : HUB_TIMEOUT_MS,
230
314
  prepareSubjectOf(operation.kind),
315
+ operation.kind === 'ixo.chain.tx.v1'
316
+ ? operation.messages.map((message) => message.type)
317
+ : [],
231
318
  );
232
319
  if (!response || typeof response !== 'object' || !('reference' in response))
233
320
  throw new Error('Hub action response has no reference');
234
321
  return assertHubReferenceUrl(response.reference, this.origin);
235
322
  }
236
323
 
237
- async capabilities(): Promise<ActionCapabilitiesV1> {
238
- return parseCapabilities(
239
- await this.send(
240
- `/api/internal/actions/operations/capabilities?${HUB_PROTOCOL_QUERY}`,
324
+ /** `timeoutMs`: a turn's tool list passes a short one (a slow Hub must not hold a turn). */
325
+ async capabilities(
326
+ timeoutMs = HUB_TIMEOUT_MS,
327
+ ): Promise<ActionCapabilitiesV1> {
328
+ return this.negotiated(async (version) =>
329
+ parseCapabilities(
330
+ await this.send(
331
+ `/api/internal/actions/operations/capabilities?version=${version}`,
332
+ undefined,
333
+ timeoutMs,
334
+ ),
241
335
  ),
242
336
  );
243
337
  }
@@ -249,16 +343,18 @@ export class HubActionClient {
249
343
  // Options version 3 (asked at the protocol version; version 4 answers
250
344
  // the same): version 2 plus the owner's bank tokens
251
345
  // (`transfer.tokens`), which any-token transfers are chosen from.
252
- const query = new URLSearchParams({
253
- bindingId: input.bindingId,
254
- bindingRevision: String(input.bindingRevision),
255
- version: HUB_PROTOCOL_VERSION,
346
+ const options = await this.negotiated(async (version) => {
347
+ const query = new URLSearchParams({
348
+ bindingId: input.bindingId,
349
+ bindingRevision: String(input.bindingRevision),
350
+ version,
351
+ });
352
+ return parseActionOptions(
353
+ await this.send(
354
+ `/api/internal/actions/operations/options?${query.toString()}`,
355
+ ),
356
+ );
256
357
  });
257
- const options = parseActionOptions(
258
- await this.send(
259
- `/api/internal/actions/operations/options?${query.toString()}`,
260
- ),
261
- );
262
358
  if (options.version !== 'ixo.auth-options/3')
263
359
  throw new Error('Hub action options require version 3');
264
360
  return options;
@@ -295,10 +391,12 @@ export class HubActionClient {
295
391
  ): Promise<HubActionStatus> {
296
392
  if (!/^[A-Za-z0-9_-]{32}$/.test(id))
297
393
  throw new Error('Invalid Hub action id');
298
- const raw = await this.send(
299
- `/api/internal/actions/operations/${id}?${HUB_PROTOCOL_QUERY}`,
300
- undefined,
301
- timeoutMs,
394
+ const raw = await this.negotiated((version) =>
395
+ this.send(
396
+ `/api/internal/actions/operations/${id}?version=${version}`,
397
+ undefined,
398
+ timeoutMs,
399
+ ),
302
400
  );
303
401
  if (!isRecord(raw)) throw new Error('Invalid Hub action status');
304
402
  const value = raw;
@@ -7,32 +7,28 @@ import {
7
7
  type ChainTxMessage,
8
8
  type ChainTxOperation,
9
9
  } from '@ixo/action-protocol';
10
+ import type { ChainTxOwnerField, DistributiveOmit } from '../plugin-api/types';
10
11
  import type { HubActionClient } from './client';
11
12
  import { HubActionRefused, type HubActionOrigin } from './store';
12
13
 
13
14
  /**
14
- * A chain-transaction message as the owner object completes it: bank
15
- * amounts already in base units (`bankStepsInBaseUnits`), the owner's
16
- * identity in claims messages still to add (`chainTxOperation`).
15
+ * A chain-transaction message as the owner object completes it: bank and
16
+ * stake amounts already in base units (`chainTxAmountsInBaseUnits`), the
17
+ * owner's identity (`ChainTxOwnerField`) still to add (`chainTxOperation`).
17
18
  */
18
- export type ChainTxHostMessage =
19
- | Extract<
20
- ChainTxMessage,
21
- { type: 'cosmos.bank.send' } | { type: 'ixo.token.transfer' }
22
- >
23
- | Omit<Extract<ChainTxMessage, { type: 'ixo.claims.submit' }>, 'agentDid'>
24
- | Omit<
25
- Extract<ChainTxMessage, { type: 'ixo.claims.evaluate' }>,
26
- 'agentDid' | 'agentAddress'
27
- >;
19
+ export type ChainTxHostMessage = DistributiveOmit<
20
+ ChainTxMessage,
21
+ ChainTxOwnerField
22
+ >;
28
23
 
29
24
  /**
30
25
  * The chain transaction (`ixo.chain.tx.v1`) of the model's messages: the
31
26
  * signer is the owner's account (their DID is `did:ixo:<address>`), and
32
- * the owner's identity in claims messages is the signer's: `agentDid` is
33
- * `did:ixo:<signer>` (the DID the runtime authenticates to the Hub as) and
34
- * an evaluation's `agentAddress` is the signer (the chain records the
35
- * evaluator, and pays it, by that address; Auth Hub requires both). The
27
+ * the owner's identity in the messages is the signer's: `agentDid`,
28
+ * `creatorDid`, `controllerDid` and `ownerDid` are `did:ixo:<signer>` (the
29
+ * DID the runtime authenticates to the Hub as) and an evaluation's
30
+ * `agentAddress` is the signer (the chain records the evaluator, and pays
31
+ * it, by that address; Auth Hub requires each to be the owner's). The
36
32
  * protocol parses the result; a field the runtime sets (signer, network,
37
33
  * chain id) is refused in fixed words, a field from the model's messages
38
34
  * in the protocol's (`Invalid messages[0].tokens[0].id`).
@@ -49,15 +45,26 @@ export function chainTxOperation(input: {
49
45
  const signer = input.ownerDid.startsWith('did:ixo:')
50
46
  ? input.ownerDid.slice('did:ixo:'.length)
51
47
  : '';
52
- const agentDid = `did:ixo:${signer}`;
48
+ const ownDid = `did:ixo:${signer}`;
53
49
  // Identity last: a stray identity field cannot survive.
54
- const messages: ChainTxMessage[] = input.messages.map((message) =>
55
- message.type === 'ixo.claims.submit'
56
- ? { ...message, agentDid }
57
- : message.type === 'ixo.claims.evaluate'
58
- ? { ...message, agentDid, agentAddress: signer }
59
- : message,
60
- );
50
+ const messages = input.messages.map((message): ChainTxMessage => {
51
+ switch (message.type) {
52
+ case 'ixo.claims.submit':
53
+ case 'ixo.claims.claim-intent':
54
+ case 'ixo.claims.dispute':
55
+ return { ...message, agentDid: ownDid };
56
+ case 'ixo.claims.evaluate':
57
+ return { ...message, agentDid: ownDid, agentAddress: signer };
58
+ case 'ixo.claims.create-claim-authorization':
59
+ return { ...message, creatorDid: ownDid };
60
+ case 'ixo.entity.update':
61
+ return { ...message, controllerDid: ownDid };
62
+ case 'ixo.entity.create':
63
+ return { ...message, ownerDid: ownDid };
64
+ default:
65
+ return message;
66
+ }
67
+ });
61
68
  try {
62
69
  return parseOperation({
63
70
  kind: 'ixo.chain.tx.v1',
@@ -1,9 +1,18 @@
1
- import type {
2
- ActionOperationV1,
3
- ActionReceiptV1,
4
- OptionsToken,
1
+ import {
2
+ CHAIN_TX_GROUP_MESSAGE_TYPES,
3
+ parseChainTxRefusal,
4
+ type ActionOperationV1,
5
+ type ActionReceiptV1,
6
+ type ChainTxGroupAction,
7
+ type ChainTxMessageType,
8
+ type OptionsToken,
5
9
  } from '@ixo/action-protocol';
6
- import type { ChainTxMessageInput } from '../plugin-api/types';
10
+ import { chainTxRefusalWords, wordableRefusal } from './chain-tx-refusals';
11
+ import type {
12
+ ChainTxGroupActionInput,
13
+ ChainTxMessageInput,
14
+ ChainTxStakeInput,
15
+ } from '../plugin-api/types';
7
16
  import { quoteHubText } from './hub-text';
8
17
  import type { ChainTxHostMessage } from './request';
9
18
  import { HubActionRefused } from './store';
@@ -90,25 +99,147 @@ const IXO_TOKEN: TransferToken = {
90
99
  verified: true,
91
100
  };
92
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
+
93
120
  /**
94
- * A chain transaction's bank steps in base units; the other messages pass
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
+
180
+ /**
181
+ * A chain transaction's amounts in base units; messages without one pass
95
182
  * unchanged. Each bank step names a display `amount` of a token from the
96
183
  * owner's list (`denom`, absent = uixo): the token must be listed, the
97
184
  * amount converts with its listed decimals (`toBaseUnits`: a plain decimal,
98
185
  * no more digits after the point than the decimals, above zero; an
99
186
  * unverified token lists 0: base units), and the bank steps of one token
100
- * together stay within its listed balance. `tokens` is the owner's list
101
- * (hub_action_options `transfer.tokens`), null when the Hub listed none;
102
- * the caller reads it only when there is a bank step.
187
+ * together stay within its listed balance. Each stake step (stake, unstake,
188
+ * move stake) names a display `amount` of IXO, converted with IXO's 6
189
+ * decimals; what a stake takes from the wallet counts against the listed
190
+ * IXO balance together with the IXO bank steps, when the list has IXO
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`.
103
198
  */
104
- export function bankStepsInBaseUnits(
199
+ export function chainTxAmountsInBaseUnits(
105
200
  messages: readonly ChainTxMessageInput[],
106
201
  tokens: readonly OptionsToken[] | null,
107
202
  ): ChainTxHostMessage[] {
108
203
  const spent = new Map<string, bigint>();
109
- return messages.map((message, index) => {
110
- if (message.type !== 'cosmos.bank.send') return message;
204
+ /** The bank and stake steps of one listed token together, within its listed balance. */
205
+ const spend = (
206
+ at: string,
207
+ token: OptionsToken,
208
+ amount: bigint,
209
+ what: string,
210
+ ) => {
211
+ const total = (spent.get(token.denom) ?? 0n) + amount;
212
+ spent.set(token.denom, total);
213
+ if (total > BigInt(token.balanceBaseUnits))
214
+ throw new HubActionRefused(
215
+ 'refused',
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'}.`,
217
+ );
218
+ };
219
+ return messages.map((message, index): ChainTxHostMessage => {
111
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
+ };
228
+ if (
229
+ message.type === 'cosmos.staking.delegate' ||
230
+ message.type === 'cosmos.staking.undelegate' ||
231
+ message.type === 'cosmos.staking.redelegate'
232
+ ) {
233
+ const { amount: display, ...rest } = message;
234
+ const amount = converted(at, display, IXO_TOKEN.decimals);
235
+ // Only a stake takes IXO from the wallet: unstaking and moving stake
236
+ // work on what is already staked.
237
+ const ixo = tokens?.find((item) => item.denom === IXO_TOKEN.denom);
238
+ if (message.type === 'cosmos.staking.delegate' && ixo)
239
+ spend(at, ixo, amount, 'this stake');
240
+ return { ...rest, amountBaseUnits: amount.toString() };
241
+ }
242
+ if (message.type !== 'cosmos.bank.send') return message;
112
243
  if (!tokens?.length)
113
244
  throw new HubActionRefused(
114
245
  'refused',
@@ -124,21 +255,8 @@ export function bankStepsInBaseUnits(
124
255
  .slice(0, 10)
125
256
  .join(', ')}).`,
126
257
  );
127
- let amount: bigint;
128
- try {
129
- amount = toBaseUnits(message.amount, token.decimals);
130
- } catch (error) {
131
- if (error instanceof HubActionRefused)
132
- throw new HubActionRefused('refused', `${at}.amount: ${error.message}`);
133
- throw error;
134
- }
135
- const total = (spent.get(token.denom) ?? 0n) + amount;
136
- spent.set(token.denom, total);
137
- if (total > BigInt(token.balanceBaseUnits))
138
- throw new HubActionRefused(
139
- 'refused',
140
- `${at}.amount: the owner holds ${describeAmount(token.balanceBaseUnits, token)}, less than ${total === amount ? 'this send' : 'the bank steps of this token in this transaction'}.`,
141
- );
258
+ const amount = converted(at, message.amount, token.decimals);
259
+ spend(at, token, amount, 'this send');
142
260
  return {
143
261
  type: 'cosmos.bank.send',
144
262
  toAddress: message.toAddress,
@@ -199,10 +317,26 @@ export function prepareSubjectOf(
199
317
  : 'operation';
200
318
  }
201
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
+
202
336
  /** What the model is told when Auth Hub refuses a prepare (400). */
203
337
  const HUB_PREPARE_REFUSALS: Record<
204
338
  string,
205
- (subject: HubPrepareSubject) => string
339
+ (subject: HubPrepareSubject, steps: readonly ChainTxMessageType[]) => string
206
340
  > = {
207
341
  simulation_failed: (subject) =>
208
342
  `Auth Hub simulated this ${subject} and it would fail on chain`,
@@ -215,10 +349,11 @@ const HUB_PREPARE_REFUSALS: Record<
215
349
  // it or tell the owner what was wrong.
216
350
  operation_not_registered: (subject) =>
217
351
  `Auth Hub did not accept this ${subject} as written`,
218
- // The chain's authorization module refused the simulation: for claims,
219
- // the collection admin has not authorized this account.
220
- not_authorized: (subject) =>
221
- `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)}`,
222
357
  };
223
358
  /** Appended to every refusal no retry or change of arguments can lift. */
224
359
  const NOT_FIXABLE = 'retrying or changing the fields will not help';
@@ -310,12 +445,31 @@ export function hubPrepareRefusal(
310
445
  status: number,
311
446
  body: unknown,
312
447
  subject: HubPrepareSubject = 'transfer',
448
+ steps: readonly ChainTxMessageType[] = [],
313
449
  ): HubActionRefused | null {
314
450
  const parsed = hubErrorBody(body);
315
451
  const unfixable = unfixableRefusal(status, parsed, subject);
316
452
  if (unfixable)
317
453
  return new HubActionRefused('refused', unfixable, undefined, status);
318
454
  if (!parsed) return null;
455
+ // A chain transaction Auth Hub would not make from what it read on chain,
456
+ // or whose simulation named one message: a fixed code, in the runtime's
457
+ // own words (the reason text is not passed on). Without a code, the
458
+ // reason is passed on quoted, as below.
459
+ const coded =
460
+ status === 400 &&
461
+ subject === 'transaction' &&
462
+ (parsed.error === 'operation_not_registered' ||
463
+ parsed.error === 'simulation_failed')
464
+ ? wordableRefusal(parseChainTxRefusal(parsed.reason))
465
+ : null;
466
+ if (coded)
467
+ return new HubActionRefused(
468
+ 'refused',
469
+ chainTxRefusalWords(coded),
470
+ undefined,
471
+ status,
472
+ );
319
473
  const reason = parsed.reason
320
474
  ? quoteHubText('reason', parsed.reason, HUB_REASON_MAX)
321
475
  : '';
@@ -326,7 +480,7 @@ export function hubPrepareRefusal(
326
480
  if (words)
327
481
  return new HubActionRefused(
328
482
  'refused',
329
- `${words(subject)}.${reason ? ` ${reason}.` : ''}`,
483
+ `${words(subject, steps)}.${reason ? ` ${reason}.` : ''}`,
330
484
  undefined,
331
485
  status,
332
486
  );