@parity/product-sdk-host 0.17.0 → 0.19.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,30 +371,32 @@ export interface AccountsProvider {
341
371
  ): HostSubscription;
342
372
  }
343
373
 
374
+ const V5_FORMAT_SELECTOR = 5;
375
+
344
376
  /**
345
- * Map metadata's supported extrinsic formats to the host wire protocol.
346
- *
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.
377
+ * Pick the `txExtVersion` for the host's `create_transaction` from the extrinsic formats
378
+ * the runtime offers. The host treats the field as a format switch: `0` builds V4, `5`
379
+ * builds a V5 general transaction, anything else is `NotSupported`. Prefer V4 while
380
+ * offered, since it carries the account signature in its envelope. The host derives the
381
+ * transaction-extension version from the metadata itself.
353
382
  */
354
- function selectHostTxExtVersion(versions: readonly number[]): number {
355
- if (versions.length === 0) {
383
+ function selectHostTxExtVersion(formatVersions: readonly number[]): number {
384
+ if (formatVersions.length === 0) {
356
385
  throw new Error("No extrinsic version found in metadata");
357
386
  }
358
- if (versions.includes(4)) {
387
+ if (formatVersions.includes(4)) {
359
388
  return 0;
360
389
  }
361
- return versions.reduce((acc, version) => Math.max(acc, version), 0);
390
+ if (formatVersions.includes(5)) {
391
+ return V5_FORMAT_SELECTOR;
392
+ }
393
+ throw new Error(
394
+ `Runtime offers no extrinsic format 4 or 5 (offers: ${formatVersions.join(", ")}); the host protocol has no txExtVersion for it.`,
395
+ );
362
396
  }
363
397
 
364
- /** Derive the host's transaction-extension version from SCALE metadata. */
365
398
  function deriveTxExtVersion(metadata: Uint8Array): number {
366
- const versions = unifyMetadata(decAnyMetadata(metadata)).extrinsic.version;
367
- return selectHostTxExtVersion(versions);
399
+ return selectHostTxExtVersion(unifyMetadata(decAnyMetadata(metadata)).extrinsic.version);
368
400
  }
369
401
 
370
402
  /** Internal seam so `import.meta.vitest` can stub the metadata decode. @internal */
@@ -400,6 +432,28 @@ function toWireProductAccountId({
400
432
  return { dotNsIdentifier, derivationIndex: { tag: "Index", value: derivationIndex } };
401
433
  }
402
434
 
435
+ /**
436
+ * Route a thrown/rejected response-decode error onto the `Result` err channel.
437
+ *
438
+ * The truapi client catches a decode throw in its message handler and turns it
439
+ * into a promise rejection, then wraps each call with
440
+ * `ResultAsync.fromSafePromise`, which installs no rejection handler — so when
441
+ * the host's reply doesn't match the client's codec (a version skew), that
442
+ * rejection escapes the `Result` channel rather than landing on its err side,
443
+ * surfacing as a raw `RangeError`. Wrapping the call re-homes that rejection as
444
+ * a typed {@link HostResponseDecodeError} that names the call, while ok values
445
+ * and the call's own typed `Err` values pass through untouched.
446
+ */
447
+ function guardDecode<T, E>(
448
+ call: string,
449
+ result: ResultAsync<T, E>,
450
+ ): ResultAsync<T, E | HostResponseDecodeError> {
451
+ return ResultAsync.fromPromise(
452
+ Promise.resolve(result),
453
+ (cause) => new HostResponseDecodeError(call, cause),
454
+ ).andThen((inner) => inner);
455
+ }
456
+
403
457
  /** Build an {@link AccountsProvider} over a TruAPI client's `account` / `signing` domains. */
404
458
  function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
405
459
  const account = client.account;
@@ -407,98 +461,128 @@ function adaptAccountsProvider(client: TrUApiClient): AccountsProvider {
407
461
 
408
462
  return {
409
463
  getUserId() {
410
- return account.getUserId().map((response) => ({
411
- primaryUsername: response.primaryUsername,
412
- }));
464
+ return guardDecode(
465
+ "getUserId",
466
+ account.getUserId().map((response) => ({
467
+ primaryUsername: response.primaryUsername,
468
+ })),
469
+ );
413
470
  },
414
471
  requestLogin(reason) {
415
- return account.requestLogin({ reason });
472
+ return guardDecode("requestLogin", account.requestLogin({ reason }));
416
473
  },
417
474
  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
- }));
475
+ return guardDecode(
476
+ "getProductAccount",
477
+ account
478
+ .getAccount({
479
+ productAccountId: toWireProductAccountId({
480
+ dotNsIdentifier,
481
+ derivationIndex,
482
+ }),
483
+ })
484
+ .map((response) => ({
485
+ publicKey: fromHex(response.account.publicKey),
486
+ dotNsIdentifier,
487
+ derivationIndex,
488
+ })),
489
+ );
427
490
  },
428
491
  registerRingVrfKey(index, ring) {
429
- return account
430
- .registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
431
- .map(fromHex);
492
+ return guardDecode(
493
+ "registerRingVrfKey",
494
+ account
495
+ .registerRingVrfKey({ index: { tag: "Index", value: index }, ring })
496
+ .map(fromHex),
497
+ );
432
498
  },
433
499
  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
- })),
500
+ return guardDecode(
501
+ "listRingVrfKeys",
502
+ account.listRingVrfKeys({ owner, disclosure }).map((keys) =>
503
+ keys.map((key) => ({
504
+ ...key,
505
+ handle: key.handle as unknown as RingVrfKeyHandle,
506
+ publicKey: key.publicKey === undefined ? undefined : fromHex(key.publicKey),
507
+ })),
508
+ ),
440
509
  );
