@parity/product-sdk-host 0.0.0-dev.312.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,339 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import type { JsonRpcProvider } from "polkadot-api";
4
+ import type { HexString, TrUApiClient } from "@parity/truapi";
5
+
6
+ import { formatHostError } from "./errors.js";
7
+ import { createHostPapiProvider } from "./papi-provider.js";
8
+ import { getClient, isCorrectEnvironment, subscribeWithInterrupt } from "./transport.js";
9
+ import { fromHex, toHex, unwrapHostResult } from "./truapi.js";
10
+ import type { HostLocalStorage, HostStatementStore } from "./types.js";
11
+
12
+ const textEncoder = new TextEncoder();
13
+ const textDecoder = new TextDecoder();
14
+
15
+ /**
16
+ * Synchronous container detection — fast heuristic check (iframe, webview
17
+ * marker, or injected host message port). Re-exported from the transport
18
+ * bootstrap, which owns the detection logic.
19
+ */
20
+ export { isCorrectEnvironment as isInsideContainerSync } from "./transport.js";
21
+
22
+ /**
23
+ * Thrown by {@link getHostProvider} when the host container is reachable but does
24
+ * not support the requested chain — e.g. the chain isn't enabled in this host
25
+ * build, or the descriptor's genesis hash has drifted from the host's after a
26
+ * network reset.
27
+ *
28
+ * Surfacing this as a thrown error (rather than handing back a provider that
29
+ * silently swallows every JSON-RPC request) is what lets callers of
30
+ * `createChainClient` detect the failure. Without it, the host's fallback no-op
31
+ * provider drops every request on the floor and queries await forever.
32
+ */
33
+ export class ChainNotSupportedError extends Error {
34
+ /** Genesis hash of the chain the host refused, for programmatic detection. */
35
+ readonly genesisHash: string;
36
+
37
+ constructor(genesisHash: string) {
38
+ super(
39
+ `Chain ${genesisHash} is not supported by the current host. It may not be enabled in this host build, or its genesis hash may have drifted after a network reset.`,
40
+ );
41
+ this.name = "ChainNotSupportedError";
42
+ this.genesisHash = genesisHash;
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Ask the host whether it can serve the given chain, via
48
+ * `system.featureSupported({ tag: "Chain", … })`. Gates {@link getHostProvider}
49
+ * the same way the upstream wrapper's provider gated itself internally before
50
+ * deciding whether to start a real provider or a no-op one.
51
+ *
52
+ * @throws If the host rejects the support check outright — a non-hanging,
53
+ * catchable failure.
54
+ */
55
+ async function isChainSupportedByHost(
56
+ client: TrUApiClient,
57
+ genesisHash: HexString,
58
+ ): Promise<boolean> {
59
+ return client.system.featureSupported({ tag: "Chain", value: { genesisHash } }).match(
60
+ (response) => response.supported,
61
+ (error) => {
62
+ throw new Error(
63
+ `Host rejected the chain-support check for ${genesisHash}: ${formatHostError(error)}`,
64
+ );
65
+ },
66
+ );
67
+ }
68
+
69
+ /**
70
+ * Detect if running inside a Host container (Polkadot Browser / Polkadot Desktop).
71
+ *
72
+ * The SDK is designed to run exclusively inside a host container. This function
73
+ * is primarily useful for early validation or informational purposes.
74
+ */
75
+ export async function isInsideContainer(): Promise<boolean> {
76
+ return isCorrectEnvironment();
77
+ }
78
+
79
+ /**
80
+ * Adapt the TruAPI client's raw `localStorage` domain (hex-encoded
81
+ * `read`/`write`/`clear`) into the richer {@link HostLocalStorage} surface that
82
+ * the Storage package's `KvStore` and other consumers expect.
83
+ */
84
+ function adaptLocalStorage(client: TrUApiClient): HostLocalStorage {
85
+ const ls = client.localStorage;
86
+
87
+ async function readBytes(key: string): Promise<Uint8Array | undefined> {
88
+ const response = await unwrapHostResult(ls.read({ key }), "host localStorage read failed");
89
+ return response.value !== undefined ? fromHex(response.value) : undefined;
90
+ }
91
+
92
+ async function writeBytes(key: string, value: Uint8Array): Promise<void> {
93
+ await unwrapHostResult(
94
+ ls.write({ key, value: toHex(value) }),
95
+ "host localStorage write failed",
96
+ );
97
+ }
98
+
99
+ async function readString(key: string): Promise<string> {
100
+ const bytes = await readBytes(key);
101
+ return bytes ? textDecoder.decode(bytes) : "";
102
+ }
103
+
104
+ async function writeString(key: string, value: string): Promise<void> {
105
+ return writeBytes(key, textEncoder.encode(value));
106
+ }
107
+
108
+ async function readJSON(key: string): Promise<unknown> {
109
+ const text = await readString(key);
110
+ return text ? JSON.parse(text) : null;
111
+ }
112
+
113
+ async function writeJSON(key: string, value: unknown): Promise<void> {
114
+ return writeString(key, JSON.stringify(value));
115
+ }
116
+
117
+ async function clear(key: string): Promise<void> {
118
+ await unwrapHostResult(ls.clear({ key }), "host localStorage clear failed");
119
+ }
120
+
121
+ return { readString, writeString, readJSON, writeJSON, readBytes, writeBytes, clear };
122
+ }
123
+
124
+ /**
125
+ * Get the Host API localStorage instance when running inside a container.
126
+ * Returns null outside a container or when the host transport is unavailable.
127
+ */
128
+ export async function getHostLocalStorage(): Promise<HostLocalStorage | null> {
129
+ const client = await getClient();
130
+ return client ? adaptLocalStorage(client) : null;
131
+ }
132
+
133
+ /**
134
+ * Construct a host-backed `HostLocalStorage` instance. Retained for API
135
+ * compatibility; with the single cached TruAPI client this is equivalent to
136
+ * {@link getHostLocalStorage}.
137
+ *
138
+ * @returns A `HostLocalStorage` instance, or `null` if unavailable.
139
+ */
140
+ export async function createHostLocalStorage(): Promise<HostLocalStorage | null> {
141
+ return getHostLocalStorage();
142
+ }
143
+
144
+ /**
145
+ * Get a PAPI-compatible JSON-RPC provider that routes through the host connection.
146
+ *
147
+ * When running inside a Polkadot container, this builds a `JsonRpcProvider` over
148
+ * `truApi.chain.*` (see {@link module:papi-provider}), enabling shared
149
+ * connections and efficient routing. Returns `null` when not running inside a
150
+ * container.
151
+ *
152
+ * @param genesisHash - Genesis hash of the target chain (`0x`-prefixed hex string).
153
+ * @returns A host-routed `JsonRpcProvider`, or `null` if unavailable.
154
+ * @throws {ChainNotSupportedError} When inside a container but the host can't serve
155
+ * the chain — surfaced instead of returning a provider that would hang forever.
156
+ */
157
+ export async function getHostProvider(genesisHash: HexString): Promise<JsonRpcProvider | null> {
158
+ const client = await getClient();
159
+ if (!client) return null;
160
+ return resolveHostProvider(client, genesisHash);
161
+ }
162
+
163
+ /**
164
+ * Decide whether to build a host provider for `genesisHash`, given a ready
165
+ * TruAPI client. Split out of {@link getHostProvider} so the decision logic can
166
+ * be unit-tested with a fake client.
167
+ *
168
+ * @returns the provider.
169
+ * @throws {ChainNotSupportedError} when the host can't serve the chain.
170
+ */
171
+ async function resolveHostProvider(
172
+ client: TrUApiClient,
173
+ genesisHash: HexString,
174
+ ): Promise<JsonRpcProvider> {
175
+ // Confirm the host can actually serve this chain before handing PAPI a
176
+ // provider. When the host doesn't support the chain, a provider would silently
177
+ // swallow every JSON-RPC request and the caller hangs forever with no
178
+ // rejection. Surface a catchable error instead.
179
+ if (!(await isChainSupportedByHost(client, genesisHash))) {
180
+ throw new ChainNotSupportedError(genesisHash);
181
+ }
182
+
183
+ return createHostPapiProvider(client, genesisHash);
184
+ }
185
+
186
+ /** Build a {@link HostStatementStore} over a TruAPI client's `statementStore` domain. */
187
+ function adaptStatementStore(client: TrUApiClient): HostStatementStore {
188
+ const ss = client.statementStore;
189
+ return {
190
+ subscribe(filter, callback) {
191
+ const request =
192
+ "matchAll" in filter
193
+ ? ({ tag: "MatchAll", value: filter.matchAll } as const)
194
+ : ({ tag: "MatchAny", value: filter.matchAny } as const);
195
+ // `RemoteStatementStoreSubscribeItem` is structurally a StatementsPage.
196
+ return subscribeWithInterrupt(ss.subscribe({ request }), callback);
197
+ },
198
+ async createProofAuthorized(statement) {
199
+ const response = await unwrapHostResult(
200
+ ss.createProofAuthorized(statement),
201
+ "createProofAuthorized failed",
202
+ );
203
+ return response.proof;
204
+ },
205
+ async submit(signedStatement) {
206
+ await unwrapHostResult(ss.submit(signedStatement), "statement submit failed");
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Get the host statement store when running inside a container, backed by
213
+ * `truApi.statementStore.*`.
214
+ *
215
+ * Returns a store with `subscribe`, `createProofAuthorized`, and `submit` that
216
+ * communicate through the host's native binary protocol — bypassing JSON-RPC
217
+ * entirely. Returns `null` outside a host container.
218
+ *
219
+ * @returns The host statement store, or `null` if unavailable.
220
+ */
221
+ export async function getStatementStore(): Promise<HostStatementStore | null> {
222
+ const client = await getClient();
223
+ return client ? adaptStatementStore(client) : null;
224
+ }
225
+
226
+ if (import.meta.vitest) {
227
+ const { test, expect, vi, afterEach } = import.meta.vitest;
228
+
229
+ afterEach(() => {
230
+ vi.unstubAllGlobals();
231
+ });
232
+
233
+ // A self-contained fake TruAPI client exposing just the `system` and `chain`
234
+ // domains the provider gate touches, so the chain-support decision can be
235
+ // tested without a real host connection.
236
+ function makeFakeClient(opts: { supported?: boolean; featureErr?: string | null }) {
237
+ const { supported = true, featureErr = null } = opts;
238
+ return {
239
+ system: {
240
+ featureSupported: (_request: unknown) => ({
241
+ match: (
242
+ okFn: (ok: { supported: boolean }) => boolean,
243
+ errFn: (err: { reason: string }) => boolean,
244
+ ) => (featureErr ? errFn({ reason: featureErr }) : okFn({ supported })),
245
+ }),
246
+ },
247
+ // Read synchronously by createHostPapiProvider; never invoked here.
248
+ chain: {},
249
+ } as unknown as TrUApiClient;
250
+ }
251
+
252
+ test("isInsideContainer is false in a Node environment (no window)", async () => {
253
+ expect(await isInsideContainer()).toBe(false);
254
+ });
255
+
256
+ test("isInsideContainer detects an injected host port", async () => {
257
+ const win = {};
258
+ Object.defineProperty(win, "top", { get: () => win });
259
+ (win as Record<string, unknown>).__HOST_API_PORT__ = 12345;
260
+ vi.stubGlobal("window", win);
261
+ expect(await isInsideContainer()).toBe(true);
262
+ });
263
+
264
+ test("getHostLocalStorage returns null outside container", async () => {
265
+ expect(await getHostLocalStorage()).toBeNull();
266
+ });
267
+
268
+ test("createHostLocalStorage returns null outside container", async () => {
269
+ expect(await createHostLocalStorage()).toBeNull();
270
+ });
271
+
272
+ test("adaptLocalStorage round-trips strings, JSON, and bytes over the TruAPI client", async () => {
273
+ // Minimal in-memory fake of the TruAPI localStorage domain (hex values).
274
+ const store = new Map<string, `0x${string}`>();
275
+ const okAsync = <T>(value: T) => ({
276
+ match: async (onOk: (v: T) => unknown) => onOk(value),
277
+ });
278
+ const fakeClient = {
279
+ localStorage: {
280
+ read: ({ key }: { key: string }) => okAsync({ value: store.get(key) }),
281
+ write: ({ key, value }: { key: string; value: `0x${string}` }) => {
282
+ store.set(key, value);
283
+ return okAsync(undefined);
284
+ },
285
+ clear: ({ key }: { key: string }) => {
286
+ store.delete(key);
287
+ return okAsync(undefined);
288
+ },
289
+ },
290
+ } as unknown as TrUApiClient;
291
+
292
+ const ls = adaptLocalStorage(fakeClient);
293
+ expect(await ls.readString("missing")).toBe("");
294
+ expect(await ls.readJSON("missing")).toBeNull();
295
+ expect(await ls.readBytes("missing")).toBeUndefined();
296
+
297
+ await ls.writeString("s", "hello");
298
+ expect(await ls.readString("s")).toBe("hello");
299
+
300
+ await ls.writeJSON("j", { a: 1 });
301
+ expect(await ls.readJSON("j")).toEqual({ a: 1 });
302
+
303
+ await ls.writeBytes("b", new Uint8Array([1, 2, 3]));
304
+ expect(Array.from((await ls.readBytes("b")) ?? [])).toEqual([1, 2, 3]);
305
+
306
+ await ls.clear("s");
307
+ expect(await ls.readString("s")).toBe("");
308
+ });
309
+
310
+ // --- chain-support gating (resolveHostProvider over truApi.system/chain) ---
311
+
312
+ test("resolves to a provider when the host supports the chain", async () => {
313
+ const provider = await resolveHostProvider(makeFakeClient({ supported: true }), "0xabc");
314
+ // createHostPapiProvider returns a JsonRpcProvider (an onMessage -> connection fn).
315
+ expect(typeof provider).toBe("function");
316
+ });
317
+
318
+ test("throws ChainNotSupportedError when the host doesn't support the chain", async () => {
319
+ const err = await resolveHostProvider(makeFakeClient({ supported: false }), "0xfeed").catch(
320
+ (e) => e,
321
+ );
322
+ expect(err).toBeInstanceOf(ChainNotSupportedError);
323
+ expect((err as ChainNotSupportedError).genesisHash).toBe("0xfeed");
324
+ });
325
+
326
+ test("throws when the host rejects the support check", async () => {
327
+ await expect(
328
+ resolveHostProvider(makeFakeClient({ featureErr: "boom" }), "0xabc"),
329
+ ).rejects.toThrow(/boom/);
330
+ });
331
+
332
+ test("getHostProvider returns null outside a container", async () => {
333
+ expect(await getHostProvider("0xabc")).toBeNull();
334
+ });
335
+
336
+ test("getStatementStore returns null outside a container", async () => {
337
+ expect(await getStatementStore()).toBeNull();
338
+ });
339
+ }
package/src/entropy.ts ADDED
@@ -0,0 +1,105 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Higher-level wrapper for the host's entropy derivation (RFC-0007).
5
+ *
6
+ * `truApi.entropy.derive` takes a hex `context` and returns a hex `entropy`
7
+ * payload wrapped in a neverthrow `ResultAsync`. `deriveEntropy` keeps the
8
+ * ergonomic `Uint8Array → Result<Uint8Array, HostError>` signature: it
9
+ * hex-encodes the context on the way in and decodes the entropy on the way out.
10
+ *
11
+ * @module
12
+ */
13
+
14
+ import { createLogger } from "@parity/product-sdk-logger";
15
+
16
+ import { type HostError, HostUnavailableError } from "./errors.js";
17
+ import { type Result, err } from "./result.js";
18
+ import { fromHex, getTruApi, mapHostResult, toHex } from "./truapi.js";
19
+
20
+ const log = createLogger("host:entropy");
21
+
22
+ /**
23
+ * Derive deterministic entropy from a context key (RFC-0007).
24
+ *
25
+ * The host derives entropy from the user's wallet + the provided context
26
+ * key. Calling with the same key on the same wallet yields the same bytes;
27
+ * different keys (or different wallets) yield uncorrelated entropy.
28
+ *
29
+ * @param key - Context key bytes (typically a SCALE-encoded discriminator).
30
+ * @returns `ok` with the derived entropy bytes, or
31
+ * `err(HostUnavailableError | HostCallFailedError)`.
32
+ *
33
+ * @example
34
+ * ```ts
35
+ * import { deriveEntropy } from "@parity/product-sdk-host";
36
+ *
37
+ * const r = await deriveEntropy(new TextEncoder().encode("my-app:seed-v1"));
38
+ * if (r.ok) { const seed = r.value; }
39
+ * ```
40
+ */
41
+ export async function deriveEntropy(key: Uint8Array): Promise<Result<Uint8Array, HostError>> {
42
+ const truApi = await getTruApi();
43
+ if (!truApi) {
44
+ return err(new HostUnavailableError("deriveEntropy: TruAPI unavailable"));
45
+ }
46
+ log.debug("deriveEntropy", { keyLen: key.length });
47
+
48
+ return mapHostResult(
49
+ truApi.entropy.derive({ context: toHex(key) }),
50
+ (response) => fromHex(response.entropy),
51
+ "deriveEntropy failed",
52
+ );
53
+ }
54
+
55
+ if (import.meta.vitest) {
56
+ const { test, expect, describe, vi } = import.meta.vitest;
57
+
58
+ function okAsync<T>(value: T) {
59
+ return { match: async (onOk: (v: T) => unknown) => onOk(value) };
60
+ }
61
+
62
+ async function withMockedTruApi<T>(
63
+ client: unknown,
64
+ fn: (mod: typeof import("./entropy.js")) => Promise<T>,
65
+ ): Promise<T> {
66
+ vi.resetModules();
67
+ vi.doMock("./truapi.js", async (importOriginal) => {
68
+ const original = await importOriginal<typeof import("./truapi.js")>();
69
+ return { ...original, getTruApi: async () => client };
70
+ });
71
+ try {
72
+ const mod = await import("./entropy.js");
73
+ return await fn(mod);
74
+ } finally {
75
+ vi.doUnmock("./truapi.js");
76
+ vi.resetModules();
77
+ }
78
+ }
79
+
80
+ // Tests live inside `describe` so the re-import in `withMockedTruApi`
81
+ // (via `vi.resetModules`) doesn't re-register top-level `test()` calls.
82
+ describe("deriveEntropy", () => {
83
+ test("returns err(HostUnavailableError) when TruAPI is unavailable", async () => {
84
+ await withMockedTruApi(null, async (mod) => {
85
+ const result = await mod.deriveEntropy(new Uint8Array([1, 2, 3]));
86
+ expect(result.ok).toBe(false);
87
+ if (!result.ok) {
88
+ expect(result.error.name).toBe("HostUnavailableError");
89
+ }
90
+ });
91
+ });
92
+
93
+ test("hex-encodes the context and decodes the entropy bytes", async () => {
94
+ const derive = vi.fn(() => okAsync({ entropy: "0xc0ffee" }));
95
+ await withMockedTruApi({ entropy: { derive } }, async (mod) => {
96
+ const result = await mod.deriveEntropy(new Uint8Array([0xab, 0xcd]));
97
+ expect(derive).toHaveBeenCalledWith({ context: "0xabcd" });
98
+ expect(result.ok).toBe(true);
99
+ if (result.ok) {
100
+ expect(Array.from(result.value)).toEqual([0xc0, 0xff, 0xee]);
101
+ }
102
+ });
103
+ });
104
+ });
105
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,239 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Typed errors carried on the `err` channel of the host public API's
5
+ * {@link Result} returns.
6
+ *
7
+ * The hierarchy mirrors `@parity/product-sdk-signer`'s error classes
8
+ * (`HostUnavailableError` / `HostRejectedError`), so the two layers share one
9
+ * idiom: branch with `instanceof`, and every error is a real `Error` with a
10
+ * stack trace and `cause`. The structured truapi wire error
11
+ * ({@link HostErrorPayload}) rides along as {@link HostCallFailedError.payload}
12
+ * for callers that want fine-grained tag-level handling.
13
+ *
14
+ * This module also owns {@link HostErrorPayload} (the wire-error shape) and
15
+ * {@link formatHostError} (renders a payload to a message) — co-located with the
16
+ * error classes that consume them so the host error model lives in one place.
17
+ *
18
+ * @module
19
+ */
20
+ import type { SdkError } from "@parity/product-sdk-errors";
21
+ import type { scale } from "@parity/truapi";
22
+
23
+ /**
24
+ * What a `Domain`-tagged call error carries. Widened from truapi's per-domain
25
+ * `Versioned*Error` types (all `{ tag: "V1", value: <domain error> }` today)
26
+ * so one payload type covers every call.
27
+ */
28
+ type VersionedDomainError = { tag: string; value?: unknown };
29
+
30
+ /**
31
+ * The error a host call puts on its `Err` channel — truapi's canonical
32
+ * {@link scale.CallErrorValue} envelope. `Denied` / `Unsupported` /
33
+ * `MalformedFrame` / `HostFailure` are transport-level failures; `Domain`
34
+ * wraps the actual per-domain error in a versioned envelope, which
35
+ * {@link formatHostError} digs through when rendering.
36
+ *
37
+ * This is the payload {@link HostCallFailedError} carries — not the error
38
+ * type consumers branch on.
39
+ */
40
+ export type HostErrorPayload = scale.CallErrorValue<VersionedDomainError>;
41
+
42
+ /** Narrow to a tagged-union member: `{ tag, value? }`. */
43
+ function isTagged(value: unknown): value is { tag: string; value?: unknown } {
44
+ return (
45
+ value != null &&
46
+ typeof value === "object" &&
47
+ typeof (value as { tag?: unknown }).tag === "string"
48
+ );
49
+ }
50
+
51
+ /** Narrow to a reason-carrying payload — truapi's `GenericError` shape. */
52
+ function hasReason(value: unknown): value is { reason: string } {
53
+ return (
54
+ value != null &&
55
+ typeof value === "object" &&
56
+ typeof (value as { reason?: unknown }).reason === "string"
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Extract a human-readable message from a host-side error.
62
+ *
63
+ * Renders the {@link HostErrorPayload} shapes `@parity/truapi` surfaces. Accepts
64
+ * `unknown` because it is also the catch-all formatter for thrown adapter-method
65
+ * `Error` messages, so it falls back to `Error`/string/JSON rendering for
66
+ * anything that isn't a recognized host error payload.
67
+ *
68
+ * Used by {@link HostCallFailedError} to render its message, and by the throwing
69
+ * adapter-method helper `unwrapHostResult`.
70
+ */
71
+ export function formatHostError(error: unknown): string {
72
+ if (error instanceof Error) return error.message;
73
+ if (typeof error === "string") return error;
74
+
75
+ if (isTagged(error)) {
76
+ // `Domain` carries the real error inside a versioned envelope — unwrap it.
77
+ if (error.tag === "Domain" && isTagged(error.value) && error.value.value !== undefined) {
78
+ return formatHostError(error.value.value);
79
+ }
80
+ // Tagged variant carrying a reason: { tag, value: { reason } }
81
+ if (hasReason(error.value)) {
82
+ return `${error.tag}: ${error.value.reason}`;
83
+ }
84
+ // Unit tagged variant, e.g. { tag: "Denied" } / { tag: "PermissionDenied" }
85
+ return error.tag;
86
+ }
87
+ // GenericError: { reason }
88
+ if (hasReason(error)) {
89
+ return error.reason;
90
+ }
91
+
92
+ if (error != null && typeof error === "object" && "message" in error) {
93
+ const message = (error as { message: unknown }).message;
94
+ if (typeof message === "string") return message;
95
+ }
96
+
97
+ try {
98
+ return JSON.stringify(error);
99
+ } catch {
100
+ return String(error);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Base class for all host errors. Use `instanceof HostError` (or {@link isHostError})
106
+ * to catch any host-related failure. Implements the cross-package
107
+ * {@link SdkError} marker so `isSdkError(e)` also recognizes it.
108
+ */
109
+ export class HostError extends Error implements SdkError {
110
+ readonly isSdkError = true as const;
111
+ readonly source = "host";
112
+
113
+ constructor(message: string, options?: ErrorOptions) {
114
+ super(message, options);
115
+ this.name = "HostError";
116
+ }
117
+ }
118
+
119
+ /**
120
+ * The host API is not available — the app is running outside a Polkadot host
121
+ * container (no injected TruAPI transport). The dominant case during local
122
+ * development. Branch with `instanceof HostUnavailableError` to surface an
123
+ * "open this app in a Polkadot host" message.
124
+ */
125
+ export class HostUnavailableError extends HostError {
126
+ constructor(message = "Host API is not available") {
127
+ super(message);
128
+ this.name = "HostUnavailableError";
129
+ }
130
+ }
131
+
132
+ /**
133
+ * A host call reached the container but failed on the `Err` channel. Wraps the
134
+ * structured truapi {@link HostErrorPayload} as {@link payload} (also preserved
135
+ * as `cause`); the message is rendered via {@link formatHostError}.
136
+ */
137
+ export class HostCallFailedError extends HostError {
138
+ readonly payload: HostErrorPayload;
139
+
140
+ constructor(label: string, payload: HostErrorPayload) {
141
+ super(`${label}: ${formatHostError(payload)}`, { cause: payload });
142
+ this.name = "HostCallFailedError";
143
+ this.payload = payload;
144
+ }
145
+ }
146
+
147
+ /** Check whether a value is any {@link HostError}. */
148
+ export function isHostError(error: unknown): error is HostError {
149
+ return error instanceof HostError;
150
+ }
151
+
152
+ if (import.meta.vitest) {
153
+ const { test, expect, describe } = import.meta.vitest;
154
+
155
+ describe("host error classes", () => {
156
+ test("HostError is the base class", () => {
157
+ const e = new HostUnavailableError();
158
+ expect(e).toBeInstanceOf(HostError);
159
+ expect(e).toBeInstanceOf(Error);
160
+ });
161
+
162
+ test("HostUnavailableError default message", () => {
163
+ const e = new HostUnavailableError();
164
+ expect(e.name).toBe("HostUnavailableError");
165
+ expect(e.message).toBe("Host API is not available");
166
+ });
167
+
168
+ test("HostUnavailableError custom message", () => {
169
+ expect(new HostUnavailableError("nope").message).toBe("nope");
170
+ });
171
+
172
+ test("HostCallFailedError renders payload and preserves it", () => {
173
+ const payload: HostErrorPayload = {
174
+ tag: "Domain",
175
+ value: {
176
+ tag: "V1",
177
+ value: { tag: "PermissionDenied", value: { reason: "user said no" } },
178
+ },
179
+ };
180
+ const e = new HostCallFailedError("requestPermission failed", payload);
181
+ expect(e).toBeInstanceOf(HostError);
182
+ expect(e.payload).toBe(payload);
183
+ expect(e.cause).toBe(payload);
184
+ expect(e.message).toBe("requestPermission failed: PermissionDenied: user said no");
185
+ });
186
+
187
+ test("HostCallFailedError renders a Domain-wrapped GenericError payload", () => {
188
+ const e = new HostCallFailedError("submit failed", {
189
+ tag: "Domain",
190
+ value: { tag: "V1", value: { reason: "timeout" } },
191
+ });
192
+ expect(e.message).toBe("submit failed: timeout");
193
+ });
194
+
195
+ test("isHostError narrows host errors only", () => {
196
+ expect(isHostError(new HostUnavailableError())).toBe(true);
197
+ expect(isHostError(new HostCallFailedError("x", { tag: "Denied" }))).toBe(true);
198
+ expect(isHostError(new Error("plain"))).toBe(false);
199
+ expect(isHostError("string")).toBe(false);
200
+ });
201
+ });
202
+
203
+ describe("formatHostError", () => {
204
+ test("renders the TruAPI error payload shapes", () => {
205
+ // GenericError: { reason }
206
+ expect(formatHostError({ reason: "boom" })).toBe("boom");
207
+ // Tagged variant carrying a reason: { tag, value: { reason } }
208
+ expect(formatHostError({ tag: "Unknown", value: { reason: "boom" } })).toBe(
209
+ "Unknown: boom",
210
+ );
211
+ // Unit tagged variant: { tag }
212
+ expect(formatHostError({ tag: "Full" })).toBe("Full");
213
+ });
214
+
215
+ test("unwraps the CallError Domain envelope to the domain error", () => {
216
+ // { tag: "Domain", value: { tag: "V1", value: <domain error> } }
217
+ expect(
218
+ formatHostError({
219
+ tag: "Domain",
220
+ value: { tag: "V1", value: { tag: "PermissionDenied" } },
221
+ }),
222
+ ).toBe("PermissionDenied");
223
+ expect(
224
+ formatHostError({ tag: "Domain", value: { tag: "V1", value: { reason: "boom" } } }),
225
+ ).toBe("boom");
226
+ // Transport-level CallError variants render as-is.
227
+ expect(formatHostError({ tag: "Denied" })).toBe("Denied");
228
+ expect(formatHostError({ tag: "HostFailure", value: { reason: "crashed" } })).toBe(
229
+ "HostFailure: crashed",
230
+ );
231
+ });
232
+
233
+ test("falls back for non-host-error input", () => {
234
+ expect(formatHostError(new Error("plain"))).toBe("plain");
235
+ expect(formatHostError("string err")).toBe("string err");
236
+ expect(formatHostError({ message: "loose" })).toBe("loose");
237
+ });
238
+ });
239
+ }