@ixo/oracle-runtime-workers 0.33.2 → 0.34.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/README.md CHANGED
@@ -104,22 +104,22 @@ lifetime are in [operations](docs/operations.md).
104
104
 
105
105
  ## Documentation
106
106
 
107
- | Page | What it covers |
108
- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
109
- | [architecture](docs/architecture.md) | The objects, the gateway split, self-sovereign storage, cost model, workerd rules, tasks |
110
- | [node-parity](docs/node-parity.md) | What is identical to the Node runtime, what differs, what is Workers-only, what is not ported |
111
- | [configuration](docs/configuration.md) | Wrangler config, migrations, every env var and binding, `createOracleWorker` options, setup |
112
- | [operations](docs/operations.md) | Routes, status fields, gateway and user-object lifecycle, the ChatGPT proxy, the runbook |
113
- | [chat-delivery](docs/chat-delivery.md) | Chat replies on WhatsApp and Matrix: profiles, Reply Plans, artefacts and their links |
114
- | [channels](docs/channels.md) | The IXO Channels ingress: authorization, retries, Reply Plans, Matrix continuity |
115
- | [frontend-bridge](docs/frontend-bridge.md) | Browser tools and AG-UI actions over the socket: one invocation, one socket, unknown outcomes |
116
- | [pod-creator](docs/pod-creator.md) | The POD Creator plugin: roles, state in `ctx.kv`, the create path and its launch gate |
117
- | [ixo-transaction](docs/ixo-transaction.md) | The opt-in wallet-signing plugin: chain ids, signing outcomes, the mainnet receipt rule |
118
- | [action-proposals](docs/action-proposals.md) | Action cards: a transfer prepared from chat, signed in the Portal, verified on chain, receipted |
119
- | [topic-action-proposals](docs/topic-action-proposals.md) | Topic cards: discussion drafts and claim-access grants as multi-step journals with one-use permits |
120
- | [chain-tools](docs/chain-tools.md) | The bundled `ixo-chain` reader: live balances, batched Blocksync reads, retries and limits |
121
- | [testing](docs/testing.md) | Unit, harness e2e, the devnet feature matrix, stress |
122
- | [load-tests](docs/load-tests.md) | Measurements and the findings that shaped the gateway |
107
+ | Page | What it covers |
108
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
109
+ | [architecture](docs/architecture.md) | The objects, the gateway split, self-sovereign storage, cost model, workerd rules, tasks |
110
+ | [node-parity](docs/node-parity.md) | What is identical to the Node runtime, what differs, what is Workers-only, what is not ported |
111
+ | [configuration](docs/configuration.md) | Wrangler config, migrations, every env var and binding, `createOracleWorker` options, setup |
112
+ | [operations](docs/operations.md) | Routes, status fields, gateway and user-object lifecycle, the ChatGPT proxy, the runbook |
113
+ | [chat-delivery](docs/chat-delivery.md) | Chat replies on WhatsApp and Matrix: profiles, Reply Plans, artefacts and their links |
114
+ | [channels](docs/channels.md) | The IXO Channels ingress: authorization, retries, Reply Plans, Matrix continuity |
115
+ | [frontend-bridge](docs/frontend-bridge.md) | Browser tools and AG-UI actions over the socket: one invocation, one socket, unknown outcomes |
116
+ | [pod-creator](docs/pod-creator.md) | The POD Creator plugin: roles, state in `ctx.kv`, the create path and its launch gate |
117
+ | [ixo-transaction](docs/ixo-transaction.md) | The opt-in wallet-signing plugin: chain ids, signing outcomes, the mainnet receipt rule |
118
+ | [action-proposals](docs/action-proposals.md) | Action cards: a transfer prepared from chat, signed in the Portal, verified on chain, receipted |
119
+ | [topic-action-proposals](docs/topic-action-proposals.md) | Topic cards: discussion drafts and claim-access grants as multi-step journals with one-use permits |
120
+ | [chain-tools](docs/chain-tools.md) | The bundled `ixo-chain` reader: live balances, the 30 lookup kinds, names from the IXO Names worker, retries and limits |
121
+ | [testing](docs/testing.md) | Unit, harness e2e, the devnet feature matrix, stress |
122
+ | [load-tests](docs/load-tests.md) | Measurements and the findings that shaped the gateway |
123
123
 
124
124
  ## Status (September 2026)
