@ixo/oracle-runtime-workers 0.33.1 → 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 +16 -16
- package/package.json +3 -3
- package/src/hub-actions/transfer.ts +19 -1
- package/src/plugins/ixo-chain/denoms.ts +15 -12
- package/src/plugins/ixo-chain/http.ts +109 -29
- package/src/plugins/ixo-chain/ixo-chain.plugin.ts +122 -14
- package/src/plugins/ixo-chain/kinds/chain/shared.ts +15 -12
- package/src/plugins/ixo-chain/lookup.ts +66 -6
- package/src/plugins/ixo-chain/names/client.ts +456 -0
- package/src/plugins/ixo-chain/names/collect.ts +180 -0
- package/src/plugins/ixo-chain/names/types.ts +25 -0
- package/src/plugins/ixo-chain/overview.ts +92 -34
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,
|
|
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.
|
|
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/
|
|
70
|
+
"@ixo/ixo-transaction": "0.2.0",
|
|
71
71
|
"@ixo/ucan": "2.3.0",
|
|
72
|
-
"@ixo/
|
|
72
|
+
"@ixo/common": "1.7.1"
|
|
73
73
|
},
|
|
74
74
|
"devDependencies": {
|
|
75
75
|
"@cloudflare/vitest-pool-workers": "^0.22.0",
|
|
@@ -406,6 +406,16 @@ const HUB_TRANSIENT_CODES: Readonly<Record<number, readonly string[]>> = {
|
|
|
406
406
|
503: ['transfer_unavailable'],
|
|
407
407
|
};
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* Auth Hub's 409 `sequence_reserved`: another card of the owner's is still
|
|
411
|
+
* waiting for their review and holds the account's next transaction, so no
|
|
412
|
+
* new card can be made until that one is approved or declined. Plain words
|
|
413
|
+
* and the next step; the account's sequence is never named (the model
|
|
414
|
+
* repeated "reserved transaction sequence" to the person on devnet).
|
|
415
|
+
*/
|
|
416
|
+
export const PENDING_CARD_WORDS =
|
|
417
|
+
'The owner already has another card waiting for their review, so this one cannot be prepared yet. Tell them, in these words or close to them: “You already have another card waiting for your review. Approve or decline that one first, then I can prepare this.” Do not retry until they have, and never mention sequences or anything technical.';
|
|
418
|
+
|
|
409
419
|
function hubErrorBody(
|
|
410
420
|
body: unknown,
|
|
411
421
|
): { error: string; reason?: string } | null {
|
|
@@ -436,6 +446,8 @@ const HUB_REASON_MAX = 200;
|
|
|
436
446
|
* `not_authorized`, `operation_not_registered`: the transfer or
|
|
437
447
|
* transaction as asked cannot go ahead (with the Hub's reason, quoted and
|
|
438
448
|
* labelled as data, see `quoteHubText`);
|
|
449
|
+
* - 409 `sequence_reserved`: another card is waiting for the owner's
|
|
450
|
+
* review; approve or decline it first (`PENDING_CARD_WORDS`);
|
|
439
451
|
* - 409 `sequence_mismatch`, 503 `transfer_unavailable`: temporary, try
|
|
440
452
|
* again shortly;
|
|
441
453
|
* - any other 409: the account may not use this operation (deterministic,
|
|
@@ -484,10 +496,16 @@ export function hubPrepareRefusal(
|
|
|
484
496
|
undefined,
|
|
485
497
|
status,
|
|
486
498
|
);
|
|
499
|
+
if (status === 409 && parsed.error === 'sequence_reserved')
|
|
500
|
+
return new HubActionRefused('refused', PENDING_CARD_WORDS, undefined, 409);
|
|
487
501
|
if (HUB_TRANSIENT_CODES[status]?.includes(parsed.error))
|
|
488
502
|
return new HubActionRefused(
|
|
489
503
|
'refused',
|
|
490
|
-
|
|
504
|
+
// A sequence race is named by its effect only: the code would read as
|
|
505
|
+
// jargon if repeated to the person.
|
|
506
|
+
parsed.error === 'sequence_mismatch'
|
|
507
|
+
? 'Auth Hub could not prepare this just now because the account changed while it was being prepared; try again shortly.'
|
|
508
|
+
: `Auth Hub is temporarily unavailable for this (${parsed.error}); try again shortly.`,
|
|
491
509
|
undefined,
|
|
492
510
|
status,
|
|
493
511
|
);
|
|
@@ -34,9 +34,10 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { z } from 'zod';
|
|
36
36
|
import {
|
|
37
|
+
ReadsInFlight,
|
|
37
38
|
hasBudget,
|
|
38
39
|
throwIfAborted,
|
|
39
|
-
|
|
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
|
|
257
|
-
* turn's abort, never
|
|
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
|
|
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.
|
|
279
|
+
const running = this.inflight.joinable(id);
|
|
277
280
|
if (running) {
|
|
278
|
-
const lookup = await
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
15
|
-
* plugin (`../yoma`) adds the Jambo
|
|
16
|
-
* entities link to (profiles, forms),
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
370
|
-
function
|
|
371
|
-
|
|
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 (
|
|
374
|
-
released = true;
|
|
456
|
+
if (!permits.held.delete(permit)) return;
|
|
375
457
|
const next = permits.waiting.values().next();
|
|
376
|
-
if (!next.done)
|
|
377
|
-
|
|
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
|
-
|
|
394
|
-
waiting: new Set
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
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 {
|
|
28
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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.
|
|
400
|
-
*
|
|
401
|
-
*
|
|
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,
|