@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.31.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.7.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",
@@ -67,9 +67,9 @@
67
67
  "protobufjs": "^7.6.5",
68
68
  "@ixo/topic-protocol": "1.0.0-rc.8",
69
69
  "survey-core": "2.3.11",
70
+ "@ixo/common": "1.7.1",
70
71
  "@ixo/ixo-transaction": "0.2.0",
71
- "@ixo/ucan": "2.3.0",
72
- "@ixo/common": "1.7.1"
72
+ "@ixo/ucan": "2.3.0"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -7,6 +7,7 @@ import { type AgentMiddleware, createMiddleware } from 'langchain';
7
7
  import type { Logger } from '../../plugin-api/types';
8
8
  import { NOOP_LOGGER } from '../utils';
9
9
  import { isCapabilityGateRefusal } from './capability-gate';
10
+ import { isToolNotRun } from '../tool-not-run';
10
11
  import { turnCarryOf, turnStart } from './turn-boundary';
11
12
 
12
13
  export { turnStart };
@@ -145,9 +146,11 @@ export const createToolRepetitionGuardMiddleware = (
145
146
  const msg = messages[i];
146
147
  if (!(msg instanceof ToolMessage)) continue;
147
148
  if (msg.status !== 'error') continue;
148
- // The capability gate refused it without running it; after a
149
- // `load_capability` the same call is the expected next step.
150
- if (isCapabilityGateRefusal(msg)) continue;
149
+ // The capability gate refused it without running it, or the tool
150
+ // said it did not run until a step comes first: after that step
151
+ // (`load_capability`, reading the rules) the same call is the
152
+ // expected next one.
153
+ if (isCapabilityGateRefusal(msg) || isToolNotRun(msg)) continue;
151
154
  if (msg.name !== toolName) continue;
152
155
 
153
156
  const priorArgs = argsById.get(msg.tool_call_id);
@@ -22,6 +22,7 @@ import {
22
22
  } from '@langchain/core/messages';
23
23
  import { isAttachmentViewMessage } from '../../attachments/retention';
24
24
  import { isCapabilityGateRefusal } from './capability-gate';
25
+ import { isToolNotRun } from '../tool-not-run';
25
26
 
26
27
  export const SUMMARY_PREFIX = 'Here is a summary of the conversation to date:';
27
28
 
@@ -140,7 +141,11 @@ export function carriedCallsOfTurn(
140
141
  if (call.id) calls.set(call.id, { name: call.name, args: call.args });
141
142
  continue;
142
143
  }
143
- if (!ToolMessage.isInstance(message) || isCapabilityGateRefusal(message))
144
+ if (
145
+ !ToolMessage.isInstance(message) ||
146
+ isCapabilityGateRefusal(message) ||
147
+ isToolNotRun(message)
148
+ )
144
149
  continue;
145
150
  const call = calls.get(message.tool_call_id);
146
151
  if (!call) continue;
@@ -0,0 +1,30 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
2
+
3
+ /** `response_metadata` key of a result whose tool did not run (never sent to a provider). */
4
+ const NOT_RUN = 'toolNotRun';
5
+
6
+ /**
7
+ * A tool result saying the tool did not run because another step must come
8
+ * first (for example reading a message type's rules before preparing it).
9
+ * It is an error result, so it never counts as a write that happened, and
10
+ * like a capability-gate refusal the repetition guard lets the identical
11
+ * call through once the step is done (`isToolNotRun`).
12
+ */
13
+ export function toolNotRunMessage(input: {
14
+ toolCallId: string;
15
+ name: string;
16
+ content: string;
17
+ }): ToolMessage {
18
+ return new ToolMessage({
19
+ tool_call_id: input.toolCallId,
20
+ name: input.name,
21
+ content: input.content,
22
+ status: 'error',
23
+ response_metadata: { [NOT_RUN]: true },
24
+ });
25
+ }
26
+
27
+ /** A result `toolNotRunMessage` made: the tool did not run. */
28
+ export function isToolNotRun(message: ToolMessage): boolean {
29
+ return message.response_metadata?.[NOT_RUN] === true;
30
+ }
@@ -1,3 +1,4 @@
1
+ import { ToolMessage } from '@langchain/core/messages';
1
2
  import { Command } from '@langchain/langgraph';
2
3
  import { capToolResult, type ResultCapConfig } from './middlewares/result-cap';
3
4
  import { tool } from '@langchain/core/tools';
@@ -125,7 +126,14 @@ export function wrapPluginTool(
125
126
  if (pluginName !== undefined)
126
127
  requireToolPlane(ctx, pluginName, pluginTool);
127
128
  const output = await pluginTool.handler(args, ctx);
128
- if (!resultCap || output instanceof Command || output == null)
129
+ // A tool's own message (a result that says the tool did not run) is
130
+ // returned whole, never capped into text.
131
+ if (
132
+ !resultCap ||
133
+ output instanceof Command ||
134
+ ToolMessage.isInstance(output) ||
135
+ output == null
136
+ )
129
137
  return output;
130
138
  const text = typeof output === 'string' ? output : JSON.stringify(output);
131
139
  if (typeof text !== 'string' || text.length <= resultCap.capChars)
@@ -54,7 +54,8 @@ import {
54
54
  import { chainTxOperation, prepareHubAction } from '../hub-actions/request';
55
55
  import {
56
56
  bankReceiptSummary,
57
- bankStepsInBaseUnits,
57
+ chainTxAmountsInBaseUnits,
58
+ chainTxNeedsTokenList,
58
59
  } from '../hub-actions/transfer';
59
60
  import {
60
61
  HUB_CHECK_AFTER_EXPIRY_MS,
@@ -638,6 +639,14 @@ const ACTION_TICK_BUDGET_MS = 15_000;
638
639
  const ACTION_PRUNE_INTERVAL_MS = 60 * 60_000;
639
640
  /** How long the Hub's capabilities answer is reused per object. */
640
641
  const HUB_CAPABILITIES_CACHE_MS = 60_000;
642
+ /**
643
+ * A capabilities read on a turn's critical path (the chain transaction
644
+ * tool's types): a short timeout, and after a failed read none for this
645
+ * long, so a slow or failing Hub costs a turn at most this timeout once per
646
+ * window instead of the full Hub timeout on every turn.
647
+ */
648
+ const HUB_TURN_CAPABILITIES_TIMEOUT_MS = 3_000;
649
+ const HUB_CAPABILITIES_FAILURE_CACHE_MS = 30_000;
641
650
  /** A Hub network name as the chain-id config key it is configured under. */
642
651
  /**
643
652
  * Whether two Hub outcomes are the same event. Receipts are compared in the
@@ -1054,11 +1063,17 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1054
1063
  private actionProposals: ActionProposalStore | null = null;
1055
1064
  private hubActionStore: HubActionStore | null = null;
1056
1065
  private hubActionClient: HubActionClient | null = null;
1057
- /** The Hub's capabilities for this oracle account, reused for `HUB_CAPABILITIES_CACHE_MS`. */
1066
+ /** The Hub's capabilities for this oracle account, reused for `HUB_CAPABILITIES_CACHE_MS` (and on a turn, while a read fails). */
1058
1067
  private hubCapabilities: {
1059
1068
  value: ActionCapabilitiesV1;
1060
1069
  at: number;
1061
1070
  } | null = null;
1071
+ /** The last failed capabilities read: a turn does not read again within `HUB_CAPABILITIES_FAILURE_CACHE_MS`. */
1072
+ private hubCapabilitiesFailure: { error: unknown; at: number } | null =
1073
+ null;
1074
+ /** A turn's capabilities read in flight, shared by concurrent turns. */
1075
+ private hubTurnCapabilitiesRead: Promise<ActionCapabilitiesV1> | null =
1076
+ null;
1062
1077
  /** The last `hub_action_options` answer for this owner (Gmail connection checks). */
1063
1078
  private lastHubOptions: {
1064
1079
  value: HubActionOptionsV2 | HubActionOptionsV3;
@@ -2277,7 +2292,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2277
2292
  ...(this.hubActionStore && this.hubActionClient
2278
2293
  ? {
2279
2294
  hubActions: {
2280
- capabilities: () => this.cachedHubCapabilities(),
2295
+ capabilities: (opts) =>
2296
+ opts?.turn
2297
+ ? this.turnHubCapabilities()
2298
+ : this.cachedHubCapabilities(),
2281
2299
  options: (input) => this.hubActionOptions(input),
2282
2300
  request: (input: {
2283
2301
  ownerDid: string;
@@ -2385,6 +2403,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2385
2403
  this.hubActionStore = null;
2386
2404
  this.hubActionClient = null;
2387
2405
  this.hubCapabilities = null;
2406
+ this.hubCapabilitiesFailure = null;
2407
+ this.hubTurnCapabilitiesRead = null;
2388
2408
  this.lastHubOptions = null;
2389
2409
  this.topicProposals = null;
2390
2410
  this.attachmentTextCache = null;
@@ -3524,16 +3544,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3524
3544
  }
3525
3545
 
3526
3546
  /** The Hub's capabilities, cached per object for `HUB_CAPABILITIES_CACHE_MS`. */
3527
- private async cachedHubCapabilities(): Promise<ActionCapabilitiesV1> {
3547
+ private async cachedHubCapabilities(
3548
+ timeoutMs?: number,
3549
+ ): Promise<ActionCapabilitiesV1> {
3528
3550
  const client = this.hubActionClient;
3529
3551
  if (!client) throw new Error('Hub actions are unavailable');
3530
3552
  const now = Date.now();
3531
3553
  const cached = this.hubCapabilities ?? null;
3532
3554
  if (cached && now - cached.at < HUB_CAPABILITIES_CACHE_MS)
3533
3555
  return cached.value;
3534
- const value = await client.capabilities();
3535
- this.hubCapabilities = { value, at: now };
3536
- return value;
3556
+ try {
3557
+ const value = await client.capabilities(timeoutMs);
3558
+ this.hubCapabilities = { value, at: Date.now() };
3559
+ this.hubCapabilitiesFailure = null;
3560
+ return value;
3561
+ } catch (error) {
3562
+ this.hubCapabilitiesFailure = { error, at: Date.now() };
3563
+ throw error;
3564
+ }
3565
+ }
3566
+
3567
+ /**
3568
+ * The capabilities for a turn's tool list: the cached ones while fresh;
3569
+ * else one read with `HUB_TURN_CAPABILITIES_TIMEOUT_MS`, shared by
3570
+ * concurrent turns and not repeated within
3571
+ * `HUB_CAPABILITIES_FAILURE_CACHE_MS` of a failed read. While reads
3572
+ * fail, the last capabilities read are answered (the Hub enforces its
3573
+ * own on every prepare); with none, the failure.
3574
+ */
3575
+ private async turnHubCapabilities(): Promise<ActionCapabilitiesV1> {
3576
+ const cached = this.hubCapabilities;
3577
+ if (cached && Date.now() - cached.at < HUB_CAPABILITIES_CACHE_MS)
3578
+ return cached.value;
3579
+ const failure = this.hubCapabilitiesFailure;
3580
+ if (
3581
+ failure &&
3582
+ Date.now() - failure.at < HUB_CAPABILITIES_FAILURE_CACHE_MS
3583
+ ) {
3584
+ if (cached) return cached.value;
3585
+ throw failure.error;
3586
+ }
3587
+ this.hubTurnCapabilitiesRead ??= this.cachedHubCapabilities(
3588
+ HUB_TURN_CAPABILITIES_TIMEOUT_MS,
3589
+ ).finally(() => {
3590
+ this.hubTurnCapabilitiesRead = null;
3591
+ });
3592
+ try {
3593
+ return await this.hubTurnCapabilitiesRead;
3594
+ } catch (error) {
3595
+ if (cached) return cached.value;
3596
+ throw error;
3597
+ }
3537
3598
  }
3538
3599
 
3539
3600
  /**
@@ -3586,13 +3647,15 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3586
3647
  /**
3587
3648
  * One chain transaction of the model's messages. The network is the
3588
3649
  * account's capability, the chain id this Worker's own for that
3589
- * network; the signer and the owner's identity in claims messages come
3650
+ * network; the signer and the owner's identity in the messages come
3590
3651
  * from the owner's DID (`chainTxOperation`). The model names none of
3591
- * them. Bank steps name a listed token and a display amount: with at
3592
- * least one, the owner's options are read once, fresh (the balances
3593
- * must be current), and each step is converted to base units and
3594
- * checked against the listed balance (`bankStepsInBaseUnits`). No fee
3595
- * is sent: the Hub simulates gas and sets it.
3652
+ * them. Bank steps name a listed token and a display amount, stake
3653
+ * steps an amount of IXO: with at least one of either, the owner's
3654
+ * options are read once, fresh (the balances must be current), and each
3655
+ * amount is converted to base units and checked against the listed
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.
3596
3659
  */
3597
3660
  private async chainTransactionHubAction(input: {
3598
3661
  ownerDid: string;
@@ -3623,9 +3686,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3623
3686
  const chainId = chainIdsFromConfig(chainIdConfigSchema.parse(this.env))[
3624
3687
  HUB_NETWORK_CHAIN[capabilities.network]
3625
3688
  ];
3626
- const tokens = input.messages.some(
3627
- (message) => message.type === 'cosmos.bank.send',
3628
- )
3689
+ const tokens = chainTxNeedsTokenList(input.messages)
3629
3690
  ? ((await this.hubActionOptions(input, true)).transfer?.tokens ?? null)
3630
3691
  : null;
3631
3692
  const operation = chainTxOperation({
@@ -3633,7 +3694,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3633
3694
  chainId,
3634
3695
  network: capabilities.network,
3635
3696
  memo: input.memo,
3636
- messages: bankStepsInBaseUnits(input.messages, tokens),
3697
+ messages: chainTxAmountsInBaseUnits(input.messages, tokens),
3637
3698
  });
3638
3699
  return this.requestHubAction({ ...input, operation }, true);
3639
3700
  }
@@ -0,0 +1,307 @@
1
+ import {
2
+ CHAIN_TX_MAX_MESSAGES,
3
+ CHAIN_TX_REFUSAL_CODES,
4
+ type ChainTxRefusal,
5
+ type ChainTxRefusalCode,
6
+ } from '@ixo/action-protocol';
7
+
8
+ /**
9
+ * What each chain transaction refusal code means, for the person (`means`,
10
+ * plain words the model may say) and what the model does next (`next`).
11
+ * Auth Hub sends the code when it will not make a card from what it read on
12
+ * chain, or when the node's simulation names one message
13
+ * (`invalid_message: messages[<i>].<field>: <code>`); the reason text itself
14
+ * is never given to the person. `satisfies` makes every code of the
15
+ * protocol need an entry.
16
+ */
17
+ export const CHAIN_TX_REFUSAL_WORDS = {
18
+ validator_not_found: {
19
+ means: 'there is no validator with that address on the network',
20
+ next: 'Find the validator again with chain_lookup validators and copy its address exactly.',
21
+ },
22
+ no_stake: {
23
+ means: 'they have nothing staked with this validator',
24
+ next: 'Check where their stake is with chain_lookup staking.',
25
+ },
26
+ stake_too_small: {
27
+ means: 'that is more than they have staked with this validator',
28
+ next: 'Check how much is staked there with chain_lookup staking and ask for a smaller amount.',
29
+ },
30
+ same_validator: {
31
+ means: 'the stake is already with that validator',
32
+ next: 'Ask which other validator it should move to.',
33
+ },
34
+ invalid_amount: {
35
+ means: 'the network does not accept that amount',
36
+ next: 'Ask for the amount again.',
37
+ },
38
+ nothing_to_collect: {
39
+ means: 'there are no rewards to collect from this validator yet',
40
+ next: 'Leave this validator out of the card.',
41
+ },
42
+ proposal_not_found: {
43
+ means: 'there is no proposal with that number',
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
+ },
46
+ proposal_not_open: {
47
+ means:
48
+ 'voting on this proposal is not open (it has closed, or a network proposal has not started yet)',
49
+ next: 'Tell them; a vote cannot be recorded now.',
50
+ },
51
+ invalid_vote_option: {
52
+ means: 'that is not a vote the network accepts',
53
+ next: 'Ask whether they vote yes, no, abstain or no with veto.',
54
+ },
55
+ token_not_found: {
56
+ means: 'one of these impact tokens could not be found',
57
+ next: 'Check the tokens they hold with chain_lookup token_balances.',
58
+ },
59
+ collection_not_found: {
60
+ means: 'there is no claim collection with that number',
61
+ next: 'Find the collection with chain_lookup claim_collections.',
62
+ },
63
+ reservations_off: {
64
+ means: 'this collection does not take reservations',
65
+ next: 'File the claim without a reservation.',
66
+ },
67
+ reservation_exists: {
68
+ means: 'they already have a reservation in this collection',
69
+ next: 'File the claim with it, or wait for it to run out.',
70
+ },
71
+ claim_not_found: {
72
+ means: 'there is no claim with that id',
73
+ next: 'Find the claim with chain_lookup claims.',
74
+ },
75
+ claim_in_other_collection: {
76
+ means: 'that claim belongs to a different collection',
77
+ next: 'Check the claim’s collection with chain_lookup claim.',
78
+ },
79
+ wrong_admin: {
80
+ means: 'that is not the collection’s admin account',
81
+ next: 'Copy the admin account from chain_lookup claim_collection.',
82
+ },
83
+ already_in_state: {
84
+ means: 'the collection is already in that state',
85
+ next: 'Nothing needs to change; tell them.',
86
+ },
87
+ start_date_missing: {
88
+ means: 'the collection has no start date yet',
89
+ next: 'Ask when it should open and include that start date.',
90
+ },
91
+ end_before_start: {
92
+ means: 'the end date would come before the start date',
93
+ next: 'Ask for dates where the end comes after the start.',
94
+ },
95
+ quota_below_count: {
96
+ means: 'the new limit is below the number of claims already filed',
97
+ next: 'Check the count with chain_lookup claim_collection and ask for a higher limit.',
98
+ },
99
+ token_payments_present: {
100
+ means:
101
+ 'that payment includes contract tokens, which cannot be changed from here',
102
+ next: 'Leave that payment out of the card so it stays as it is.',
103
+ },
104
+ ends_too_soon: {
105
+ means:
106
+ 'the end date is too soon: it has to be after this review link expires',
107
+ next: 'Ask for a later end date.',
108
+ },
109
+ deadline_too_soon: {
110
+ means:
111
+ 'the date to decide by is too soon: it has to be after this review link expires',
112
+ next: 'Ask for a later date.',
113
+ },
114
+ current_permission_ends_too_soon: {
115
+ means: 'their current permission ends too soon to keep its end date',
116
+ next: 'Ask whether to set a new end date.',
117
+ },
118
+ unlimited_permission_exists: {
119
+ means: 'they already have an unlimited permission of this kind',
120
+ next: 'It has to be removed first; ask whether to remove it in the same card.',
121
+ },
122
+ other_permission_exists: {
123
+ means: 'a different kind of permission is already set up for them here',
124
+ next: 'It has to be removed first; ask whether to remove it in the same card.',
125
+ },
126
+ too_many_constraints: {
127
+ means:
128
+ 'this permission already covers too many collections to change it from a card',
129
+ next: 'Some collections have to be removed from it first; tell them.',
130
+ },
131
+ no_such_permission: {
132
+ means: 'they do not have this permission',
133
+ next: 'Check what they have with chain_lookup authz_grants.',
134
+ },
135
+ collection_of_other_entity: {
136
+ means: 'that collection belongs to a different entity',
137
+ next: 'Check the collection with chain_lookup claim_collection.',
138
+ },
139
+ entity_not_found: {
140
+ means: 'there is no entity with that id',
141
+ next: 'Find the entity with chain_lookup entities.',
142
+ },
143
+ account_name_taken: {
144
+ means: 'this entity already has an account with that name',
145
+ next: 'Ask for another name.',
146
+ },
147
+ document_not_found: {
148
+ means: 'there is no identity record with that id',
149
+ next: 'Check the id with chain_lookup did_document.',
150
+ },
151
+ nothing_to_remove: {
152
+ means: 'that item is not in the identity record',
153
+ next: 'Read the record with chain_lookup did_document and copy the id exactly.',
154
+ },
155
+ already_exists: {
156
+ means: 'an item with that id is already there',
157
+ next: 'Choose another id, or remove the old item first.',
158
+ },
159
+ not_available: {
160
+ means: 'that isn’t available on this service yet',
161
+ next: 'Do not retry; tell the person it cannot be done here for now.',
162
+ },
163
+ chain_data_too_large: {
164
+ means: 'this is too large to change in one go here',
165
+ next: 'Do not retry; tell the person it cannot be done through chat.',
166
+ },
167
+ invalid_did: {
168
+ means: 'one of the identifiers is not a valid ID on the network',
169
+ next: 'Copy it again exactly from a lookup.',
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
+ },
255
+ } as const satisfies Record<
256
+ ChainTxRefusalCode,
257
+ { means: string; next: string }
258
+ >;
259
+
260
+ /**
261
+ * A message field path as the protocol names one (`validatorAddress`,
262
+ * `payments.approval.amount[1].denom`): checked here as well, because the
263
+ * path is repeated in text the model reads.
264
+ */
265
+ const REFUSAL_FIELD =
266
+ /^[A-Za-z][A-Za-z0-9_]*(?:\[\d{1,3}\])?(?:\.[A-Za-z][A-Za-z0-9_]*(?:\[\d{1,3}\])?){0,5}$/;
267
+
268
+ /**
269
+ * A parsed refusal the runtime puts into words: a message index 0..7, a
270
+ * plain field path of at most 100 characters and a code of the protocol's
271
+ * list. Anything else is passed on as the Hub's quoted reason instead.
272
+ */
273
+ export function wordableRefusal(
274
+ refusal: ChainTxRefusal | null,
275
+ ): (ChainTxRefusal & { code: ChainTxRefusalCode }) | null {
276
+ if (!refusal) return null;
277
+ const { index, field, code } = refusal;
278
+ if (
279
+ !Number.isInteger(index) ||
280
+ index < 0 ||
281
+ index >= CHAIN_TX_MAX_MESSAGES ||
282
+ field.length > 100 ||
283
+ !REFUSAL_FIELD.test(field) ||
284
+ code === null ||
285
+ !CHAIN_TX_REFUSAL_CODES.includes(code)
286
+ )
287
+ return null;
288
+ return { index, field, code };
289
+ }
290
+
291
+ /**
292
+ * A coded chain transaction refusal in the model's words: which step, what
293
+ * it means for the person and the next step. The field and code are for
294
+ * the model only; the Hub's reason text is not repeated.
295
+ */
296
+ export function chainTxRefusalWords(
297
+ refusal: ChainTxRefusal & { code: ChainTxRefusalCode },
298
+ ): string {
299
+ const words = CHAIN_TX_REFUSAL_WORDS[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}).`;
307
+ }