125
125
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ixo/oracle-runtime-workers",
3
- "version": "0.33.2",
3
+ "version": "0.34.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": {
@@ -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",
71
70
  "@ixo/ixo-transaction": "0.2.0",
72
- "@ixo/ucan": "2.3.0"
71
+ "@ixo/ucan": "2.3.0",
72
+ "@ixo/common": "1.7.1"
73
73
  },
74
74
  "devDependencies": {
75
75
  "@cloudflare/vitest-pool-workers": "^0.22.0",
@@ -34,9 +34,10 @@
34
34
  */
35
35
  import { z } from 'zod';
36
36
  import {
37
+ ReadsInFlight,
37
38
  hasBudget,
38
39
  throwIfAborted,
39
- untilAborted,
40
+ untilDeadline,
40
41
  type ChainCall,
41
42
  } from './http';
42
43
  import { restJson } from './rest';
@@ -253,15 +254,17 @@ type Lookup<T> = { value: T | null } | 'failed' | 'timeout' | 'aborted';
253
254
  * read in flight is shared by every call of the isolate that needs the
254
255
  * same key; none is started with less than 1 s of the budget left. A call
255
256
  * that joined a read which then timed out or was aborted by its launching
256
- * turn reads again itself while its own budget allows; it waits on its own
257
- * turn's abort, never another's.
257
+ * turn reads again itself while its own budget allows; it waits until its
258
+ * own deadline at most (`timeout` then), on its own turn's abort, never
259
+ * another's. A read that died with its actor is replaced by the next
260
+ * caller past its age limit ({@link ReadsInFlight}).
258
261
  */
259
262
  class ReadCache<T> {
260
263
  private readonly answers = new Map<
261
264
  string,
262
265
  { lookup: Lookup<T>; expiresAt: number }
263
266
  >();
264
- private readonly inflight = new Map<string, Promise<Lookup<T>>>();
267
+ private readonly inflight = new ReadsInFlight<Lookup<T>>();
265
268
 
266
269
  constructor(private readonly foundTtlMs: number) {}
267
270
 
@@ -273,22 +276,22 @@ class ReadCache<T> {
273
276
  const id = `${call.restUrl}|${key}`;
274
277
  const hit = this.answers.get(id);
275
278
  if (hit && hit.expiresAt > Date.now()) return hit.lookup;
276
- const running = this.inflight.get(id);
279
+ const running = this.inflight.joinable(id);
277
280
  if (running) {
278
- const lookup = await untilAborted(running, call.signal);
281
+ const lookup = (await untilDeadline(running, call)) ?? 'timeout';
279
282
  return (lookup === 'timeout' || lookup === 'aborted') && hasBudget(call)
280
283
  ? this.get(call, key, read)
281
284
  : lookup;
282
285
  }
283
286
  if (!hasBudget(call)) return 'timeout';
284
- const pending = read()
285
- .then((lookup) => {
287
+ return this.inflight.track(
288
+ id,
289
+ call,
290
+ read().then((lookup) => {
286
291
  this.remember(id, lookup);
287
292
  return lookup;
288
- })
289
- .finally(() => this.inflight.delete(id));
290
- this.inflight.set(id, pending);
291
- return pending;
293
+ }),
294
+ );
292
295
  }
293
296
 
294
297
  clear(): void {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The network edge shared by the chain REST and Blocksync reads: one bounded
2
+ * The network edge shared by the chain REST, Blocksync and names reads: one bounded
3
3
  * HTTP exchange per attempt, the retry policy around it, and the per-user
4
4
  * permits every attempt takes. Upstream failures become a {@link Failure}
5
5
  * the tools hand to the model; nothing here throws for them. Only the
@@ -11,11 +11,12 @@ import {
11
11
  } from '../../core/domain-context/transport';
12
12
 
13
13
  /**
14
- * Where a read goes. `chain` and `blocksync` are ixo-chain's; the Yoma
15
- * plugin (`../yoma`) adds the Jambo worker's job list and the documents
16
- * entities link to (profiles, forms), under the same policy.
14
+ * Where a read goes. `chain`, `blocksync` and `names` (the IXO Names
15
+ * worker) are ixo-chain's; the Yoma plugin (`../yoma`) adds the Jambo
16
+ * worker's job list and the documents entities link to (profiles, forms),
17
+ * under the same policy.
17
18
  */
18
- export type Source = 'chain' | 'blocksync' | 'job-list' | 'document';
19
+ export type Source = 'chain' | 'blocksync' | 'names' | 'job-list' | 'document';
19
20
 
20
21
  export type FailureReason =
21
22
  | 'rate_limited'
@@ -48,6 +49,12 @@ export const MAX_RETRY_DELAY_MS = 5_000;
48
49
  export const TOOL_BUDGET_MS = 20_000;
49
50
  /** An attempt, the first one included, is not started with less time than this left in the budget. */
50
51
  export const MIN_ATTEMPT_MS = 1_000;
52
+ /**
53
+ * How long past its call's deadline a read still counts as running. Every
54
+ * wait inside a read ends by that deadline, so a read older than this will
55
+ * never settle: it died with the actor that started it.
56
+ */
57
+ export const READ_GRACE_MS = 1_000;
51
58
  /**
52
59
  * How long a 429, 502, 503, 504 or network error answers the isolate's
53
60
  * other reads of that upstream without asking it.
@@ -66,6 +73,7 @@ export const ERROR_BODY_MAX_BYTES = 8 * 1024;
66
73
  const SOURCE_NAME: Record<Source, string> = {
67
74
  chain: 'The chain node',
68
75
  blocksync: 'Blocksync',
76
+ names: 'The IXO Names worker',
69
77
  'job-list': 'The Yoma job list',
70
78
  document: 'The document host',
71
79
  };
@@ -158,7 +166,11 @@ export type Attempt<T> =
158
166
  ok: false;
159
167
  failure: Failure;
160
168
  retry: boolean;
161
- /** The upstream's `Retry-After`, at most {@link MAX_RETRY_DELAY_MS}. */
169
+ /**
170
+ * The upstream's `Retry-After` on a 429/502/503/504. {@link withRetry}
171
+ * waits it only when the attempt asks for a retry, which
172
+ * {@link exchange} does for at most {@link MAX_RETRY_DELAY_MS}.
173
+ */
162
174
  retryAfterMs?: number;
163
175
  /**
164
176
  * A 429, 502, 503, 504 or network error: the upstream is marked
@@ -211,6 +223,70 @@ export function untilAborted<T>(
211
223
  );
212
224
  }
213
225
 
226
+ /**
227
+ * `shared` as this call waits for it, as {@link untilAborted}, and no longer
228
+ * than the call's own deadline: `undefined` then. The read may belong to an
229
+ * actor that died, and such a read never settles.
230
+ */
231
+ export async function untilDeadline<T>(
232
+ shared: Promise<T>,
233
+ call: Pick<ChainCall, 'deadline' | 'signal'>,
234
+ ): Promise<T | undefined> {
235
+ let timer: ReturnType<typeof setTimeout> | undefined;
236
+ const deadline = new Promise<undefined>((resolve) => {
237
+ timer = setTimeout(
238
+ () => resolve(undefined),
239
+ Math.max(0, call.deadline - Date.now()),
240
+ );
241
+ });
242
+ try {
243
+ return await untilAborted(Promise.race([shared, deadline]), call.signal);
244
+ } finally {
245
+ clearTimeout(timer);
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Reads in flight that other calls may join, by key. The map is shared by
251
+ * every Durable Object of the isolate, while a read lives and dies with the
252
+ * actor that started it: a dead one never settles, so its `finally` never
253
+ * runs. Age recovers it instead: past its call's deadline and
254
+ * {@link READ_GRACE_MS} a read is forgotten by the next caller, who reads
255
+ * for itself. A replaced read that settles after all leaves its
256
+ * replacement in place.
257
+ */
258
+ export class ReadsInFlight<T> {
259
+ private readonly reads = new Map<
260
+ string,
261
+ { read: Promise<T>; forgetAt: number }
262
+ >();
263
+
264
+ /** The read of `key` a call can join, if one is running. */
265
+ joinable(key: string): Promise<T> | undefined {
266
+ const entry = this.reads.get(key);
267
+ if (!entry) return undefined;
268
+ if (entry.forgetAt > Date.now()) return entry.read;
269
+ this.reads.delete(key);
270
+ return undefined;
271
+ }
272
+
273
+ /** Shares `read`, started for `call`, until it settles; what comes back is what joiners get. */
274
+ track(key: string, call: ChainCall, read: Promise<T>): Promise<T> {
275
+ const entry = {
276
+ read: read.finally(() => {
277
+ if (this.reads.get(key) === entry) this.reads.delete(key);
278
+ }),
279
+ forgetAt: call.deadline + READ_GRACE_MS,
280
+ };
281
+ this.reads.set(key, entry);
282
+ return entry.read;
283
+ }
284
+
285
+ clear(): void {
286
+ this.reads.clear();
287
+ }
288
+ }
289
+
214
290
  /** Resolves after `ms`; rejects with the turn's abort reason if it ends first. */
215
291
  function sleep(ms: number, signal: AbortSignal): Promise<void> {
216
292
  return new Promise((resolve, reject) => {
@@ -349,9 +425,12 @@ const REDIRECTED = 'ixo-chain-redirect';
349
425
 
350
426
  /* ── per-user permits ─────────────────────────────────────────────────── */
351
427
 
352
- /** One user's permits: how many are taken, and the reads waiting for one, oldest first. */
428
+ /**
429
+ * One user's permits: those taken, each with the moment it counts as lost,
430
+ * and the reads waiting for one, oldest first.
431
+ */
353
432
  interface Permits {
354
- taken: number;
433
+ held: Set<{ lostAt: number }>;
355
434
  waiting: Set<() => void>;
356
435
  }
357
436
 
@@ -366,19 +445,18 @@ export function usersReading(): number {
366
445
  return permitsByUser.size;
367
446
  }
368
447
 
369
- /** Gives a permit back: to the oldest waiting read, else to the user's pool. */
370
- function releaserOf(user: string, permits: Permits): () => void {
371
- let released = false;
448
+ /**
449
+ * Takes a permit for `call`, as the function that gives it back: to the
450
+ * oldest waiting read, else to the user's pool.
451
+ */
452
+ function holdPermit(call: ChainCall, permits: Permits): () => void {
453
+ const permit = { lostAt: call.deadline + READ_GRACE_MS };
454
+ permits.held.add(permit);
372
455
  return () => {
373
- if (released) return;
374
- released = true;
456
+ if (!permits.held.delete(permit)) return;
375
457
  const next = permits.waiting.values().next();
376
- if (!next.done) {
377
- next.value();
378
- return;
379
- }
380
- permits.taken -= 1;
381
- if (permits.taken === 0) permitsByUser.delete(user);
458
+ if (!next.done) next.value();
459
+ else if (permits.held.size === 0) permitsByUser.delete(call.user);
382
460
  };
383
461
  }
384
462
 
@@ -386,28 +464,29 @@ function releaserOf(user: string, permits: Permits): () => void {
386
464
  * One of the call's user's {@link USER_READ_PERMITS} permits, as the
387
465
  * function that gives it back; null when none frees up while the call
388
466
  * still has {@link MIN_ATTEMPT_MS} of its budget left. Rejects with the
389
- * turn's abort reason when the turn ends while it waits.
467
+ * turn's abort reason when the turn ends while it waits. A permit past its
468
+ * read's deadline and {@link READ_GRACE_MS} is taken back here, by the next
469
+ * caller: a read that died with its actor never returns the one it holds.
390
470
  */
391
471
  function takePermit(call: ChainCall): Promise<(() => void) | null> {
392
- const permits = permitsByUser.get(call.user) ?? {
393
- taken: 0,
394
- waiting: new Set<() => void>(),
472
+ const permits: Permits = permitsByUser.get(call.user) ?? {
473
+ held: new Set(),
474
+ waiting: new Set(),
395
475
  };
396
476
  permitsByUser.set(call.user, permits);
397
- if (permits.taken < USER_READ_PERMITS) {
398
- permits.taken += 1;
399
- return Promise.resolve(releaserOf(call.user, permits));
400
- }
477
+ for (const permit of permits.held)
478
+ if (permit.lostAt <= Date.now()) permits.held.delete(permit);
479
+ if (permits.held.size < USER_READ_PERMITS)
480
+ return Promise.resolve(holdPermit(call, permits));
401
481
  return new Promise((resolve, reject) => {
402
482
  const leave = () => {
403
483
  permits.waiting.delete(handOver);
404
484
  clearTimeout(timer);
405
485
  call.signal.removeEventListener('abort', onAbort);
406
486
  };
407
- // A released permit passes straight to this read: `taken` is unchanged.
408
487
  const handOver = () => {
409
488
  leave();
410
- resolve(releaserOf(call.user, permits));
489
+ resolve(holdPermit(call, permits));
411
490
  };
412
491
  const onAbort = () => {
413
492
  leave();
@@ -546,6 +625,7 @@ async function exchangeHolding(
546
625
  retry: false,
547
626
  unhealthy: true,
548
627
  status,
628
+ retryAfterMs: wait,
549
629
  note: `HTTP ${status}, Retry-After ${Math.ceil(wait / 1000)} s`,
550
630
  };
551
631
  return {
@@ -24,8 +24,18 @@ import {
24
24
  } from './http';
25
25
  import { KINDS, kindByName, kindNames } from './kinds/registry';
26
26
  import type { KindDef } from './kinds/types';
27
- import { chainLookup, MAX_RESULT_CHARS, shrink } from './lookup';
28
- import { accountOverview, overviewSchema } from './overview';
27
+ import {
28
+ chainLookup,
29
+ cutRow,
30
+ cutRowAcross,
31
+ fitNames,
32
+ MAX_RESULT_CHARS,
33
+ shrink,
34
+ } from './lookup';
35
+ import { NAMES_URLS, resolveNames } from './names/client';
36
+ import { collectIdentifiers } from './names/collect';
37
+ import type { NameMap } from './names/types';
38
+ import { accountOverview, cutTopRow, overviewSchema } from './overview';
29
39
  import { accountOf, type Subject } from './queries';
30
40
  import { restJson } from './rest';
31
41
 
@@ -54,6 +64,10 @@ const configSchema = z.object({
54
64
  if (!parsed.ok) ctx.addIssue({ code: 'custom', message: parsed.error });
55
65
  })
56
66
  .optional(),
67
+ /** The IXO Names worker's origin; defaults per `NETWORK`. https only. */
68
+ IXO_NAMES_URL: rpcOriginSchema.optional(),
69
+ /** `off` leaves `names` out of every answer; unset or `on` adds them. */
70
+ IXO_NAMES: z.enum(['on', 'off']).default('on'),
57
71
  });
58
72
 
59
73
  type Network = z.infer<typeof NetworkSchema>;
@@ -73,6 +87,37 @@ export function chainRestUrl(config: MergedConfig): string {
73
87
  : CHAIN_REST_URLS[networkOf(config)];
74
88
  }
75
89
 
90
+ /** The names worker's origin: `IXO_NAMES_URL` when set, else the network's default. */
91
+ export function namesUrl(config: MergedConfig): string {
92
+ const override = configSchema.shape.IXO_NAMES_URL.safeParse(
93
+ config.IXO_NAMES_URL,
94
+ );
95
+ // The worker's routes sit at the origin's root.
96
+ return override.success && override.data
97
+ ? new URL(override.data).origin
98
+ : NAMES_URLS[networkOf(config)];
99
+ }
100
+
101
+ /** The longest `x-client-id` the worker takes. */
102
+ const CLIENT_ID_MAX = 64;
103
+
104
+ /**
105
+ * The stable id the oracle asks the names worker under:
106
+ * `ixo-chain-<ORACLE_NAME as a slug, or "oracle">-<network>`, at most 64
107
+ * characters of `[a-z0-9-]`.
108
+ */
109
+ export function namesClientId(config: MergedConfig): string {
110
+ const network = networkOf(config);
111
+ const name = typeof config.ORACLE_NAME === 'string' ? config.ORACLE_NAME : '';
112
+ const room = CLIENT_ID_MAX - `ixo-chain--${network}`.length;
113
+ const slug = name
114
+ .toLowerCase()
115
+ .replace(/[^a-z0-9]+/g, '-')
116
+ .slice(0, room)
117
+ .replace(/^-+|-+$/g, '');
118
+ return `ixo-chain-${slug || 'oracle'}-${network}`;
119
+ }
120
+
76
121
  /**
77
122
  * `BLOCKSYNC_GRAPHQL_URL` when it parses as an http(s) URL, else `''`. The
78
123
  * env schema only requires a string (the local harness serves http); a
@@ -131,6 +176,49 @@ function invalidInput(detail: string): Failure {
131
176
  return { reason: 'invalid_input', detail, retryable: false };
132
177
  }
133
178
 
179
+ /* ── names ────────────────────────────────────────────────────────────── */
180
+
181
+ /** The longest a call waits for its names. */
182
+ export const NAMES_BUDGET_MS = 4_000;
183
+ /** What the names wait leaves of the call's budget. */
184
+ export const NAMES_RESERVE_MS = 1_000;
185
+
186
+ /**
187
+ * `answer` with the `names` of the identifiers it carries (the user's own
188
+ * left out), then fitted to {@link MAX_RESULT_CHARS} with `cutRows` cutting
189
+ * its rows first. Waits at most {@link NAMES_BUDGET_MS}, and never into the
190
+ * last {@link NAMES_RESERVE_MS} of the call's budget. Unchanged when names
191
+ * are off, it carries no identifier or none was named in time; only the
192
+ * turn's abort is thrown.
193
+ */
194
+ async function withNames<T extends { names?: NameMap }>(
195
+ call: ChainCall,
196
+ ctx: RuntimeContext,
197
+ answer: T,
198
+ cutRows: () => boolean,
199
+ ): Promise<T> {
200
+ if (ctx.config.IXO_NAMES === 'off') return answer;
201
+ const identifiers = collectIdentifiers(answer, identityOf(ctx));
202
+ if (!identifiers.length) return answer;
203
+ const deadline = Math.min(
204
+ Date.now() + NAMES_BUDGET_MS,
205
+ call.deadline - NAMES_RESERVE_MS,
206
+ );
207
+ if (deadline <= Date.now()) return answer;
208
+ const names = await resolveNames(identifiers, {
209
+ network: call.network,
210
+ baseUrl: namesUrl(ctx.config),
211
+ clientId: namesClientId(ctx.config),
212
+ fetch: call.fetch,
213
+ deadline,
214
+ signal: call.signal,
215
+ });
216
+ if (!Object.keys(names).length) return answer;
217
+ answer.names = names;
218
+ fitNames(answer, cutRows);
219
+ return answer;
220
+ }
221
+
134
222
  /* ── get_account_balances ─────────────────────────────────────────────── */
135
223
 
136
224
  // `address` is bounded by `accountOf`'s patterns, not by a length cap here.
@@ -174,6 +262,7 @@ async function getAccountBalances(
174
262
  tokens: BalanceToken[];
175
263
  moreDenoms?: true;
176
264
  truncated?: boolean;
265
+ names?: NameMap;
177
266
  } = {
178
267
  ok: true,
179
268
  network: call.network,
@@ -185,7 +274,7 @@ async function getAccountBalances(
185
274
  ...(moreDenoms ? { moreDenoms: true } : {}),
186
275
  };
187
276
  shrink(answer, answer.tokens, MAX_RESULT_CHARS);
188
- return answer;
277
+ return withNames(call, ctx, answer, () => cutRow(answer, answer.tokens));
189
278
  }
190
279
 
191
280
  /* ── chain_lookup ─────────────────────────────────────────────────────── */
@@ -216,35 +305,42 @@ function registeredKind(name: string): KindDef {
216
305
  return kind;
217
306
  }
218
307
 
219
- function lookupHandler(input: unknown, ctx: RuntimeContext): Promise<unknown> {
308
+ async function lookupHandler(
309
+ input: unknown,
310
+ ctx: RuntimeContext,
311
+ ): Promise<unknown> {
220
312
  // The tool layer validates `input` against the schema before the
221
313
  // handler runs (an unknown kind or key rejects the whole call there); a
222
314
  // mismatch here is a programmer error and throws.
223
315
  const { requests } = chainLookupSchema.parse(input);
224
316
  const call = chainCall(ctx);
225
- return chainLookup(
317
+ const answer = await chainLookup(
226
318
  call,
227
319
  identityOf(ctx),
228
320
  requests.map(({ kind, args }) => ({ kind: registeredKind(kind), args })),
229
321
  { ibcDenoms: ibcDenomsOf(ctx, call) },
230
322
  );
323
+ return withNames(call, ctx, answer, () => cutRowAcross(answer.results));
231
324
  }
232
325
 
233
326
  /* ── get_account_overview ─────────────────────────────────────────────── */
234
327
 
235
- function overviewHandler(
328
+ async function overviewHandler(
236
329
  input: unknown,
237
330
  ctx: RuntimeContext,
238
331
  ): Promise<unknown> {
239
332
  // Validated by the tool layer before the handler runs.
240
333
  const { address, sections } = overviewSchema.parse(input);
241
334
  const call = chainCall(ctx);
242
- return accountOverview(
335
+ const answer = await accountOverview(
243
336
  call,
244
337
  identityOf(ctx),
245
338
  { address, sections },
246
339
  { ibcDenoms: ibcDenomsOf(ctx, call) },
247
340
  );
341
+ return answer.ok
342
+ ? withNames(call, ctx, answer, () => cutTopRow(answer))
343
+ : answer;
248
344
  }
249
345
 
250
346
  /* ── describe_chain_kinds ─────────────────────────────────────────────── */
@@ -373,20 +469,29 @@ const ORIGIN_RULE =
373
469
  const LOOKUP_UNITS_RULE =
374
470
  '`amountBaseUnits` is base units (uixo ÷ 1,000,000 = IXO), an `amount` beside a `symbol` converted; 1155 token amounts are whole units.';
375
471
 
376
- /** Says the overview is broad and costly; its units and text rules are `chain_lookup`'s, bound beside it. */
472
+ /**
473
+ * Every answer's `names` map (`{ [id]: { name, source, selfChosen } }`);
474
+ * in the guide and the `chain_lookup` description, which the other tools'
475
+ * descriptions point to. Anyone can pick a self-chosen name, so the short
476
+ * address beside it tells look-alikes apart.
477
+ */
478
+ const NAMES_RULE =
479
+ 'Call people and things by their `names` entry; give an address or DID only when asked; beside a `selfChosen` name the user did not pick, also give the short address (first 12 … last 6 characters).';
480
+
481
+ /** Says the overview is broad and costly; its units, text and names rules are `chain_lookup`'s, bound beside it. */
377
482
  const OVERVIEW_DESCRIPTION = [
378
483
  "A broad look at an IXO account (default: the user's) by `sections` (default: all): per section: counts, top 3 rows, `more` (the request for the rest).",
379
- 'Several reads: for one fact use `get_account_balances` or `chain_lookup`. Units and text as in `chain_lookup`.',
484
+ 'Several reads: for one fact use `get_account_balances` or `chain_lookup`. Units, text and names as in `chain_lookup`.',
380
485
  ].join(' ');
381
486
 
382
487
  // The guide states every rule once, briefly; each tool description carries
383
488
  // the ones its own answers need, in full.
384
489
  const OPERATING_GUIDE = [
385
490
  '### IXO chain reader',
386
- '- Broad account questions: `get_account_overview`; one fact: `get_account_balances` or `chain_lookup`.',
491
+ '- Broad account questions: `get_account_overview`; one fact: `get_account_balances` or `chain_lookup`. Batch questions into one `chain_lookup`.',
387
492
  "- `chain` answers are live, `blocksync` ones indexed (seconds behind); say each answer's `network`.",
388
493
  "- Units: `amount` beside `symbol` is converted (IXO = uixo ÷ 1,000,000), `amountBaseUnits` not; 1155 token amounts are whole units. Say a token's `origin` when two share a symbol; an unverified token's `trace` is only the sending chain's claim, never what the token is.",
389
- '- Batch questions into one `chain_lookup`.',
494
+ `- ${NAMES_RULE}`,
390
495
  `- ${READ_ONLY_RULE}`,
391
496
  `- ${UNTRUSTED_TEXT_RULE}`,
392
497
  ].join('\n');
@@ -396,9 +501,10 @@ const OPERATING_GUIDE = [
396
501
  * gateway, `get_account_overview` (several kinds per account section in
397
502
  * one lookup call), and `chain_lookup` over a catalogue of kinds — chain
398
503
  * REST reads and one batched Blocksync GraphQL document per call — with
399
- * `describe_chain_kinds` giving each kind's arguments on request. Public
400
- * data only: no delegation capability is needed. On unless
401
- * `IXO_CHAIN_TOOLS=off`.
504
+ * `describe_chain_kinds` giving each kind's arguments on request. Every
505
+ * answer but the catalogue's also carries `names` for the identifiers in
506
+ * it, from the IXO Names worker. Public data only: no delegation
507
+ * capability is needed. On unless `IXO_CHAIN_TOOLS=off`.
402
508
  */
403
509
  export class IxoChainPlugin extends OraclePlugin {
404
510
  readonly name = 'ixo-chain';
@@ -419,6 +525,7 @@ export class IxoChainPlugin extends OraclePlugin {
419
525
  '`amount` is display units (IXO = uixo ÷ 1,000,000), `amountBaseUnits` base units; unverified tokens: base units, full denom; `metadataUnavailable`: may still be verified.',
420
526
  ORIGIN_RULE,
421
527
  NETWORK_RULE,
528
+ 'Names as in `chain_lookup`.',
422
529
  READ_ONLY_RULE,
423
530
  ].join(' '),
424
531
  schema: balancesSchema,
@@ -440,6 +547,7 @@ export class IxoChainPlugin extends OraclePlugin {
440
547
  '`source`: `chain` live, `blocksync` indexed (seconds behind).',
441
548
  LOOKUP_UNITS_RULE,
442
549
  NETWORK_RULE,
550
+ NAMES_RULE,
443
551
  UNTRUSTED_TEXT_RULE,
444
552
  ].join(' '),
445
553
  schema: chainLookupSchema,
@@ -7,9 +7,10 @@
7
7
  import { z } from 'zod';
8
8
  import {
9
9
  BUDGET_SPENT,
10
+ ReadsInFlight,
10
11
  hasBudget,
11
12
  throwIfAborted,
12
- untilAborted,
13
+ untilDeadline,
13
14
  type Failure,
14
15
  type Outcome,
15
16
  } from '../../http';
@@ -213,12 +214,14 @@ export function resetChainKindCaches(): void {
213
214
  * is never kept. A read in flight is shared by every call that needs the
214
215
  * same key. A call that joined a read which then timed out (the upstream,
215
216
  * or the launching call's budget) or was aborted by its launching turn
216
- * reads again itself while its own budget allows; it waits on its own
217
- * turn's abort and throws only that, never another turn's.
217
+ * reads again itself while its own budget allows; it waits until its own
218
+ * deadline at most, on its own turn's abort, and throws only that, never
219
+ * another turn's. A read that died with its actor is replaced by the next
220
+ * caller past its age limit ({@link ReadsInFlight}).
218
221
  */
219
222
  export class ReadCache<T> {
220
223
  private readonly answers = new Map<string, { value: T; expiresAt: number }>();
221
- private readonly inflight = new Map<string, Promise<Outcome<T>>>();
224
+ private readonly inflight = new ReadsInFlight<Outcome<T>>();
222
225
 
223
226
  constructor(
224
227
  private readonly ttlMs: number,
@@ -237,33 +240,33 @@ export class ReadCache<T> {
237
240
  if (hit && hit.expiresAt > Date.now())
238
241
  return { ok: true, value: hit.value };
239
242
  if (hit) this.answers.delete(id);
240
- const running = this.inflight.get(id);
243
+ const running = this.inflight.joinable(id);
241
244
  if (running) {
242
245
  const shared = await this.join(running, ctx);
243
246
  if (shared && (shared.ok || shared.failure.reason !== 'timeout'))
244
247
  return shared;
245
- // The shared read timed out, or its launching turn was aborted.
248
+ // The shared read timed out, its launching turn was aborted, or this
249
+ // call's deadline came first.
246
250
  if (hasBudget(ctx.call)) return this.get(ctx, key, read);
247
251
  return shared ?? { ok: false, failure: BUDGET_SPENT };
248
252
  }
249
- const pending = read().finally(() => this.inflight.delete(id));
250
- this.inflight.set(id, pending);
251
- const outcome = await pending;
253
+ const outcome = await this.inflight.track(id, ctx.call, read());
252
254
  if (outcome.ok) this.remember(id, outcome.value);
253
255
  return outcome;
254
256
  }
255
257
 
256
258
  /**
257
259
  * A read another call started, as this call waits for it: its outcome,
258
- * or null when it threw (its launching turn was aborted). This turn's
259
- * own abort is thrown as soon as it happens.
260
+ * or null when it threw (its launching turn was aborted) or this call's
261
+ * deadline came first. This turn's own abort is thrown as soon as it
262
+ * happens.
260
263
  */
261
264
  private async join(
262
265
  running: Promise<Outcome<T>>,
263
266
  ctx: KindContext,
264
267
  ): Promise<Outcome<T> | null> {
265
268
  try {
266
- return await untilAborted(running, ctx.call.signal);
269
+ return (await untilDeadline(running, ctx.call)) ?? null;
267
270
  } catch {
268
271
  throwIfAborted(ctx.call.signal);
269
272
  return null;