@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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|