@ixo/oracle-runtime-workers 0.26.0 → 0.28.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.
Files changed (36) hide show
  1. package/README.md +1 -0
  2. package/package.json +1 -1
  3. package/src/channels/auth.ts +2 -1
  4. package/src/channels/contract.ts +104 -0
  5. package/src/channels/replies.ts +58 -0
  6. package/src/channels/routing.ts +222 -0
  7. package/src/channels/turns.ts +1252 -16
  8. package/src/core/domain-context/turn-context.ts +1 -1
  9. package/src/core/env.ts +22 -4
  10. package/src/core/main-agent.ts +31 -5
  11. package/src/core/prompt-composer.ts +14 -0
  12. package/src/core/sub-agent-fallback.ts +3 -2
  13. package/src/core/user-identity.ts +67 -0
  14. package/src/do/contracts.ts +19 -0
  15. package/src/do/room-mirror.ts +8 -1
  16. package/src/do/run-coordinator.ts +27 -0
  17. package/src/do/transcript.ts +14 -11
  18. package/src/do/user-oracle-do.ts +269 -63
  19. package/src/memory/session-history-indexer.ts +442 -103
  20. package/src/memory/user-context-fetcher.ts +13 -0
  21. package/src/memory/user-context-invalidation.ts +2 -2
  22. package/src/plugins/action-proposals/evidence.ts +7 -8
  23. package/src/plugins/hub-actions/hub-actions.plugin.ts +6 -2
  24. package/src/plugins/index.ts +6 -2
  25. package/src/plugins/ixo-chain/blocksync.ts +214 -0
  26. package/src/plugins/ixo-chain/denoms.ts +189 -0
  27. package/src/plugins/ixo-chain/http.ts +388 -0
  28. package/src/plugins/ixo-chain/index.ts +1 -0
  29. package/src/plugins/ixo-chain/ixo-chain.plugin.ts +543 -0
  30. package/src/plugins/ixo-chain/queries.ts +401 -0
  31. package/src/plugins/ixo-chain/rest.ts +79 -0
  32. package/src/plugins/ixo-chain/summaries.ts +799 -0
  33. package/src/plugins/tasks/middleware.ts +4 -9
  34. package/src/shell/app.ts +70 -0
  35. package/src/sqlite/serialization.ts +20 -0
  36. package/src/sqlite/sessions-store.ts +43 -0
@@ -2,8 +2,8 @@
2
2
  * Drops a room's prefetched user context (`UserContextFetcher.invalidate`)
3
3
  * after a tool call that changed the user's memory succeeded, so the next
4
4
  * session in the room gathers again instead of reading the pre-write answer
5
- * for the rest of the cache TTL. It never changes the current thread: a
6
- * thread keeps the context it got on first contact.
5
+ * for the rest of the cache TTL. Channel turns adopt the refreshed context
6
+ * on a later turn; other clients keep their first-contact context.
7
7
  *
8
8
  * Applied as a tool middleware to the main agent and every sub-agent; a
9
9
  * failed call (thrown, or an error `ToolMessage`) leaves the cache alone.
@@ -1,8 +1,5 @@
1
1
  import type { BaseMessage } from '@langchain/core/messages';
2
- import {
3
- stripTurnTimeNote,
4
- TURN_TIME_NOTE_KWARG,
5
- } from '../../core/turn-time-note';
2
+ import { userContent } from '../../channels/replies';
6
3
  import { contentToText } from '../../do/transcript';
7
4
 
8
5
  /** How many of the user's own messages count as evidence, and how much of each. */
@@ -18,8 +15,10 @@ export const NEGATION =
18
15
 
19
16
  /**
20
17
  * The user's own words from the last few turns. The runtime's time note
21
- * (`Current time: … 14:05 …`) rides on every message the user sent; it is
22
- * stripped here so its digits can never stand in for an amount.
18
+ * (`Current time: … 14:05 …`) rides on every message the user sent, and a
19
+ * WhatsApp reply carries the quoted message; both are stripped here so
20
+ * neither the note's digits nor an address or amount the user merely quoted
21
+ * can stand in for what they typed.
23
22
  */
