@parity/product-sdk-signer 0.4.0 → 0.6.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-signer",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Signer manager for Polkadot — Host API and dev accounts",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -18,18 +18,18 @@
18
18
  "src"
19
19
  ],
20
20
  "dependencies": {
21
- "polkadot-api": "^2.1.2",
21
+ "polkadot-api": "^2.1.5",
22
+ "@parity/product-sdk-host": "0.6.1",
23
+ "@parity/product-sdk-keys": "0.3.3",
22
24
  "@parity/product-sdk-address": "0.1.1",
23
- "@parity/product-sdk-host": "0.5.0",
24
- "@parity/product-sdk-keys": "0.3.1",
25
25
  "@parity/product-sdk-logger": "0.1.1"
26
26
  },
27
27
  "optionalDependencies": {
28
- "@novasamatech/host-api-wrapper": "^0.7.9",
29
- "@novasamatech/host-api": "^0.7.9"
28
+ "@novasamatech/host-api-wrapper": "^0.8.4",
29
+ "@novasamatech/host-api": "^0.8.4"
30
30
  },
31
31
  "devDependencies": {
32
- "tsup": "^8.4.0",
32
+ "tsup": "^8.5.1",
33
33
  "typescript": "^5.9.3",
34
34
  "vitest": "^3.1.4"
35
35
  },
package/src/errors.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import type { ProviderType } from "./types.js";
2
4
 
3
5
  /** Base class for all signer errors. Use `instanceof SignerError` to catch any signer-related error. */
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * @parity/product-sdk-signer — Account connection and signing, decoupled from where the keys actually live.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import { seedToAccount } from "@parity/product-sdk-keys";
2
4
  import { createLogger } from "@parity/product-sdk-logger";
3
5
 
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import { deriveH160, ss58Encode } from "@parity/product-sdk-address";
2
4
  import { createLogger } from "@parity/product-sdk-logger";
3
5
 
@@ -48,6 +50,21 @@ export interface HostProviderOptions {
48
50
  requestChainSubmitPermission?: boolean;
49
51
  /** @deprecated Renamed to `requestChainSubmitPermission`. */
50
52
  requestTransactionSubmitPermission?: boolean;
53
+ /**
54
+ * If set, `connect()` returns a single product account for the given
55
+ * `dotNsIdentifier`, skipping the legacy fetch entirely. For apps
56
+ * that sign exclusively with a per-dapp derived account.
57
+ *
58
+ * `SignerAccount.name` is populated best-effort from
59
+ * `accounts.getUserId().primaryUsername`; failures leave it null.
60
+ * Signing is pinned to `createTransaction` (see PR #96).
61
+ */
62
+ productAccount?: {
63
+ /** App identifier (e.g., `"playground.dot"`). */
64
+ dotNsIdentifier: string;
65
+ /** Derivation index within the app scope. Default: 0. */
66
+ derivationIndex?: number;
67
+ };
51
68
  }
52
69
 
