@parity/product-sdk-host 0.11.0 → 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.
- package/dist/index.d.ts +528 -534
- package/dist/index.js +853 -285
- package/dist/index.js.map +1 -1
- package/package.json +5 -3
- package/src/accounts.ts +544 -0
- package/src/chain-spec.ts +126 -84
- package/src/chain-transaction.ts +107 -78
- package/src/chat.ts +81 -85
- package/src/container.ts +211 -246
- package/src/entropy.ts +63 -25
- package/src/errors.ts +198 -0
- package/src/features.ts +66 -55
- package/src/index.ts +33 -22
- package/src/navigation.ts +50 -49
- package/src/notifications.ts +59 -69
- package/src/papi-provider.ts +673 -0
- package/src/payments.ts +77 -61
- package/src/permissions.ts +107 -105
- package/src/result.ts +56 -0
- package/src/theme.ts +35 -63
- package/src/transport.ts +71 -0
- package/src/truapi.ts +166 -409
- package/src/types.ts +69 -61
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
// Copyright 2026 Parity Technologies (UK) Ltd.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* Host-routed PAPI `JsonRpcProvider`, backed by `truApi.chain.*`.
|
|
5
|
+
*
|
|
6
|
+
* This is a backport of `@novasamatech/host-api-wrapper`'s
|
|
7
|
+
* `createPapiProvider` (`dist/papiProvider.js`) into product-sdk, with the
|
|
8
|
+
* call layer swapped from the novasama `hostApi` to the
|
|
9
|
+
* `@parity/truapi` client. The JSON-RPC ↔ chainHead bridge — request dispatch,
|
|
10
|
+
* the `chainHead_v1_followEvent` notification synthesis, the synthetic
|
|
11
|
+
* follow-subscription ids, and the operation/broadcast bookkeeping — is carried
|
|
12
|
+
* over from the upstream module; only the per-method transport calls and their
|
|
13
|
+
* error/response unwrapping are re-pointed at `truApi.chain.*`.
|
|
14
|
+
*
|
|
15
|
+
* **Why a bridge at all.** PAPI speaks the JSON-RPC `chainHead`/`chainSpec`/
|
|
16
|
+
* `transaction` API; the host exposes the same operations as structured,
|
|
17
|
+
* SCALE-typed calls. This module parses each inbound JSON-RPC message, routes it
|
|
18
|
+
* to the matching `truApi.chain` method, and translates the typed follow stream
|
|
19
|
+
* back into JSON-RPC notifications.
|
|
20
|
+
*
|
|
21
|
+
* **Divergences from the upstream:**
|
|
22
|
+
* - No `getSyncProvider` wrapper / no-op fallback provider. The upstream had to
|
|
23
|
+
* defer its async readiness check because `createPapiProvider` was synchronous;
|
|
24
|
+
* here {@link module:container.getHostProvider} is async and runs the
|
|
25
|
+
* `system.featureSupported` gate (throwing `ChainNotSupportedError`) *before*
|
|
26
|
+
* building the provider, so there is no async work left to defer.
|
|
27
|
+
* - `RuntimeSpec.apis` arrives as `Array<{ name, version }>` (truapi) rather than
|
|
28
|
+
* the upstream's `[name, version]` tuples; {@link convertRuntimeToJsonRpc}
|
|
29
|
+
* adjusts the loop accordingly.
|
|
30
|
+
* - The follow stream's transport interrupt (`error`/`complete`) is surfaced as a
|
|
31
|
+
* synthetic `stop` event so the consumer's substrate-client refollows; the
|
|
32
|
+
* upstream's bare callback had no such channel.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type {
|
|
38
|
+
JsonRpcConnection,
|
|
39
|
+
JsonRpcMessage,
|
|
40
|
+
JsonRpcRequest,
|
|
41
|
+
} from "@polkadot-api/json-rpc-provider";
|
|
42
|
+
import type { JsonRpcProvider } from "polkadot-api";
|
|
43
|
+
import type {
|
|
44
|
+
HexString,
|
|
45
|
+
OperationStartedResult,
|
|
46
|
+
RemoteChainHeadFollowItem,
|
|
47
|
+
RuntimeType,
|
|
48
|
+
StorageQueryType,
|
|
49
|
+
TrUApiClient,
|
|
50
|
+
} from "@parity/truapi";
|
|
51
|
+
import { createLogger } from "@parity/product-sdk-logger";
|
|
52
|
+
|
|
53
|
+
import { formatHostError } from "./errors.js";
|
|
54
|
+
import { subscribeWithInterrupt } from "./transport.js";
|
|
55
|
+
import type { HostSubscription } from "./types.js";
|
|
56
|
+
|
|
57
|
+
const log = createLogger("host:papi");
|
|
58
|
+
|
|
59
|
+
/** JSON-RPC internal-error code used for every host-side failure (matches the upstream). */
|
|
60
|
+
const JSON_RPC_INTERNAL_ERROR = -32603;
|
|
61
|
+
/** JSON-RPC method-not-found code for chainHead methods the host doesn't serve. */
|
|
62
|
+
const JSON_RPC_METHOD_NOT_FOUND = -32601;
|
|
63
|
+
|
|
64
|
+
/** A `chainHead_v1_followEvent` payload (loosely typed — consumed by PAPI's substrate-client). */
|
|
65
|
+
type FollowEvent = { event: string } & Record<string, unknown>;
|
|
66
|
+
|
|
67
|
+
/** Map a JSON-RPC storage query-type string to the truapi `StorageQueryType` tag. */
|
|
68
|
+
const STORAGE_TYPE_MAP: Record<string, StorageQueryType> = {
|
|
69
|
+
value: "Value",
|
|
70
|
+
hash: "Hash",
|
|
71
|
+
closestDescendantMerkleValue: "ClosestDescendantMerkleValue",
|
|
72
|
+
descendantsValues: "DescendantsValues",
|
|
73
|
+
descendantsHashes: "DescendantsHashes",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert a truapi `RuntimeType` into the JSON-RPC `chainHead` runtime shape.
|
|
78
|
+
* Returns `null` for absent/unknown runtimes. Note `RuntimeSpec.apis` is an
|
|
79
|
+
* array of `{ name, version }` (truapi), flattened here into a `{ name: version }`
|
|
80
|
+
* map as the JSON-RPC spec expects.
|
|
81
|
+
*/
|
|
82
|
+
function convertRuntimeToJsonRpc(runtime: RuntimeType | undefined): unknown {
|
|
83
|
+
if (!runtime || typeof runtime !== "object") return null;
|
|
84
|
+
if (runtime.tag === "Valid") {
|
|
85
|
+
const spec = runtime.value;
|
|
86
|
+
const apis: Record<string, number> = {};
|
|
87
|
+
for (const api of spec.apis) {
|
|
88
|
+
apis[api.name] = api.version;
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
type: "valid",
|
|
92
|
+
spec: {
|
|
93
|
+
specName: spec.specName,
|
|
94
|
+
implName: spec.implName,
|
|
95
|
+
specVersion: spec.specVersion,
|
|
96
|
+
implVersion: spec.implVersion,
|
|
97
|
+
transactionVersion: spec.transactionVersion,
|
|
98
|
+
apis,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (runtime.tag === "Invalid") {
|
|
103
|
+
return { type: "invalid", error: runtime.value.error };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Translate a typed follow-stream item into its JSON-RPC `followEvent` form. */
|
|
109
|
+
function convertFollowEventToJsonRpc(item: RemoteChainHeadFollowItem): FollowEvent {
|
|
110
|
+
switch (item.tag) {
|
|
111
|
+
case "Initialized":
|
|
112
|
+
return {
|
|
113
|
+
event: "initialized",
|
|
114
|
+
finalizedBlockHashes: item.value.finalizedBlockHashes,
|
|
115
|
+
finalizedBlockRuntime: convertRuntimeToJsonRpc(item.value.finalizedBlockRuntime),
|
|
116
|
+
};
|
|
117
|
+
case "NewBlock":
|
|
118
|
+
return {
|
|
119
|
+
event: "newBlock",
|
|
120
|
+
blockHash: item.value.blockHash,
|
|
121
|
+
parentBlockHash: item.value.parentBlockHash,
|
|
122
|
+
newRuntime: convertRuntimeToJsonRpc(item.value.newRuntime),
|
|
123
|
+
};
|
|
124
|
+
case "BestBlockChanged":
|
|
125
|
+
return { event: "bestBlockChanged", bestBlockHash: item.value.bestBlockHash };
|
|
126
|
+
case "Finalized":
|
|
127
|
+
return {
|
|
128
|
+
event: "finalized",
|
|
129
|
+
finalizedBlockHashes: item.value.finalizedBlockHashes,
|
|
130
|
+
prunedBlockHashes: item.value.prunedBlockHashes,
|
|
131
|
+
};
|
|
132
|
+
case "OperationBodyDone":
|
|
133
|
+
return {
|
|
134
|
+
event: "operationBodyDone",
|
|
135
|
+
operationId: item.value.operationId,
|
|
136
|
+
value: item.value.value,
|
|
137
|
+
};
|
|
138
|
+
case "OperationCallDone":
|
|
139
|
+
return {
|
|
140
|
+
event: "operationCallDone",
|
|
141
|
+
operationId: item.value.operationId,
|
|
142
|
+
output: item.value.output,
|
|
143
|
+
};
|
|
144
|
+
case "OperationStorageItems":
|
|
145
|
+
return {
|
|
146
|
+
event: "operationStorageItems",
|
|
147
|
+
operationId: item.value.operationId,
|
|
148
|
+
items: item.value.items,
|
|
149
|
+
};
|
|
150
|
+
case "OperationStorageDone":
|
|
151
|
+
return { event: "operationStorageDone", operationId: item.value.operationId };
|
|
152
|
+
case "OperationWaitingForContinue":
|
|
153
|
+
return { event: "operationWaitingForContinue", operationId: item.value.operationId };
|
|
154
|
+
case "OperationInaccessible":
|
|
155
|
+
return { event: "operationInaccessible", operationId: item.value.operationId };
|
|
156
|
+
case "OperationError":
|
|
157
|
+
return {
|
|
158
|
+
event: "operationError",
|
|
159
|
+
operationId: item.value.operationId,
|
|
160
|
+
error: item.value.error,
|
|
161
|
+
};
|
|
162
|
+
case "Stop":
|
|
163
|
+
return { event: "stop" };
|
|
164
|
+
default: {
|
|
165
|
+
// Exhaustiveness: a new follow-item tag in @parity/truapi fails to compile here.
|
|
166
|
+
const _exhaustive: never = item;
|
|
167
|
+
return { event: "stop" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Map a JSON-RPC storage query-type string to the truapi `StorageQueryType` tag. */
|
|
173
|
+
function convertStorageType(type: string): StorageQueryType {
|
|
174
|
+
return STORAGE_TYPE_MAP[type] ?? "Value";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Translate a truapi `OperationStartedResult` into the JSON-RPC operation-start shape. */
|
|
178
|
+
function convertOperationResultToJsonRpc(result: OperationStartedResult): unknown {
|
|
179
|
+
if (result.tag === "Started") {
|
|
180
|
+
return { result: "started", operationId: result.value.operationId };
|
|
181
|
+
}
|
|
182
|
+
return { result: "limitReached" };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build a host-routed PAPI `JsonRpcProvider` for `genesisHash` over the given
|
|
187
|
+
* TruAPI client. The caller is responsible for confirming the host supports the
|
|
188
|
+
* chain (and for the handshake) before calling this — see
|
|
189
|
+
* {@link module:container.getHostProvider}.
|
|
190
|
+
*/
|
|
191
|
+
export function createHostPapiProvider(
|
|
192
|
+
client: TrUApiClient,
|
|
193
|
+
genesisHash: HexString,
|
|
194
|
+
): JsonRpcProvider {
|
|
195
|
+
const chain = client.chain;
|
|
196
|
+
|
|
197
|
+
return (onMessage: (message: JsonRpcMessage) => void): JsonRpcConnection => {
|
|
198
|
+
const activeFollows = new Map<string, HostSubscription>();
|
|
199
|
+
const activeBroadcasts = new Set<string>();
|
|
200
|
+
let nextSubId = 0;
|
|
201
|
+
|
|
202
|
+
const getNextSubId = () => `follow_${nextSubId++}`;
|
|
203
|
+
|
|
204
|
+
function sendJsonRpcResponse(id: JsonRpcRequest["id"], result: unknown): void {
|
|
205
|
+
onMessage({ jsonrpc: "2.0", id, result } as JsonRpcMessage);
|
|
206
|
+
}
|
|
207
|
+
function sendJsonRpcError(id: JsonRpcRequest["id"], code: number, message: string): void {
|
|
208
|
+
onMessage({ jsonrpc: "2.0", id, error: { code, message } } as JsonRpcMessage);
|
|
209
|
+
}
|
|
210
|
+
function sendFollowEvent(subscription: string, event: FollowEvent): void {
|
|
211
|
+
onMessage({
|
|
212
|
+
jsonrpc: "2.0",
|
|
213
|
+
method: "chainHead_v1_followEvent",
|
|
214
|
+
params: { subscription, result: event },
|
|
215
|
+
} as JsonRpcMessage);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Reject an inbound request with the host's error reason as the JSON-RPC message. */
|
|
219
|
+
const hostError = (id: JsonRpcRequest["id"]) => (error: unknown) =>
|
|
220
|
+
sendJsonRpcError(id, JSON_RPC_INTERNAL_ERROR, formatHostError(error));
|
|
221
|
+
|
|
222
|
+
function handleMessage(message: JsonRpcRequest): void {
|
|
223
|
+
const { id, method } = message;
|
|
224
|
+
// chainHead/chainSpec/transaction JSON-RPC params are positional arrays.
|
|
225
|
+
const params = (message.params ?? []) as unknown[];
|
|
226
|
+
|
|
227
|
+
switch (method) {
|
|
228
|
+
case "chainHead_v1_follow": {
|
|
229
|
+
const [withRuntime] = params as [boolean];
|
|
230
|
+
const syntheticSubId = getNextSubId();
|
|
231
|
+
// The Stop branch unsubscribes its own host subscription, but the
|
|
232
|
+
// handle is this call's return value — the ref breaks that
|
|
233
|
+
// chicken-and-egg. (Releasing before forwarding the Stop is just
|
|
234
|
+
// cleanup; the consumer's synchronous refollow gets a fresh wire
|
|
235
|
+
// subscription either way.)
|
|
236
|
+
const ref: { handle?: HostSubscription } = {};
|
|
237
|
+
ref.handle = subscribeWithInterrupt(
|
|
238
|
+
chain.followHeadSubscribe({ request: { genesisHash, withRuntime } }),
|
|
239
|
+
(item) => {
|
|
240
|
+
if (item.tag === "Stop" && activeFollows.delete(syntheticSubId)) {
|
|
241
|
+
ref.handle?.unsubscribe();
|
|
242
|
+
}
|
|
243
|
+
sendFollowEvent(syntheticSubId, convertFollowEventToJsonRpc(item));
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
// A transport interrupt/close ends the stream without a Stop
|
|
247
|
+
// item; synthesize one so the consumer refollows.
|
|
248
|
+
ref.handle.onInterrupt(() => {
|
|
249
|
+
if (activeFollows.delete(syntheticSubId)) {
|
|
250
|
+
sendFollowEvent(syntheticSubId, { event: "stop" });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
activeFollows.set(syntheticSubId, ref.handle);
|
|
254
|
+
sendJsonRpcResponse(id, syntheticSubId);
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case "chainHead_v1_unfollow": {
|
|
258
|
+
const [followSubId] = params as [string];
|
|
259
|
+
const follow = activeFollows.get(followSubId);
|
|
260
|
+
if (follow) {
|
|
261
|
+
follow.unsubscribe();
|
|
262
|
+
activeFollows.delete(followSubId);
|
|
263
|
+
}
|
|
264
|
+
sendJsonRpcResponse(id, null);
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
case "chainHead_v1_header": {
|
|
268
|
+
const [followSubscriptionId, hash] = params as [string, HexString];
|
|
269
|
+
chain
|
|
270
|
+
.getHeadHeader({ genesisHash, followSubscriptionId, hash })
|
|
271
|
+
.match(
|
|
272
|
+
(response) => sendJsonRpcResponse(id, response.header ?? null),
|
|
273
|
+
hostError(id),
|
|
274
|
+
);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "chainHead_v1_body": {
|
|
278
|
+
const [followSubscriptionId, hash] = params as [string, HexString];
|
|
279
|
+
chain
|
|
280
|
+
.getHeadBody({ genesisHash, followSubscriptionId, hash })
|
|
281
|
+
.match(
|
|
282
|
+
(response) =>
|
|
283
|
+
sendJsonRpcResponse(
|
|
284
|
+
id,
|
|
285
|
+
convertOperationResultToJsonRpc(response.operation),
|
|
286
|
+
),
|
|
287
|
+
hostError(id),
|
|
288
|
+
);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "chainHead_v1_storage": {
|
|
292
|
+
const [followSubscriptionId, hash, items, childTrie] = params as [
|
|
293
|
+
string,
|
|
294
|
+
HexString,
|
|
295
|
+
Array<{ key: HexString; type: string }>,
|
|
296
|
+
HexString | undefined,
|
|
297
|
+
];
|
|
298
|
+
const queryItems = items.map((item) => ({
|
|
299
|
+
key: item.key,
|
|
300
|
+
queryType: convertStorageType(item.type),
|
|
301
|
+
}));
|
|
302
|
+
chain
|
|
303
|
+
.getHeadStorage({
|
|
304
|
+
genesisHash,
|
|
305
|
+
followSubscriptionId,
|
|
306
|
+
hash,
|
|
307
|
+
items: queryItems,
|
|
308
|
+
// PAPI passes `null` for an absent child trie, but the
|
|
309
|
+
// truapi codec encodes the optional `childTrie` field as
|
|
310
|
+
// `Option<Hex>` — it treats `undefined` as None yet runs
|
|
311
|
+
// the inner Hex codec on `null`, which throws
|
|
312
|
+
// (`null.startsWith`). Coerce `null` → `undefined`.
|
|
313
|
+
childTrie: childTrie ?? undefined,
|
|
314
|
+
})
|
|
315
|
+
.match(
|
|
316
|
+
(response) =>
|
|
317
|
+
sendJsonRpcResponse(
|
|
318
|
+
id,
|
|
319
|
+
convertOperationResultToJsonRpc(response.operation),
|
|
320
|
+
),
|
|
321
|
+
hostError(id),
|
|
322
|
+
);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
case "chainHead_v1_call": {
|
|
326
|
+
const [followSubscriptionId, hash, fn, callParameters] = params as [
|
|
327
|
+
string,
|
|
328
|
+
HexString,
|
|
329
|
+
string,
|
|
330
|
+
HexString,
|
|
331
|
+
];
|
|
332
|
+
chain
|
|
333
|
+
.callHead({
|
|
334
|
+
genesisHash,
|
|
335
|
+
followSubscriptionId,
|
|
336
|
+
hash,
|
|
337
|
+
function: fn,
|
|
338
|
+
callParameters,
|
|
339
|
+
})
|
|
340
|
+
.match(
|
|
341
|
+
(response) =>
|
|
342
|
+
sendJsonRpcResponse(
|
|
343
|
+
id,
|
|
344
|
+
convertOperationResultToJsonRpc(response.operation),
|
|
345
|
+
),
|
|
346
|
+
hostError(id),
|
|
347
|
+
);
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
case "chainHead_v1_unpin": {
|
|
351
|
+
const [followSubscriptionId, hashOrHashes] = params as [
|
|
352
|
+
string,
|
|
353
|
+
HexString | HexString[],
|
|
354
|
+
];
|
|
355
|
+
const hashes = Array.isArray(hashOrHashes) ? hashOrHashes : [hashOrHashes];
|
|
356
|
+
chain
|
|
357
|
+
.unpinHead({ genesisHash, followSubscriptionId, hashes })
|
|
358
|
+
.match(() => sendJsonRpcResponse(id, null), hostError(id));
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case "chainHead_v1_continue": {
|
|
362
|
+
const [followSubscriptionId, operationId] = params as [string, string];
|
|
363
|
+
chain
|
|
364
|
+
.continueHead({ genesisHash, followSubscriptionId, operationId })
|
|
365
|
+
.match(() => sendJsonRpcResponse(id, null), hostError(id));
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
case "chainHead_v1_stopOperation": {
|
|
369
|
+
const [followSubscriptionId, operationId] = params as [string, string];
|
|
370
|
+
chain
|
|
371
|
+
.stopHeadOperation({ genesisHash, followSubscriptionId, operationId })
|
|
372
|
+
.match(() => sendJsonRpcResponse(id, null), hostError(id));
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case "chainSpec_v1_genesisHash": {
|
|
376
|
+
chain
|
|
377
|
+
.getSpecGenesisHash({ genesisHash })
|
|
378
|
+
.match(
|
|
379
|
+
(response) => sendJsonRpcResponse(id, response.genesisHash),
|
|
380
|
+
hostError(id),
|
|
381
|
+
);
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
case "chainSpec_v1_chainName": {
|
|
385
|
+
chain
|
|
386
|
+
.getSpecChainName({ genesisHash })
|
|
387
|
+
.match(
|
|
388
|
+
(response) => sendJsonRpcResponse(id, response.chainName),
|
|
389
|
+
hostError(id),
|
|
390
|
+
);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
case "chainSpec_v1_properties": {
|
|
394
|
+
chain.getSpecProperties({ genesisHash }).match((response) => {
|
|
395
|
+
try {
|
|
396
|
+
sendJsonRpcResponse(id, JSON.parse(response.properties));
|
|
397
|
+
} catch {
|
|
398
|
+
sendJsonRpcResponse(id, response.properties);
|
|
399
|
+
}
|
|
400
|
+
}, hostError(id));
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
case "transaction_v1_broadcast": {
|
|
404
|
+
const [transaction] = params as [HexString];
|
|
405
|
+
chain.broadcastTransaction({ genesisHash, transaction }).match((response) => {
|
|
406
|
+
const operationId = response.operationId ?? null;
|
|
407
|
+
if (operationId !== null) activeBroadcasts.add(operationId);
|
|
408
|
+
sendJsonRpcResponse(id, operationId);
|
|
409
|
+
}, hostError(id));
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
case "transaction_v1_stop": {
|
|
413
|
+
const [operationId] = params as [string];
|
|
414
|
+
activeBroadcasts.delete(operationId);
|
|
415
|
+
chain
|
|
416
|
+
.stopTransaction({ genesisHash, operationId })
|
|
417
|
+
.match(() => sendJsonRpcResponse(id, null), hostError(id));
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
default:
|
|
421
|
+
sendJsonRpcError(
|
|
422
|
+
id,
|
|
423
|
+
JSON_RPC_METHOD_NOT_FOUND,
|
|
424
|
+
`Method "${method}" is not supported by the host`,
|
|
425
|
+
);
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return {
|
|
431
|
+
send(message) {
|
|
432
|
+
// A synchronous throw inside a handler (e.g. a malformed positional
|
|
433
|
+
// param) must not leave the JSON-RPC request unsettled — the caller
|
|
434
|
+
// would hang. Surface it as an error response for the request id.
|
|
435
|
+
try {
|
|
436
|
+
handleMessage(message);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
// Handlers must settle the request themselves; reaching here means a
|
|
439
|
+
// synchronous throw before any response was sent. (A handler that
|
|
440
|
+
// sends then throws would double-respond — none do today.)
|
|
441
|
+
log.warn("send: handler threw before settling the request", {
|
|
442
|
+
error: formatHostError(error),
|
|
443
|
+
});
|
|
444
|
+
sendJsonRpcError(message.id, JSON_RPC_INTERNAL_ERROR, formatHostError(error));
|
|
445
|
+
}
|
|
446
|
+
},
|
|
447
|
+
disconnect() {
|
|
448
|
+
for (const handle of activeFollows.values()) {
|
|
449
|
+
handle.unsubscribe();
|
|
450
|
+
}
|
|
451
|
+
activeFollows.clear();
|
|
452
|
+
for (const operationId of activeBroadcasts) {
|
|
453
|
+
// Fire-and-forget: the transport may already be torn down.
|
|
454
|
+
chain.stopTransaction({ genesisHash, operationId }).match(
|
|
455
|
+
() => {},
|
|
456
|
+
() => {},
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
activeBroadcasts.clear();
|
|
460
|
+
},
|
|
461
|
+
};
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (import.meta.vitest) {
|
|
466
|
+
const { test, expect, vi } = import.meta.vitest;
|
|
467
|
+
|
|
468
|
+
// A minimal fake of the truapi chain domain. ResultAsync is approximated by a
|
|
469
|
+
// `.match(ok, err)` thenable; the follow subscription is a hand-driven
|
|
470
|
+
// ObservableLike whose observer we capture to push items at will.
|
|
471
|
+
function makeFakeClient(opts: {
|
|
472
|
+
onCall?: (method: string, args: unknown) => void;
|
|
473
|
+
responses?: Record<string, unknown>;
|
|
474
|
+
/** Methods named here resolve to their `.match` error arm carrying the given value. */
|
|
475
|
+
errors?: Record<string, unknown>;
|
|
476
|
+
/** Unsubscribe spy used by the follow subscription (defaults to a fresh `vi.fn()`). */
|
|
477
|
+
unsubscribe?: () => void;
|
|
478
|
+
captureObserver?: (observer: {
|
|
479
|
+
next: (i: unknown) => void;
|
|
480
|
+
error: (e: unknown) => void;
|
|
481
|
+
complete: () => void;
|
|
482
|
+
}) => void;
|
|
483
|
+
}) {
|
|
484
|
+
const okMatch = (value: unknown) => ({
|
|
485
|
+
match: (ok: (v: unknown) => unknown, _err: (e: unknown) => unknown) => ok(value),
|
|
486
|
+
});
|
|
487
|
+
const errMatch = (error: unknown) => ({
|
|
488
|
+
match: (_ok: (v: unknown) => unknown, err: (e: unknown) => unknown) => err(error),
|
|
489
|
+
});
|
|
490
|
+
const method = (name: string, response: unknown) => (args: unknown) => {
|
|
491
|
+
opts.onCall?.(name, args);
|
|
492
|
+
const errors = opts.errors ?? {};
|
|
493
|
+
return name in errors ? errMatch(errors[name]) : okMatch(response);
|
|
494
|
+
};
|
|
495
|
+
return {
|
|
496
|
+
chain: {
|
|
497
|
+
followHeadSubscribe: (_args: unknown) => ({
|
|
498
|
+
subscribe: (observer: {
|
|
499
|
+
next: (i: unknown) => void;
|
|
500
|
+
error: (e: unknown) => void;
|
|
501
|
+
complete: () => void;
|
|
502
|
+
}) => {
|
|
503
|
+
opts.captureObserver?.(observer);
|
|
504
|
+
return { unsubscribe: opts.unsubscribe ?? vi.fn() };
|
|
505
|
+
},
|
|
506
|
+
[Symbol.observable as symbol]() {
|
|
507
|
+
return this;
|
|
508
|
+
},
|
|
509
|
+
}),
|
|
510
|
+
getHeadHeader: method(
|
|
511
|
+
"getHeadHeader",
|
|
512
|
+
opts.responses?.getHeadHeader ?? { header: "0x01" },
|
|
513
|
+
),
|
|
514
|
+
getHeadBody: method("getHeadBody", {
|
|
515
|
+
operation: { tag: "Started", value: { operationId: "op1" } },
|
|
516
|
+
}),
|
|
517
|
+
getHeadStorage: method("getHeadStorage", { operation: { tag: "LimitReached" } }),
|
|
518
|
+
callHead: method("callHead", {
|
|
519
|
+
operation: { tag: "Started", value: { operationId: "op2" } },
|
|
520
|
+
}),
|
|
521
|
+
unpinHead: method("unpinHead", undefined),
|
|
522
|
+
continueHead: method("continueHead", undefined),
|
|
523
|
+
stopHeadOperation: method("stopHeadOperation", undefined),
|
|
524
|
+
getSpecGenesisHash: method("getSpecGenesisHash", { genesisHash: "0xabc" }),
|
|
525
|
+
getSpecChainName: method("getSpecChainName", { chainName: "Polkadot" }),
|
|
526
|
+
getSpecProperties: method("getSpecProperties", { properties: '{"ss58Format":0}' }),
|
|
527
|
+
broadcastTransaction: method("broadcastTransaction", { operationId: "bc1" }),
|
|
528
|
+
stopTransaction: method("stopTransaction", undefined),
|
|
529
|
+
},
|
|
530
|
+
} as unknown as TrUApiClient;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
test("follow returns a synthetic id and forwards translated events", () => {
|
|
534
|
+
let observer:
|
|
535
|
+
| { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
|
|
536
|
+
| undefined;
|
|
537
|
+
const client = makeFakeClient({
|
|
538
|
+
captureObserver: (o) => {
|
|
539
|
+
observer = o;
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
543
|
+
|
|
544
|
+
const messages: JsonRpcMessage[] = [];
|
|
545
|
+
const conn = provider((m) => messages.push(m));
|
|
546
|
+
|
|
547
|
+
conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [true] });
|
|
548
|
+
// The follow response carries the synthetic subscription id.
|
|
549
|
+
expect(messages[0]).toEqual({ jsonrpc: "2.0", id: 1, result: "follow_0" });
|
|
550
|
+
|
|
551
|
+
// A typed BestBlockChanged item becomes a chainHead_v1_followEvent.
|
|
552
|
+
observer?.next({ tag: "BestBlockChanged", value: { bestBlockHash: "0xbeef" } });
|
|
553
|
+
expect(messages[1]).toEqual({
|
|
554
|
+
jsonrpc: "2.0",
|
|
555
|
+
method: "chainHead_v1_followEvent",
|
|
556
|
+
params: {
|
|
557
|
+
subscription: "follow_0",
|
|
558
|
+
result: { event: "bestBlockChanged", bestBlockHash: "0xbeef" },
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("chainSpec_v1_properties parses the JSON-encoded properties string", () => {
|
|
564
|
+
const client = makeFakeClient({});
|
|
565
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
566
|
+
const messages: JsonRpcMessage[] = [];
|
|
567
|
+
provider((m) => messages.push(m)).send({
|
|
568
|
+
jsonrpc: "2.0",
|
|
569
|
+
id: 7,
|
|
570
|
+
method: "chainSpec_v1_properties",
|
|
571
|
+
params: [],
|
|
572
|
+
});
|
|
573
|
+
expect(messages[0]).toEqual({ jsonrpc: "2.0", id: 7, result: { ss58Format: 0 } });
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("chainHead_v1_body unwraps the operation into a JSON-RPC start result", () => {
|
|
577
|
+
const calls: Array<[string, unknown]> = [];
|
|
578
|
+
const client = makeFakeClient({ onCall: (m, a) => calls.push([m, a]) });
|
|
579
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
580
|
+
const messages: JsonRpcMessage[] = [];
|
|
581
|
+
provider((m) => messages.push(m)).send({
|
|
582
|
+
jsonrpc: "2.0",
|
|
583
|
+
id: 3,
|
|
584
|
+
method: "chainHead_v1_body",
|
|
585
|
+
params: ["follow_0", "0xhash"],
|
|
586
|
+
});
|
|
587
|
+
expect(calls[0]).toEqual([
|
|
588
|
+
"getHeadBody",
|
|
589
|
+
{ genesisHash: "0xfeed", followSubscriptionId: "follow_0", hash: "0xhash" },
|
|
590
|
+
]);
|
|
591
|
+
expect(messages[0]).toEqual({
|
|
592
|
+
jsonrpc: "2.0",
|
|
593
|
+
id: 3,
|
|
594
|
+
result: { result: "started", operationId: "op1" },
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test("unknown methods produce a method-not-found error", () => {
|
|
599
|
+
const client = makeFakeClient({});
|
|
600
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
601
|
+
const messages: JsonRpcMessage[] = [];
|
|
602
|
+
provider((m) => messages.push(m)).send({
|
|
603
|
+
jsonrpc: "2.0",
|
|
604
|
+
id: 9,
|
|
605
|
+
method: "system_health",
|
|
606
|
+
params: [],
|
|
607
|
+
});
|
|
608
|
+
expect(messages[0]).toMatchObject({ id: 9, error: { code: -32601 } });
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
test("a transport interrupt synthesizes a stop follow-event", () => {
|
|
612
|
+
let observer:
|
|
613
|
+
| { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
|
|
614
|
+
| undefined;
|
|
615
|
+
const client = makeFakeClient({
|
|
616
|
+
captureObserver: (o) => {
|
|
617
|
+
observer = o;
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
621
|
+
const messages: JsonRpcMessage[] = [];
|
|
622
|
+
const conn = provider((m) => messages.push(m));
|
|
623
|
+
|
|
624
|
+
conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [true] });
|
|
625
|
+
// The stream ends via transport close (no Stop item) — the consumer must
|
|
626
|
+
// still see a stop so its substrate-client refollows.
|
|
627
|
+
observer?.complete();
|
|
628
|
+
expect(messages[1]).toEqual({
|
|
629
|
+
jsonrpc: "2.0",
|
|
630
|
+
method: "chainHead_v1_followEvent",
|
|
631
|
+
params: { subscription: "follow_0", result: { event: "stop" } },
|
|
632
|
+
});
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
test("a host error becomes a JSON-RPC -32603 with the formatted reason", () => {
|
|
636
|
+
const client = makeFakeClient({ errors: { getHeadHeader: { reason: "no such block" } } });
|
|
637
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
638
|
+
const messages: JsonRpcMessage[] = [];
|
|
639
|
+
provider((m) => messages.push(m)).send({
|
|
640
|
+
jsonrpc: "2.0",
|
|
641
|
+
id: 4,
|
|
642
|
+
method: "chainHead_v1_header",
|
|
643
|
+
params: ["follow_0", "0xhash"],
|
|
644
|
+
});
|
|
645
|
+
expect(messages[0]).toEqual({
|
|
646
|
+
jsonrpc: "2.0",
|
|
647
|
+
id: 4,
|
|
648
|
+
error: { code: -32603, message: "no such block" },
|
|
649
|
+
});
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
test("disconnect unsubscribes active follows and stops active broadcasts", () => {
|
|
653
|
+
const unsubscribe = vi.fn();
|
|
654
|
+
const calls: Array<[string, unknown]> = [];
|
|
655
|
+
const client = makeFakeClient({ unsubscribe, onCall: (m, a) => calls.push([m, a]) });
|
|
656
|
+
const provider = createHostPapiProvider(client, "0xfeed");
|
|
657
|
+
const conn = provider(() => {});
|
|
658
|
+
|
|
659
|
+
// Open a follow and an in-flight broadcast (operationId "bc1" is tracked).
|
|
660
|
+
conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [true] });
|
|
661
|
+
conn.send({ jsonrpc: "2.0", id: 2, method: "transaction_v1_broadcast", params: ["0xtx"] });
|
|
662
|
+
|
|
663
|
+
conn.disconnect();
|
|
664
|
+
|
|
665
|
+
// The follow subscription is torn down...
|
|
666
|
+
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
|
667
|
+
// ...and the tracked broadcast is stopped on the way out.
|
|
668
|
+
expect(calls).toContainEqual([
|
|
669
|
+
"stopTransaction",
|
|
670
|
+
{ genesisHash: "0xfeed", operationId: "bc1" },
|
|
671
|
+
]);
|
|
672
|
+
});
|
|
673
|
+
}
|