@parity/product-sdk-host 0.10.3 → 0.12.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,272 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Higher-level wrapper for the host's chain-spec lookups.
5
+ *
6
+ * The host exposes three separate chain-spec calls — `chain.getSpecGenesisHash`,
7
+ * `chain.getSpecChainName`, and `chain.getSpecProperties` — each reachable via
8
+ * {@link getTruApi} and each returning a neverthrow `ResultAsync`.
9
+ * {@link getChainSpec} fetches all three in one call and returns a single
10
+ * struct so callers read whichever field they need, matching the JSON-RPC
11
+ * `chainSpec_v1_*` family they mirror.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import { createLogger } from "@parity/product-sdk-logger";
17
+
18
+ import type { HostError } from "./errors.js";
19
+ import { type Result, ok } from "./result.js";
20
+ import { getTruApi, type HexString, mapHostResult } from "./truapi.js";
21
+
22
+ const log = createLogger("host:chain-spec");
23
+
24
+ /**
25
+ * Chain SS58/token properties as reported by the host's
26
+ * `chainSpecProperties` call.
27
+ *
28
+ * The host returns this as a JSON string (mirroring the substrate
29
+ * `chainSpec_v1_properties` JSON-RPC, whose payload is an open-ended object).
30
+ * {@link getChainSpec} parses it into {@link ChainSpec.properties} and also
31
+ * surfaces the untouched JSON as {@link ChainSpec.propertiesRaw}. The well-known substrate fields are
32
+ * typed for convenience; the index signature keeps any chain-specific extras
33
+ * reachable without `any` at the call site.
34
+ */
35
+ export interface ChainProperties {
36
+ /** Address prefix used for SS58 encoding (e.g. `0` for Polkadot). */
37
+ ss58Format?: number;
38
+ /** Decimal places of the chain's native token(s). */
39
+ tokenDecimals?: number | number[];
40
+ /** Ticker symbol(s) of the chain's native token(s). */
41
+ tokenSymbol?: string | string[];
42
+ /** Chain-specific extras passed through verbatim from the JSON payload. */
43
+ [key: string]: unknown;
44
+ }
45
+
46
+ /**
47
+ * Combined chain-spec view returned by {@link getChainSpec}.
48
+ */
49
+ export interface ChainSpec {
50
+ /** The chain's `0x`-prefixed genesis hash, as reported by the host. */
51
+ genesisHash: HexString;
52
+ /** Human-readable chain name (e.g. `"Polkadot"`). */
53
+ name: string;
54
+ /**
55
+ * Parsed chain properties, or `null` if the host's JSON payload couldn't
56
+ * be parsed. Inspect {@link propertiesRaw} for the original string.
57
+ */
58
+ properties: ChainProperties | null;
59
+ /** The untouched JSON string the host returned for properties. */
60
+ propertiesRaw: string;
61
+ }
62
+
63
+ /**
64
+ * Fetch a chain's full spec (genesis hash, name, and properties) from the host
65
+ * in one call.
66
+ *
67
+ * Issues the three underlying `chain.getSpec*` requests concurrently, unwraps
68
+ * each response, and parses the properties JSON. Note the `genesisHash` in the
69
+ * result is the value the host echoes back from `getSpecGenesisHash` for the
70
+ * looked-up chain — pass the chain's known genesis hash as the lookup key.
71
+ *
72
+ * `null` (outside a container) is preserved as an `ok` value — it is an
73
+ * expected state, not a failure — so callers branch on `r.ok && r.value`. A
74
+ * real host-call failure surfaces on the `err` channel.
75
+ *
76
+ * @param genesisHash - The `0x`-prefixed genesis hash identifying the chain.
77
+ * @returns `ok(spec)` with the combined {@link ChainSpec}, `ok(null)` if the
78
+ * host is unavailable (running outside a container), or
79
+ * `err(HostCallFailedError)` if any underlying host call fails.
80
+ *
81
+ * @example
82
+ * ```ts
83
+ * import { getChainSpec } from "@parity/product-sdk-host";
84
+ *
85
+ * const r = await getChainSpec(genesisHash);
86
+ * if (r.ok && r.value) {
87
+ * console.log(r.value.name, r.value.properties?.tokenSymbol);
88
+ * }
89
+ * ```
90
+ */
91
+ export async function getChainSpec(
92
+ genesisHash: HexString,
93
+ ): Promise<Result<ChainSpec | null, HostError>> {
94
+ const truApi = await getTruApi();
95
+ if (!truApi) {
96
+ log.debug("getChainSpec: TruAPI unavailable");
97
+ return ok(null);
98
+ }
99
+ log.debug("getChainSpec", { genesisHash });
100
+
101
+ const [genesisHashResult, nameResult, propertiesResult] = await Promise.all([
102
+ mapHostResult(
103
+ truApi.chain.getSpecGenesisHash({ genesisHash }),
104
+ (response) => response.genesisHash,
105
+ "getChainSpec (genesisHash) failed",
106
+ ),
107
+ mapHostResult(
108
+ truApi.chain.getSpecChainName({ genesisHash }),
109
+ (response) => response.chainName,
110
+ "getChainSpec (chainName) failed",
111
+ ),
112
+ mapHostResult(
113
+ truApi.chain.getSpecProperties({ genesisHash }),
114
+ (response) => response.properties,
115
+ "getChainSpec (properties) failed",
116
+ ),
117
+ ]);
118
+
119
+ // Short-circuit on the first failing call.
120
+ if (!genesisHashResult.ok) return genesisHashResult;
121
+ if (!nameResult.ok) return nameResult;
122
+ if (!propertiesResult.ok) return propertiesResult;
123
+
124
+ const propertiesRaw = propertiesResult.value;
125
+ let properties: ChainProperties | null;
126
+ try {
127
+ properties = JSON.parse(propertiesRaw) as ChainProperties;
128
+ } catch (parseError) {
129
+ log.debug("getChainSpec: properties JSON parse failed", parseError);
130
+ properties = null;
131
+ }
132
+
133
+ return ok({
134
+ genesisHash: genesisHashResult.value,
135
+ name: nameResult.value,
136
+ properties,
137
+ propertiesRaw,
138
+ });
139
+ }
140
+
141
+ if (import.meta.vitest) {
142
+ const { test, expect, describe, vi } = import.meta.vitest;
143
+
144
+ async function withMockedTruApi<T>(
145
+ bridge: {
146
+ chain?: {
147
+ getSpecGenesisHash?: (req: unknown) => unknown;
148
+ getSpecChainName?: (req: unknown) => unknown;
149
+ getSpecProperties?: (req: unknown) => unknown;
150
+ };
151
+ } | null,
152
+ fn: (mod: typeof import("./chain-spec.js")) => Promise<T>,
153
+ ): Promise<T> {
154
+ vi.resetModules();
155
+ vi.doMock("./truapi.js", async (importOriginal) => {
156
+ const original = await importOriginal<typeof import("./truapi.js")>();
157
+ return {
158
+ ...original,
159
+ getTruApi: async () => bridge,
160
+ };
161
+ });
162
+ try {
163
+ const mod = await import("./chain-spec.js");
164
+ return await fn(mod);
165
+ } finally {
166
+ vi.doUnmock("./truapi.js");
167
+ vi.resetModules();
168
+ }
169
+ }
170
+
171
+ /** A resolved ResultAsync stub yielding the given response object. */
172
+ const okAsync = (response: unknown) => ({
173
+ match: async (onOk: (v: unknown) => unknown) => onOk(response),
174
+ });
175
+
176
+ describe("getChainSpec", () => {
177
+ test("returns ok(null) when TruAPI is unavailable", async () => {
178
+ await withMockedTruApi(null, async (mod) => {
179
+ expect(await mod.getChainSpec("0x00")).toEqual({ ok: true, value: null });
180
+ });
181
+ });
182
+
183
+ test("combines the three calls and parses properties JSON", async () => {
184
+ await withMockedTruApi(
185
+ {
186
+ chain: {
187
+ getSpecGenesisHash: vi
188
+ .fn()
189
+ .mockReturnValue(okAsync({ genesisHash: "0xabcd" })),
190
+ getSpecChainName: vi
191
+ .fn()
192
+ .mockReturnValue(okAsync({ chainName: "Polkadot" })),
193
+ getSpecProperties: vi.fn().mockReturnValue(
194
+ okAsync({
195
+ properties:
196
+ '{"ss58Format":0,"tokenDecimals":10,"tokenSymbol":"DOT"}',
197
+ }),
198
+ ),
199
+ },
200
+ },
201
+ async (mod) => {
202
+ const result = await mod.getChainSpec("0xabcd");
203
+ expect(result).toEqual({
204
+ ok: true,
205
+ value: {
206
+ genesisHash: "0xabcd",
207
+ name: "Polkadot",
208
+ properties: { ss58Format: 0, tokenDecimals: 10, tokenSymbol: "DOT" },
209
+ propertiesRaw:
210
+ '{"ss58Format":0,"tokenDecimals":10,"tokenSymbol":"DOT"}',
211
+ },
212
+ });
213
+ },
214
+ );
215
+ });
216
+
217
+ test("leaves properties null when the JSON is malformed", async () => {
218
+ await withMockedTruApi(
219
+ {
220
+ chain: {
221
+ getSpecGenesisHash: vi
222
+ .fn()
223
+ .mockReturnValue(okAsync({ genesisHash: "0xabcd" })),
224
+ getSpecChainName: vi
225
+ .fn()
226
+ .mockReturnValue(okAsync({ chainName: "Polkadot" })),
227
+ getSpecProperties: vi
228
+ .fn()
229
+ .mockReturnValue(okAsync({ properties: "not json" })),
230
+ },
231
+ },
232
+ async (mod) => {
233
+ const result = await mod.getChainSpec("0xabcd");
234
+ expect(result.ok).toBe(true);
235
+ if (result.ok) {
236
+ expect(result.value?.properties).toBeNull();
237
+ expect(result.value?.propertiesRaw).toBe("not json");
238
+ }
239
+ },
240
+ );
241
+ });
242
+
243
+ test("returns err(HostCallFailedError) when a host call fails", async () => {
244
+ await withMockedTruApi(
245
+ {
246
+ chain: {
247
+ getSpecGenesisHash: vi.fn().mockReturnValue({
248
+ match: async (
249
+ _onOk: (v: unknown) => unknown,
250
+ onErr: (e: unknown) => unknown,
251
+ ) => onErr({ reason: "boom" }),
252
+ }),
253
+ getSpecChainName: vi
254
+ .fn()
255
+ .mockReturnValue(okAsync({ chainName: "Polkadot" })),
256
+ getSpecProperties: vi.fn().mockReturnValue(okAsync({ properties: "{}" })),
257
+ },
258
+ },
259
+ async (mod) => {
260
+ const result = await mod.getChainSpec("0xabcd");
261
+ expect(result.ok).toBe(false);
262
+ if (!result.ok) {
263
+ expect(result.error.name).toBe("HostCallFailedError");
264
+ expect(result.error.message).toMatch(
265
+ /getChainSpec \(genesisHash\) failed: boom/,
266
+ );
267
+ }
268
+ },
269
+ );
270
+ });
271
+ });
272
+ }
@@ -0,0 +1,241 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Higher-level wrappers for the host's transaction broadcast lifecycle.
5
+ *
6
+ * `truApi.chain.broadcastTransaction` / `truApi.chain.stopTransaction` are
7
+ * reachable via {@link getTruApi}, but consumers have to unwrap the neverthrow
8
+ * `ResultAsync` themselves. {@link broadcastTransaction} and
9
+ * {@link stopTransaction} collapse that to `Result`-returning Promises, mirroring
10
+ * the JSON-RPC `transaction_v1_broadcast` / `transaction_v1_stop` pair they
11
+ * wrap.
12
+ *
13
+ * @module
14
+ */
15
+
16
+ import { createLogger } from "@parity/product-sdk-logger";
17
+
18
+ import { type HostError, HostUnavailableError } from "./errors.js";
19
+ import { type Result, err } from "./result.js";
20
+ import { getTruApi, type HexString, mapHostResult } from "./truapi.js";
21
+
22
+ const log = createLogger("host:chain-transaction");
23
+
24
+ /**
25
+ * Broadcast a signed transaction to the network via the host.
26
+ *
27
+ * Calls `truApi.chain.broadcastTransaction` and unwraps the response. The host
28
+ * keeps re-broadcasting until the transaction is finalized/dropped or
29
+ * {@link stopTransaction} is called with the returned operation id.
30
+ *
31
+ * @param genesisHash - The `0x`-prefixed genesis hash of the target chain.
32
+ * @param transaction - The `0x`-prefixed SCALE-encoded signed transaction.
33
+ * @returns `ok` with the operation id to pass to {@link stopTransaction} (or
34
+ * `null` if the host accepted the broadcast without issuing one), or
35
+ * `err(HostUnavailableError | HostCallFailedError)`.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { broadcastTransaction, stopTransaction } from "@parity/product-sdk-host";
40
+ *
41
+ * const r = await broadcastTransaction(genesisHash, signedTx);
42
+ * // later, to stop re-broadcasting:
43
+ * if (r.ok && r.value) await stopTransaction(genesisHash, r.value);
44
+ * ```
45
+ */
46
+ export async function broadcastTransaction(
47
+ genesisHash: HexString,
48
+ transaction: HexString,
49
+ ): Promise<Result<string | null, HostError>> {
50
+ const truApi = await getTruApi();
51
+ if (!truApi) {
52
+ return err(new HostUnavailableError("broadcastTransaction: TruAPI unavailable"));
53
+ }
54
+ log.debug("broadcastTransaction", { genesisHash });
55
+
56
+ return mapHostResult(
57
+ truApi.chain.broadcastTransaction({ genesisHash, transaction }),
58
+ (response) => response.operationId ?? null,
59
+ "broadcastTransaction failed",
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Stop an in-flight broadcast started by {@link broadcastTransaction}.
65
+ *
66
+ * Calls `truApi.chain.stopTransaction` and unwraps the response.
67
+ *
68
+ * @param genesisHash - The `0x`-prefixed genesis hash of the target chain.
69
+ * @param operationId - The operation id returned by
70
+ * {@link broadcastTransaction}.
71
+ * @returns `ok` on success, or `err(HostUnavailableError | HostCallFailedError)`.
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * await stopTransaction(genesisHash, operationId);
76
+ * ```
77
+ */
78
+ export async function stopTransaction(
79
+ genesisHash: HexString,
80
+ operationId: string,
81
+ ): Promise<Result<void, HostError>> {
82
+ const truApi = await getTruApi();
83
+ if (!truApi) {
84
+ return err(new HostUnavailableError("stopTransaction: TruAPI unavailable"));
85
+ }
86
+ log.debug("stopTransaction", { genesisHash, operationId });
87
+
88
+ return mapHostResult(
89
+ truApi.chain.stopTransaction({ genesisHash, operationId }),
90
+ () => undefined,
91
+ "stopTransaction failed",
92
+ );
93
+ }
94
+
95
+ if (import.meta.vitest) {
96
+ const { test, expect, describe, vi } = import.meta.vitest;
97
+
98
+ async function withMockedTruApi<T>(
99
+ bridge: {
100
+ chain?: {
101
+ broadcastTransaction?: (req: unknown) => unknown;
102
+ stopTransaction?: (req: unknown) => unknown;
103
+ };
104
+ } | null,
105
+ fn: (mod: typeof import("./chain-transaction.js")) => Promise<T>,
106
+ ): Promise<T> {
107
+ vi.resetModules();
108
+ vi.doMock("./truapi.js", async (importOriginal) => {
109
+ const original = await importOriginal<typeof import("./truapi.js")>();
110
+ return {
111
+ ...original,
112
+ getTruApi: async () => bridge,
113
+ };
114
+ });
115
+ try {
116
+ const mod = await import("./chain-transaction.js");
117
+ return await fn(mod);
118
+ } finally {
119
+ vi.doUnmock("./truapi.js");
120
+ vi.resetModules();
121
+ }
122
+ }
123
+
124
+ /** A resolved ResultAsync stub yielding the given response object. */
125
+ const ok = (response: unknown) => ({
126
+ match: async (onOk: (v: unknown) => unknown) => onOk(response),
127
+ });
128
+ /** A rejected ResultAsync stub yielding a truapi `GenericError` (`{ reason }`). */
129
+ const errResult = (reason: string) => ({
130
+ match: async (_onOk: (v: unknown) => unknown, onErr: (e: unknown) => unknown) =>
131
+ onErr({ reason }),
132
+ });
133
+
134
+ describe("broadcastTransaction", () => {
135
+ test("returns err(HostUnavailableError) when TruAPI is unavailable", async () => {
136
+ await withMockedTruApi(null, async (mod) => {
137
+ const result = await mod.broadcastTransaction("0x00", "0x01");
138
+ expect(result.ok).toBe(false);
139
+ if (!result.ok) {
140
+ expect(result.error.name).toBe("HostUnavailableError");
141
+ }
142
+ });
143
+ });
144
+
145
+ test("returns ok with the operation id", async () => {
146
+ await withMockedTruApi(
147
+ {
148
+ chain: {
149
+ broadcastTransaction: vi.fn().mockReturnValue(ok({ operationId: "op-1" })),
150
+ },
151
+ },
152
+ async (mod) => {
153
+ expect(await mod.broadcastTransaction("0x00", "0x01")).toEqual({
154
+ ok: true,
155
+ value: "op-1",
156
+ });
157
+ },
158
+ );
159
+ });
160
+
161
+ test("passes through a missing operation id as ok(null)", async () => {
162
+ await withMockedTruApi(
163
+ {
164
+ chain: {
165
+ broadcastTransaction: vi.fn().mockReturnValue(ok({})),
166
+ },
167
+ },
168
+ async (mod) => {
169
+ expect(await mod.broadcastTransaction("0x00", "0x01")).toEqual({
170
+ ok: true,
171
+ value: null,
172
+ });
173
+ },
174
+ );
175
+ });
176
+
177
+ test("wraps host errors in err(HostCallFailedError) with a diagnostic message", async () => {
178
+ await withMockedTruApi(
179
+ {
180
+ chain: {
181
+ broadcastTransaction: vi.fn().mockReturnValue(errResult("boom")),
182
+ },
183
+ },
184
+ async (mod) => {
185
+ const result = await mod.broadcastTransaction("0x00", "0x01");
186
+ expect(result.ok).toBe(false);
187
+ if (!result.ok) {
188
+ expect(result.error.name).toBe("HostCallFailedError");
189
+ expect(result.error.message).toMatch(/broadcastTransaction failed: boom/);
190
+ }
191
+ },
192
+ );
193
+ });
194
+ });
195
+
196
+ describe("stopTransaction", () => {
197
+ test("returns err(HostUnavailableError) when TruAPI is unavailable", async () => {
198
+ await withMockedTruApi(null, async (mod) => {
199
+ const result = await mod.stopTransaction("0x00", "op-1");
200
+ expect(result.ok).toBe(false);
201
+ if (!result.ok) {
202
+ expect(result.error.name).toBe("HostUnavailableError");
203
+ }
204
+ });
205
+ });
206
+
207
+ test("returns ok on success", async () => {
208
+ await withMockedTruApi(
209
+ {
210
+ chain: {
211
+ stopTransaction: vi.fn().mockReturnValue(ok(undefined)),
212
+ },
213
+ },
214
+ async (mod) => {
215
+ expect(await mod.stopTransaction("0x00", "op-1")).toEqual({
216
+ ok: true,
217
+ value: undefined,
218
+ });
219
+ },
220
+ );
221
+ });
222
+
223
+ test("wraps host errors in err(HostCallFailedError) with a diagnostic message", async () => {
224
+ await withMockedTruApi(
225
+ {
226
+ chain: {
227
+ stopTransaction: vi.fn().mockReturnValue(errResult("boom")),
228
+ },
229
+ },
230
+ async (mod) => {
231
+ const result = await mod.stopTransaction("0x00", "op-1");
232
+ expect(result.ok).toBe(false);
233
+ if (!result.ok) {
234
+ expect(result.error.name).toBe("HostCallFailedError");
235
+ expect(result.error.message).toMatch(/stopTransaction failed: boom/);
236
+ }
237
+ },
238
+ );
239
+ });
240
+ });
241
+ }