@parity/product-sdk-signer 0.1.0 → 0.2.1

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-signer",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Signer manager for Polkadot — Host API and dev accounts",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -18,15 +18,15 @@
18
18
  "src"
19
19
  ],
20
20
  "dependencies": {
21
- "polkadot-api": "^1.23.3",
22
- "@parity/product-sdk-address": "0.1.0",
23
- "@parity/product-sdk-host": "0.1.0",
24
- "@parity/product-sdk-keys": "0.1.0",
25
- "@parity/product-sdk-logger": "0.1.0"
21
+ "polkadot-api": "^2.1.2",
22
+ "@parity/product-sdk-keys": "0.2.0",
23
+ "@parity/product-sdk-logger": "0.1.1",
24
+ "@parity/product-sdk-address": "0.1.1",
25
+ "@parity/product-sdk-host": "0.2.0"
26
26
  },
27
27
  "optionalDependencies": {
28
- "@novasamatech/product-sdk": "^0.6.17",
29
- "@novasamatech/host-api": "^0.7.0"
28
+ "@novasamatech/product-sdk": "^0.7.7",
29
+ "@novasamatech/host-api": "^0.7.7"
30
30
  },
31
31
  "devDependencies": {
32
32
  "tsup": "^8.4.0",
package/src/index.ts CHANGED
@@ -1,3 +1,15 @@
1
+ /**
2
+ * @parity/product-sdk-signer — Account connection and signing, decoupled from where the keys actually live.
3
+ *
4
+ * `SignerManager` wraps one or more `SignerProvider` implementations behind a
5
+ * `Result`-typed API for connecting, listing accounts, and reacting to status or
6
+ * account changes. The two built-in providers — `HostProvider` (Polkadot
7
+ * Desktop/Mobile) and `DevProvider` (the well-known Alice/Bob accounts) — let
8
+ * the same call sites work in production and in tests.
9
+ *
10
+ * @packageDocumentation
11
+ */
12
+
1
13
  // Core manager
2
14
  export { SignerManager } from "./signer-manager.js";
3
15
 
@@ -14,6 +14,7 @@ const DEV_PHRASE = "bottom drive obey lake curtain smoke basket hold race lonely
14
14
  /** Standard Substrate dev account names. */
15
15
  const DEFAULT_DEV_NAMES = ["Alice", "Bob", "Charlie", "Dave", "Eve", "Ferdie"] as const;
16
16
 
17
+ /** A well-known Substrate development account name (Alice, Bob, …) used to derive deterministic dev accounts from the standard Substrate dev mnemonic. */
17
18
  export type DevAccountName = (typeof DEFAULT_DEV_NAMES)[number];
18
19
 
19
20
  /** Supported key types for dev account derivation. */
@@ -30,18 +30,23 @@ export interface HostProviderOptions {
30
30
  loadSdk?: () => Promise<ProductSdkModule>;
31
31
  /**
32
32
  * Custom loader for `@novasamatech/host-api` (used to construct the
33
- * `TransactionSubmit` permission request). Defaults to dynamic import.
33
+ * `ChainSubmit` permission request). Defaults to dynamic import.
34
34
  * @internal
35
35
  */
36
36
  loadHostApiEnum?: () => Promise<HostApiEnumHelper>;
37
37
  /**
38
- * Whether to request the host's `TransactionSubmit` permission after a
38
+ * Whether to request the host's `ChainSubmit` permission after a
39
39
  * successful `connect()`. Without this, subsequent signing requests are
40
40
  * rejected by the host with `PermissionDenied`. Default: `true`.
41
41
  *
42
42
  * Set to `false` if your app needs to defer the permission prompt or
43
43
  * drives it manually.
44
+ *
45
+ * (Previously named `requestTransactionSubmitPermission` — alias kept
46
+ * for backwards compatibility but the new wire format uses `ChainSubmit`.)
44
47
  */
48
+ requestChainSubmitPermission?: boolean;
49
+ /** @deprecated Renamed to `requestChainSubmitPermission`. */
45
50
  requestTransactionSubmitPermission?: boolean;
46
51
  }
47
52
 
@@ -97,8 +102,8 @@ interface NeverthrowResultAsync<T, E> {
97
102
 
98
103
  /** @internal */
99
104
  export interface AccountsProvider {
100
- getNonProductAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
101
- getNonProductAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
105
+ getLegacyAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
106
+ getLegacyAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
102
107
  getProductAccount: (
103
108
  dotNsIdentifier: string,
104
109
  derivationIndex?: number,
@@ -168,7 +173,7 @@ export class HostProvider implements SignerProvider {
168
173
  private readonly retryDelay: number;
169
174
  private readonly loadSdk: () => Promise<ProductSdkModule>;
170
175
  private readonly loadHostApiEnum: () => Promise<HostApiEnumHelper>;
171
- private readonly requestTxPermission: boolean;
176
+ private readonly requestChainSubmitPermission: boolean;
172
177
 
173
178
  private accountsProvider: AccountsProvider | null = null;
174
179
  private statusCleanup: (() => void) | null = null;
@@ -181,7 +186,11 @@ export class HostProvider implements SignerProvider {
181
186
  this.retryDelay = options?.retryDelay ?? 500;
182
187
  this.loadSdk = options?.loadSdk ?? defaultLoadSdk;
183
188
  this.loadHostApiEnum = options?.loadHostApiEnum ?? defaultLoadHostApiEnum;
184
- this.requestTxPermission = options?.requestTransactionSubmitPermission ?? true;
189
+ // New name takes precedence; fall back to the deprecated alias.
190
+ this.requestChainSubmitPermission =
191
+ options?.requestChainSubmitPermission ??
192
+ options?.requestTransactionSubmitPermission ??
193
+ true;
185
194
  }
186
195
 
187
196
  async connect(signal?: AbortSignal): Promise<Result<SignerAccount[], SignerError>> {
@@ -404,7 +413,7 @@ export class HostProvider implements SignerProvider {
404
413
  // Step 3: Fetch non-product accounts
405
414
  let rawAccounts: RawAccount[];
406
415
  try {
407
- rawAccounts = (await provider.getNonProductAccounts().match(
416
+ rawAccounts = (await provider.getLegacyAccounts().match(
408
417
  (accounts) => accounts,
409
418
  (error) => {
410
419
  throw new Error(`Host rejected account request: ${formatError(error)}`);
@@ -424,35 +433,42 @@ export class HostProvider implements SignerProvider {
424
433
  return err(new NoAccountsError("host"));
425
434
  }
426
435
 
427
- // Step 4: Request TransactionSubmit permission up-front.
436
+ // Step 4: Request ChainSubmit permission up-front.
428
437
  //
429
- // The host gates signing on this permission — without it `handleSignPayload`
430
- // (and the production host) rejects every sign request with
431
- // `PermissionDenied`, which typically manifests as a silently-hanging tx.
432
- // Doing it once during connect() matches what production apps need and
433
- // spares consumers the boilerplate.
438
+ // The host gates signing on this permission — without it
439
+ // `handleSignPayload` (and the production host) rejects every sign
440
+ // request with `PermissionDenied`, which typically manifests as a
441
+ // silently-hanging tx. Doing it once during connect() matches what
442
+ // production apps need and spares consumers the boilerplate.
434
443
  //
435
444
  // We don't fail `connect()` if this step fails: the consumer can still
436
- // use the signer for read-only code paths, and the actual sign call will
437
- // surface a clear error if permission is missing.
438
- if (this.requestTxPermission && sdk.hostApi) {
445
+ // use the signer for read-only code paths, and the actual sign call
446
+ // will surface a clear error if permission is missing.
447
+ //
448
+ // The legal v1 RemotePermission variants per
449
+ // `@novasamatech/host-api@0.7.7` are: Remote, WebRTC, ChainSubmit,
450
+ // PreimageSubmit, StatementSubmit. ChainSubmit is the chain-tx
451
+ // permission (was named TransactionSubmit in earlier host-api
452
+ // revisions; renamed in 0.7).
453
+ if (this.requestChainSubmitPermission && sdk.hostApi) {
439
454
  try {
440
455
  const hostApiEnum = await this.loadHostApiEnum();
441
456
  const request = hostApiEnum.enumValue("v1", {
442
- tag: "TransactionSubmit",
457
+ tag: "ChainSubmit",
458
+ value: undefined,
443
459
  });
444
460
  await sdk.hostApi.permission(request).match(
445
461
  () => {
446
- log.debug("TransactionSubmit permission granted");
462
+ log.debug("ChainSubmit permission granted");
447
463
  },
448
464
  (error) => {
449
- log.warn("TransactionSubmit permission rejected by host", {
465
+ log.warn("ChainSubmit permission rejected by host", {
450
466
  error: formatError(error),
451
467
  });
452
468
  },
453
469
  );
454
470
  } catch (cause) {
455
- log.warn("failed to request TransactionSubmit permission", { cause });
471
+ log.warn("failed to request ChainSubmit permission", { cause });
456
472
  }
457
473
  }
458
474
 
@@ -487,7 +503,7 @@ export class HostProvider implements SignerProvider {
487
503
  if (!this.accountsProvider) {
488
504
  throw new Error("Host provider is disconnected");
489
505
  }
490
- return this.accountsProvider.getNonProductAccountSigner({
506
+ return this.accountsProvider.getLegacyAccountSigner({
491
507
  dotNsIdentifier: "",
492
508
  derivationIndex: 0,
493
509
  publicKey: raw.publicKey,
@@ -498,11 +514,48 @@ export class HostProvider implements SignerProvider {
498
514
  }
499
515
  }
500
516
 
517
+ /**
518
+ * Format a host-error for logging.
519
+ *
520
+ * host-api errors come back as `{ tag: "v1", value: <inner> }` where the
521
+ * inner can be either another tagged enum (with its own tag/value) or a
522
+ * plain `Error`-shaped object surfacing client-side codec failures
523
+ * (e.g. `GenericError: inner[tag] is not a function` when the SDK
524
+ * encodes a request the codec doesn't understand).
525
+ *
526
+ * Walking the value side as well as the tag means schema drift between
527
+ * host-api versions and the SDK produces something more diagnostic than
528
+ * just the outermost wrapper tag.
529
+ */
501
530
  function formatError(error: unknown): string {
502
- if (error && typeof error === "object" && "tag" in (error as Record<string, unknown>)) {
503
- return (error as Record<string, string>).tag;
531
+ if (!error || typeof error !== "object") return String(error);
532
+ const e = error as Record<string, unknown>;
533
+ if (!("tag" in e)) return String(error);
534
+
535
+ const outerTag = String(e.tag);
536
+ const inner = e.value;
537
+
538
+ // Inner is an Error-shaped object with name/message — surface those.
539
+ if (inner && typeof inner === "object") {
540
+ const innerObj = inner as Record<string, unknown>;
541
+ if (typeof innerObj.message === "string") {
542
+ const innerName =
543
+ typeof innerObj.name === "string" && innerObj.name !== "Error"
544
+ ? `${innerObj.name}: `
545
+ : "";
546
+ return `${outerTag} → ${innerName}${innerObj.message}`;
547
+ }
548
+ // Inner is a nested tagged-enum — recurse.
549
+ if ("tag" in innerObj) {
550
+ return `${outerTag} → ${formatError(inner)}`;
551
+ }
552
+ }
553
+
554
+ // Inner is a primitive or absent — fall back to the outer tag alone.
555
+ if (inner !== undefined) {
556
+ return `${outerTag} (${String(inner)})`;
504
557
  }
505
- return String(error);
558
+ return outerTag;
506
559
  }
507
560
 
508
561
  if (import.meta.vitest) {
@@ -527,7 +580,7 @@ if (import.meta.vitest) {
527
580
  } as unknown as import("polkadot-api").PolkadotSigner;
528
581
 
529
582
  return {
530
- getNonProductAccounts: vi.fn().mockReturnValue({
583
+ getLegacyAccounts: vi.fn().mockReturnValue({
531
584
  match: async (
532
585
  onOk: (v: RawAccountTest[]) => unknown,
533
586
  onErr: (e: unknown) => unknown,
@@ -538,7 +591,7 @@ if (import.meta.vitest) {
538
591
  return onOk(accounts);
539
592
  },
540
593
  }),
541
- getNonProductAccountSigner: vi.fn().mockReturnValue(mockSigner),
594
+ getLegacyAccountSigner: vi.fn().mockReturnValue(mockSigner),
542
595
  getProductAccount: vi.fn().mockReturnValue({
543
596
  match: async (
544
597
  onOk: (v: RawAccountTest) => unknown,
@@ -622,7 +675,7 @@ if (import.meta.vitest) {
622
675
  }
623
676
  });
624
677
 
625
- test("returns HOST_REJECTED when getNonProductAccounts fails", async () => {
678
+ test("returns HOST_REJECTED when getLegacyAccounts fails", async () => {
626
679
  const mockProvider = createMockProvider({ shouldReject: true, error: "Rejected" });
627
680
  const provider = new HostProvider({
628
681
  maxRetries: 1,
@@ -691,4 +744,245 @@ if (import.meta.vitest) {
691
744
  unsub();
692
745
  });
693
746
  });
747
+
748
+ describe("ChainSubmit permission request", () => {
749
+ test("sends a v1 ChainSubmit request (regression guard for the TransactionSubmit bug)", async () => {
750
+ const captured: unknown[] = [];
751
+ const hostApi: HostApiPermissionBridge = {
752
+ permission: (request) => {
753
+ captured.push(request);
754
+ return fakeResult(undefined);
755
+ },
756
+ };
757
+ const mockProvider = createMockProvider({
758
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
759
+ });
760
+ const provider = new HostProvider({
761
+ maxRetries: 1,
762
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
763
+ loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
764
+ });
765
+
766
+ await provider.connect();
767
+
768
+ expect(captured).toHaveLength(1);
769
+ // The fake hostApiEnum returns `{ version, value }` so we can
770
+ // assert on the exact wire shape that would reach
771
+ // host-api's RemotePermission codec.
772
+ expect(captured[0]).toEqual({
773
+ version: "v1",
774
+ value: { tag: "ChainSubmit", value: undefined },
775
+ });
776
+ });
777
+
778
+ test("does NOT send a TransactionSubmit tag (the bug)", async () => {
779
+ const captured: unknown[] = [];
780
+ const hostApi: HostApiPermissionBridge = {
781
+ permission: (request) => {
782
+ captured.push(request);
783
+ return fakeResult(undefined);
784
+ },
785
+ };
786
+ const mockProvider = createMockProvider({
787
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
788
+ });
789
+ const provider = new HostProvider({
790
+ maxRetries: 1,
791
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
792
+ loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
793
+ });
794
+
795
+ await provider.connect();
796
+
797
+ const sent = JSON.stringify(captured[0]);
798
+ expect(sent).not.toContain("TransactionSubmit");
799
+ });
800
+
801
+ test("skipped when sdk.hostApi is unavailable (older product-sdk)", async () => {
802
+ const mockProvider = createMockProvider({
803
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
804
+ });
805
+ const provider = new HostProvider({
806
+ maxRetries: 1,
807
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider /* no hostApi */)),
808
+ loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
809
+ });
810
+
811
+ const result = await provider.connect();
812
+ // Connect should succeed even without the hostApi bridge —
813
+ // permission is best-effort.
814
+ expect(result.ok).toBe(true);
815
+ });
816
+
817
+ test("skipped when requestChainSubmitPermission is false", async () => {
818
+ const captured: unknown[] = [];
819
+ const hostApi: HostApiPermissionBridge = {
820
+ permission: (request) => {
821
+ captured.push(request);
822
+ return fakeResult(undefined);
823
+ },
824
+ };
825
+ const mockProvider = createMockProvider({
826
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
827
+ });
828
+ const provider = new HostProvider({
829
+ maxRetries: 1,
830
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
831
+ loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
832
+ requestChainSubmitPermission: false,
833
+ });
834
+
835
+ await provider.connect();
836
+ expect(captured).toHaveLength(0);
837
+ });
838
+
839
+ test("deprecated requestTransactionSubmitPermission alias still controls the request", async () => {
840
+ const captured: unknown[] = [];
841
+ const hostApi: HostApiPermissionBridge = {
842
+ permission: (request) => {
843
+ captured.push(request);
844
+ return fakeResult(undefined);
845
+ },
846
+ };
847
+ const mockProvider = createMockProvider({
848
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
849
+ });
850
+ const provider = new HostProvider({
851
+ maxRetries: 1,
852
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
853
+ loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
854
+ // Old name; new code path should still respect it as `false`.
855
+ requestTransactionSubmitPermission: false,
856
+ });
857
+
858
+ await provider.connect();
859
+ expect(captured).toHaveLength(0);
860
+ });
861
+
862
+ test("connect succeeds even when permission request rejects", async () => {
863
+ // Whatever the host says about permission, connect() should
864
+ // still return ok — the consumer can sign later with whatever
865
+ // permission they negotiate.
866
+ const hostApi: HostApiPermissionBridge = {
867
+ permission: () => fakeResult(undefined, { tag: "PermissionDenied" }),
868
+ };
869
+ const mockProvider = createMockProvider({
870
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
871
+ });
872
+ const provider = new HostProvider({
873
+ maxRetries: 1,
874
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
875
+ loadHostApiEnum: () => Promise.resolve(fakeHostApiEnum),
876
+ });
877
+
878
+ const result = await provider.connect();
879
+ expect(result.ok).toBe(true);
880
+ });
881
+
882
+ test("connect succeeds even when the hostApiEnum loader throws (codec drift)", async () => {
883
+ // The original bug: the v1 RemotePermission codec didn't
884
+ // recognize the TransactionSubmit tag and threw client-side.
885
+ // Even when something like that happens, connect() must
886
+ // remain ok — permission is best-effort.
887
+ const hostApi: HostApiPermissionBridge = {
888
+ permission: () => fakeResult(undefined),
889
+ };
890
+ const mockProvider = createMockProvider({
891
+ accounts: [{ publicKey: new Uint8Array(32).fill(0x01) }],
892
+ });
893
+ const provider = new HostProvider({
894
+ maxRetries: 1,
895
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider, { hostApi })),
896
+ loadHostApiEnum: () => Promise.reject(new Error("codec drift")),
897
+ });
898
+
899
+ const result = await provider.connect();
900
+ expect(result.ok).toBe(true);
901
+ });
902
+ });
903
+
904
+ describe("formatError", () => {
905
+ // Direct unit tests for the helper. The previous implementation
906
+ // collapsed any tagged-enum error to its outer tag — losing the
907
+ // inner reason. The fix surfaces the inner Error-shape (name +
908
+ // message) and recurses through nested tagged enums.
909
+
910
+ test("returns a string for a primitive error", () => {
911
+ expect(formatError("Rejected")).toBe("Rejected");
912
+ expect(formatError(42)).toBe("42");
913
+ expect(formatError(null)).toBe("null");
914
+ expect(formatError(undefined)).toBe("undefined");
915
+ });
916
+
917
+ test("surfaces inner Error name + message under the outer tag", () => {
918
+ // Simulates the exact shape the original bug produced:
919
+ // `{ tag: "v1", value: { name: "GenericError", message: "..." } }`
920
+ const wrapped = {
921
+ tag: "v1",
922
+ value: {
923
+ name: "GenericError",
924
+ message: "Unknown error: inner[tag] is not a function",
925
+ },
926
+ };
927
+ const out = formatError(wrapped);
928
+ expect(out).toContain("v1");
929
+ expect(out).toContain("GenericError");
930
+ expect(out).toContain("inner[tag] is not a function");
931
+ });
932
+
933
+ test("strips the redundant 'Error' name when the inner is a plain Error", () => {
934
+ const wrapped = {
935
+ tag: "v1",
936
+ value: { name: "Error", message: "boom" },
937
+ };
938
+ expect(formatError(wrapped)).toBe("v1 → boom");
939
+ });
940
+
941
+ test("recurses through nested tagged-enum errors", () => {
942
+ const wrapped = {
943
+ tag: "v1",
944
+ value: { tag: "Inner", value: { name: "NestedErr", message: "deep" } },
945
+ };
946
+ expect(formatError(wrapped)).toContain("v1");
947
+ expect(formatError(wrapped)).toContain("Inner");
948
+ expect(formatError(wrapped)).toContain("NestedErr");
949
+ expect(formatError(wrapped)).toContain("deep");
950
+ });
951
+
952
+ test("returns just the outer tag when value is undefined", () => {
953
+ expect(formatError({ tag: "PermissionDenied" })).toBe("PermissionDenied");
954
+ });
955
+
956
+ test("formats a primitive inner value alongside the tag", () => {
957
+ expect(formatError({ tag: "v1", value: "code-42" })).toBe("v1 (code-42)");
958
+ });
959
+ });
960
+
961
+ describe("RemotePermission codec interop", () => {
962
+ // Smoke test that the wire payload we build (`ChainSubmit`) round-trips
963
+ // through the real host-api codec. The previous bug shipped
964
+ // `TransactionSubmit`, which the codec rejects — locking this in here
965
+ // catches a regression at the codec layer without needing the host.
966
+ test("encodes ChainSubmit payload without throwing", async () => {
967
+ const { RemotePermission } = await import("@novasamatech/host-api");
968
+ const payload = { tag: "ChainSubmit" as const, value: undefined };
969
+ const encoded = RemotePermission.enc(payload);
970
+ expect(encoded).toBeInstanceOf(Uint8Array);
971
+ const decoded = RemotePermission.dec(encoded);
972
+ expect(decoded.tag).toBe("ChainSubmit");
973
+ });
974
+
975
+ test("rejects the legacy TransactionSubmit tag", async () => {
976
+ const { RemotePermission } = await import("@novasamatech/host-api");
977
+ // `TransactionSubmit` is not a valid variant in v1 — the codec
978
+ // should refuse to encode it. This proves the codec actually
979
+ // validates tags (so test 1 isn't a tautology).
980
+ expect(() =>
981
+ RemotePermission.enc({
982
+ tag: "TransactionSubmit",
983
+ value: undefined,
984
+ } as never),
985
+ ).toThrow();
986
+ });
987
+ });
694
988
  }