@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.
@@ -9,7 +9,9 @@ import {
9
9
  type ActionOutcomeV1,
10
10
  type ActionReferenceV1,
11
11
  type HubActionOptionsV2,
12
+ type HubActionOptionsV3,
12
13
  } from '@ixo/action-protocol';
14
+ import { hubPrepareRefusal } from './transfer';
13
15
 
14
16
  export interface HubActionStatus {
15
17
  reference: ActionReferenceV1;
@@ -27,8 +29,17 @@ export interface HubActionTransport {
27
29
  fetcher?: { fetch(request: Request): Promise<Response> };
28
30
  }
29
31
 
30
- /** The envelope version this runtime parses (`ixo.auth-outcome/2`, `ixo.auth-capabilities/2`). */
31
- const HUB_PROTOCOL_QUERY = 'version=2';
32
+ /**
33
+ * The protocol version this runtime declares on every operation call
34
+ * (capabilities, POST operations, GET status, options). Version 3 is the
35
+ * 0.5.0 contract: capabilities `transfer.limits`, any-denom bank sends, v2
36
+ * receipts with `denom` and `amountBaseUnits`, options with the owner's
37
+ * tokens. Version 2 callers (runtime 0.23.0) keep the 0.4.1 shapes; the
38
+ * envelopes are still tagged `ixo.auth-outcome/2` and
39
+ * `ixo.auth-capabilities/2`, which `@ixo/action-protocol` parses.
40
+ */
41
+ const HUB_PROTOCOL_VERSION = '3';
42
+ const HUB_PROTOCOL_QUERY = `version=${HUB_PROTOCOL_VERSION}`;
32
43
 
33
44
  function isRecord(value: unknown): value is Record<string, unknown> {
34
45
  return value !== null && typeof value === 'object' && !Array.isArray(value);
@@ -73,6 +84,22 @@ export class HubActionClient {
73
84
  const response = await (this.transport.fetcher
74
85
  ? this.transport.fetcher.fetch(request)
75
86
  : fetch(request));
87
+ if (
88
+ body !== undefined &&
89
+ (response.status === 400 ||
90
+ response.status === 409 ||
91
+ response.status === 503)
92
+ ) {
93
+ // A prepare the Hub refused with a typed code (a transfer that would
94
+ // fail, a fee above the ceiling, not enough funds, a busy or
95
+ // unavailable transfer path, an operation the account may not use):
96
+ // the model relays it in words.
97
+ const refusal = hubPrepareRefusal(
98
+ response.status,
99
+ await response.json().catch(() => null),
100
+ );
101
+ if (refusal) throw refusal;
102
+ }
76
103
  if (!response.ok)
77
104
  throw new Error(`Hub action request returned ${response.status}`);
78
105
  return response.json();
@@ -85,9 +112,9 @@ export class HubActionClient {
85
112
  expiresAt: number;
86
113
  operation: ActionOperationV1;
87
114
  }): Promise<ActionReferenceV1> {
88
- // `version=2` on the three operation calls: the Hub answers v1 (P1
89
- // kinds only, v1-filtered outcomes, a P2 POST refused) without it, and
90
- // stores the version per action for the outcomes it pushes.
115
+ // `version=3` on every operation call: the Hub answers older shapes
116
+ // (no any-denom sends, no receipt amounts) without it, and stores the
117
+ // version per action for the outcomes it pushes.
91
118
  const response = await this.send(
92
119
  `/api/internal/actions/operations?${HUB_PROTOCOL_QUERY}`,
93
120
  {
@@ -111,6 +138,32 @@ export class HubActionClient {
111
138
  async options(input: {
112
139
  bindingId: string;
113
140
  bindingRevision: number;
141
+ }): Promise<HubActionOptionsV3> {
142
+ // Options version 3: version 2 plus the owner's bank tokens
143
+ // (`transfer.tokens`), which any-token transfers are chosen from.
144
+ const query = new URLSearchParams({
145
+ bindingId: input.bindingId,
146
+ bindingRevision: String(input.bindingRevision),
147
+ version: HUB_PROTOCOL_VERSION,
148
+ });
149
+ const options = parseActionOptions(
150
+ await this.send(
151
+ `/api/internal/actions/operations/options?${query.toString()}`,
152
+ ),
153
+ );
154
+ if (options.version !== 'ixo.auth-options/3')
155
+ throw new Error('Hub action options require version 3');
156
+ return options;
157
+ }
158
+
159
+ /**
160
+ * Options version 2 (connections, targets, resources; no token list),
161
+ * for the Gmail connection check: it does not make the Hub read the
162
+ * owner's balances.
163
+ */
164
+ async connectionOptions(input: {
165
+ bindingId: string;
166
+ bindingRevision: number;
114
167
  }): Promise<HubActionOptionsV2> {
115
168
  const query = new URLSearchParams({
116
169
  bindingId: input.bindingId,
@@ -0,0 +1,227 @@
1
+ import {
2
+ OUTCOME_SIGNATURE_HEADERS,
3
+ OUTCOME_SIGNATURE_MAX_SKEW_SECONDS,
4
+ outcomeAudience,
5
+ publicKeyFromDidDocument,
6
+ verifyOutcomeRequest,
7
+ } from '@ixo/action-protocol';
8
+ import { boundedBytes } from '../core/domain-context/transport';
9
+
10
+ /**
11
+ * Signed Auth Hub outcome callbacks. The Hub signs every callback with the
12
+ * `#key-0` Ed25519 key of its own did:web document; the key is resolved
13
+ * from the configured `AUTH_HUB_URL` (never from the request), and the
14
+ * signature covers this oracle's origin (the audience) so a callback
15
+ * signed for another oracle never verifies here.
16
+ */
17
+
18
+ /** How long a verified Hub key is trusted per isolate. */
19
+ export const HUB_DID_CACHE_MS = 60 * 60_000;
20
+ /**
21
+ * The soonest the Hub's DID document is read again after any read, whether
22
+ * it succeeded or failed (a Hub outage or a forged stream must not become a
23
+ * stream of Hub reads).
24
+ */
25
+ export const HUB_DID_REFRESH_MIN_MS = 60_000;
26
+ /** The largest DID document read. */
27
+ const HUB_DID_MAX_BYTES = 64 * 1024;
28
+
29
+ /** Keys from documents that passed the id and key checks, per document URL. */
30
+ const hubKeys = new Map<string, { publicKey: Uint8Array; at: number }>();
31
+ /** When each document URL was last read (success or failure): the negative cache. */
32
+ const lastAttemptAt = new Map<string, number>();
33
+ /** One read per document URL at a time. */
34
+ const inFlight = new Map<string, Promise<Uint8Array | null>>();
35
+
36
+ /** Tests only: forget every cached Hub key, attempt time and pending read. */
37
+ export function resetHubDidCache(): void {
38
+ hubKeys.clear();
39
+ lastAttemptAt.clear();
40
+ inFlight.clear();
41
+ }
42
+
43
+ /** The did:web DID of the Hub at `hubUrl` (https only): its key id is `#key-0`. */
44
+ export function hubDidOf(hubUrl: string): string {
45
+ const url = new URL(hubUrl);
46
+ if (url.protocol !== 'https:' || url.username || url.password)
47
+ throw new TypeError('Auth Hub URL must be https');
48
+ return `did:web:${url.port ? `${url.hostname}%3A${url.port}` : url.hostname}`;
49
+ }
50
+
51
+ /**
52
+ * The checks that need no key: the Hub's key id, a well-formed timestamp
53
+ * within the allowed skew either way, and a signature that decodes to 64
54
+ * bytes. A callback failing any of them never causes a Hub read.
55
+ */
56
+ function plausibleSignatureHeaders(
57
+ headers: Headers,
58
+ keyId: string,
59
+ nowSeconds: number,
60
+ ): boolean {
61
+ const timestamp = headers.get(OUTCOME_SIGNATURE_HEADERS.timestamp);
62
+ const signature = headers.get(OUTCOME_SIGNATURE_HEADERS.signature);
63
+ if (headers.get(OUTCOME_SIGNATURE_HEADERS.keyId) !== keyId) return false;
64
+ if (!timestamp || !/^[1-9][0-9]{0,15}$/.test(timestamp)) return false;
65
+ if (
66
+ Math.abs(nowSeconds - Number(timestamp)) >
67
+ OUTCOME_SIGNATURE_MAX_SKEW_SECONDS
68
+ )
69
+ return false;
70
+ // 64 bytes in unpadded base64url is exactly 86 characters.
71
+ if (!signature || !/^[A-Za-z0-9_-]{86}$/.test(signature)) return false;
72
+ try {
73
+ const padded = signature.replace(/-/g, '+').replace(/_/g, '/');
74
+ return atob(`${padded}==`).length === 64;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Read the Hub's DID document and return its `#key-0` key, or null. Every
82
+ * attempt is recorded first, so a read happens at most once per
83
+ * `HUB_DID_REFRESH_MIN_MS` per URL whatever the outcome; concurrent callers
84
+ * share one read; only a document that names exactly the Hub's DID and holds
85
+ * the key is cached.
86
+ */
87
+ function readHubKey(
88
+ hubUrl: string,
89
+ hubDid: string,
90
+ keyId: string,
91
+ fetcher: { fetch(request: Request): Promise<Response> } | undefined,
92
+ nowMs: number,
93
+ ): Promise<Uint8Array | null> {
94
+ const url = new URL('/.well-known/did.json', hubUrl);
95
+ const pending = inFlight.get(url.href);
96
+ if (pending) return pending;
97
+ const last = lastAttemptAt.get(url.href);
98
+ if (last !== undefined && nowMs - last < HUB_DID_REFRESH_MIN_MS)
99
+ return Promise.resolve(null);
100
+ lastAttemptAt.set(url.href, nowMs);
101
+ const read = (async (): Promise<Uint8Array | null> => {
102
+ try {
103
+ const request = new Request(url, {
104
+ headers: { accept: 'application/did+json, application/json' },
105
+ // workerd has no `redirect: 'error'`; a redirect is not ok below.
106
+ redirect: 'manual',
107
+ signal: AbortSignal.timeout(5_000),
108
+ });
109
+ const response = await (fetcher
110
+ ? fetcher.fetch(request)
111
+ : fetch(request));
112
+ if (!response.ok)
113
+ throw new Error(`Hub DID document returned ${response.status}`);
114
+ const document: unknown = JSON.parse(
115
+ new TextDecoder().decode(
116
+ await boundedBytes(response, HUB_DID_MAX_BYTES),
117
+ ),
118
+ );
119
+ if (
120
+ !document ||
121
+ typeof document !== 'object' ||
122
+ !('id' in document) ||
123
+ document.id !== hubDid
124
+ )
125
+ throw new TypeError('Hub DID document is for another DID');
126
+ const publicKey = publicKeyFromDidDocument(document, keyId);
127
+ hubKeys.set(url.href, { publicKey, at: nowMs });
128
+ return publicKey;
129
+ } catch (error) {
130
+ console.warn(
131
+ `[hub-actions] Hub DID key unavailable: ${error instanceof Error ? error.message : String(error)}`,
132
+ );
133
+ return null;
134
+ }
135
+ })();
136
+ inFlight.set(url.href, read);
137
+ void read.finally(() => {
138
+ if (inFlight.get(url.href) === read) inFlight.delete(url.href);
139
+ });
140
+ return read;
141
+ }
142
+
143
+ async function verifiedWith(
144
+ publicKey: Uint8Array,
145
+ input: { rawBody: string; headers: Headers },
146
+ keyId: string,
147
+ audience: string,
148
+ nowSeconds: number,
149
+ ): Promise<boolean> {
150
+ try {
151
+ return await verifyOutcomeRequest({
152
+ rawBody: input.rawBody,
153
+ headers: input.headers,
154
+ publicKey,
155
+ expectedKeyId: keyId,
156
+ expectedAudience: audience,
157
+ now: nowSeconds,
158
+ });
159
+ } catch {
160
+ // A key WebCrypto refuses to import is a failed check, not a 500.
161
+ return false;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * Whether `rawBody` with `headers` is a callback the configured Hub signed
167
+ * for `audience` (this oracle's https origin) within the allowed clock skew.
168
+ * The cheap header checks run first and need no key. The key comes from the
169
+ * Hub's DID document, trusted for `HUB_DID_CACHE_MS`. A Hub key rotation
170
+ * (same `#key-0` id, new key) is picked up by reading the document again
171
+ * when the cached key does not verify, subject to the same once-a-minute
172
+ * read limit. Anything that fails is false.
173
+ */
174
+ export async function verifyHubOutcomeCallback(input: {
175
+ hubUrl: string;
176
+ rawBody: string;
177
+ headers: Headers;
178
+ audience: string;
179
+ fetcher?: { fetch(request: Request): Promise<Response> };
180
+ now?: number;
181
+ }): Promise<boolean> {
182
+ const nowMs = input.now ?? Date.now();
183
+ const nowSeconds = Math.floor(nowMs / 1000);
184
+ let hubDid: string;
185
+ let audience: string;
186
+ try {
187
+ hubDid = hubDidOf(input.hubUrl);
188
+ audience = outcomeAudience(input.audience);
189
+ } catch {
190
+ return false;
191
+ }
192
+ const keyId = `${hubDid}#key-0`;
193
+ if (!plausibleSignatureHeaders(input.headers, keyId, nowSeconds))
194
+ return false;
195
+ const href = new URL('/.well-known/did.json', input.hubUrl).href;
196
+ const cached = hubKeys.get(href);
197
+ if (cached && nowMs - cached.at < HUB_DID_CACHE_MS) {
198
+ if (
199
+ await verifiedWith(cached.publicKey, input, keyId, audience, nowSeconds)
200
+ )
201
+ return true;
202
+ const fresh = await readHubKey(
203
+ input.hubUrl,
204
+ hubDid,
205
+ keyId,
206
+ input.fetcher,
207
+ nowMs,
208
+ );
209
+ return (
210
+ !!fresh &&
211
+ !sameBytes(fresh, cached.publicKey) &&
212
+ verifiedWith(fresh, input, keyId, audience, nowSeconds)
213
+ );
214
+ }
215
+ const key = await readHubKey(
216
+ input.hubUrl,
217
+ hubDid,
218
+ keyId,
219
+ input.fetcher,
220
+ nowMs,
221
+ );
222
+ return !!key && verifiedWith(key, input, keyId, audience, nowSeconds);
223
+ }
224
+
225
+ function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
226
+ return a.length === b.length && a.every((byte, i) => byte === b[i]);
227
+ }
@@ -36,14 +36,23 @@ export function assertOperationEnabled(
36
36
  )
37
37
  throw new Error('Hub form does not match the registered target');
38
38
  } else if (operation.kind === 'ixo.bank.send.v1') {
39
- if (
40
- capabilities.network !== operation.network ||
41
- !capabilities.transfer ||
42
- BigInt(operation.amountBaseUnits) >
43
- BigInt(capabilities.transfer.maxAmountUixo) ||
44
- BigInt(operation.feeBaseUnits) > BigInt(capabilities.transfer.maxFeeUixo)
45
- )
39
+ if (capabilities.network !== operation.network || !capabilities.transfer)
46
40
  throw new Error("Hub transfer is outside this oracle's capability");
41
+ // A pre-check of the per-denom cap only (the Hub enforces it, and the
42
+ // fee is the Hub's: it simulates gas and sets it).
43
+ const transfer = capabilities.transfer;
44
+ const cap =
45
+ 'limits' in transfer
46
+ ? transfer.limits.find((limit) => limit.denom === operation.denom)
47
+ ?.maxAmountBaseUnits
48
+ : operation.denom === 'uixo'
49
+ ? transfer.maxAmountUixo
50
+ : undefined;
51
+ if (cap !== undefined && BigInt(operation.amountBaseUnits) > BigInt(cap))
52
+ throw new HubActionRefused(
53
+ 'refused',
54
+ `This oracle may move at most ${cap} base units of ${operation.denom} per transfer.`,
55
+ );
47
56
  } else if (operation.kind === 'resource.read.v1') {
48
57
  if (
49
58
  operation.ownerDid !== ownerDid ||
@@ -7,6 +7,23 @@ import {
7
7
  type ActionReferenceV1,
8
8
  } from '@ixo/action-protocol';
9
9
  import type { DoSqliteDatabase } from '../sqlite/database';
10
+ import type { TransferToken } from './transfer';
11
+
12
+ function isTransferToken(value: unknown): value is TransferToken {
13
+ return (
14
+ !!value &&
15
+ typeof value === 'object' &&
16
+ 'denom' in value &&
17
+ typeof value.denom === 'string' &&
18
+ 'symbol' in value &&
19
+ typeof value.symbol === 'string' &&
20
+ 'decimals' in value &&
21
+ typeof value.decimals === 'number' &&
22
+ Number.isSafeInteger(value.decimals) &&
23
+ 'verified' in value &&
24
+ typeof value.verified === 'boolean'
25
+ );
26
+ }
10
27
 
11
28
  /**
12
29
  * Outcomes after which the Hub never reports another for the action
@@ -19,10 +36,6 @@ export const HUB_CHECK_BASE_MS = 15_000;
19
36
  export const HUB_CHECK_MAX_MS = 10 * 60_000;
20
37
  /** A non-terminal action is no longer refreshed this long after its review expired. */
21
38
  export const HUB_CHECK_AFTER_EXPIRY_MS = 24 * 60 * 60_000;
22
- /** Prepares (Hub POSTs) per agent run, per user per hour and per user per day. */
23
- export const HUB_ACTIONS_PER_RUN = 2;
24
- export const HUB_ACTIONS_PER_HOUR = 5;
25
- export const HUB_ACTIONS_PER_DAY = 10;
26
39
  /** Rows are dropped this long after their review expired. */
27
40
  const HUB_RETENTION_SECONDS = 30 * 24 * 60 * 60;
28
41
 
@@ -108,6 +121,10 @@ export class HubActionStore {
108
121
  await this.db.run(
109
122
  `ALTER TABLE hub_actions ADD COLUMN check_attempts INTEGER NOT NULL DEFAULT 0`,
110
123
  );
124
+ // The token a transfer was prepared with (symbol, decimals, verified),
125
+ // for the owner-facing outcome line.
126
+ if (!actionColumns.some((c) => c.name === 'token'))
127
+ await this.db.run(`ALTER TABLE hub_actions ADD COLUMN token TEXT`);
111
128
  if (!actionColumns.some((c) => c.name === 'expires_at'))
112
129
  await this.db.run(
113
130
  `ALTER TABLE hub_actions ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0`,
@@ -185,6 +202,31 @@ export class HubActionStore {
185
202
  return row?.action_id ?? null;
186
203
  }
187
204
 
205
+ /** Keep the token a transfer was prepared with, for its outcome line. */
206
+ async setToken(actionId: string, token: TransferToken): Promise<void> {
207
+ await this.db.run('UPDATE hub_actions SET token = ? WHERE action_id = ?', [
208
+ JSON.stringify({
209
+ denom: token.denom,
210
+ symbol: token.symbol,
211
+ decimals: token.decimals,
212
+ verified: token.verified,
213
+ }),
214
+ actionId,
215
+ ]);
216
+ }
217
+ async token(
218
+ ownerDid: string,
219
+ actionId: string,
220
+ ): Promise<TransferToken | null> {
221
+ const row = await this.db.get<{ token: string | null }>(
222
+ 'SELECT token FROM hub_actions WHERE owner_did = ? AND action_id = ?',
223
+ [ownerDid, actionId],
224
+ );
225
+ if (!row?.token) return null;
226
+ const value: unknown = JSON.parse(row.token);
227
+ return isTransferToken(value) ? value : null;
228
+ }
229
+
188
230
  /** The reference already recorded for a request key, if any. */
189
231
  async byRequestKey(requestKey: string): Promise<ActionReferenceV1 | null> {
190
232
  const row = await this.db.get<{ reference: string }>(
@@ -194,38 +236,6 @@ export class HubActionStore {
194
236
  return row ? parseReference(JSON.parse(row.reference)) : null;
195
237
  }
196
238
 
197
- /**
198
- * A new request (not a retry of a reserved key) within the caps:
199
- * `HUB_ACTIONS_PER_RUN` per run, `HUB_ACTIONS_PER_HOUR` and
200
- * `HUB_ACTIONS_PER_DAY` per owner.
201
- */
202
- private async assertCapacity(origin: HubActionOrigin): Promise<void> {
203
- const run = await this.db.get<{ n: number }>(
204
- 'SELECT COUNT(*) AS n FROM hub_action_requests WHERE owner_did = ? AND session_id = ? AND run_id = ?',
205
- [origin.ownerDid, origin.sessionId, origin.runId],
206
- );
207
- if ((run?.n ?? 0) >= HUB_ACTIONS_PER_RUN)
208
- throw new HubActionRefused(
209
- 'limit',
210
- 'This request already prepared the most reviews one turn may prepare.',
211
- );
212
- const now = this.now();
213
- const counts = await this.db.get<{ hour: number; day: number }>(
214
- 'SELECT SUM(created_at > ?) AS hour, COUNT(*) AS day FROM hub_action_requests WHERE owner_did = ? AND created_at > ?',
215
- [now - 60 * 60_000, origin.ownerDid, now - 24 * 60 * 60_000],
216
- );
217
- if ((counts?.hour ?? 0) >= HUB_ACTIONS_PER_HOUR)
218
- throw new HubActionRefused(
219
- 'limit',
220
- 'Too many reviews were prepared in the last hour; try again later.',
221
- );
222
- if ((counts?.day ?? 0) >= HUB_ACTIONS_PER_DAY)
223
- throw new HubActionRefused(
224
- 'limit',
225
- 'Too many reviews were prepared today; try again tomorrow.',
226
- );
227
- }
228
-
229
239
  async reserve(
230
240
  origin: HubActionOrigin,
231
241
  operation: ActionOperationV1,
@@ -253,7 +263,6 @@ export class HubActionStore {
253
263
  );
254
264
  return previous.expires_at;
255
265
  }
256
- await this.assertCapacity(origin);
257
266
  await this.db.run(
258
267
  `INSERT INTO hub_action_requests(request_key,owner_did,session_id,run_id,request_id,binding_id,binding_revision,operation,expires_at,created_at)
259
268
  VALUES(?,?,?,?,?,?,?,?,?,?)`,
@@ -467,6 +476,21 @@ export class HubActionStore {
467
476
  });
468
477
  }
469
478
 
479
+ /** The stored outcome of the owner's action at `sequence`, if any. */
480
+ async outcomeAt(
481
+ ownerDid: string,
482
+ actionId: string,
483
+ sequence: number,
484
+ ): Promise<ActionOutcomeV1 | null> {
485
+ const row = await this.db.get<{ outcome: string }>(
486
+ `SELECT o.outcome FROM hub_action_outcomes o
487
+ JOIN hub_actions a ON a.action_id = o.action_id
488
+ WHERE a.owner_did = ? AND o.action_id = ? AND o.sequence = ?`,
489
+ [ownerDid, actionId, sequence],
490
+ );
491
+ return row ? parseOutcome(JSON.parse(row.outcome)) : null;
492
+ }
493
+
470
494
  async outcomesForRun(
471
495
  ownerDid: string,
472
496
  sessionId: string,
@@ -0,0 +1,182 @@
1
+ import type { ActionReceiptV1, OptionsToken } from '@ixo/action-protocol';
2
+ import { HubActionRefused } from './store';
3
+
4
+ /**
5
+ * Any-token bank sends: the model names a token from the owner's
6
+ * `hub_action_options` list and a decimal amount in that token's display
7
+ * units; the runtime does the base-unit arithmetic itself, from the decimals
8
+ * the Hub listed. It never guesses a denom (`ibc/…` hashes included) or a
9
+ * fee: the Hub simulates gas and sets the fee.
10
+ */
11
+
12
+ /** The token as the owner object stores it next to a prepared transfer. */
13
+ export type TransferToken = Pick<
14
+ OptionsToken,
15
+ 'denom' | 'symbol' | 'decimals' | 'verified'
16
+ >;
17
+
18
+ const DECIMAL_AMOUNT = /^(0|[1-9][0-9]*)(\.[0-9]+)?$/;
19
+
20
+ /**
21
+ * `amount` (a decimal string in display units) as base units for a token
22
+ * with `decimals`. Refused: not a plain decimal, more fraction digits than
23
+ * the token has (an unverified token has 0: base units), and zero.
24
+ */
25
+ export function toBaseUnits(amount: string, decimals: number): bigint {
26
+ const match = DECIMAL_AMOUNT.exec(amount);
27
+ if (!match || amount.length > 80)
28
+ throw new HubActionRefused(
29
+ 'refused',
30
+ 'The amount must be a plain decimal number such as 1.5.',
31
+ );
32
+ const whole = match[1]!;
33
+ // Trailing zeros carry no value: "1.50" of a 1-decimal token is 1.5.
34
+ const fraction = (match[2] ?? '').slice(1).replace(/0+$/, '');
35
+ if (fraction.length > decimals)
36
+ throw new HubActionRefused(
37
+ 'refused',
38
+ decimals === 0
39
+ ? 'This token is counted in whole base units: use an amount without decimals.'
40
+ : `This token has ${decimals} decimals: use at most ${decimals} digits after the point.`,
41
+ );
42
+ const base =
43
+ BigInt(whole) * 10n ** BigInt(decimals) +
44
+ BigInt(fraction.padEnd(decimals, '0') || '0');
45
+ if (base <= 0n)
46
+ throw new HubActionRefused('refused', 'The amount must be above zero.');
47
+ return base;
48
+ }
49
+
50
+ /** Base units in display units for `decimals` (no rounding, no trailing zeros). */
51
+ export function fromBaseUnits(base: string | bigint, decimals: number): string {
52
+ const value = BigInt(base);
53
+ if (decimals === 0) return value.toString();
54
+ const scale = 10n ** BigInt(decimals);
55
+ const whole = value / scale;
56
+ const fraction = (value % scale)
57
+ .toString()
58
+ .padStart(decimals, '0')
59
+ .replace(/0+$/, '');
60
+ return fraction ? `${whole}.${fraction}` : whole.toString();
61
+ }
62
+
63
+ /**
64
+ * How an amount of `token` reads to the owner: the listed symbol for a
65
+ * verified token (with the full denom for anything but `uixo`), base units
66
+ * and the full denom with an unverified marker otherwise.
67
+ */
68
+ export function describeAmount(
69
+ amountBaseUnits: string,
70
+ token: TransferToken,
71
+ ): string {
72
+ if (!token.verified)
73
+ return `${amountBaseUnits} base units of ${token.denom} (unverified token)`;
74
+ const display = `${fromBaseUnits(amountBaseUnits, token.decimals)} ${token.symbol}`;
75
+ return token.denom === 'uixo' ? display : `${display} (${token.denom})`;
76
+ }
77
+
78
+ /** IXO itself: the chain's staking token, always verified. */
79
+ const IXO_TOKEN: TransferToken = {
80
+ denom: 'uixo',
81
+ symbol: 'IXO',
82
+ decimals: 6,
83
+ verified: true,
84
+ };
85
+
86
+ /**
87
+ * The owner-facing line for a bank receipt (v2 receipts carry `denom` and
88
+ * `amountBaseUnits`), or null for any other receipt. Without a stored token
89
+ * for that denom (another runtime prepared it) `uixo` still reads as IXO;
90
+ * any other denom is shown in base units, unverified.
91
+ */
92
+ export function bankReceiptSummary(
93
+ receipt: ActionReceiptV1 | null,
94
+ token: TransferToken | null,
95
+ ): string | null {
96
+ if (!receipt || receipt.kind !== 'ixo.bank.send.v1') return null;
97
+ const amount =
98
+ receipt.amountBaseUnits && receipt.denom
99
+ ? describeAmount(
100
+ receipt.amountBaseUnits,
101
+ token && token.denom === receipt.denom
102
+ ? token
103
+ : receipt.denom === IXO_TOKEN.denom
104
+ ? IXO_TOKEN
105
+ : {
106
+ denom: receipt.denom,
107
+ symbol: receipt.denom,
108
+ decimals: 0,
109
+ verified: false,
110
+ },
111
+ )
112
+ : null;
113
+ const what = amount ? `Transfer of ${amount}` : 'Transfer';
114
+ if (receipt.height === null)
115
+ return `${what} was not included on chain${receipt.reason ? ` (${receipt.reason.replaceAll('_', ' ')})` : ''}. Transaction ${receipt.txHash}.`;
116
+ return `${what} ${receipt.code === 0 ? 'confirmed' : 'failed'} on chain. Transaction ${receipt.txHash}, block ${receipt.height}.`;
117
+ }
118
+
119
+ /** What the model is told when Auth Hub refuses a transfer at preparation (400). */
120
+ const HUB_TRANSFER_REFUSALS: Record<string, string> = {
121
+ simulation_failed:
122
+ 'Auth Hub simulated this transfer and it would fail on chain',
123
+ fee_above_ceiling:
124
+ "The network fee for this transfer is above this oracle's fee limit",
125
+ insufficient_funds:
126
+ 'The account does not hold enough to cover this transfer and its network fee',
127
+ };
128
+ /** Hub codes that pass with time (a sequence race, the transfer path down). */
129
+ const HUB_TRANSIENT_CODES: Readonly<Record<number, readonly string[]>> = {
130
+ 409: ['sequence_mismatch'],
131
+ 503: ['transfer_unavailable'],
132
+ };
133
+
134
+ function hubErrorBody(
135
+ body: unknown,
136
+ ): { error: string; reason?: string } | null {
137
+ if (!body || typeof body !== 'object' || !('error' in body)) return null;
138
+ const error = body.error;
139
+ if (typeof error !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(error))
140
+ return null;
141
+ const reason =
142
+ 'reason' in body && typeof body.reason === 'string' && body.reason.trim()
143
+ ? body.reason.trim().slice(0, 200)
144
+ : undefined;
145
+ return reason ? { error, reason } : { error };
146
+ }
147
+
148
+ /**
149
+ * A Hub refusal of a prepare (`{error, reason?}` with status 400, 409 or
150
+ * 503) as a refusal the model can relay, or null when the status and code
151
+ * are not one of those it maps:
152
+ * - 400 `simulation_failed`, `fee_above_ceiling`, `insufficient_funds`: the
153
+ * transfer as asked cannot go ahead (with the Hub's reason);
154
+ * - 409 `sequence_mismatch`, 503 `transfer_unavailable`: temporary, try
155
+ * again shortly;
156
+ * - any other 409: the account may not use this operation (deterministic,
157
+ * a retry gets the same answer).
158
+ */
159
+ export function hubPrepareRefusal(
160
+ status: number,
161
+ body: unknown,
162
+ ): HubActionRefused | null {
163
+ const parsed = hubErrorBody(body);
164
+ if (!parsed) return null;
165
+ const detail = parsed.reason ? `: ${parsed.reason}` : '';
166
+ if (status === 400 && Object.hasOwn(HUB_TRANSFER_REFUSALS, parsed.error))
167
+ return new HubActionRefused(
168
+ 'refused',
169
+ `${HUB_TRANSFER_REFUSALS[parsed.error]}${detail}.`,
170
+ );
171
+ if (HUB_TRANSIENT_CODES[status]?.includes(parsed.error))
172
+ return new HubActionRefused(
173
+ 'refused',
174
+ `Auth Hub is temporarily unavailable for this (${parsed.error}); try again shortly.`,
175
+ );
176
+ if (status === 409)
177
+ return new HubActionRefused(
178
+ 'refused',
179
+ `This operation is not available for this account (${parsed.error}${detail}).`,
180
+ );
181
+ return null;
182
+ }