@parity/product-sdk-host 0.16.0 → 0.18.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/src/accounts.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { decAnyMetadata, unifyMetadata } from "@polkadot-api/substrate-bindings";
26
- import type { ResultAsync } from "neverthrow";
26
+ import { errAsync, okAsync, ResultAsync } from "neverthrow";
27
27
  import { AccountId, type PolkadotSigner } from "polkadot-api";
28
28
 
29
29
  import type {
@@ -55,6 +55,7 @@ import type {
55
55
  } from "@parity/truapi";
56
56
 
57
57
  import { getClient, subscribeWithInterrupt } from "./transport.js";
58
+ import { HostResponseDecodeError } from "./errors.js";
58
59
  import { fromHex, toHex, unwrapHostResult } from "./truapi.js";
59
60
  import type { HostSubscription } from "./types.js";
60
61
 
@@ -223,6 +224,14 @@ export type VrfTranscriptItem = { [K in keyof WireVrfTranscriptItem]: Uint8Array
223
224
  */
224
225
  export type VrfSignature = { [K in keyof WireVrfSignature]: Uint8Array };
225
226
 
227
+ /**
228
+ * A call's declared `Err` channel, plus {@link HostResponseDecodeError}: any
229
+ * host reply can fail to decode if the host and the product's `@parity/truapi`
230
+ * client are on different protocol versions, so every decoded call can surface
231
+ * it in addition to its own typed errors.
232
+ */
233
+ export type WithDecodeError<E> = E | HostResponseDecodeError;
234
+
226
235
  /**
227
236
  * Accounts provider handle, backed by `truApi.account.*` / `truApi.signing.*`.
228
237
  * Surfaces the user's wallet accounts, app-scoped product accounts, Ring VRF,
@@ -231,20 +240,29 @@ export type VrfSignature = { [K in keyof WireVrfSignature]: Uint8Array };
231
240
  * Lookup methods return a neverthrow `ResultAsync` (use `.match(ok, err)`);
232
241
  * the signer factories return a synchronous PAPI `PolkadotSigner`. The `err`
233
242
  * channel carries truapi's canonical `CallErrorValue` envelope around the
234
- * per-call versioned domain error, exactly as the generated client returns it.
243
+ * per-call versioned domain error, exactly as the generated client returns it,
244
+ * plus a {@link HostResponseDecodeError} for the case where the host's reply
245
+ * cannot be decoded at all (a host/client protocol-version skew) — see
246
+ * {@link WithDecodeError}.
235
247
  */
236
248
  export interface AccountsProvider {
237
249
  getUserId(): ResultAsync<
238
250
  { primaryUsername: string },
239
- scale.CallErrorValue<VersionedHostGetUserIdError>
251
+ WithDecodeError<scale.CallErrorValue<VersionedHostGetUserIdError>>
240
252
  >;
241
253
  requestLogin(
242
254
  reason?: string,
243
- ): ResultAsync<HostRequestLoginResponse, scale.CallErrorValue<VersionedHostRequestLoginError>>;
255
+ ): ResultAsync<
256
+ HostRequestLoginResponse,
257
+ WithDecodeError<scale.CallErrorValue<VersionedHostRequestLoginError>>
258
+ >;
244
259
  getProductAccount(
245
260
  dotNsIdentifier: string,
246
261
  derivationIndex?: number,
247
- ): ResultAsync<ProductAccount, scale.CallErrorValue<VersionedHostAccountGetError>>;
262
+ ): ResultAsync<
263
+ ProductAccount,
264
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountGetError>>
265
+ >;
248
266
  /**
249
267
  * Register a ring-VRF key owned by the calling product.
250
268
  *
@@ -259,7 +277,7 @@ export interface AccountsProvider {
259
277
  ring: RingLocation,
260
278
  ): ResultAsync<
261
279
  RingVrfPublicKey,
262
- scale.CallErrorValue<VersionedHostAccountRegisterRingVrfKeyError>
280
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountRegisterRingVrfKeyError>>
263
281
  >;
264
282
  /** List an owner's registered ring-VRF keys. */
265
283
  listRingVrfKeys(
@@ -267,17 +285,20 @@ export interface AccountsProvider {
267
285
  disclosure?: RingVrfKeyDisclosure,
268
286
  ): ResultAsync<
269
287
  RegisteredRingVrfKey[],
270
- scale.CallErrorValue<VersionedHostAccountListRingVrfKeysError>
288
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountListRingVrfKeysError>>
271
289
  >;
272
290
  /** Derive a contextual alias with an explicitly registered ring-VRF key. */
273
291
  getProductAccountAlias(
274
292
  keyHandle: RingVrfKeyHandle,
275
293
  context: ProductProofContext,
276
294
  location: RingLocation,
277
- ): ResultAsync<ContextualAlias, scale.CallErrorValue<VersionedHostAccountGetAliasError>>;
295
+ ): ResultAsync<
296
+ ContextualAlias,
297
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountGetAliasError>>
298
+ >;
278
299
  getLegacyAccounts(): ResultAsync<
279
300
  HostAccount[],
280
- scale.CallErrorValue<VersionedHostGetLegacyAccountsError>
301
+ WithDecodeError<scale.CallErrorValue<VersionedHostGetLegacyAccountsError>>
281
302
  >;
282
303
  /**
283
304
  * Generate a Ring VRF proof with an explicitly registered key, binding
@@ -288,7 +309,10 @@ export interface AccountsProvider {
288
309
  context: ProductProofContext,
289
310
  location: RingLocation,
290
311
  message: Uint8Array,
291
- ): ResultAsync<RingVRFProof, scale.CallErrorValue<VersionedHostAccountCreateProofError>>;
312
+ ): ResultAsync<
313
+ RingVRFProof,
314
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountCreateProofError>>
315
+ >;
292
316
  /**
293
317
  * Sign `message` directly with an explicitly registered ring-VRF key.
294
318
  *
@@ -299,7 +323,10 @@ export interface AccountsProvider {
299
323
  ringVrfSign(
300
324
  keyHandle: RingVrfKeyHandle,
301
325
  message: Uint8Array,
302
- ): ResultAsync<Uint8Array, scale.CallErrorValue<VersionedHostAccountRingVrfSignError>>;
326
+ ): ResultAsync<
327
+ Uint8Array,
328
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountRingVrfSignError>>
329
+ >;
303
330
  /**
304
331
  * Produce an sr25519 VRF signature from a product account (RFC-0023).
305
332
  *
@@ -323,7 +350,10 @@ export interface AccountsProvider {
323
350
  account: ProductAccountLookup,
324
351
  transcriptLabel: Uint8Array,
325
352
  items: VrfTranscriptItem[],
326
- ): ResultAsync<VrfSignature, scale.CallErrorValue<VersionedHostAccountSignVrfError>>;
353
+ ): ResultAsync<
354
+ VrfSignature,
355
+ WithDecodeError<scale.CallErrorValue<VersionedHostAccountSignVrfError>>
356
+ >;
327
357
  /**
328
358
  * Build a `PolkadotSigner` for a product account. Signing routes through the
329
359
  * host's `createTransaction` path: the host decodes the metadata and forwards
@@ -341,22 +371,57 @@ export interface AccountsProvider {
341
371
  ): HostSubscription;
342
372
  }
343
373
 
374
+ /** The transaction-extension version used by V5 general transactions. */
375
+ const GENERAL_TX_EXT_VERSION = 5;
376
+
344
377
  /**
345
- * Derive the host's extrinsic-extension version from SCALE-encoded metadata:
346
- * v4 0, otherwise the latest supported version. `unifyMetadata` normalizes
347
- * v14/v15 so `.extrinsic.version` is an array.
378
+ * Choose the wire `txExtVersion` — the **transaction-extension** version the
379
+ * host must assemble under, which is distinct from the extrinsic *format*
380
+ * version (4 / 5).
381
+ *
382
+ * - `formatVersions` — `metadata.extrinsic.version`, the extrinsic *formats* the
383
+ * runtime accepts (e.g. `[4]`, `[5]`, `[4, 5]`).
384
+ * - `txExtVersions` — the keys of `metadata.extrinsic.signedExtensions`, the
385
+ * transaction-extension versions the runtime supports (its v16
386
+ * `transactionExtensionsByVersion`; always includes `0`).
348
387
  *
349
- * Indirected through {@link deps} so the SCALE decode (which needs a real
350
- * metadata blob) can be stubbed in unit tests while the rest of the `signTx`
351
- * flowgenesis extraction, extension mapping, the host call is exercised.
388
+ * A V4 signed extrinsic always uses transaction-extension version `0` (a fixed
389
+ * sentinel, independent of what the extension map contains). Prefer V4 while it
390
+ * is offered it carries the account signature in its envelope, and the host
391
+ * can build it. Only when V4 is absent do we fall to a V5 general transaction,
392
+ * whose transaction-extension version is `5` (the key the runtime and host
393
+ * agree on for the general format); require it to actually be present in the
394
+ * map rather than assuming it.
395
+ *
396
+ * Passing the extrinsic *format* number here was the bug (host-rust-core#528):
397
+ * `5` is both a format version and the general extension version, so it looked
398
+ * right, but the two are unrelated in general and the host decodes extension
399
+ * values by this number.
352
400
  */
353
- function deriveTxExtVersion(metadata: Uint8Array): number {
354
- const versions = unifyMetadata(decAnyMetadata(metadata)).extrinsic.version;
355
- if (versions.length === 0) {
401
+ function selectHostTxExtVersion(
402
+ formatVersions: readonly number[],
403
+ txExtVersions: readonly number[],
404
+ ): number {
405
+ if (formatVersions.length === 0) {
356
406
  throw new Error("No extrinsic version found in metadata");
357
407
  }
358
- const latestVersion = versions.reduce((acc, v) => Math.max(acc, v), 0);
359
- return latestVersion === 4 ? 0 : latestVersion;
408
+ if (formatVersions.includes(4)) {
409
+ return 0;
410
+ }
411
+ if (txExtVersions.includes(GENERAL_TX_EXT_VERSION)) {
412
+ return GENERAL_TX_EXT_VERSION;
413
+ }
414
+ throw new Error(
415
+ `Runtime offers no V4 extrinsic and no transaction-extension version ${GENERAL_TX_EXT_VERSION} ` +
416
+ `(supported: ${txExtVersions.join(", ") || "none"}); cannot select a txExtVersion the host can assemble.`,
417
+ );
418
+ }
419
+
420
+ /** Derive the host's transaction-extension version from SCALE metadata. */
421
+ function deriveTxExtVersion(metadata: Uint8Array): number {
422
+ const extrinsic = unifyMetadata(decAnyMetadata(metadata)).extrinsic;
423
+ const txExtVersions = Object.keys(extrinsic.signedExtensions).map(Number);
424
+ return selectHostTxExtVersion(extrinsic.version, txExtVersions);
360
425
  }
361
426
 
362
427
  /** Internal seam so `import.meta.vitest` can stub the metadata decode. @internal */
@@ -392,6 +457,28 @@ function toWireProductAccountId({
392
457
  return { dotNsIdentifier, derivationIndex: { tag: "Index", value: derivationIndex } };
393
458
  }
394
459
 
460
+ /**
461
+ * Route a thrown/rejected response-decode error onto the `Result` err channel.
462
+ *
463
+ * The truapi client catches a decode throw in its message handler and turns it
464
+ * into a promise rejection, then wraps each call with
465
+ * `ResultAsync.fromSafePromise`, which installs no rejection handler — so when
466
+ * the host's reply doesn't match the client's codec (a version skew), that
467
+ * rejection escapes the `Result` channel rather than landing on its err side,
468
+ * surfacing as a raw `RangeError`. Wrapping the call re-homes that rejection as
469
+ * a typed {@link HostResponseDecodeError} that names the call, while ok values
470
+ * and the call's own typed `Err` values pass through untouched.
471
+ */
472
+ function guardDecode<T, E>(
473
+ call: string,
474
+ result: ResultAsync<T, E>,
475
+ ): ResultAsync<T, E | HostResponseDecodeError> {
476
+ return ResultAsync.fromPromise(
477
+ Promise.resolve(result),
478
+ (cause) => new HostResponseDecodeError(call, cause),
479
+ ).andThen((inner) => inner);
480
+ }
481
+
395
482
  /** Build an {@link AccountsProvider} over a TruAPI client's `account` / `signing` domains. */
396
483
  function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
397
484
  const account = client.account;
@@ -399,98 +486,128 @@ function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
399
486
 
400
487
  return {
401
488
  getUserId() {
402
- return account.getUserId().map((response) => ({
403
- primaryUsername: response.primaryUsername,
404
- }));
489
+ return guardDecode(
490
+ "getUserId",
491
+ account.getUserId().map((response) => ({
492
+ primaryUsername: response.primaryUsername,
493
+ })),
494
+ );
405
495
  },
406
496
  requestLogin(reason) {
407
- return account.requestLogin({ reason });
497
+ return guardDecode("requestLogin", account.requestLogin({ reason }));
408
498
  },
409
499
  getProductAccount(dotNsIdentifier, derivationIndex = 0) {
410
- return account
411
- .getAccount({
412
- productAccountId: toWireProductAccountId({ dotNsIdentifier, derivationIndex }),
413
- })
414
- .map((response) => ({
415
- publicKey: fromHex(response.account.publicKey),
416
- dotNsIdentifier,
417
- derivationIndex,
418
- }));
500
+ return guardDecode(
501
+ "getProductAccount",
502
+ account
503
+ .getAccount({
504
+ productAccountId: toWireProductAccountId({
505
+ dotNsIdentifier,
506
+ derivationIndex,
507
+ }),
508
+ })
509
+ .map((response) => ({
510
+ publicKey: fromHex(response.account.publicKey),
511
+ dotNsIdentifier,
512
+ derivationIndex,
513
+ })),
514
+ );
419
515
  },
420
516
  registerRingVrfKey(index, ring) {
421
- return account
422
- .registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
423
- .map(fromHex);
517
+ return guardDecode(
518
+ "registerRingVrfKey",
519
+ account
520
+ .registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
521
+ .map(fromHex),
522
+ );
424
523
  },
425
524
  listRingVrfKeys(owner, disclosure = "Anonymized") {
426
- return account.listRingVrfKeys({ owner, disclosure }).map((keys) =>
427
- keys.map((key) => ({
428
- ...key,
429
- handle: key.handle as unknown as RingVrfKeyHandle,
430
- publicKey: key.publicKey === undefined ? undefined : fromHex(key.publicKey),
431
- })),
525
+ return guardDecode(
526
+ "listRingVrfKeys",
527
+ account.listRingVrfKeys({ owner, disclosure }).map((keys) =>
528
+ keys.map((key) => ({
529
+ ...key,
530
+ handle: key.handle as unknown as RingVrfKeyHandle,
531
+ publicKey: key.publicKey === undefined ? undefined : fromHex(key.publicKey),
532
+ })),
533
+ ),
432
534
  );
433
535
  },
434
536
  getProductAccountAlias(keyHandle, context, location) {
435
- return account
436
- .getAccountAlias({
437
- keyHandle: keyHandle as unknown as ProductAccountId,
438
- context,
439
- ringLocation: location,
440
- })
441
- .map((response) => ({
442
- context: fromHex(response.context),
443
- alias: fromHex(response.alias),
444
- }));
537
+ return guardDecode(
538
+ "getProductAccountAlias",
539
+ account
540
+ .getAccountAlias({
541
+ keyHandle: keyHandle as unknown as ProductAccountId,
542
+ context,
543
+ ringLocation: location,
544
+ })
545
+ .map((response) => ({
546
+ context: fromHex(response.context),
547
+ alias: fromHex(response.alias),
548
+ })),
549
+ );
445
550
  },
446
551
  getLegacyAccounts() {
447
- return account.getLegacyAccounts().map((response) =>
448
- response.accounts.map((a) => ({
449
- publicKey: fromHex(a.publicKey),
450
- name: a.name,
451
- })),
552
+ return guardDecode(
553
+ "getLegacyAccounts",
554
+ account.getLegacyAccounts().map((response) =>
555
+ response.accounts.map((a) => ({
556
+ publicKey: fromHex(a.publicKey),
557
+ name: a.name,
558
+ })),
559
+ ),
452
560
  );
453
561
  },
454
562
  createRingVRFProof(keyHandle, context, location, message) {
455
- return account
456
- .createAccountProof({
457
- keyHandle: keyHandle as unknown as ProductAccountId,
458
- context,
459
- ringLocation: location,
460
- message: toHex(message),
461
- })
462
- .map((response) => ({
463
- proof: fromHex(response.proof),
464
- contextualAlias: {
465
- context: fromHex(response.contextualAlias.context),
466
- alias: fromHex(response.contextualAlias.alias),
467
- },
468
- ringIndex: response.ringIndex,
469
- ringRevision: response.ringRevision,
470
- }));
563
+ return guardDecode(
564
+ "createRingVRFProof",
565
+ account
566
+ .createAccountProof({
567
+ keyHandle: keyHandle as unknown as ProductAccountId,
568
+ context,
569
+ ringLocation: location,
570
+ message: toHex(message),
571
+ })
572
+ .map((response) => ({
573
+ proof: fromHex(response.proof),
574
+ contextualAlias: {
575
+ context: fromHex(response.contextualAlias.context),
576
+ alias: fromHex(response.contextualAlias.alias),
577
+ },
578
+ ringIndex: response.ringIndex,
579
+ ringRevision: response.ringRevision,
580
+ })),
581
+ );
471
582
  },
472
583
  ringVrfSign(keyHandle, message) {
473
- return account
474
- .ringVrfSign({
475
- keyHandle: keyHandle as unknown as ProductAccountId,
476
- message: toHex(message),
477
- })
478
- .map(fromHex);
584
+ return guardDecode(
585
+ "ringVrfSign",
586
+ account
587
+ .ringVrfSign({
588
+ keyHandle: keyHandle as unknown as ProductAccountId,
589
+ message: toHex(message),
590
+ })
591
+ .map(fromHex),
592
+ );
479
593
  },
480
594
  signVrf(account_, transcriptLabel, items) {
481
- return account
482
- .signVrf({
483
- account: toWireProductAccountId(account_),
484
- transcriptLabel: toHex(transcriptLabel),
485
- items: items.map(({ label, value }) => ({
486
- label: toHex(label),
487
- value: toHex(value),
595
+ return guardDecode(
596
+ "signVrf",
597
+ account
598
+ .signVrf({
599
+ account: toWireProductAccountId(account_),
600
+ transcriptLabel: toHex(transcriptLabel),
601
+ items: items.map(({ label, value }) => ({
602
+ label: toHex(label),
603
+ value: toHex(value),
604
+ })),
605
+ })
606
+ .map((response) => ({
607
+ preOutput: fromHex(response.preOutput),
608
+ proof: fromHex(response.proof),
488
609
  })),
489
- })
490
- .map((response) => ({
491
- preOutput: fromHex(response.preOutput),
492
- proof: fromHex(response.proof),
493
- }));
610
+ );
494
611
  },
495
612
  getProductAccountSigner(account_) {
496
613
  const productAccountId = toWireProductAccountId(account_);
@@ -585,18 +702,44 @@ export async function getAccountsProvider(): Promise<AccountsProvider | null> {
585
702
  }
586
703
 
587
704
  if (import.meta.vitest) {
588
- const { test, expect, vi } = import.meta.vitest;
705
+ const { test, expect, vi, describe } = import.meta.vitest;
706
+
707
+ test("host signing prefers V4 (tx-ext version 0) on a dual V4/V5 runtime", () => {
708
+ // txExtVersion 0 regardless of what the extension map lists.
709
+ expect(selectHostTxExtVersion([4, 5], [0])).toBe(0);
710
+ });
711
+
712
+ test("host signing uses the general tx-ext version 5 when V4 is unavailable", () => {
713
+ // The value comes from the extension-version map, not the format list.
714
+ expect(selectHostTxExtVersion([5], [0, 5])).toBe(5);
715
+ });
716
+
717
+ test("host signing maps a V4-only runtime to the wire sentinel", () => {
718
+ expect(selectHostTxExtVersion([4], [0])).toBe(0);
719
+ });
720
+
721
+ test("host signing does not confuse extrinsic format 5 with a tx-ext version", () => {
722
+ // V5-only runtime that only supports tx-ext version 0 (no general
723
+ // extension set): the old code returned 5 (the format number); now this
724
+ // must throw rather than send an unsupported txExtVersion.
725
+ expect(() => selectHostTxExtVersion([5], [0])).toThrow(/no.*version 5/i);
726
+ });
727
+
728
+ test("host signing rejects metadata with no extrinsic version", () => {
729
+ expect(() => selectHostTxExtVersion([], [0])).toThrow(
730
+ "No extrinsic version found in metadata",
731
+ );
732
+ });
589
733
 
590
734
  /** Minimal fake of the truapi account/signing domains used to test the adapter. */
591
735
  function makeFakeClient(opts: { onCall?: (method: string, args: unknown) => void } = {}) {
592
- const okMatch = (value: unknown) => ({
593
- // neverthrow ResultAsync surface used by the adapter: .map + .match.
594
- map: (fn: (v: unknown) => unknown) => okMatch(fn(value)),
595
- match: (ok: (v: unknown) => unknown, _err: (e: unknown) => unknown) => ok(value),
596
- });
736
+ // A real neverthrow `okAsync`, not a hand-rolled `{ map, match }` stub:
737
+ // a stub with no `.then` would be passed through un-awaited by
738
+ // `guardDecode`'s `Promise.resolve(result)`, so the tests would bypass
739
+ // the guard's real path. A genuine `ResultAsync` exercises it.
597
740
  const method = (name: string, response: unknown) => (args: unknown) => {
598
741
  opts.onCall?.(name, args);
599
- return okMatch(response);
742
+ return okAsync(response);
600
743
  };
601
744
  return {
602
745
  account: {
@@ -1049,4 +1192,87 @@ if (import.meta.vitest) {
1049
1192
  expect(signed).toEqual(fromHex("0xfeed"));
1050
1193
  vi.restoreAllMocks();
1051
1194
  });
1195
+
1196
+ describe("response-decode boundary (guardDecode)", () => {
1197
+ // A client whose `createAccountProof` returns a REAL neverthrow
1198
+ // `ResultAsync` — the hand-rolled `okMatch` fake can't reject, and
1199
+ // rejection (a thrown SCALE decode) is exactly what this boundary
1200
+ // exists to catch. `createRingVRFProof` is the reported call (#270).
1201
+ function clientWithProof(result: ResultAsync<unknown, unknown>): TrUApiClient {
1202
+ return {
1203
+ account: { createAccountProof: () => result },
1204
+ } as unknown as TrUApiClient;
1205
+ }
1206
+
1207
+ const KEY_HANDLE = {
1208
+ dotNsIdentifier: "people.dot",
1209
+ derivationIndex: { tag: "Index", value: 0 },
1210
+ } as unknown as RingVrfKeyHandle;
1211
+ const CONTEXT = {
1212
+ productId: "app.dot",
1213
+ suffix: { tag: "Index", value: 0 },
1214
+ } as ProductProofContext;
1215
+ const RING: RingLocation = { chainId: "0x01", junctions: [] };
1216
+ const MESSAGE = new Uint8Array([1, 2, 3]);
1217
+
1218
+ const callProof = (result: ResultAsync<unknown, unknown>) =>
1219
+ adaptAccountsProvider(clientWithProof(result)).createRingVRFProof(
1220
+ KEY_HANDLE,
1221
+ CONTEXT,
1222
+ RING,
1223
+ MESSAGE,
1224
+ );
1225
+
1226
+ test("a thrown decode error (RangeError) becomes a HostResponseDecodeError naming the call", async () => {
1227
+ const rangeError = new RangeError("Offset is outside the bounds of the DataView");
1228
+ const result = await callProof(ResultAsync.fromSafePromise(Promise.reject(rangeError)));
1229
+
1230
+ expect(result.isErr()).toBe(true);
1231
+ const error = result._unsafeUnwrapErr();
1232
+ expect(error).toBeInstanceOf(HostResponseDecodeError);
1233
+ expect((error as HostResponseDecodeError).call).toBe("createRingVRFProof");
1234
+ // The original error is preserved as `cause` so a bug report can see it.
1235
+ expect((error as HostResponseDecodeError).cause).toBe(rangeError);
1236
+ });
1237
+
1238
+ test("a synchronous throw in the response mapping is caught too", async () => {
1239
+ // e.g. a malformed hex field reaching `fromHex` inside `.map`.
1240
+ const result = await callProof(
1241
+ okAsync({
1242
+ proof: "not-hex",
1243
+ contextualAlias: { context: "0x01", alias: "0x02" },
1244
+ ringIndex: 0,
1245
+ ringRevision: 0,
1246
+ }),
1247
+ );
1248
+ expect(result.isErr()).toBe(true);
1249
+ expect(result._unsafeUnwrapErr()).toBeInstanceOf(HostResponseDecodeError);
1250
+ });
1251
+
1252
+ test("a well-formed response passes through unchanged", async () => {
1253
+ const result = await callProof(
1254
+ okAsync({
1255
+ proof: "0xc0ffee",
1256
+ contextualAlias: { context: "0x01", alias: "0x02" },
1257
+ ringIndex: 3,
1258
+ ringRevision: 7,
1259
+ }),
1260
+ );
1261
+ expect(result.isOk()).toBe(true);
1262
+ const proof = result._unsafeUnwrap();
1263
+ expect(proof.proof).toEqual(fromHex("0xc0ffee"));
1264
+ expect(proof.ringIndex).toBe(3);
1265
+ expect(proof.ringRevision).toBe(7);
1266
+ });
1267
+
1268
+ test("the call's own typed Err passes through, not wrapped as a decode error", async () => {
1269
+ const typedErr = { tag: "Domain", value: { tag: "RingNotFound" } };
1270
+ const result = await callProof(errAsync(typedErr));
1271
+
1272
+ expect(result.isErr()).toBe(true);
1273
+ const error = result._unsafeUnwrapErr();
1274
+ expect(error).not.toBeInstanceOf(HostResponseDecodeError);
1275
+ expect(error).toEqual(typedErr);
1276
+ });
1277
+ });
1052
1278
  }
package/src/chains.ts CHANGED
@@ -6,12 +6,14 @@
6
6
  */
7
7
 
8
8
  /**
9
- * Bulletin Chain RPC endpoints per network environment. `paseo` (Paseo Next v2)
10
- * and `devnet` (public Paseo testnet) are populated today; `polkadot` and
11
- * `kusama` are reserved for when those Bulletin deployments go live.
9
+ * Bulletin Chain RPC endpoints per network environment. `paseo` (Paseo Next v2),
10
+ * `previewnet` (zombienet, a step ahead of paseo), and `devnet` (public Paseo
11
+ * testnet) are populated today; `polkadot` and `kusama` are reserved for when
12
+ * those Bulletin deployments go live.
12
13
  */
13
14
  export const BULLETIN_RPCS = {
14
15
  paseo: ["wss://paseo-bulletin-next-rpc.polkadot.io"],
16
+ previewnet: ["wss://previewnet.substrate.dev/bulletin"],
15
17
  devnet: ["wss://bulletin-paseo.tservices.es:8443"],
16
18
  polkadot: [] as string[],
17
19
  kusama: [] as string[],
@@ -29,6 +31,11 @@ if (import.meta.vitest) {
29
31
  expect(BULLETIN_RPCS.paseo[0]).toMatch(/^wss:\/\//);
30
32
  });
31
33
 
34
+ test("BULLETIN_RPCS has previewnet endpoint", () => {
35
+ expect(BULLETIN_RPCS.previewnet.length).toBeGreaterThan(0);
36
+ expect(BULLETIN_RPCS.previewnet[0]).toMatch(/^wss:\/\//);
37
+ });
38
+
32
39
  test("BULLETIN_RPCS has devnet endpoint", () => {
33
40
  expect(BULLETIN_RPCS.devnet.length).toBeGreaterThan(0);
34
41
  expect(BULLETIN_RPCS.devnet[0]).toMatch(/^wss:\/\//);
package/src/errors.ts CHANGED
@@ -144,6 +144,38 @@ export class HostCallFailedError extends HostError {
144
144
  }
145
145
  }
146
146
 
147
+ /**
148
+ * A host call could not be processed to completion: the `ResultAsync` the
149
+ * truapi client returns rejected instead of resolving to an ok/err. The usual
150
+ * cause is a response the client's SCALE codec can't decode (a
151
+ * `RangeError: Offset is outside the bounds of the DataView`) because the host
152
+ * and the `@parity/truapi` version the product is built against disagree on the
153
+ * wire shape of that call — a protocol-version skew. A host channel that closed
154
+ * mid-call looks identical from here, so this does not assert the skew; the
155
+ * real error is preserved on {@link cause}.
156
+ *
157
+ * The truapi client catches the decode throw in its message handler and turns
158
+ * it into a promise rejection, then wraps the call with
159
+ * `ResultAsync.fromSafePromise`, which installs no rejection handler — so the
160
+ * rejection escapes the `Result` channel rather than landing on its err side.
161
+ * Without this boundary that surfaces as a raw `RangeError` with a stack naming
162
+ * neither the call nor the cause. This names the call, so a bug report has
163
+ * somewhere to start.
164
+ */
165
+ export class HostResponseDecodeError extends HostError {
166
+ /** The host-API call whose response failed to decode, e.g. `"createRingVRFProof"`. */
167
+ readonly call: string;
168
+
169
+ constructor(call: string, cause: unknown) {
170
+ super(
171
+ `Could not process the host's response to ${call}: ${formatHostError(cause)}. The usual cause is a protocol-version skew between the host app and the @parity/truapi version this product is built against; a host channel that closed mid-call looks the same.`,
172
+ { cause },
173
+ );
174
+ this.name = "HostResponseDecodeError";
175
+ this.call = call;
176
+ }
177
+ }
178
+
147
179
  /** Check whether a value is any {@link HostError}. */
148
180
  export function isHostError(error: unknown): error is HostError {
149
181
  return error instanceof HostError;
@@ -192,9 +224,23 @@ if (import.meta.vitest) {
192
224
  expect(e.message).toBe("submit failed: timeout");
193
225
  });
194
226
 
227
+ test("HostResponseDecodeError names the call, interpolates the cause, and preserves it", () => {
228
+ const cause = new RangeError("Offset is outside the bounds of the DataView");
229
+ const e = new HostResponseDecodeError("createRingVRFProof", cause);
230
+ expect(e).toBeInstanceOf(HostError);
231
+ expect(e.name).toBe("HostResponseDecodeError");
232
+ expect(e.call).toBe("createRingVRFProof");
233
+ expect(e.cause).toBe(cause);
234
+ expect(e.message).toContain("createRingVRFProof");
235
+ // The rendered cause is in the message, not just on `.cause`.
236
+ expect(e.message).toContain("Offset is outside the bounds of the DataView");
237
+ expect(e.message).toContain("protocol-version skew");
238
+ });
239
+
195
240
  test("isHostError narrows host errors only", () => {
196
241
  expect(isHostError(new HostUnavailableError())).toBe(true);
197
242
  expect(isHostError(new HostCallFailedError("x", { tag: "Denied" }))).toBe(true);
243
+ expect(isHostError(new HostResponseDecodeError("c", new Error("boom")))).toBe(true);
198
244
  expect(isHostError(new Error("plain"))).toBe(false);
199
245
  expect(isHostError("string")).toBe(false);
200
246
  });