53
70
  /**
@@ -109,7 +126,7 @@ interface NeverthrowResultAsync<T, E> {
109
126
  * `"signPayload"` path wraps via PJS and throws
110
127
  * `"PJS does not support this signed-extension: AsPgas"` on those chains.
111
128
  *
112
- * Nova's `host-api-wrapper@0.7.9` already defaults to `"createTransaction"`,
129
+ * Nova's `host-api-wrapper@0.8.0` already defaults to `"createTransaction"`,
113
130
  * so this is a defensive pin rather than an opt-in — it guards against a
114
131
  * future upstream default flip and makes the routing legible at the call
115
132
  * site. The legacy-account signer doesn't expose this switch.
@@ -132,6 +149,7 @@ export interface AccountsProvider {
132
149
  dotNsIdentifier: string,
133
150
  derivationIndex?: number,
134
151
  ) => NeverthrowResultAsync<ContextualAlias, unknown>;
152
+ getUserId: () => NeverthrowResultAsync<{ primaryUsername: string }, unknown>;
135
153
  createRingVRFProof: (
136
154
  dotNsIdentifier: string,
137
155
  derivationIndex: number,
@@ -193,6 +211,7 @@ export class HostProvider implements SignerProvider {
193
211
  private readonly loadSdk: () => Promise<ProductSdkModule>;
194
212
  private readonly loadHostApiEnum: () => Promise<HostApiEnumHelper>;
195
213
  private readonly requestChainSubmitPermission: boolean;
214
+ private readonly productAccount: HostProviderOptions["productAccount"];
196
215
 
197
216
  private accountsProvider: AccountsProvider | null = null;
198
217
  private statusCleanup: (() => void) | null = null;
@@ -210,6 +229,7 @@ export class HostProvider implements SignerProvider {
210
229
  options?.requestChainSubmitPermission ??
211
230
  options?.requestTransactionSubmitPermission ??
212
231
  true;
232
+ this.productAccount = options?.productAccount;
213
233
  }
214
234
 
215
235
  async connect(signal?: AbortSignal): Promise<Result<SignerAccount[], SignerError>> {
@@ -438,27 +458,46 @@ export class HostProvider implements SignerProvider {
438
458
  const provider = sdk.createAccountsProvider();
439
459
  this.accountsProvider = provider;
440
460
 
441
- // Step 3: Fetch non-product accounts
442
- let rawAccounts: RawAccount[];
443
- try {
444
- rawAccounts = (await provider.getLegacyAccounts().match(
445
- (accounts) => accounts,
446
- (error) => {
447
- throw new Error(`Host rejected account request: ${formatError(error)}`);
448
- },
449
- )) as RawAccount[];
450
- } catch (cause) {
451
- log.error("failed to get accounts from host", { cause });
452
- return err(
453
- new HostRejectedError(
454
- cause instanceof Error ? cause.message : "Failed to get accounts from host",
455
- ),
461
+ // Step 3: Fetch accounts.
462
+ //
463
+ // When `productAccount` is configured, skip the legacy fetch entirely
464
+ // and return a single product account. Product-account-only apps
465
+ // (no wallet picker) often run against hosts that have no legacy
466
+ // accounts to surface — calling `getLegacyAccounts()` there returns
467
+ // an empty list and the connect would fail with `NoAccountsError`.
468
+ let signerAccounts: SignerAccount[];
469
+ if (this.productAccount) {
470
+ const accountResult = await this.fetchProductSignerAccount(
471
+ provider,
472
+ this.productAccount.dotNsIdentifier,
473
+ this.productAccount.derivationIndex ?? 0,
456
474
  );
457
- }
475
+ if (!accountResult.ok) return accountResult;
476
+ signerAccounts = [accountResult.value];
477
+ } else {
478
+ let rawAccounts: RawAccount[];
479
+ try {
480
+ rawAccounts = (await provider.getLegacyAccounts().match(
481
+ (accounts) => accounts,
482
+ (error) => {
483
+ throw new Error(`Host rejected account request: ${formatError(error)}`);
484
+ },
485
+ )) as RawAccount[];
486
+ } catch (cause) {
487
+ log.error("failed to get accounts from host", { cause });
488
+ return err(
489
+ new HostRejectedError(
490
+ cause instanceof Error ? cause.message : "Failed to get accounts from host",
491
+ ),
492
+ );
493
+ }
458
494
 
459
- if (rawAccounts.length === 0) {
460
- log.warn("host returned no accounts");
461
- return err(new NoAccountsError("host"));
495
+ if (rawAccounts.length === 0) {
496
+ log.warn("host returned no accounts");
497
+ return err(new NoAccountsError("host"));
498
+ }
499
+
500
+ signerAccounts = this.mapAccounts(rawAccounts);
462
501
  }
463
502
 
464
503
  // Step 4: Request ChainSubmit permission up-front.
@@ -476,10 +515,11 @@ export class HostProvider implements SignerProvider {
476
515
  // will surface a clear error if permission is missing.
477
516
  //
478
517
  // The legal v1 RemotePermission variants per
479
- // `@novasamatech/host-api@0.7.7` are: Remote, WebRTC, ChainSubmit,
518
+ // `@novasamatech/host-api@0.8.0` are: Remote, WebRtc, ChainSubmit,
480
519
  // PreimageSubmit, StatementSubmit. ChainSubmit is the chain-tx
481
520
  // permission (was named TransactionSubmit in earlier host-api
482
- // revisions; renamed in 0.7).
521
+ // revisions; renamed in 0.7). `WebRtc` was spelled `WebRTC` before
522
+ // 0.8.
483
523
  if (this.requestChainSubmitPermission && sdk.hostApi) {
484
524
  try {
485
525
  const hostApiEnum = await this.loadHostApiEnum();
@@ -502,9 +542,7 @@ export class HostProvider implements SignerProvider {
502
542
  }
503
543
  }
504
544
 
505
- // Step 5: Map to SignerAccount[]
506
- const accounts = this.mapAccounts(rawAccounts);
507
- log.info("host connected", { accounts: accounts.length });
545
+ log.info("host connected", { accounts: signerAccounts.length });
508
546
 
509
547
  // Step 6: Subscribe to connection status
510
548
  const sub = provider.subscribeAccountConnectionStatus((status) => {
@@ -516,7 +554,43 @@ export class HostProvider implements SignerProvider {
516
554
  });
517
555
  this.statusCleanup = typeof sub === "function" ? sub : () => sub.unsubscribe();
518
556
 
519
- return ok(accounts);
557
+ return ok(signerAccounts);
558
+ }
559
+
560
+ private async fetchProductSignerAccount(
561
+ provider: AccountsProvider,
562
+ dotNsIdentifier: string,
563
+ derivationIndex: number,
564
+ ): Promise<Result<SignerAccount, SignerError>> {
565
+ // Run the account fetch and the best-effort username fetch in
566
+ // parallel — they're independent host RPCs. `getUserId` failures
567
+ // (NotConnected, PermissionDenied, codec drift) resolve to `null`
568
+ // so they never abort connect; the account name falls back to
569
+ // whatever `getProductAccount` returned (typically also null,
570
+ // since product accounts are nameless on the host side).
571
+ const fetchUsername = async (): Promise<string | null> => {
572
+ try {
573
+ return await provider.getUserId().match(
574
+ (result) => result.primaryUsername,
575
+ (error) => {
576
+ log.debug("getUserId failed; product account name stays null", {
577
+ error: formatError(error),
578
+ });
579
+ return null as string | null;
580
+ },
581
+ );
582
+ } catch (cause) {
583
+ log.debug("getUserId threw; product account name stays null", { cause });
584
+ return null;
585
+ }
586
+ };
587
+ const [accountResult, primaryUsername] = await Promise.all([
588
+ this.getProductAccount(dotNsIdentifier, derivationIndex),
589
+ fetchUsername(),
590
+ ]);
591
+ if (!accountResult.ok) return accountResult;
592
+ const account = accountResult.value;
593
+ return ok({ ...account, name: account.name ?? primaryUsername });
520
594
  }
521
595
 
522
596
  private mapAccounts(rawAccounts: ReadonlyArray<RawAccount>): SignerAccount[] {
@@ -601,6 +675,7 @@ if (import.meta.vitest) {
601
675
  accounts?: RawAccountTest[];
602
676
  shouldReject?: boolean;
603
677
  error?: unknown;
678
+ primaryUsername?: string;
604
679
  } = {},
605
680
  ) {
606
681
  const accounts = options.accounts ?? [];
@@ -654,6 +729,17 @@ if (import.meta.vitest) {
654
729
  },
655
730
  }),
656
731
  subscribeAccountConnectionStatus: vi.fn().mockReturnValue(() => {}),
732
+ getUserId: vi.fn().mockReturnValue({
733
+ match: async (
734
+ onOk: (v: { primaryUsername: string }) => unknown,
735
+ onErr: (e: unknown) => unknown,
736
+ ) => {
737
+ if (shouldReject) {
738
+ return onErr(options.error ?? "Unknown");
739
+ }
740
+ return onOk({ primaryUsername: options.primaryUsername ?? "" });
741
+ },
742
+ }),
657
743
  };
658
744
  }
659
745
 
@@ -812,6 +898,93 @@ if (import.meta.vitest) {
812
898
  expect(typeof unsub).toBe("function");
813
899
  unsub();
814
900
  });
901
+
902
+ test("productAccount option returns the derived account, populates name via getUserId, and skips the legacy fetch", async () => {
903
+ const productPubkey = new Uint8Array(32).fill(0xcc);
904
+ const mockProvider = createMockProvider({
905
+ accounts: [{ publicKey: productPubkey, name: undefined }],
906
+ primaryUsername: "alice",
907
+ });
908
+ const provider = new HostProvider({
909
+ maxRetries: 1,
910
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
911
+ productAccount: { dotNsIdentifier: "myapp.dot", derivationIndex: 0 },
912
+ });
913
+ const result = await provider.connect();
914
+
915
+ expect(result.ok).toBe(true);
916
+ if (result.ok) {
917
+ expect(result.value).toHaveLength(1);
918
+ expect(result.value[0].publicKey).toEqual(productPubkey);
919
+ expect(result.value[0].source).toBe("host");
920
+ expect(result.value[0].name).toBe("alice");
921
+ result.value[0].getSigner();
922
+ expect(mockProvider.getProductAccountSigner).toHaveBeenLastCalledWith(
923
+ expect.objectContaining({
924
+ dotNsIdentifier: "myapp.dot",
925
+ derivationIndex: 0,
926
+ }),
927
+ "createTransaction",
928
+ );
929
+ }
930
+ expect(mockProvider.getProductAccount).toHaveBeenCalledWith("myapp.dot", 0);
931
+ expect(mockProvider.getUserId).toHaveBeenCalled();
932
+ expect(mockProvider.getLegacyAccounts).not.toHaveBeenCalled();
933
+ });
934
+
935
+ test("productAccount option survives getUserId failure (name stays null, connect still succeeds)", async () => {
936
+ const productPubkey = new Uint8Array(32).fill(0xee);
937
+ const mockProvider = createMockProvider({
938
+ accounts: [{ publicKey: productPubkey, name: undefined }],
939
+ });
940
+ // Force getUserId to reject — connect must still succeed with name=null.
941
+ mockProvider.getUserId.mockReturnValue({
942
+ match: async (
943
+ _onOk: (v: { primaryUsername: string }) => unknown,
944
+ onErr: (e: unknown) => unknown,
945
+ ) => onErr({ tag: "v1", value: { tag: "GetUserIdErr::PermissionDenied" } }),
946
+ });
947
+ const provider = new HostProvider({
948
+ maxRetries: 1,
949
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
950
+ productAccount: { dotNsIdentifier: "myapp.dot" },
951
+ });
952
+ const result = await provider.connect();
953
+
954
+ expect(result.ok).toBe(true);
955
+ if (result.ok) {
956
+ expect(result.value[0].name).toBeNull();
957
+ }
958
+ });
959
+
960
+ test("productAccount option succeeds when host has no legacy accounts (regression: signer 0.5.0 NoAccountsError)", async () => {
961
+ // Without the option, this scenario returned `err(NoAccountsError)`
962
+ // before any product-account fetch could happen — breaking every
963
+ // product-only app whose host doesn't surface legacy accounts.
964
+ const productPubkey = new Uint8Array(32).fill(0xdd);
965
+ const mockProvider = createMockProvider({
966
+ accounts: [{ publicKey: productPubkey, name: undefined }],
967
+ });
968
+ // Force the legacy path to look empty if it were ever consulted.
969
+ mockProvider.getLegacyAccounts.mockReturnValue({
970
+ match: async (
971
+ onOk: (v: RawAccountTest[]) => unknown,
972
+ _onErr: (e: unknown) => unknown,
973
+ ) => onOk([]),
974
+ });
975
+ const provider = new HostProvider({
976
+ maxRetries: 1,
977
+ loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
978
+ productAccount: { dotNsIdentifier: "playground.dot" },
979
+ });
980
+ const result = await provider.connect();
981
+
982
+ expect(result.ok).toBe(true);
983
+ if (result.ok) {
984
+ expect(result.value).toHaveLength(1);
985
+ expect(result.value[0].publicKey).toEqual(productPubkey);
986
+ }
987
+ });
815
988
  });
816
989
 
817
990
  describe("ChainSubmit permission request", () => {
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  // Provider interface
2
4
  export type { SignerProvider, Unsubscribe } from "./types.js";
3
5
 
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import type { SignerError } from "../errors.js";
2
4
  import type { ConnectionStatus, ProviderType, Result, SignerAccount } from "../types.js";
3
5
 
package/src/retry.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * Result-based retry with exponential backoff for signer connection attempts.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import type { PolkadotSigner } from "polkadot-api";
2
4
 
3
5
  import { createLogger } from "@parity/product-sdk-logger";
package/src/sleep.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * Sleep for a given duration, cancellable via AbortSignal.
3
5
  *
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import type { PolkadotSigner } from "polkadot-api";
2
4
 
3
5
  import type { SS58String } from "@parity/product-sdk-address";