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