441
510
  },
442
511
  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
- }));
512
+ return guardDecode(
513
+ "getProductAccountAlias",
514
+ account
515
+ .getAccountAlias({
516
+ keyHandle: keyHandle as unknown as ProductAccountId,
517
+ context,
518
+ ringLocation: location,
519
+ })
520
+ .map((response) => ({
521
+ context: fromHex(response.context),
522
+ alias: fromHex(response.alias),
523
+ })),
524
+ );
453
525
  },
454
526
  getLegacyAccounts() {
455
- return account.getLegacyAccounts().map((response) =>
456
- response.accounts.map((a) => ({
457
- publicKey: fromHex(a.publicKey),
458
- name: a.name,
459
- })),
527
+ return guardDecode(
528
+ "getLegacyAccounts",
529
+ account.getLegacyAccounts().map((response) =>
530
+ response.accounts.map((a) => ({
531
+ publicKey: fromHex(a.publicKey),
532
+ name: a.name,
533
+ })),
534
+ ),
460
535
  );
461
536
  },
462
537
  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
- }));
538
+ return guardDecode(
539
+ "createRingVRFProof",
540
+ account
541
+ .createAccountProof({
542
+ keyHandle: keyHandle as unknown as ProductAccountId,
543
+ context,
544
+ ringLocation: location,
545
+ message: toHex(message),
546
+ })
547
+ .map((response) => ({
548
+ proof: fromHex(response.proof),
549
+ contextualAlias: {
550
+ context: fromHex(response.contextualAlias.context),
551
+ alias: fromHex(response.contextualAlias.alias),
552
+ },
553
+ ringIndex: response.ringIndex,
554
+ ringRevision: response.ringRevision,
555
+ })),
556
+ );
479
557
  },
480
558
  ringVrfSign(keyHandle, message) {
481
- return account
482
- .ringVrfSign({
483
- keyHandle: keyHandle as unknown as ProductAccountId,
484
- message: toHex(message),
485
- })
486
- .map(fromHex);
559
+ return guardDecode(
560
+ "ringVrfSign",
561
+ account
562
+ .ringVrfSign({
563
+ keyHandle: keyHandle as unknown as ProductAccountId,
564
+ message: toHex(message),
565
+ })
566
+ .map(fromHex),
567
+ );
487
568
  },
488
569
  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),
570
+ return guardDecode(
571
+ "signVrf",
572
+ account
573
+ .signVrf({
574
+ account: toWireProductAccountId(account_),
575
+ transcriptLabel: toHex(transcriptLabel),
576
+ items: items.map(({ label, value }) => ({
577
+ label: toHex(label),
578
+ value: toHex(value),
579
+ })),
580
+ })
581
+ .map((response) => ({
582
+ preOutput: fromHex(response.preOutput),
583
+ proof: fromHex(response.proof),
496
584
  })),
497
- })
498
- .map((response) => ({
499
- preOutput: fromHex(response.preOutput),
500
- proof: fromHex(response.proof),
501
- }));
585
+ );
502
586
  },
