@ixo/oracle-runtime-workers 0.24.0 → 0.25.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.24.0",
3
+ "version": "0.25.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": {
@@ -33,7 +33,7 @@
33
33
  "!src/plugins/pod-creator/test-fixtures.ts"
34
34
  ],
35
35
  "dependencies": {
36
- "@ixo/action-protocol": "0.4.1",
36
+ "@ixo/action-protocol": "0.5.0",
37
37
  "@blocknote/server-util": "0.29.1",
38
38
  "@composio/core": "0.10.0",
39
39
  "@composio/langchain": "0.9.2",
@@ -65,12 +65,13 @@
65
65
  "protobufjs": "^7.6.5",
66
66
  "@ixo/topic-protocol": "1.0.0-rc.8",
67
67
  "@ixo/common": "1.7.1",
68
- "@ixo/ixo-transaction": "0.2.0",
69
- "@ixo/ucan": "2.3.0"
68
+ "@ixo/ucan": "2.3.0",
69
+ "@ixo/ixo-transaction": "0.2.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@cloudflare/vitest-pool-workers": "^0.22.0",
73
73
  "@cloudflare/workers-types": "^5.20260825.1",
74
+ "@ixo/action-protocol-041": "npm:@ixo/action-protocol@0.4.1",
74
75
  "@modelcontextprotocol/sdk": "1.30.0",
75
76
  "happy-dom": "^19.0.0",
76
77
  "typescript": "^5.9.3",
@@ -76,12 +76,6 @@ export const ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS = 180 * 24 * 60 * 60_000;
76
76
  * still possible, so the link stays used up.
77
77
  */
78
78
  export const ACTION_PROPOSAL_EXECUTION_WINDOW_MS = 60 * 60_000;
79
- /** Cards one agent run may prepare; more is a looping run, refused. */
80
- export const ACTION_PROPOSALS_PER_RUN = 8;
81
- /** Cards still awaiting the owner (live `prepared`, `claimed`, hash-less `unknown`) per owner. */
82
- export const ACTION_PROPOSALS_OPEN_PER_OWNER = 16;
83
- /** A `prepared` card counts as open this long after creation (its TTL plus clock skew). */
84
- export const OPEN_PREPARED_MS = 20 * 60_000;
85
79
 
86
80
  /**
87
81
  * A card's claim time from its lifecycle JSON, NULL when the JSON is not
@@ -256,11 +250,6 @@ export class ActionProposalStore {
256
250
  }
257
251
  if (Date.parse(subject.expiresAt) <= this.now())
258
252
  throw new ActionProposalConflict('Proposal expired');
259
- await this.assertCapacity(
260
- subject.ownerDid,
261
- subject.origin.sessionId,
262
- subject.origin.runId,
263
- );
264
253
  await this.db.run(
265
254
  `INSERT INTO action_proposals (id,owner_did,invocation_id,session_id,run_id,subject,digest,lifecycle,status,created_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
266
255
  [
@@ -293,35 +282,6 @@ export class ActionProposalStore {
293
282
  });
294
283
  }
295
284
 
296
- /**
297
- * A looping or injected run must not mint cards without bound: at most
298
- * `ACTION_PROPOSALS_PER_RUN` per run and `ACTION_PROPOSALS_OPEN_PER_OWNER`
299
- * still awaiting the owner. A resumed tool call never reaches here (it
300
- * returns its original card above).
301
- */
302
- private async assertCapacity(
303
- ownerDid: string,
304
- sessionId: string,
305
- runId: string,
306
- ): Promise<void> {
307
- const run = await this.db.get<{ n: number }>(
308
- `SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
309
- [ownerDid, sessionId, runId],
310
- );
311
- if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
312
- throw new ActionProposalConflict(
313
- 'Too many action cards in this request; finish the open ones first',
314
- );
315
- const open = await this.db.get<{ n: number }>(
316
- `SELECT COUNT(*) AS n FROM action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR status='claimed' OR (status='unknown' AND transaction_hash IS NULL))`,
317
- [ownerDid, this.now() - OPEN_PREPARED_MS],
318
- );
319
- if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
320
- throw new ActionProposalConflict(
321
- 'Too many action cards are open; finish or decline them first',
322
- );
323
- }
324
-
325
285
  async get(ownerDid: string, id: string): Promise<ActionProposal | null> {
326
286
  const row = await this.db.get<{
327
287
  subject: string;
@@ -24,9 +24,6 @@ import {
24
24
  ActionProposalConflict,
25
25
  ACTION_PROPOSAL_RETENTION_MS,
26
26
  ACTION_PROPOSAL_UNRESOLVED_RETENTION_MS,
27
- ACTION_PROPOSALS_OPEN_PER_OWNER,
28
- ACTION_PROPOSALS_PER_RUN,
29
- OPEN_PREPARED_MS,
30
27
  RECEIPT_MAX_ATTEMPTS,
31
28
  RECEIPT_RETRY_BASE_MS,
32
29
  RECEIPT_RETRY_MAX_MS,
@@ -163,11 +160,6 @@ export class TopicProposalStore {
163
160
  }
164
161
  if (Date.parse(subject.expiresAt) <= this.now())
165
162
  throw new ActionProposalConflict('Proposal expired');
166
- await this.assertCapacity(
167
- subject.ownerDid,
168
- subject.origin.sessionId,
169
- subject.origin.runId,
170
- );
171
163
  const steps: TopicStepId[] =
172
164
  subject.action.name === 'create_topic'
173
165
  ? ['anchor', 'body', 'operation', 'snapshot']
@@ -201,40 +193,6 @@ export class TopicProposalStore {
201
193
  return TopicProposalV2Schema.parse({ ...subject, digest, lifecycle });
202
194
  });
203
195
  }
204
- /**
205
- * At most `ACTION_PROPOSALS_PER_RUN` cards per run and
206
- * `ACTION_PROPOSALS_OPEN_PER_OWNER` open ones (prepared in the last 20
207
- * minutes, or claimed / unknown in the last two hours: the claim window
208
- * plus the execution window). The owner object runs this before the
209
- * authority read a refused card would waste; `create` runs it again.
210
- */
211
- async assertCapacity(
212
- owner: string,
213
- sessionId: string,
214
- runId: string,
215
- ): Promise<void> {
216
- const now = this.now();
217
- const run = await this.db.get<{ n: number }>(
218
- `SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND session_id=? AND run_id=?`,
219
- [owner, sessionId, runId],
220
- );
221
- if ((run?.n ?? 0) >= ACTION_PROPOSALS_PER_RUN)
222
- throw new ActionProposalConflict(
223
- 'Too many action cards in this request; finish the open ones first',
224
- );
225
- const open = await this.db.get<{ n: number }>(
226
- `SELECT COUNT(*) AS n FROM topic_action_proposals WHERE owner_did=? AND ((status='prepared' AND created_at>?) OR (status IN ('claimed','unknown') AND created_at>?))`,
227
- [
228
- owner,
229
- now - OPEN_PREPARED_MS,
230
- now - OPEN_PREPARED_MS - TOPIC_EXECUTION_WINDOW_MS - 40 * 60_000,
231
- ],
232
- );
233
- if ((open?.n ?? 0) >= ACTION_PROPOSALS_OPEN_PER_OWNER)
234
- throw new ActionProposalConflict(
235
- 'Too many action cards are open; finish or decline them first',
236
- );
237
- }
238
196
  async get(owner: string, id: string): Promise<TopicActionProposal | null> {
239
197
  const row = await this.db.get<{
240
198
  subject: string;
@@ -163,7 +163,10 @@ export function draftReplyPlan(input: {
163
163
  else parts.push({ kind: 'spill', key: `step-${i}`, spill: shape.spill });
164
164
  }
165
165
  for (const call of step.toolCalls) {
166
- if (call.name === 'request_hub_action') {
166
+ if (
167
+ call.name === 'request_hub_action' ||
168
+ call.name === 'request_hub_transfer'
169
+ ) {
167
170
  try {
168
171
  const result = JSON.parse(input.toolResults.get(call.id) ?? 'null');
169
172
  if (result?.status === 'prepared') {
@@ -85,7 +85,6 @@ export interface OracleWorkerEnv {
85
85
  AUTH_HUB_URL?: string;
86
86
  AUTH_HUB_CHANNEL_SERVICE_KEY?: string;
87
87
  HUB_ACTIONS_ENABLED?: string;
88
- AUTH_HUB_ACTION_OUTCOME_KEY?: string;
89
88
 
90
89
  // --- matrix -------------------------------------------------------------
91
90
  MATRIX_BASE_URL: string;
@@ -39,6 +39,11 @@ import type {
39
39
  import { ChannelTurns } from '../channels/turns';
40
40
  import { HubActionClient } from '../hub-actions/client';
41
41
  import { prepareHubAction } from '../hub-actions/request';
42
+ import {
43
+ bankReceiptSummary,
44
+ describeAmount,
45
+ toBaseUnits,
46
+ } from '../hub-actions/transfer';
42
47
  import {
43
48
  HUB_CHECK_AFTER_EXPIRY_MS,
44
49
  HubActionRefused,
@@ -49,6 +54,9 @@ import {
49
54
  canonicalJson,
50
55
  type ActionCapabilitiesV1,
51
56
  type HubActionOptionsV2,
57
+ type HubActionOptionsV3,
58
+ parseOperation,
59
+ v1Receipt,
52
60
  type ActionOperationV1,
53
61
  type ActionOutcomeV1,
54
62
  } from '@ixo/action-protocol';
@@ -599,6 +607,25 @@ const ACTION_TICK_BUDGET_MS = 15_000;
599
607
  const ACTION_PRUNE_INTERVAL_MS = 60 * 60_000;
600
608
  /** How long the Hub's capabilities answer is reused per object. */
601
609
  const HUB_CAPABILITIES_CACHE_MS = 60_000;
610
+ /** A Hub network name as the chain-id config key it is configured under. */
611
+ /**
612
+ * Whether two Hub outcomes are the same event. Receipts are compared in the
613
+ * 0.4.1 shape: an action prepared at protocol version 2 (runtime 0.23.0) gets
614
+ * its callback without the 0.5.0 bank fields (`denom`, `amountBaseUnits`),
615
+ * while the confirming status read (version 3) carries them.
616
+ */
617
+ function sameHubOutcome(a: ActionOutcomeV1, b: ActionOutcomeV1): boolean {
618
+ return (
619
+ canonicalJson({ ...a, receipt: v1Receipt(a.receipt) }) ===
620
+ canonicalJson({ ...b, receipt: v1Receipt(b.receipt) })
621
+ );
622
+ }
623
+
624
+ const HUB_NETWORK_CHAIN = {
625
+ 'ixo mainnet': 'mainnet',
626
+ 'ixo testnet': 'testnet',
627
+ 'ixo devnet': 'devnet',
628
+ } as const;
602
629
  /** How long a `hub_action_options` answer vouches for a Gmail connection. */
603
630
  const HUB_OPTIONS_REUSE_MS = 10 * 60_000;
604
631
  /** A channel poll waits this long for Auth Hub, once per poll at most. */
@@ -1002,8 +1029,10 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1002
1029
  at: number;
1003
1030
  } | null = null;
1004
1031
  /** The last `hub_action_options` answer for this owner (Gmail connection checks). */
1005
- private lastHubOptions: { value: HubActionOptionsV2; at: number } | null =
1006
- null;
1032
+ private lastHubOptions: {
1033
+ value: HubActionOptionsV2 | HubActionOptionsV3;
1034
+ at: number;
1035
+ } | null = null;
1007
1036
  private topicProposals: TopicProposalStore | null = null;
1008
1037
  /** `assertTopicDependenciesMemo`: card id and digest -> when it passed. */
1009
1038
  private topicAuthorityMemo: Map<string, number> | undefined;
@@ -2187,8 +2216,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2187
2216
  requestKey: string;
2188
2217
  operation: ActionOperationV1;
2189
2218
  }) => this.requestHubAction(input),
2219
+ transfer: (input) => this.transferHubAction(input),
2190
2220
  status: (ownerDid: string, actionId: string) =>
2191
- this.statusHubAction(ownerDid, actionId),
2221
+ this.hubActionStatusForTool(ownerDid, actionId),
2192
2222
  },
2193
2223
  }
2194
2224
  : {}),
@@ -3207,11 +3237,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3207
3237
  this.markDirty();
3208
3238
  await this.requestHousekeeping(Date.now() + 1000);
3209
3239
  }
3210
- const hubActionOutcomes = await this.hubOutcomesForRun(
3211
- ownerDid,
3212
- result.sessionId,
3213
- result.runId,
3214
- );
3240
+ // The gateway parses with @ixo/action-protocol 0.4.1, whose bank
3241
+ // receipt is exact: the 0.5.0 `denom`/`amountBaseUnits` fields are
3242
+ // dropped here (the owner-facing amount line comes from
3243
+ // hub_action_status instead), so the emitted shape stays 0.4.1.
3244
+ const hubActionOutcomes = (
3245
+ await this.hubOutcomesForRun(ownerDid, result.sessionId, result.runId)
3246
+ ).map((outcome) => ({ ...outcome, receipt: v1Receipt(outcome.receipt) }));
3215
3247
  return {
3216
3248
  ...result,
3217
3249
  ...(actionOutcomes.length ? { actionOutcomes } : {}),
@@ -3219,14 +3251,64 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3219
3251
  };
3220
3252
  }
3221
3253
 
3222
- private async hubActionOptions(input: {
3254
+ /** `hub_action_options` and the transfer path: version 3, with the owner's tokens. */
3255
+ private async hubActionOptions(
3256
+ input: {
3257
+ ownerDid: string;
3258
+ provider: string;
3259
+ bindingId: string;
3260
+ bindingRevision: number;
3261
+ },
3262
+ bindingChecked = false,
3263
+ ): Promise<HubActionOptionsV3> {
3264
+ const client = this.hubActionClient;
3265
+ if (!client || input.ownerDid !== this.userDid)
3266
+ throw new Error('Hub action options are unavailable for this owner');
3267
+ return this.readHubOptions(input, bindingChecked, (binding) =>
3268
+ client.options(binding),
3269
+ );
3270
+ }
3271
+
3272
+ /**
3273
+ * Read the owner's options for the trusted binding (validated here
3274
+ * unless the caller already validated it in this same call) and keep
3275
+ * the read for the Gmail connection check.
3276
+ */
3277
+ private async readHubOptions<
3278
+ T extends HubActionOptionsV2 | HubActionOptionsV3,
3279
+ >(
3280
+ input: {
3281
+ ownerDid: string;
3282
+ provider: string;
3283
+ bindingId: string;
3284
+ bindingRevision: number;
3285
+ },
3286
+ bindingChecked: boolean,
3287
+ read: (binding: {
3288
+ bindingId: string;
3289
+ bindingRevision: number;
3290
+ }) => Promise<T>,
3291
+ ): Promise<T> {
3292
+ if (!bindingChecked) await this.assertHubBinding(input);
3293
+ const options = await read(input);
3294
+ if (
3295
+ options.ownerDid !== input.ownerDid ||
3296
+ options.bindingId !== input.bindingId ||
3297
+ options.bindingRevision !== input.bindingRevision
3298
+ )
3299
+ throw new Error(
3300
+ 'Hub action options do not match this owner and binding',
3301
+ );
3302
+ this.lastHubOptions = { value: options, at: Date.now() };
3303
+ return options;
3304
+ }
3305
+
3306
+ private async assertHubBinding(input: {
3223
3307
  ownerDid: string;
3224
3308
  provider: string;
3225
3309
  bindingId: string;
3226
3310
  bindingRevision: number;
3227
- }) {
3228
- if (!this.hubActionClient || input.ownerDid !== this.userDid)
3229
- throw new Error('Hub action options are unavailable for this owner');
3311
+ }): Promise<void> {
3230
3312
  await assertActiveChannelBinding(
3231
3313
  {
3232
3314
  userDid: input.ownerDid,
@@ -3238,17 +3320,34 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3238
3320
  },
3239
3321
  this.env,
3240
3322
  );
3241
- const options = await this.hubActionClient.options(input);
3323
+ }
3324
+
3325
+ /**
3326
+ * Only a run an inbound channel message started may prepare a review:
3327
+ * a scheduled or background run has no user asking for it. Checked
3328
+ * before any Hub read.
3329
+ */
3330
+ private async assertHubTurnOrigin(input: {
3331
+ ownerDid: string;
3332
+ sessionId: string;
3333
+ runId: string;
3334
+ }): Promise<void> {
3335
+ const record = await this.runStore?.get(input.runId);
3336
+ const turn = record
3337
+ ? (JSON.parse(record.request) as StoredRunRequest).turn
3338
+ : null;
3242
3339
  if (
3243
- options.ownerDid !== input.ownerDid ||
3244
- options.bindingId !== input.bindingId ||
3245
- options.bindingRevision !== input.bindingRevision
3340
+ !turn ||
3341
+ turn.client !== 'channel' ||
3342
+ !turn.channel?.remoteMessageRef ||
3343
+ turn.identity.userDid !== input.ownerDid ||
3344
+ turn.sessionId !== input.sessionId ||
3345
+ turn.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)
3246
3346
  )
3247
- throw new Error(
3248
- 'Hub action options do not match this owner and binding',
3347
+ throw new HubActionRefused(
3348
+ 'limit',
3349
+ 'Reviews are prepared only in reply to a message from the user.',
3249
3350
  );
3250
- this.lastHubOptions = { value: options, at: Date.now() };
3251
- return options;
3252
3351
  }
3253
3352
 
3254
3353
  /**
@@ -3280,7 +3379,9 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3280
3379
  cached.value.bindingId === input.bindingId &&
3281
3380
  cached.value.bindingRevision === input.bindingRevision
3282
3381
  ? cached.value
3283
- : await this.hubActionOptions(input);
3382
+ : await this.readHubOptions(input, true, (binding) =>
3383
+ this.hubActionClient!.connectionOptions(binding),
3384
+ );
3284
3385
  if (
3285
3386
  !options.connections.some(
3286
3387
  (c) =>
@@ -3320,50 +3421,32 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3320
3421
  return value;
3321
3422
  }
3322
3423
 
3323
- private async requestHubAction(input: {
3324
- ownerDid: string;
3325
- sessionId: string;
3326
- runId: string;
3327
- requestId: string;
3328
- provider: string;
3329
- bindingId: string;
3330
- bindingRevision: number;
3331
- requestKey: string;
3332
- operation: ActionOperationV1;
3333
- }) {
3424
+ /**
3425
+ * `checked`: the transfer path already ran the turn-origin refusal and
3426
+ * validated the binding in this same call.
3427
+ */
3428
+ private async requestHubAction(
3429
+ input: {
3430
+ ownerDid: string;
3431
+ sessionId: string;
3432
+ runId: string;
3433
+ requestId: string;
3434
+ provider: string;
3435
+ bindingId: string;
3436
+ bindingRevision: number;
3437
+ requestKey: string;
3438
+ operation: ActionOperationV1;
3439
+ },
3440
+ checked = false,
3441
+ ) {
3334
3442
  const store = this.hubActionStore;
3335
3443
  const client = this.hubActionClient;
3336
3444
  if (!store || !client || input.ownerDid !== this.userDid)
3337
3445
  throw new Error('Hub actions are unavailable for this channel');
3338
- // Only a run an inbound channel message started may prepare a review:
3339
- // a scheduled or background run has no user asking for it.
3340
- const record = await this.runStore?.get(input.runId);
3341
- const turn = record
3342
- ? (JSON.parse(record.request) as StoredRunRequest).turn
3343
- : null;
3344
- if (
3345
- !turn ||
3346
- turn.client !== 'channel' ||
3347
- !turn.channel?.remoteMessageRef ||
3348
- turn.identity.userDid !== input.ownerDid ||
3349
- turn.sessionId !== input.sessionId ||
3350
- turn.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)
3351
- )
3352
- throw new HubActionRefused(
3353
- 'limit',
3354
- 'Reviews are prepared only in reply to a message from the user.',
3355
- );
3356
- await assertActiveChannelBinding(
3357
- {
3358
- userDid: input.ownerDid,
3359
- channel: {
3360
- provider: input.provider,
3361
- bindingId: input.bindingId,
3362
- bindingRevision: input.bindingRevision,
3363
- },
3364
- },
3365
- this.env,
3366
- );
3446
+ if (!checked) {
3447
+ await this.assertHubTurnOrigin(input);
3448
+ await this.assertHubBinding(input);
3449
+ }
3367
3450
  await this.assertGmailOperationAllowed(input);
3368
3451
  const reference = await prepareHubAction(
3369
3452
  {
@@ -3385,6 +3468,107 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3385
3468
  return reference;
3386
3469
  }
3387
3470
 
3471
+ /**
3472
+ * A bank send of one of the owner's listed tokens. The token comes from
3473
+ * a fresh options read (its balance must be current), the amount is
3474
+ * converted to base units with the listed decimals (unverified tokens
3475
+ * list 0: base units), refused when zero or above the balance; the
3476
+ * network is the account's capability, the chain id this Worker's own
3477
+ * for that network, the sender the owner's account. No fee is sent: the
3478
+ * Hub simulates gas and sets it.
3479
+ */
3480
+ private async transferHubAction(input: {
3481
+ ownerDid: string;
3482
+ sessionId: string;
3483
+ runId: string;
3484
+ requestId: string;
3485
+ provider: string;
3486
+ bindingId: string;
3487
+ bindingRevision: number;
3488
+ requestKey: string;
3489
+ toAddress: string;
3490
+ denom: string;
3491
+ amount: string;
3492
+ memo: string;
3493
+ }) {
3494
+ if (!this.hubActionStore || input.ownerDid !== this.userDid)
3495
+ throw new Error('Hub actions are unavailable for this channel');
3496
+ await this.assertHubTurnOrigin(input);
3497
+ await this.assertHubBinding(input);
3498
+ const options = await this.hubActionOptions(input, true);
3499
+ const tokens = options.transfer?.tokens;
3500
+ if (!tokens?.length)
3501
+ throw new HubActionRefused(
3502
+ 'refused',
3503
+ 'Bank sends are not available for this owner right now (no token list from Auth Hub).',
3504
+ );
3505
+ const token = tokens.find((item) => item.denom === input.denom);
3506
+ if (!token)
3507
+ throw new HubActionRefused(
3508
+ 'refused',
3509
+ `That token is not in the owner's list; use a denom exactly as hub_action_options lists it (${tokens
3510
+ .map((item) => item.denom)
3511
+ .slice(0, 10)
3512
+ .join(', ')}).`,
3513
+ );
3514
+ const amountBaseUnits = toBaseUnits(input.amount, token.decimals);
3515
+ if (amountBaseUnits > BigInt(token.balanceBaseUnits))
3516
+ throw new HubActionRefused(
3517
+ 'refused',
3518
+ `The owner holds ${describeAmount(token.balanceBaseUnits, token)}, less than this transfer.`,
3519
+ );
3520
+ const capabilities = await this.cachedHubCapabilities();
3521
+ if (!capabilities.network)
3522
+ throw new HubActionRefused(
3523
+ 'refused',
3524
+ 'Bank sends are not enabled for this oracle account.',
3525
+ );
3526
+ const network = HUB_NETWORK_CHAIN[capabilities.network];
3527
+ const chainId = chainIdsFromConfig(chainIdConfigSchema.parse(this.env))[
3528
+ network
3529
+ ];
3530
+ let operation: ActionOperationV1;
3531
+ try {
3532
+ operation = parseOperation({
3533
+ kind: 'ixo.bank.send.v1',
3534
+ chainId,
3535
+ network: capabilities.network,
3536
+ fromAddress: input.ownerDid.replace(/^did:ixo:/, ''),
3537
+ toAddress: input.toAddress,
3538
+ amountBaseUnits: amountBaseUnits.toString(),
3539
+ denom: token.denom,
3540
+ memo: input.memo,
3541
+ });
3542
+ } catch (error) {
3543
+ // Only the protocol refusing the operation built from the model's
3544
+ // fields (an invalid recipient or memo) is the model's to fix.
3545
+ throw new HubActionRefused(
3546
+ 'refused',
3547
+ `The transfer is incomplete (${error instanceof Error ? error.message : String(error)}).`,
3548
+ );
3549
+ }
3550
+ const reference = await this.requestHubAction(
3551
+ { ...input, operation },
3552
+ true,
3553
+ );
3554
+ await this.hubActionStore.setToken(reference.id, token);
3555
+ return reference;
3556
+ }
3557
+
3558
+ /**
3559
+ * `hub_action_status`: the outcome plus, for a bank send, the
3560
+ * owner-facing line (amount from the token stored at preparation).
3561
+ */
3562
+ private async hubActionStatusForTool(
3563
+ ownerDid: string,
3564
+ actionId: string,
3565
+ ): Promise<{ outcome: ActionOutcomeV1 | null; summary: string | null }> {
3566
+ const outcome = await this.statusHubAction(ownerDid, actionId);
3567
+ if (!outcome) return { outcome, summary: null };
3568
+ const token = await this.hubActionStore!.token(ownerDid, actionId);
3569
+ return { outcome, summary: bankReceiptSummary(outcome.receipt, token) };
3570
+ }
3571
+
3388
3572
  private async statusHubAction(
3389
3573
  ownerDid: string,
3390
3574
  actionId: string,
@@ -3475,15 +3659,24 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3475
3659
  input.reference.id,
3476
3660
  );
3477
3661
  if (!held || !this.hubActionClient) throw new Error('Unknown Hub action');
3662
+ if (canonicalJson(input.reference) !== canonicalJson(held.reference))
3663
+ throw new Error('Hub outcome does not match the prepared action');
3664
+ // A replay of an outcome already confirmed and stored (a Hub retry,
3665
+ // a callback delivered twice) is acknowledged without a Hub read.
3666
+ const stored = await this.hubActionStore!.outcomeAt(
3667
+ input.ownerDid,
3668
+ input.reference.id,
3669
+ input.sequence,
3670
+ );
3671
+ if (stored && sameHubOutcome(stored, input)) return;
3478
3672
  const remote = await this.hubActionClient.status(input.reference.id);
3479
3673
  if (
3480
3674
  !remote.outcome ||
3481
3675
  remote.ownerDid !== input.ownerDid ||
3482
3676
  canonicalJson(remote.reference) !== canonicalJson(held.reference) ||
3483
- canonicalJson(input.reference) !== canonicalJson(held.reference) ||
3484
3677
  remote.outcome.sequence < input.sequence ||
3485
3678
  (remote.outcome.sequence === input.sequence &&
3486
- canonicalJson(remote.outcome) !== canonicalJson(input))
3679
+ !sameHubOutcome(remote.outcome, input))
3487
3680
  )
3488
3681
  throw new Error(
3489
3682
  'Hub outcome was not confirmed by authenticated status',
@@ -3732,13 +3925,6 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
3732
3925
  input.evidenceRefs,
3733
3926
  );
3734
3927
  if (prior) return prior;
3735
- // The caps before the authority read it would waste (create checks
3736
- // them again, atomically).
3737
- await topics.assertCapacity(
3738
- this.userDid,
3739
- input.origin.sessionId,
3740
- input.origin.runId,
3741
- );
3742
3928
  const rpc = {
3743
3929
  devnet: this.env.ACTION_PROPOSALS_RPC_DEVNET,
3744
3930
  testnet: this.env.ACTION_PROPOSALS_RPC_TESTNET,