@parity/product-sdk-host 0.17.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parity/product-sdk-host",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Host container detection and storage access for Polkadot Desktop and Mobile environments",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -27,9 +27,9 @@
27
27
  "@polkadot-api/substrate-bindings": "^0.20.3",
28
28
  "neverthrow": "^8.2.0",
29
29
  "polkadot-api": "^2.1.6",
30
- "@parity/product-sdk-logger": "0.1.1",
30
+ "@parity/product-sdk-errors": "0.2.0",
31
31
  "@parity/result": "0.2.0",
32
- "@parity/product-sdk-errors": "0.2.0"
32
+ "@parity/product-sdk-logger": "0.1.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "tsup": "^8.5.1",
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,30 +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
- * Map metadata's supported extrinsic formats to the host wire protocol.
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`).
346
387
  *
347
- * V4 carries the account signature in its envelope. V5 General transactions
348
- * delegate authorization to the runtime's extension pipeline, but metadata
349
- * alone does not say whether the connected host can implement that pipeline.
350
- * Prefer an advertised V4 until the host protocol can negotiate V5
351
- * authorization capabilities; V5-only and unknown future runtimes retain the
352
- * previous highest-version behavior and the host remains authoritative.
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.
353
400
  */
354
- function selectHostTxExtVersion(versions: readonly number[]): number {
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
- if (versions.includes(4)) {
408
+ if (formatVersions.includes(4)) {
359
409
  return 0;
360
410
  }
361
- return versions.reduce((acc, version) => Math.max(acc, version), 0);
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
+ );
362
418
  }
363
419
 
364
420
  /** Derive the host's transaction-extension version from SCALE metadata. */
365
421
  function deriveTxExtVersion(metadata: Uint8Array): number {
366
- const versions = unifyMetadata(decAnyMetadata(metadata)).extrinsic.version;
367
- return selectHostTxExtVersion(versions);
422
+ const extrinsic = unifyMetadata(decAnyMetadata(metadata)).extrinsic;
423
+ const txExtVersions = Object.keys(extrinsic.signedExtensions).map(Number);
424
+ return selectHostTxExtVersion(extrinsic.version, txExtVersions);
368
425
  }
369
426
 
370
427
  /** Internal seam so `import.meta.vitest` can stub the metadata decode. @internal */
@@ -400,6 +457,28 @@ function toWireProductAccountId({
400
457
  return { dotNsIdentifier, derivationIndex: { tag: "Index", value: derivationIndex } };
401
458
  }
402
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
+
403
482
  /** Build an {@link AccountsProvider} over a TruAPI client's `account` / `signing` domains. */
404
483
  function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
405
484
  const account = client.account;
@@ -407,98 +486,128 @@ function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
407
486
 
408
487
  return {
409
488
  getUserId() {
410
- return account.getUserId().map((response) => ({
411
- primaryUsername: response.primaryUsername,
412
- }));
489
+ return guardDecode(
490
+ "getUserId",
491
+ account.getUserId().map((response) => ({
492
+ primaryUsername: response.primaryUsername,
493
+ })),
494
+ );
413
495
  },
414
496
  requestLogin(reason) {
415
- return account.requestLogin({ reason });
497
+ return guardDecode("requestLogin", account.requestLogin({ reason }));
416
498
  },
417
499
  getProductAccount(dotNsIdentifier, derivationIndex = 0) {
418
- return account
419
- .getAccount({
420
- productAccountId: toWireProductAccountId({ dotNsIdentifier, derivationIndex }),
421
- })
422
- .map((response) => ({
423
- publicKey: fromHex(response.account.publicKey),
424
- dotNsIdentifier,
425
- derivationIndex,
426
- }));
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
+ );
427
515
  },
428
516
  registerRingVrfKey(index, ring) {
429
- return account
430
- .registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
431
- .map(fromHex);
517
+ return guardDecode(
518
+ "registerRingVrfKey",
519
+ account
520
+ .registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
521
+ .map(fromHex),
522
+ );
432
523
  },
433
524
  listRingVrfKeys(owner, disclosure = "Anonymized") {
434
- return account.listRingVrfKeys({ owner, disclosure }).map((keys) =>
435
- keys.map((key) => ({
436
- ...key,
437
- handle: key.handle as unknown as RingVrfKeyHandle,
438
- publicKey: key.publicKey === undefined ? undefined : fromHex(key.publicKey),
439
- })),
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
+ ),
440
534
  );
441
535
  },
442
536
  getProductAccountAlias(keyHandle, context, location) {
443
- return account
444
- .getAccountAlias({
445
- keyHandle: keyHandle as unknown as ProductAccountId,
446
- context,
447
- ringLocation: location,
448
- })
449
- .map((response) => ({
450
- context: fromHex(response.context),
451
- alias: fromHex(response.alias),
452
- }));
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
+ );
453
550
  },
454
551
  getLegacyAccounts() {
455
- return account.getLegacyAccounts().map((response) =>
456
- response.accounts.map((a) => ({
457
- publicKey: fromHex(a.publicKey),
458
- name: a.name,
459
- })),
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
+ ),
460
560
  );
461
561
  },
462
562
  createRingVRFProof(keyHandle, context, location, message) {
463
- return account
464
- .createAccountProof({
465
- keyHandle: keyHandle as unknown as ProductAccountId,
466
- context,
467
- ringLocation: location,
468
- message: toHex(message),
469
- })
470
- .map((response) => ({
471
- proof: fromHex(response.proof),
472
- contextualAlias: {
473
- context: fromHex(response.contextualAlias.context),
474
- alias: fromHex(response.contextualAlias.alias),
475
- },
476
- ringIndex: response.ringIndex,
477
- ringRevision: response.ringRevision,
478
- }));
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
+ );
479
582
  },
480
583
  ringVrfSign(keyHandle, message) {
481
- return account
482
- .ringVrfSign({
483
- keyHandle: keyHandle as unknown as ProductAccountId,
484
- message: toHex(message),
485
- })
486
- .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
+ );
487
593
  },
488
594
  signVrf(account_, transcriptLabel, items) {
489
- return account
490
- .signVrf({
491
- account: toWireProductAccountId(account_),
492
- transcriptLabel: toHex(transcriptLabel),
493
- items: items.map(({ label, value }) => ({
494
- label: toHex(label),
495
- 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),
496
609
  })),
497
- })
498
- .map((response) => ({
499
- preOutput: fromHex(response.preOutput),
500
- proof: fromHex(response.proof),
501
- }));
610
+ );
502
611
  },
503
612
  getProductAccountSigner(account_) {
504
613
  const productAccountId = toWireProductAccountId(account_);
@@ -593,34 +702,44 @@ export async function getAccountsProvider(): Promise<AccountsProvider | null> {
593
702
  }
594
703
 
595
704
  if (import.meta.vitest) {
596
- const { test, expect, vi } = import.meta.vitest;
705
+ const { test, expect, vi, describe } = import.meta.vitest;
597
706
 
598
- test("host signing prefers V4 on a dual V4/V5 runtime", () => {
599
- expect(selectHostTxExtVersion([4, 5])).toBe(0);
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);
600
710
  });
601
711
 
602
- test("host signing uses V5 when V4 is unavailable", () => {
603
- expect(selectHostTxExtVersion([5])).toBe(5);
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);
604
715
  });
605
716
 
606
717
  test("host signing maps a V4-only runtime to the wire sentinel", () => {
607
- expect(selectHostTxExtVersion([4])).toBe(0);
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);
608
726
  });
609
727
 
610
728
  test("host signing rejects metadata with no extrinsic version", () => {
611
- expect(() => selectHostTxExtVersion([])).toThrow("No extrinsic version found in metadata");
729
+ expect(() => selectHostTxExtVersion([], [0])).toThrow(
730
+ "No extrinsic version found in metadata",
731
+ );
612
732
  });
613
733
 
614
734
  /** Minimal fake of the truapi account/signing domains used to test the adapter. */
615
735
  function makeFakeClient(opts: { onCall?: (method: string, args: unknown) => void } = {}) {
616
- const okMatch = (value: unknown) => ({
617
- // neverthrow ResultAsync surface used by the adapter: .map + .match.
618
- map: (fn: (v: unknown) => unknown) => okMatch(fn(value)),
619
- match: (ok: (v: unknown) => unknown, _err: (e: unknown) => unknown) => ok(value),
620
- });
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.
621
740
  const method = (name: string, response: unknown) => (args: unknown) => {
622
741
  opts.onCall?.(name, args);
623
- return okMatch(response);
742
+ return okAsync(response);
624
743
  };
625
744
  return {
626
745
  account: {
@@ -1073,4 +1192,87 @@ if (import.meta.vitest) {
1073
1192
  expect(signed).toEqual(fromHex("0xfeed"));
1074
1193
  vi.restoreAllMocks();
1075
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
+ });
1076
1278
  }
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
  });