@parity/product-sdk-host 0.15.1 → 0.17.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.
@@ -0,0 +1,287 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Host chain discovery. Resolves chain roles to genesis hashes against the
5
+ * host's configured environment instead of hard-coding them.
6
+ *
7
+ * The wire method takes one identifier per call, so the facade fires one
8
+ * concurrent call per requested identifier and caches the combined result
9
+ * for the lifetime of the connection. Consumed internally by chain-client.
10
+ * Products normally never call this directly.
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import type {
16
+ ChainIdentifier,
17
+ HexString,
18
+ TrUApiClient,
19
+ VersionedRemoteChainInfoError,
20
+ scale,
21
+ } from "@parity/truapi";
22
+ import { createLogger } from "@parity/product-sdk-logger";
23
+ import { formatHostError } from "./errors.js";
24
+ import { getClient } from "./transport.js";
25
+
26
+ const log = createLogger("host");
27
+
28
+ /**
29
+ * Chain-role identifier. A closed protocol enum, not a free-form name. The
30
+ * host maps each role to the concrete chain of its configured environment.
31
+ */
32
+ export type HostChainIdentifier = ChainIdentifier;
33
+
34
+ /** The host's configured environment plus per-identifier resolved genesis hashes. */
35
+ export interface HostChainDiscovery {
36
+ /** Ecosystem the host is configured for, e.g. `"polkadot"`, `"paseo"`. */
37
+ network: string;
38
+ /** Present for every requested identifier the host serves. */
39
+ chains: Partial<Record<HostChainIdentifier, HexString>>;
40
+ }
41
+
42
+ /** Error channel of `chain.getChainInfo`. */
43
+ type GetChainInfoError = scale.CallErrorValue<VersionedRemoteChainInfoError>;
44
+
45
+ /**
46
+ * Marks a transient probe failure. These are evicted from the cache so the
47
+ * next call re-probes. Stable "no discovery" answers stay cached.
48
+ */
49
+ const TRANSIENT_FAILURE = Symbol("transient-failure");
50
+
51
+ /**
52
+ * Hosts that predate the wire-id reservation never answer the probe at all,
53
+ * so a silent host must resolve as "no discovery" instead of hanging. The
54
+ * answer comes from host config with no chain I/O, so a short deadline is
55
+ * enough. A timeout is treated as transient, never cached: a host that
56
+ * supports discovery but started slowly would otherwise be recorded as
57
+ * pre-discovery for the life of the client. The cost is that a genuinely
58
+ * legacy host pays the deadline again on the next call.
59
+ */
60
+ const PROBE_TIMEOUT_MS = 3_000;
61
+
62
+ const discoveryCache = new WeakMap<TrUApiClient, Map<string, Promise<HostChainDiscovery | null>>>();
63
+
64
+ /**
65
+ * Resolve chain roles against the current host.
66
+ *
67
+ * Returns `null` when discovery is unavailable: outside a container, on a
68
+ * legacy host, or when the host serves none of the requested identifiers.
69
+ * Callers treat `null` as "fall back to configured constants".
70
+ *
71
+ * One concurrent `getChainInfo` call is made per identifier. Identifiers
72
+ * the host answers `NotSupported` for are absent from `chains`. Stable
73
+ * answers are cached per client and identifier set. Unexpected wire failures
74
+ * and probe timeouts are logged, return `null` and are not cached, so a later
75
+ * call re-probes.
76
+ */
77
+ export async function getHostChainInfo(
78
+ identifiers: readonly HostChainIdentifier[],
79
+ ): Promise<HostChainDiscovery | null> {
80
+ const client = await getClient();
81
+ if (!client) return null;
82
+ let bySet = discoveryCache.get(client);
83
+ if (!bySet) {
84
+ bySet = new Map();
85
+ discoveryCache.set(client, bySet);
86
+ }
87
+ const key = [...identifiers].sort().join(",");
88
+ let cached = bySet.get(key);
89
+ if (!cached) {
90
+ cached = fetchChainInfo(client, identifiers).then((result) => {
91
+ if (result === TRANSIENT_FAILURE) {
92
+ // Evict so the next caller re-probes.
93
+ bySet.delete(key);
94
+ return null;
95
+ }
96
+ return result;
97
+ });
98
+ bySet.set(key, cached);
99
+ }
100
+ return cached;
101
+ }
102
+
103
+ async function fetchChainInfo(
104
+ client: TrUApiClient,
105
+ identifiers: readonly HostChainIdentifier[],
106
+ ): Promise<HostChainDiscovery | null | typeof TRANSIENT_FAILURE> {
107
+ try {
108
+ let timer: ReturnType<typeof setTimeout> | undefined;
109
+ const probe = Promise.all(
110
+ identifiers.map((id) =>
111
+ client.chain.getChainInfo({ chain: id }).match(
112
+ (value) => ({ id, ok: value }) as const,
113
+ (error) => ({ id, err: error }) as const,
114
+ ),
115
+ ),
116
+ );
117
+ const outcomes = await Promise.race([
118
+ probe,
119
+ new Promise<"timeout">((resolve) => {
120
+ timer = setTimeout(() => resolve("timeout"), PROBE_TIMEOUT_MS);
121
+ }),
122
+ ]).finally(() => clearTimeout(timer));
123
+ if (outcomes === "timeout") {
124
+ log.warn("getChainInfo probe timed out, treating the host as pre-discovery for now");
125
+ return TRANSIENT_FAILURE;
126
+ }
127
+ let network: string | undefined;
128
+ const chains: Partial<Record<HostChainIdentifier, HexString>> = {};
129
+ for (const outcome of outcomes) {
130
+ if ("ok" in outcome) {
131
+ network = outcome.ok.network;
132
+ chains[outcome.id] = outcome.ok.genesisHash;
133
+ continue;
134
+ }
135
+ // "Unsupported" means the host predates the method entirely.
136
+ // "NotSupported" means this one identifier is not served.
137
+ if (outcome.err.tag === "Unsupported") return null;
138
+ if (isNotSupported(outcome.err)) continue;
139
+ log.warn(`getChainInfo failed: ${formatHostError(outcome.err)}`);
140
+ return TRANSIENT_FAILURE;
141
+ }
142
+ // Every identifier was refused, so the host never revealed its network.
143
+ if (network === undefined) return null;
144
+ return { network, chains };
145
+ } catch (error) {
146
+ log.warn(`getChainInfo failed: ${formatHostError(error)}`);
147
+ return TRANSIENT_FAILURE;
148
+ }
149
+ }
150
+
151
+ /** True when a domain error is the unit `NotSupported` variant. */
152
+ function isNotSupported(error: GetChainInfoError): boolean {
153
+ return error.tag === "Domain" && error.value.value.tag === "NotSupported";
154
+ }
155
+
156
+ if (import.meta.vitest) {
157
+ const { test, expect, afterEach, vi } = import.meta.vitest;
158
+ const { setTruApiClient } = await import("./transport.js");
159
+ type ChainInfoResponse = import("@parity/truapi").RemoteChainInfoResponse;
160
+
161
+ type Served = Partial<Record<HostChainIdentifier, HexString>>;
162
+
163
+ const NOT_SUPPORTED: GetChainInfoError = {
164
+ tag: "Domain",
165
+ value: { tag: "V1", value: { tag: "NotSupported" } },
166
+ };
167
+
168
+ type FakeBehavior = { network: string; served: Served } | { err: GetChainInfoError };
169
+
170
+ /** Fake client answering from a served-chains map or a per-call behavior function. */
171
+ function fakeClient(
172
+ behavior: FakeBehavior | ((call: number) => FakeBehavior),
173
+ calls: { count: number; requests: HostChainIdentifier[] } = { count: 0, requests: [] },
174
+ ): TrUApiClient {
175
+ return {
176
+ chain: {
177
+ getChainInfo: (request: { chain: HostChainIdentifier }) => {
178
+ const current =
179
+ typeof behavior === "function" ? behavior(calls.count) : behavior;
180
+ calls.count += 1;
181
+ calls.requests.push(request.chain);
182
+ return {
183
+ match: async <A, B>(
184
+ onOk: (v: ChainInfoResponse) => A,
185
+ onErr: (e: GetChainInfoError) => B,
186
+ ) => {
187
+ if ("err" in current) return onErr(current.err);
188
+ const genesisHash = current.served[request.chain];
189
+ if (!genesisHash) return onErr(NOT_SUPPORTED);
190
+ return onOk({
191
+ network: current.network,
192
+ chain: request.chain,
193
+ genesisHash,
194
+ });
195
+ },
196
+ };
197
+ },
198
+ },
199
+ } as unknown as TrUApiClient;
200
+ }
201
+
202
+ const PASEO_SERVED: Served = {
203
+ AssetHub: "0xaa" as HexString,
204
+ Bulletin: "0xbb" as HexString,
205
+ People: "0xcc" as HexString,
206
+ };
207
+
208
+ afterEach(() => {
209
+ setTruApiClient(null);
210
+ vi.restoreAllMocks();
211
+ });
212
+
213
+ test("resolves each requested identifier once, unserved ones absent", async () => {
214
+ const calls = { count: 0, requests: [] as HostChainIdentifier[] };
215
+ setTruApiClient(
216
+ fakeClient({ network: "paseo", served: { AssetHub: "0xaa" as HexString } }, calls),
217
+ );
218
+ const result = await getHostChainInfo(["AssetHub", "Bulletin", "People"]);
219
+ expect(result).toEqual({ network: "paseo", chains: { AssetHub: "0xaa" } });
220
+ expect(calls.requests).toEqual(["AssetHub", "Bulletin", "People"]);
221
+ });
222
+
223
+ test("returns null when discovery is unavailable and caches the answer", async () => {
224
+ // Outside a container.
225
+ expect(await getHostChainInfo(["AssetHub"])).toBeNull();
226
+ // Every identifier refused, so the network is never revealed.
227
+ const refused = { count: 0, requests: [] as HostChainIdentifier[] };
228
+ setTruApiClient(fakeClient({ network: "devnet", served: {} }, refused));
229
+ expect(await getHostChainInfo(["Bulletin"])).toBeNull();
230
+ expect(await getHostChainInfo(["Bulletin"])).toBeNull();
231
+ expect(refused.count).toBe(1);
232
+ // Legacy host answering Unsupported.
233
+ const legacy = { count: 0, requests: [] as HostChainIdentifier[] };
234
+ setTruApiClient(fakeClient({ err: { tag: "Unsupported" } }, legacy));
235
+ expect(await getHostChainInfo(["AssetHub"])).toBeNull();
236
+ expect(await getHostChainInfo(["AssetHub"])).toBeNull();
237
+ expect(legacy.count).toBe(1);
238
+ });
239
+
240
+ test("caches per client and identifier set, ignoring order", async () => {
241
+ const calls = { count: 0, requests: [] as HostChainIdentifier[] };
242
+ setTruApiClient(fakeClient({ network: "paseo", served: PASEO_SERVED }, calls));
243
+ await getHostChainInfo(["AssetHub", "Bulletin"]);
244
+ await getHostChainInfo(["Bulletin", "AssetHub"]);
245
+ expect(calls.count).toBe(2);
246
+ await getHostChainInfo(["People"]);
247
+ expect(calls.count).toBe(3);
248
+ });
249
+
250
+ test("a silent host times out to null and the next call re-probes", async () => {
251
+ vi.useFakeTimers();
252
+ try {
253
+ const calls = { count: 0 };
254
+ setTruApiClient({
255
+ chain: {
256
+ getChainInfo: () => {
257
+ calls.count += 1;
258
+ return { match: () => new Promise(() => {}) };
259
+ },
260
+ },
261
+ } as unknown as TrUApiClient);
262
+ const pending = getHostChainInfo(["AssetHub"]);
263
+ await vi.advanceTimersByTimeAsync(3_000);
264
+ expect(await pending).toBeNull();
265
+ // A host that merely started slowly must not stay classified as
266
+ // pre-discovery, so the timeout is never cached.
267
+ const retry = getHostChainInfo(["AssetHub"]);
268
+ await vi.advanceTimersByTimeAsync(3_000);
269
+ expect(await retry).toBeNull();
270
+ expect(calls.count).toBe(2);
271
+ } finally {
272
+ vi.useRealTimers();
273
+ }
274
+ });
275
+
276
+ test("transient wire failures return null but are not cached, the next call re-probes", async () => {
277
+ setTruApiClient(
278
+ fakeClient((call) =>
279
+ call === 0
280
+ ? { err: { tag: "HostFailure", value: { reason: "boom" } } }
281
+ : { network: "paseo", served: PASEO_SERVED },
282
+ ),
283
+ );
284
+ expect(await getHostChainInfo(["AssetHub"])).toBeNull();
285
+ expect((await getHostChainInfo(["AssetHub"]))?.network).toBe("paseo");
286
+ });
287
+ }
package/src/chains.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  */
7
7
 