24
23
  export function userEvidence(messages: readonly BaseMessage[]): string[] {
25
24
  return messages
@@ -27,9 +26,9 @@ export function userEvidence(messages: readonly BaseMessage[]): string[] {
27
26
  .slice(-EVIDENCE_MESSAGES)
28
27
  .map((m) =>
29
28
  contentToText(
30
- stripTurnTimeNote(
29
+ userContent(
31
30
  m.content,
32
- m.additional_kwargs?.[TURN_TIME_NOTE_KWARG],
31
+ m.additional_kwargs as Record<string, unknown> | undefined,
33
32
  ),
34
33
  ).slice(0, EVIDENCE_MESSAGE_CHARS),
35
34
  );
@@ -10,6 +10,7 @@ import type {
10
10
  PluginTool,
11
11
  RuntimeContext,
12
12
  } from '../../plugin-api/types';
13
+ import { ixoAddressOf } from '../../core/user-identity';
13
14
 
14
15
  const configSchema = z.object({
15
16
  HUB_ACTIONS_ENABLED: z.enum(['true', 'false']).default('false'),
@@ -284,12 +285,15 @@ async function hubActionOptions(
284
285
  !ctx.hubActions
285
286
  )
286
287
  return { status: 'unavailable' };
287
- return ctx.hubActions.options({
288
+ const options = await ctx.hubActions.options({
288
289
  ownerDid: ctx.user.did,
289
290
  provider: channel.provider,
290
291
  bindingId: channel.bindingId,
291
292
  bindingRevision: channel.bindingRevision,
292
293
  });
294
+ // The address is read off the owner's DID, so the model can name the
295
+ // account the listed balances belong to without a lookup.
296
+ return { ...options, ownerAddress: ixoAddressOf(ctx.user.did) };
293
297
  }
294
298
 
295
299
  const manifest: PluginManifest = {
@@ -339,7 +343,7 @@ export class HubActionsPlugin extends OraclePlugin {
339
343
  {
340
344
  name: 'hub_action_options',
341
345
  description:
342
- 'Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units.',
346
+ "Read this authenticated owner’s confirmed Gmail and Calendar connections, requestable documents, action resources and bank tokens (`transfer.tokens`: denom, symbol, decimals, verified, balance in base units). Use each returned service, identifier and label before preparing a related Hub review. A Gmail profile connection does not authorize sending. For a token that is not verified, name its full denom and say it is unverified; its amounts are base units. ownerDid is the authenticated user's DID and ownerAddress their IXO account address.",
343
347
  schema: discoverSchema,
344
348
  effect: 'read',
345
349
  handler: hubActionOptions,
@@ -1,13 +1,15 @@
1
1
  import { ActionProposalsPlugin } from './action-proposals/action-proposals.plugin';
2
2
  import { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
3
+ import { IxoChainPlugin } from './ixo-chain';
3
4
  export { ActionProposalsPlugin } from './action-proposals/action-proposals.plugin';
4
5
  export { HubActionsPlugin } from './hub-actions/hub-actions.plugin';
5
6
  /**
6
7
  * Bundled Workers-runtime plugins — ports of the Node runtime's MCP / fetch
7
8
  * plugins. Each is gated the same way as on Node: `autoDetect` on its env
8
9
  * key where one exists (memory / sandbox / firecrawl / composio), always-on
9
- * otherwise (domain-indexer; vfs self-gates on `ctx.ucan.hasSigningKey()`;
10
- * pod-creator is on-demand and stays hidden until `load_capability`).
10
+ * otherwise (domain-indexer; ixo-chain unless `IXO_CHAIN_TOOLS=off`; vfs
11
+ * self-gates on `ctx.ucan.hasSigningKey()`; pod-creator and ixo-chain are
12
+ * on-demand and stay hidden until `load_capability`).
11
13
  *
12
14
  * A host opts in by spreading `BUNDLED_WORKERS_PLUGINS` into
13
15
  * `createOracleWorker({ plugins })` — plugins whose env keys are absent are
@@ -46,6 +48,7 @@ export * from './user-preferences';
46
48
  export * from './portal';
47
49
  export * from './agui';
48
50
  export * from './ixo-transaction';
51
+ export * from './ixo-chain';
49
52
  export * from './pod-creator';
50
53
 
51
54
  export const memoryPlugin = new MemoryPlugin();
@@ -72,6 +75,7 @@ export const tasksPlugin = TasksPlugin;
72
75
  export const BUNDLED_WORKERS_PLUGINS = [
73
76
  new ActionProposalsPlugin(),
74
77
  new HubActionsPlugin(),
78
+ new IxoChainPlugin(),
75
79
  memoryPlugin,
76
80
  sandboxPlugin,
77
81
  firecrawlPlugin,
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Blocksync GraphQL reads: one POST of a document whose ids are inlined as
3
+ * literals (never JSON variables: two `messages` fields with variables in
4
+ * one document fail on the server), under the shared retry policy.
5
+ *
6
+ * PostGraphile answers field by field: one alias can be null with an error
7
+ * naming it while the others are fine, so errors are returned beside the
8
+ * data, never thrown. "current transaction is aborted" and "Connection
9
+ * terminated unexpectedly" say nothing about the question: the aliases
10
+ * they name are asked again once, in a document of only those aliases,
11
+ * and the answers merged; a statement timeout is never asked again (it
12
+ * would only time out again).
13
+ */
14
+ import { z } from 'zod';
15
+ import {
16
+ exchange,
17
+ unavailable,
18
+ withRetry,
19
+ type Attempt,
20
+ type ChainCall,
21
+ type Outcome,
22
+ } from './http';
23
+
24
+ export const BLOCKSYNC_MAX_BYTES = 1024 * 1024;
25
+ /**
26
+ * Per-attempt wall bound. Above Blocksync's own 8 s statement timeout, so
27
+ * the server's "statement timeout" answer arrives instead of our abort.
28
+ */
29
+ export const BLOCKSYNC_ATTEMPT_TIMEOUT_MS = 11_000;
30
+
31
+ export const TRANSIENT_ERROR =
32
+ /current transaction is aborted|Connection terminated unexpectedly/;
33
+ export const STATEMENT_TIMEOUT = /statement timeout/i;
34
+
35
+ export interface GraphqlError {
36
+ /** The response path; empty when the error is the whole document's. */
37
+ path: string[];
38
+ message: string;
39
+ }
40
+
41
+ export interface BlocksyncAnswer {
42
+ data: Record<string, unknown> | null;
43
+ errors: GraphqlError[];
44
+ }
45
+
46
+ const responseSchema = z.object({
47
+ data: z.record(z.string(), z.unknown()).nullable().optional(),
48
+ errors: z
49
+ .array(
50
+ z.object({
51
+ message: z.string(),
52
+ path: z.array(z.union([z.string(), z.number()])).optional(),
53
+ }),
54
+ )
55
+ .optional(),
56
+ });
57
+
58
+ /** One aliased root field of a lookup document; `field` begins with `${alias}:`. */
59
+ export interface AliasedField {
60
+ alias: string;
61
+ field: string;
62
+ }
63
+
64
+ /** The fields as one GraphQL document. */
65
+ export function lookupDocument(fields: readonly AliasedField[]): string {
66
+ return `query ChainLookup { ${fields.map((f) => f.field).join(' ')} }`;
67
+ }
68
+
69
+ /** Whether `error` concerns `alias` (an error without a path concerns every alias). */
70
+ const concerns = (error: GraphqlError, alias: string) =>
71
+ error.path.length === 0 || error.path[0] === alias;
72
+
73
+ /** The fields to ask again: those a transient error names and no statement timeout does. */
74
+ function retryFields(
75
+ answer: BlocksyncAnswer,
76
+ fields: readonly AliasedField[],
77
+ ): AliasedField[] {
78
+ const named = (alias: string, pattern: RegExp) =>
79
+ answer.errors.some(
80
+ (error) => pattern.test(error.message) && concerns(error, alias),
81
+ );
82
+ return fields.filter(
83
+ ({ alias }) =>
84
+ named(alias, TRANSIENT_ERROR) && !named(alias, STATEMENT_TIMEOUT),
85
+ );
86
+ }
87
+
88
+ /**
89
+ * The first answer with the retried aliases replaced by the second's. An
90
+ * error without a path is pinned to the aliases its answer covered, so a
91
+ * whole-document error of one answer never fails an alias the other
92
+ * answered.
93
+ */
94
+ function merge(
95
+ first: BlocksyncAnswer,
96
+ second: BlocksyncAnswer,
97
+ fields: readonly AliasedField[],
98
+ retried: readonly AliasedField[],
99
+ ): BlocksyncAnswer {
100
+ const again = new Set(retried.map((f) => f.alias));
101
+ const data: Record<string, unknown> = { ...first.data };
102
+ for (const alias of again)
103
+ if (second.data && alias in second.data) data[alias] = second.data[alias];
104
+ else delete data[alias];
105
+ const pin = (error: GraphqlError, aliases: readonly string[]) =>
106
+ error.path.length
107
+ ? [error]
108
+ : aliases.map((alias) => ({ path: [alias], message: error.message }));
109
+ const kept = fields.map((f) => f.alias).filter((alias) => !again.has(alias));
110
+ return {
111
+ data,
112
+ errors: [
113
+ ...first.errors
114
+ .filter(
115
+ (error) => !error.path.length || !again.has(error.path[0] ?? ''),
116
+ )
117
+ .flatMap((error) => pin(error, kept)),
118
+ ...second.errors.flatMap((error) => pin(error, [...again])),
119
+ ],
120
+ };
121
+ }
122
+
123
+ /** One POST of `document`, parsed. */
124
+ async function post(
125
+ call: ChainCall,
126
+ document: string,
127
+ opts: { timeoutMs?: number; maxBytes?: number },
128
+ ): Promise<Attempt<BlocksyncAnswer>> {
129
+ const answer = await exchange(
130
+ call,
131
+ 'blocksync',
132
+ call.blocksyncUrl,
133
+ {
134
+ method: 'POST',
135
+ headers: {
136
+ 'content-type': 'application/json',
137
+ accept: 'application/json',
138
+ },
139
+ body: JSON.stringify({ query: document }),
140
+ },
141
+ {
142
+ timeoutMs: opts.timeoutMs ?? BLOCKSYNC_ATTEMPT_TIMEOUT_MS,
143
+ maxBytes: opts.maxBytes ?? BLOCKSYNC_MAX_BYTES,
144
+ },
145
+ );
146
+ if (!answer.ok) return answer;
147
+ let json: unknown;
148
+ try {
149
+ json = JSON.parse(answer.value);
150
+ } catch {
151
+ return {
152
+ ok: false,
153
+ failure: unavailable(
154
+ 'blocksync',
155
+ 'it answered something that is not JSON',
156
+ false,
157
+ ),
158
+ retry: false,
159
+ note: 'malformed JSON',
160
+ };
161
+ }
162
+ const parsed = responseSchema.safeParse(json);
163
+ if (!parsed.success || (!parsed.data.data && !parsed.data.errors?.length))
164
+ return {
165
+ ok: false,
166
+ failure: unavailable(
167
+ 'blocksync',
168
+ 'it answered in an unexpected shape',
169
+ false,
170
+ ),
171
+ retry: false,
172
+ note: 'unexpected shape',
173
+ };
174
+ const errors = (parsed.data.errors ?? []).map((error) => ({
175
+ path: (error.path ?? []).map(String),
176
+ message: error.message,
177
+ }));
178
+ return { ok: true, value: { data: parsed.data.data ?? null, errors } };
179
+ }
180
+
181
+ /**
182
+ * POST the fields as one document to the configured Blocksync GraphQL
183
+ * endpoint. After a transient error, only the aliases it named are asked
184
+ * again and merged into the first answer; if that retry fails, the first
185
+ * answer stands.
186
+ */
187
+ export function blocksyncLookup(
188
+ call: ChainCall,
189
+ fields: readonly AliasedField[],
190
+ opts: { timeoutMs?: number; maxBytes?: number } = {},
191
+ ): Promise<Outcome<BlocksyncAnswer>> {
192
+ let first: BlocksyncAnswer | null = null;
193
+ let retried: AliasedField[] = [];
194
+ return withRetry(
195
+ call,
196
+ { label: 'blocksync', upstream: call.blocksyncUrl },
197
+ async () => {
198
+ const asked = first ? retried : fields;
199
+ const read = await post(call, lookupDocument(asked), opts);
200
+ if (!read.ok) return read;
201
+ const again = retryFields(read.value, asked);
202
+ const value = first
203
+ ? merge(first, read.value, fields, asked)
204
+ : read.value;
205
+ if (!first && again.length) {
206
+ first = read.value;
207
+ retried = again;
208
+ }
209
+ return again.length
210
+ ? { ok: true, value, retry: true, note: 'transient GraphQL error' }
211
+ : { ok: true, value };
212
+ },
213
+ );
214
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * How a bank denom is shown. DenomMetadata is trusted for any denom: on
3
+ * the IXO chain it is written only by chain code (liquidstake pools, which
4
+ * governance creates; the IBC transfer module; genesis and upgrades), never
5
+ * by a token's issuer (the chain has no tokenfactory module). This is
6
+ * broader than the Auth Hub's rule (ixo-auth-worker
7
+ * `src/lib/actions/denom-display.ts`), which reads metadata only for
8
+ * `ibc/…`, so the Hub may still show a native token such as `upay` as
9
+ * unverified.
10
+ *
11
+ * - `uixo`: always IXO with 6 decimals, verified, no lookup.
12
+ * - any other denom with metadata whose `display` unit is listed in
13
+ * `denom_units` with an exponent of 0–18: verified, those decimals, the
14
+ * metadata `symbol` (or, when that is empty, the display unit
15
+ * upper-cased: `uatom` → `ATOM`) if it is plain ASCII.
16
+ * - anything else (no metadata, a display unit not listed — the shape IBC
17
+ * transfers register — or a malformed denom, which is never looked up):
18
+ * unverified, base units (0 decimals), the full denom as its symbol.
19
+ *
20
+ * A denom whose metadata could not be read (a transport failure, or no
21
+ * budget left to ask) is shown the same way with `metadataUnavailable`:
22
+ * it may still be verified.
23
+ */
24
+ import { z } from 'zod';
25
+ import { MIN_ATTEMPT_MS, throwIfAborted, type ChainCall } from './http';
26
+ import { restJson } from './rest';
27
+
28
+ export type DenomMetadataView = { symbol: string; decimals: number } | null;
29
+ export type TokenDisplay = {
30
+ symbol: string;
31
+ decimals: number;
32
+ verified: boolean;
33
+ metadataUnavailable?: true;
34
+ };
35
+
36
+ /** Symbols an unverified denom may never use (compared case-insensitively). */
37
+ const RESERVED_SYMBOLS = ['IXO', 'USDC', 'USDT', 'ATOM', 'OSMO'] as const;
38
+ /** The Cosmos SDK bank denom pattern: only such a denom is looked up. */
39
+ const DENOM = /^[a-zA-Z][a-zA-Z0-9/:._-]{2,127}$/;
40
+ // Plain ASCII only: no spaces or look-alike Unicode letters ("UЅDC").
41
+ const SAFE_SYMBOL = /^[A-Za-z0-9._-]{1,32}$/;
42
+
43
+ export function trustedDenomDisplay(
44
+ denom: string,
45
+ metadata: DenomMetadataView,
46
+ ): TokenDisplay {
47
+ if (denom === 'uixo') return { symbol: 'IXO', decimals: 6, verified: true };
48
+ const symbol =
49
+ metadata && SAFE_SYMBOL.test(metadata.symbol) ? metadata.symbol : null;
50
+ if (
51
+ metadata &&
52
+ symbol &&
53
+ DENOM.test(denom) &&
54
+ Number.isInteger(metadata.decimals) &&
55
+ metadata.decimals >= 0 &&
56
+ metadata.decimals <= 18
57
+ )
58
+ return { symbol, decimals: metadata.decimals, verified: true };
59
+ const reserved =
60
+ symbol !== null &&
61
+ (RESERVED_SYMBOLS as readonly string[]).includes(symbol.toUpperCase());
62
+ return {
63
+ symbol: symbol && !reserved ? symbol : denom,
64
+ decimals: 0,
65
+ verified: false,
66
+ };
67
+ }
68
+
69
+ /** Base units as a decimal string: `("9000000", 6)` → `"9"`, `("1234567", 6)` → `"1.234567"`. */
70
+ export function formatUnits(amount: string, decimals: number): string {
71
+ if (decimals === 0) return amount;
72
+ const padded = amount.padStart(decimals + 1, '0');
73
+ const whole = padded.slice(0, -decimals);
74
+ const fraction = padded.slice(-decimals).replace(/0+$/, '');
75
+ return fraction ? `${whole}.${fraction}` : whole;
76
+ }
77
+
78
+ /**
79
+ * Per isolate: found metadata is reused for 10 min, a definite miss (404,
80
+ * or no display unit) for 60 s, a failed read for 10 s — a failure is
81
+ * never kept as a miss, so it cannot leave a real token unverified.
82
+ */
83
+ export const METADATA_TTL_MS = 600_000;
84
+ export const METADATA_NOT_FOUND_TTL_MS = 60_000;
85
+ export const METADATA_FAILED_TTL_MS = 10_000;
86
+ const CACHE_LIMIT = 256;
87
+
88
+ /** A metadata read: its view (null for a miss), or why there is none. */
89
+ type Lookup = { metadata: DenomMetadataView } | 'failed' | 'aborted';
90
+
91
+ const metadataCache = new Map<string, { lookup: Lookup; expiresAt: number }>();
92
+ /** Reads in flight, shared by every call of the isolate that needs the same denom. */
93
+ const inflight = new Map<string, Promise<Lookup>>();
94
+
95
+ /** Tests only: forget every cached metadata answer. */
96
+ export function resetDenomMetadataCache(): void {
97
+ metadataCache.clear();
98
+ inflight.clear();
99
+ }
100
+
101
+ const metadataResponse = z.object({
102
+ metadata: z.object({
103
+ denom_units: z.array(
104
+ z.object({ denom: z.string(), exponent: z.number().int() }),
105
+ ),
106
+ display: z.string(),
107
+ symbol: z.string(),
108
+ }),
109
+ });
110
+
111
+ /**
112
+ * One DenomMetadata read. The by-query-string route is used because the
113
+ * path route cannot carry the slash in `ibc/…` (the gateway answers 501).
114
+ * A 404 is a miss; any other failure is `failed`; the launching turn's
115
+ * abort is `aborted` (never cached), so a call sharing the read is not
116
+ * failed by another turn's abort.
117
+ */
118
+ async function readMetadata(call: ChainCall, denom: string): Promise<Lookup> {
119
+ let read;
120
+ try {
121
+ read = await restJson(
122
+ call,
123
+ `/cosmos/bank/v1beta1/denoms_metadata_by_query_string?denom=${encodeURIComponent(denom)}`,
124
+ metadataResponse,
125
+ { label: 'denom metadata', maxBytes: 64 * 1024, notFoundOn404: true },
126
+ );
127
+ } catch (error) {
128
+ if (call.signal.aborted) return 'aborted';
129
+ throw error;
130
+ }
131
+ if (!read.ok)
132
+ return read.failure.reason === 'not_found' ? { metadata: null } : 'failed';
133
+ const { metadata } = read.value;
134
+ const unit = metadata.denom_units.find((u) => u.denom === metadata.display);
135
+ return {
136
+ metadata:
137
+ unit && unit.exponent >= 0 && unit.exponent <= 18
138
+ ? {
139
+ symbol: metadata.symbol || metadata.display.toUpperCase(),
140
+ decimals: unit.exponent,
141
+ }
142
+ : null,
143
+ };
144
+ }
145
+
146
+ function remember(key: string, lookup: Lookup): void {
147
+ if (lookup === 'aborted') return;
148
+ const ttl =
149
+ lookup === 'failed'
150
+ ? METADATA_FAILED_TTL_MS
151
+ : lookup.metadata
152
+ ? METADATA_TTL_MS
153
+ : METADATA_NOT_FOUND_TTL_MS;
154
+ if (metadataCache.size > CACHE_LIMIT) metadataCache.clear();
155
+ metadataCache.set(key, { lookup, expiresAt: Date.now() + ttl });
156
+ }
157
+
158
+ /** The cached lookup, the read in flight, or a new read; `failed` without a read once the budget is spent. */
159
+ function lookupOf(call: ChainCall, denom: string): Promise<Lookup> {
160
+ const key = `${call.restUrl}|${denom}`;
161
+ const hit = metadataCache.get(key);
162
+ if (hit && hit.expiresAt > Date.now()) return Promise.resolve(hit.lookup);
163
+ const running = inflight.get(key);
164
+ if (running) return running;
165
+ if (call.deadline - Date.now() < MIN_ATTEMPT_MS)
166
+ return Promise.resolve('failed');
167
+ const read = readMetadata(call, denom)
168
+ .then((lookup) => {
169
+ remember(key, lookup);
170
+ return lookup;
171
+ })
172
+ .finally(() => inflight.delete(key));
173
+ inflight.set(key, read);
174
+ return read;
175
+ }
176
+
177
+ /** The display of `denom`, reading its DenomMetadata for every denom but `uixo`. */
178
+ export async function denomDisplay(
179
+ call: ChainCall,
180
+ denom: string,
181
+ ): Promise<TokenDisplay> {
182
+ if (denom === 'uixo' || !DENOM.test(denom))
183
+ return trustedDenomDisplay(denom, null);
184
+ const lookup = await lookupOf(call, denom);
185
+ throwIfAborted(call.signal);
186
+ return lookup === 'failed' || lookup === 'aborted'
187
+ ? { ...trustedDenomDisplay(denom, null), metadataUnavailable: true }
188
+ : trustedDenomDisplay(denom, lookup.metadata);
189
+ }