503
587
  getProductAccountSigner(account_) {
504
588
  const productAccountId = toWireProductAccountId(account_);
@@ -593,13 +677,13 @@ export async function getAccountsProvider(): Promise<AccountsProvider | null> {
593
677
  }
594
678
 
595
679
  if (import.meta.vitest) {
596
- const { test, expect, vi } = import.meta.vitest;
680
+ const { test, expect, vi, describe } = import.meta.vitest;
597
681
 
598
- test("host signing prefers V4 on a dual V4/V5 runtime", () => {
682
+ test("host signing prefers V4 (tx-ext version 0) on a dual V4/V5 runtime", () => {
599
683
  expect(selectHostTxExtVersion([4, 5])).toBe(0);
600
684
  });
601
685
 
602
- test("host signing uses V5 when V4 is unavailable", () => {
686
+ test("host signing uses the V5 selector when the runtime offers no V4", () => {
603
687
  expect(selectHostTxExtVersion([5])).toBe(5);
604
688
  });
605
689
 
@@ -607,20 +691,41 @@ if (import.meta.vitest) {
607
691
  expect(selectHostTxExtVersion([4])).toBe(0);
608
692
  });
609
693
 
694
+ test("host signing prefers V5 over a format it does not know", () => {
695
+ // max(formats) would send 6, which no host accepts.
696
+ expect(selectHostTxExtVersion([5, 6])).toBe(5);
697
+ });
698
+
699
+ test("host signing rejects a runtime offering neither format 4 nor 5", () => {
700
+ expect(() => selectHostTxExtVersion([6])).toThrow(/no extrinsic format 4 or 5/i);
701
+ });
702
+
610
703
  test("host signing rejects metadata with no extrinsic version", () => {
611
704
  expect(() => selectHostTxExtVersion([])).toThrow("No extrinsic version found in metadata");
612
705
  });
613
706
 
707
+ test("deriveTxExtVersion reads the format list out of every tracked chain's metadata", async () => {
708
+ const { readFileSync, readdirSync } = await import("node:fs");
709
+ const dir = new URL("../../descriptors/.papi/metadata/", import.meta.url);
710
+ const blobs = readdirSync(dir).filter((name) => name.endsWith(".scale"));
711
+
712
+ expect(blobs.length, "raise when a chain is added").toBeGreaterThanOrEqual(11);
713
+ // Every deployed runtime still offers format 4, so V4 wins. Fails the day one drops it.
714
+ for (const name of blobs) {
715
+ const metadata = new Uint8Array(readFileSync(new URL(name, dir)));
716
+ expect(deriveTxExtVersion(metadata), name).toBe(0);
717
+ }
718
+ });
719
+
614
720
  /** Minimal fake of the truapi account/signing domains used to test the adapter. */
615
721
  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
- });
722
+ // A real neverthrow `okAsync`, not a hand-rolled `{ map, match }` stub:
723
+ // a stub with no `.then` would be passed through un-awaited by
724
+ // `guardDecode`'s `Promise.resolve(result)`, so the tests would bypass
725
+ // the guard's real path. A genuine `ResultAsync` exercises it.
621
726
  const method = (name: string, response: unknown) => (args: unknown) => {
622
727
  opts.onCall?.(name, args);
623
- return okMatch(response);
728
+ return okAsync(response);
624
729
  };
625
730
  return {
626
731
  account: {
@@ -1073,4 +1178,87 @@ if (import.meta.vitest) {
1073
1178
  expect(signed).toEqual(fromHex("0xfeed"));
1074
1179
  vi.restoreAllMocks();
1075
1180
  });
1181
+
1182
+ describe("response-decode boundary (guardDecode)", () => {
1183
+ // A client whose `createAccountProof` returns a REAL neverthrow
1184
+ // `ResultAsync` — the hand-rolled `okMatch` fake can't reject, and
1185
+ // rejection (a thrown SCALE decode) is exactly what this boundary
1186
+ // exists to catch. `createRingVRFProof` is the reported call (#270).
1187
+ function clientWithProof(result: ResultAsync<unknown, unknown>): TrUApiClient {
1188
+ return {
1189
+ account: { createAccountProof: () => result },
1190
+ } as unknown as TrUApiClient;
1191
+ }
1192
+
1193
+ const KEY_HANDLE = {
1194
+ dotNsIdentifier: "people.dot",
1195
+ derivationIndex: { tag: "Index", value: 0 },
1196
+ } as unknown as RingVrfKeyHandle;
1197
+ const CONTEXT = {
1198
+ productId: "app.dot",
1199
+ suffix: { tag: "Index", value: 0 },
1200
+ } as ProductProofContext;
1201
+ const RING: RingLocation = { chainId: "0x01", junctions: [] };
1202
+ const MESSAGE = new Uint8Array([1, 2, 3]);
1203
+
1204
+ const callProof = (result: ResultAsync<unknown, unknown>) =>
1205
+ adaptAccountsProvider(clientWithProof(result)).createRingVRFProof(
1206
+ KEY_HANDLE,
1207
+ CONTEXT,
1208
+ RING,
1209
+ MESSAGE,
1210
+ );
1211
+
1212
+ test("a thrown decode error (RangeError) becomes a HostResponseDecodeError naming the call", async () => {
1213
+ const rangeError = new RangeError("Offset is outside the bounds of the DataView");
1214
+ const result = await callProof(ResultAsync.fromSafePromise(Promise.reject(rangeError)));
1215
+
1216
+ expect(result.isErr()).toBe(true);
1217
+ const error = result._unsafeUnwrapErr();
1218
+ expect(error).toBeInstanceOf(HostResponseDecodeError);
1219
+ expect((error as HostResponseDecodeError).call).toBe("createRingVRFProof");
1220
+ // The original error is preserved as `cause` so a bug report can see it.
1221
+ expect((error as HostResponseDecodeError).cause).toBe(rangeError);
1222
+ });
1223
+
1224
+ test("a synchronous throw in the response mapping is caught too", async () => {
1225
+ // e.g. a malformed hex field reaching `fromHex` inside `.map`.
1226
+ const result = await callProof(
1227
+ okAsync({
1228
+ proof: "not-hex",
1229
+ contextualAlias: { context: "0x01", alias: "0x02" },
1230
+ ringIndex: 0,
1231
+ ringRevision: 0,
1232
+ }),
1233
+ );
1234
+ expect(result.isErr()).toBe(true);
1235
+ expect(result._unsafeUnwrapErr()).toBeInstanceOf(HostResponseDecodeError);
1236
+ });
1237
+
1238
+ test("a well-formed response passes through unchanged", async () => {
1239
+ const result = await callProof(
1240
+ okAsync({
1241
+ proof: "0xc0ffee",
1242
+ contextualAlias: { context: "0x01", alias: "0x02" },
1243
+ ringIndex: 3,
1244
+ ringRevision: 7,
1245
+ }),
1246
+ );
1247
+ expect(result.isOk()).toBe(true);
1248
+ const proof = result._unsafeUnwrap();
1249
+ expect(proof.proof).toEqual(fromHex("0xc0ffee"));
1250
+ expect(proof.ringIndex).toBe(3);
1251
+ expect(proof.ringRevision).toBe(7);
1252
+ });
1253
+
1254
+ test("the call's own typed Err passes through, not wrapped as a decode error", async () => {
1255
+ const typedErr = { tag: "Domain", value: { tag: "RingNotFound" } };
1256
+ const result = await callProof(errAsync(typedErr));
1257
+
1258
+ expect(result.isErr()).toBe(true);
1259
+ const error = result._unsafeUnwrapErr();
1260
+ expect(error).not.toBeInstanceOf(HostResponseDecodeError);
1261
+ expect(error).toEqual(typedErr);
1262
+ });
1263
+ });
1076
1264
  }
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
  });
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ export {
70
70
  HostError,
71
71
  HostUnavailableError,
72
72
  HostCallFailedError,
73
+ HostResponseDecodeError,
73
74
  isHostError,
74
75
  formatHostError,
75
76
  } from "./errors.js";
@@ -79,6 +80,7 @@ export type { HostErrorPayload } from "./errors.js";
79
80
  export { getAccountsProvider, findRingVrfKeyHandle } from "./accounts.js";
80
81
  export type {
81
82
  AccountsProvider,
83
+ WithDecodeError,
82
84
  DerivationIndex,
83
85
  HostAccount,
84
86
  ProductAccount,
@@ -103,6 +105,10 @@ export type { DevicePermissionKind, RemotePermissionItem } from "./permissions.j
103
105
  export { getThemeProvider } from "./theme.js";
104
106
  export type { ThemeMode, ThemeName, ThemeProvider, ThemeVariant } from "./theme.js";
105
107
 
108
+ // Locale provider
109
+ export { getLocaleProvider } from "./locale.js";
110
+ export type { LocaleInfo, LocaleProvider } from "./locale.js";
111
+
106
112
  // Entropy derivation (RFC-0007)
107
113
  export { deriveEntropy } from "./entropy.js";
108
114