8
8
  /**
9
- * Bulletin Chain RPC endpoints per network environment. `paseo` (Paseo Next v2)
10
- * and `devnet` (public Paseo testnet) are populated today; `polkadot` and
11
- * `kusama` are reserved for when those Bulletin deployments go live.
9
+ * Bulletin Chain RPC endpoints per network environment. `paseo` (Paseo Next v2),
10
+ * `previewnet` (zombienet, a step ahead of paseo), and `devnet` (public Paseo
11
+ * testnet) are populated today; `polkadot` and `kusama` are reserved for when
12
+ * those Bulletin deployments go live.
12
13
  */
13
14
  export const BULLETIN_RPCS = {
14
15
  paseo: ["wss://paseo-bulletin-next-rpc.polkadot.io"],
16
+ previewnet: ["wss://previewnet.substrate.dev/bulletin"],
15
17
  devnet: ["wss://bulletin-paseo.tservices.es:8443"],
16
18
  polkadot: [] as string[],
17
19
  kusama: [] as string[],
@@ -29,6 +31,11 @@ if (import.meta.vitest) {
29
31
  expect(BULLETIN_RPCS.paseo[0]).toMatch(/^wss:\/\//);
30
32
  });
31
33
 
34
+ test("BULLETIN_RPCS has previewnet endpoint", () => {
35
+ expect(BULLETIN_RPCS.previewnet.length).toBeGreaterThan(0);
36
+ expect(BULLETIN_RPCS.previewnet[0]).toMatch(/^wss:\/\//);
37
+ });
38
+
32
39
  test("BULLETIN_RPCS has devnet endpoint", () => {
33
40
  expect(BULLETIN_RPCS.devnet.length).toBeGreaterThan(0);
34
41
  expect(BULLETIN_RPCS.devnet[0]).toMatch(/^wss:\/\//);
package/src/index.ts CHANGED
@@ -53,6 +53,15 @@ export type {
53
53
  RemotePermission,
54
54
  } from "./truapi.js";
55
55
 
56
+ // Host chain discovery.
57
+ export { getHostChainInfo } from "./chain-discovery.js";
58
+ export type { HostChainDiscovery, HostChainIdentifier } from "./chain-discovery.js";
59
+
60
+ // Host-channel connection status — the transport's own signal. Distinct from
61
+ // @parity/product-sdk-signer's ConnectionStatus, which tracks a signer provider.
62
+ export { subscribeConnectionStatus } from "./transport.js";
63
+ export type { HostConnectionStatus } from "./transport.js";
64
+
56
65
  // Result type + typed host errors (the throw→Result boundary)
57
66
  export { ok, err } from "./result.js";
58
67
  export type { Result } from "./result.js";
@@ -67,16 +76,23 @@ export {
67
76
  export type { HostErrorPayload } from "./errors.js";
68
77
 
69
78
  // Accounts — host wallet accounts, product accounts, Ring VRF, and signers.
70
- export { getAccountsProvider } from "./accounts.js";
79
+ export { getAccountsProvider, findRingVrfKeyHandle } from "./accounts.js";
71
80
  export type {
72
81
  AccountsProvider,
73
82
  DerivationIndex,
74
83
  HostAccount,
75
84
  ProductAccount,
85
+ ProductAccountLookup,
76
86
  ContextualAlias,
77
87
  ProductProofContext,
88
+ RegisteredRingVrfKey,
78
89
  RingLocation,
79
90
  RingVRFProof,
91
+ RingVrfKeyDisclosure,
92
+ RingVrfKeyHandle,
93
+ RingVrfPublicKey,
94
+ VrfSignature,
95
+ VrfTranscriptItem,
80
96
  } from "./accounts.js";
81
97
 
82
98
  // Higher-level permission wrappers