@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.
- package/LICENSE +201 -0
- package/dist/chain-discovery-nLrPzb3d.d.ts +70 -0
- package/dist/chunk-GDXSV7JV.js +50 -0
- package/dist/chunk-GDXSV7JV.js.map +1 -0
- package/dist/index.d.ts +1210 -0
- package/dist/index.js +1163 -0
- package/dist/index.js.map +1 -0
- package/dist/testing.d.ts +105 -0
- package/dist/testing.js +177 -0
- package/dist/testing.js.map +1 -0
- package/package.json +46 -0
- package/src/accounts.ts +1052 -0
- package/src/chain-discovery.ts +287 -0
- package/src/chain-spec.ts +272 -0
- package/src/chain-transaction.ts +241 -0
- package/src/chains.ts +46 -0
- package/src/chat.ts +122 -0
- package/src/container.ts +339 -0
- package/src/entropy.ts +105 -0
- package/src/errors.ts +239 -0
- package/src/features.ts +172 -0
- package/src/index.ts +149 -0
- package/src/navigation.ts +128 -0
- package/src/notifications.ts +113 -0
- package/src/papi-provider.ts +741 -0
- package/src/payments.ts +117 -0
- package/src/permissions.ts +236 -0
- package/src/result.ts +13 -0
- package/src/testing.ts +493 -0
- package/src/theme.ts +78 -0
- package/src/transport.ts +181 -0
- package/src/truapi.ts +313 -0
- package/src/types.ts +101 -0
- package/src/worker.ts +261 -0
package/src/container.ts
ADDED
|
@@ -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
|
+
}
|