@parity/product-sdk-signer 0.7.0 → 0.8.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.7.0",
3
+ "version": "0.8.1",
4
4
  "description": "Signer manager for Polkadot — Host API and dev accounts",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -20,13 +20,13 @@
20
20
  "dependencies": {
21
21
  "polkadot-api": "^2.1.5",
22
22
  "@parity/product-sdk-address": "0.1.1",
23
- "@parity/product-sdk-host": "0.10.0",
24
- "@parity/product-sdk-keys": "0.3.8",
25
- "@parity/product-sdk-logger": "0.1.1"
23
+ "@parity/product-sdk-keys": "0.3.10",
24
+ "@parity/product-sdk-logger": "0.1.1",
25
+ "@parity/product-sdk-host": "0.10.2"
26
26
  },
27
27
  "optionalDependencies": {
28
- "@novasamatech/host-api-wrapper": "^0.8.7",
29
- "@novasamatech/host-api": "^0.8.7"
28
+ "@novasamatech/host-api-wrapper": "^0.8.9",
29
+ "@novasamatech/host-api": "^0.8.9"
30
30
  },
31
31
  "devDependencies": {
32
32
  "tsup": "^8.5.1",
@@ -3,12 +3,7 @@
3
3
  import { deriveH160, ss58Encode } from "@parity/product-sdk-address";
4
4
  import { createLogger } from "@parity/product-sdk-logger";
5
5
 
6
- import {
7
- HostRejectedError,
8
- HostUnavailableError,
9
- NoAccountsError,
10
- type SignerError,
11
- } from "../errors.js";
6
+ import { HostRejectedError, HostUnavailableError, type SignerError } from "../errors.js";
12
7
  import { withRetry } from "../retry.js";
13
8
  import type { ConnectionStatus, ProviderType, Result, SignerAccount } from "../types.js";
14
9
  import { err, ok } from "../types.js";
@@ -24,6 +19,22 @@ export interface HostProviderOptions {
24
19
  maxRetries?: number;
25
20
  /** Initial retry delay in ms. Default: 500 */
26
21
  retryDelay?: number;
22
+ /**
23
+ * Dapp identifier the SDK falls back to when {@link productAccount} is
24
+ * not set, so `connect()` can still surface a usable account on hosts
25
+ * that don't enumerate legacy accounts.
26
+ *
27
+ * The value is treated as a dotNS identifier (`.dot` is appended if
28
+ * missing) and routed through `getProductAccount(dappName, 0)`. If the
29
+ * host rejects the derivation (e.g. the identifier isn't registered),
30
+ * `connect()` resolves with an empty accounts list rather than
31
+ * throwing — consumers can still drive the explicit signing paths
32
+ * (`signMessageWithDotNsIdentity`, `getLegacyAccountSigner`).
33
+ *
34
+ * Wired through from `SignerManager` automatically; only set directly
35
+ * when instantiating `HostProvider` outside the manager.
36
+ */
37
+ dappName?: string;
27
38
  /**
28
39
  * Custom SDK loader. Defaults to `import("@novasamatech/host-api-wrapper")`.
29
40
  * Override this for testing or custom SDK setups.
@@ -147,7 +158,6 @@ const PRODUCT_SIGNER_TYPE = "createTransaction" as const;
147
158
 
148
159
  /** @internal */
149
160
  export interface AccountsProvider {
150
- getLegacyAccounts: () => NeverthrowResultAsync<RawAccount[], unknown>;
151
161
  getLegacyAccountSigner: (account: ProductAccount) => import("polkadot-api").PolkadotSigner;
152
162
  getProductAccount: (
153
163
  dotNsIdentifier: string,
@@ -237,6 +247,7 @@ export class HostProvider implements SignerProvider {
237
247
  private readonly loadHostApiEnum: () => Promise<HostApiEnumHelper>;
238
248
  private readonly requestChainSubmitPermission: boolean;
239
249
  private readonly productAccount: HostProviderOptions["productAccount"];
250
+ private readonly dappName: string | undefined;
240
251
 
241
252
  private accountsProvider: AccountsProvider | null = null;
242
253
  private statusCleanup: (() => void) | null = null;
@@ -255,6 +266,7 @@ export class HostProvider implements SignerProvider {
255
266
  options?.requestTransactionSubmitPermission ??
256
267
  true;
257
268
  this.productAccount = options?.productAccount;
269
+ this.dappName = options?.dappName;
258
270
  }
259
271
 
260
272
  async connect(signal?: AbortSignal): Promise<Result<SignerAccount[], SignerError>> {
@@ -552,11 +564,23 @@ export class HostProvider implements SignerProvider {
552
564
 
553
565
  // Step 4: Fetch accounts.
554
566
  //
555
- // When `productAccount` is configured, skip the legacy fetch entirely
556
- // and return a single product account. Product-account-only apps
557
- // (no wallet picker) often run against hosts that have no legacy
558
- // accounts to surface — calling `getLegacyAccounts()` there returns
559
- // an empty list and the connect would fail with `NoAccountsError`.
567
+ // Both branches end in `fetchProductSignerAccount`. The difference
568
+ // is only where the dotNS identifier comes from:
569
+ // - explicit `productAccount` option (caller-supplied), OR
570
+ // - implicit derivation from `dappName` (the SDK-managed default
571
+ // for hosts that don't enumerate accounts, e.g. Polkadot Desktop).
572
+ //
573
+ // On hosts that don't enumerate accounts (PoP / product-account
574
+ // hosts), `getLegacyAccounts()` returns `[]` by design — the host
575
+ // exposes only per-dapp product accounts and never the user's
576
+ // identity account. The implicit derivation path matches that
577
+ // contract: derive the per-dapp account using the consumer's
578
+ // `dappName` as the identifier, surface it on `connect()`. When
579
+ // the host rejects the derivation (typically because the dapp's
580
+ // dotNS identifier isn't registered for this user), we resolve
581
+ // with an empty accounts list rather than throwing so consumers
582
+ // can still drive the explicit-name signing paths
583
+ // (`signMessageWithDotNsIdentity`, `getLegacyAccountSigner`).
560
584
  let signerAccounts: SignerAccount[];
561
585
  if (this.productAccount) {
562
586
  const accountResult = await this.fetchProductSignerAccount(
@@ -567,48 +591,44 @@ export class HostProvider implements SignerProvider {
567
591
  );
568
592
  if (!accountResult.ok) return accountResult;
569
593
  signerAccounts = [accountResult.value];
570
- } else {
571
- let rawAccounts: RawAccount[];
572
- try {
573
- rawAccounts = (await provider.getLegacyAccounts().match(
574
- (accounts) => accounts,
575
- (error) => {
576
- throw new Error(`Host rejected account request: ${formatError(error)}`);
594
+ } else if (this.dappName) {
595
+ // `.dot` is appended if missing so `"my-app"` and `"my-app.dot"`
596
+ // resolve to the same identifier on the host side.
597
+ const dotNsIdentifier = this.dappName.endsWith(".dot")
598
+ ? this.dappName
599
+ : `${this.dappName}.dot`;
600
+ const accountResult = await this.fetchProductSignerAccount(
601
+ provider,
602
+ dotNsIdentifier,
603
+ 0,
604
+ true,
605
+ );
606
+ if (!accountResult.ok) {
607
+ // Soft-degrade: host couldn't derive a product account for
608
+ // this dappName (most commonly because the identifier isn't
609
+ // registered for this user). Returning [] lets `connect()`
610
+ // resolve successfully — consumers handle the empty list
611
+ // and drive explicit signing paths.
612
+ log.warn(
613
+ "host could not derive a product account for dappName; resolving with empty accounts",
614
+ {
615
+ dotNsIdentifier,
616
+ error: accountResult.error.message,
577
617
  },
578
- )) as RawAccount[];
579
- } catch (cause) {
580
- // Safety net: upstream `host-api/transport.js` throws
581
- // `Error('Environment is not correct')` synchronously inside
582
- // `getLegacyAccounts()` when the env check fails. The Step 2
583
- // pre-check catches this normally, but we also re-classify
584
- // here for older wrappers that don't expose `sandboxTransport`
585
- // and for races where the env flips after the pre-check.
586
- // Without this, the user sees a misleading "Host rejected
587
- // account request:" prefix for an error nothing rejected.
588
- if (cause instanceof Error && /environment is not correct/i.test(cause.message)) {
589
- log.warn("not inside a host container (detected at getLegacyAccounts)");
590
- return err(
591
- new HostUnavailableError(
592
- "Host API is not available: not running inside a Polkadot host container. " +
593
- "Open this app inside Polkadot Desktop or the Polkadot Mobile WebView, " +
594
- "or pick a non-host signer provider (e.g. dev accounts).",
595
- ),
596
- );
597
- }
598
- log.error("failed to get accounts from host", { cause });
599
- return err(
600
- new HostRejectedError(
601
- cause instanceof Error ? cause.message : "Failed to get accounts from host",
602
- ),
603
618
  );
619
+ signerAccounts = [];
620
+ } else {
621
+ signerAccounts = [accountResult.value];
604
622
  }
605
-
606
- if (rawAccounts.length === 0) {
607
- log.warn("host returned no accounts");
608
- return err(new NoAccountsError("host"));
609
- }
610
-
611
- signerAccounts = this.mapAccounts(rawAccounts);
623
+ } else {
624
+ // No `productAccount`, no `dappName` — caller asked the SDK to
625
+ // pick accounts with no hints. We can't, so resolve with an
626
+ // empty list. Consumers driving explicit-name signing still
627
+ // work; consumers expecting enumeration get a clear `[]`.
628
+ log.warn(
629
+ "no productAccount or dappName configured; resolving connect() with empty accounts",
630
+ );
631
+ signerAccounts = [];
612
632
  }
613
633
 
614
634
  // Step 5: Request ChainSubmit permission up-front.
@@ -708,30 +728,6 @@ export class HostProvider implements SignerProvider {
708
728
  const account = accountResult.value;
709
729
  return ok({ ...account, name: account.name ?? primaryUsername });
710
730
  }
711
-
712
- private mapAccounts(rawAccounts: ReadonlyArray<RawAccount>): SignerAccount[] {
713
- return rawAccounts.map((raw) => {
714
- const address = ss58Encode(raw.publicKey, this.ss58Prefix);
715
- const h160Address = deriveH160(raw.publicKey);
716
- return {
717
- address,
718
- h160Address,
719
- publicKey: raw.publicKey,
720
- name: raw.name ?? null,
721
- source: "host" as const,
722
- getSigner: () => {
723
- if (!this.accountsProvider) {
724
- throw new Error("Host provider is disconnected");
725
- }
726
- return this.accountsProvider.getLegacyAccountSigner({
727
- dotNsIdentifier: "",
728
- derivationIndex: 0,
729
- publicKey: raw.publicKey,
730
- });
731
- },
732
- };
733
- });
734
- }
735
731
  }
736
732
 
737
733
  /**
@@ -948,92 +944,73 @@ if (import.meta.vitest) {
948
944
  expect(mockProvider.getLegacyAccounts).not.toHaveBeenCalled();
949
945
  });
950
946
 
951
- test("safety net: re-classifies upstream 'Environment is not correct' as HOST_UNAVAILABLE", async () => {
952
- // For older wrappers (or test mocks) that don't supply
953
- // `sandboxTransport`, the Step 2 pre-check is skipped and the
954
- // upstream throw surfaces at `getLegacyAccounts()`. The catch
955
- // in Step 4 must re-classify it rather than wrapping with the
956
- // misleading "Host rejected account request:" prefix.
947
+ test("connect with dappName fallback derives a product account", async () => {
948
+ // Without an explicit `productAccount`, the SDK derives the
949
+ // dotNS identifier from `dappName` (with `.dot` appended if
950
+ // missing). This is the path hosts that don't enumerate
951
+ // accounts (PoP / Polkadot Desktop) take by default.
952
+ const productPubkey = new Uint8Array(32).fill(0x42);
957
953
  const mockProvider = createMockProvider({
958
- shouldReject: true,
959
- error: "Environment is not correct",
954
+ accounts: [{ publicKey: productPubkey, name: undefined }],
955
+ primaryUsername: "alice",
960
956
  });
961
957
  const provider = new HostProvider({
962
958
  maxRetries: 1,
963
- // sandboxTransport intentionally omitted — exercises the
964
- // safety-net path, not the pre-check path.
959
+ dappName: "my-cli",
965
960
  loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
966
961
  });
967
962
  const result = await provider.connect();
968
963
 
969
- expect(result.ok).toBe(false);
970
- if (!result.ok) {
971
- expect(result.error).toBeInstanceOf(HostUnavailableError);
972
- // Must NOT contain the misleading "Host rejected account request:" prefix.
973
- expect(result.error.message).not.toMatch(/Host rejected/i);
974
- expect(result.error.message).toMatch(
975
- /not running inside a Polkadot host container/i,
976
- );
977
- }
978
- });
979
-
980
- test("connect proceeds when sandboxTransport reports a correct environment", async () => {
981
- // Mirror of the existing happy path, but with an explicit
982
- // `isCorrectEnvironment: true` to prove the pre-check doesn't
983
- // false-fail when the env IS correct.
984
- const rawAccounts: RawAccountTest[] = [
985
- { publicKey: new Uint8Array(32).fill(0x42), name: "Alice" },
986
- ];
987
- const mockProvider = createMockProvider({ accounts: rawAccounts });
988
- const provider = new HostProvider({
989
- maxRetries: 1,
990
- loadSdk: () =>
991
- Promise.resolve(createMockSdk(mockProvider, { isCorrectEnvironment: true })),
992
- });
993
- const result = await provider.connect();
994
-
995
964
  expect(result.ok).toBe(true);
996
965
  if (result.ok) {
997
966
  expect(result.value).toHaveLength(1);
998
- expect(result.value[0].address).toMatch(/^5/);
967
+ expect(result.value[0].publicKey).toEqual(productPubkey);
968
+ expect(result.value[0].source).toBe("host");
999
969
  }
1000
- expect(mockProvider.getLegacyAccounts).toHaveBeenCalled();
970
+ // `.dot` appended automatically.
971
+ expect(mockProvider.getProductAccount).toHaveBeenCalledWith("my-cli.dot", 0);
1001
972
  });
1002
973
 
1003
- test("returns HOST_REJECTED when getLegacyAccounts fails", async () => {
1004
- const mockProvider = createMockProvider({ shouldReject: true, error: "Rejected" });
974
+ test("connect with dappName already ending in .dot doesn't double-append", async () => {
975
+ const productPubkey = new Uint8Array(32).fill(0x77);
976
+ const mockProvider = createMockProvider({
977
+ accounts: [{ publicKey: productPubkey, name: undefined }],
978
+ });
1005
979
  const provider = new HostProvider({
1006
980
  maxRetries: 1,
981
+ dappName: "my-cli.dot",
1007
982
  loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1008
983
  });
1009
984
  const result = await provider.connect();
1010
985
 
1011
- expect(result.ok).toBe(false);
1012
- if (!result.ok) {
1013
- expect(result.error).toBeInstanceOf(HostRejectedError);
1014
- }
986
+ expect(result.ok).toBe(true);
987
+ expect(mockProvider.getProductAccount).toHaveBeenCalledWith("my-cli.dot", 0);
1015
988
  });
1016
989
 
1017
- test("returns NO_ACCOUNTS when host returns empty list", async () => {
1018
- const mockProvider = createMockProvider({ accounts: [] });
990
+ test("connect with dappName fallback resolves to [] when host rejects derivation", async () => {
991
+ // Soft-degrade: when the host can't derive a product account
992
+ // for the dappName (commonly because the dotNS identifier isn't
993
+ // registered for this user), connect() returns ok([]) rather
994
+ // than throwing. Consumers handle the empty list and drive
995
+ // explicit signing paths.
996
+ const mockProvider = createMockProvider({ shouldReject: true, error: "Rejected" });
1019
997
  const provider = new HostProvider({
1020
998
  maxRetries: 1,
999
+ dappName: "not-registered",
1021
1000
  loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
1022
1001
  });
1023
1002
  const result = await provider.connect();
1024
1003
 
1025
- expect(result.ok).toBe(false);
1026
- if (!result.ok) {
1027
- expect(result.error).toBeInstanceOf(NoAccountsError);
1004
+ expect(result.ok).toBe(true);
1005
+ if (result.ok) {
1006
+ expect(result.value).toEqual([]);
1028
1007
  }
1029
1008
  });
1030
1009
 
1031
- test("maps accounts correctly on success", async () => {
1032
- const rawAccounts: RawAccountTest[] = [
1033
- { publicKey: new Uint8Array(32).fill(0xaa), name: "Alice" },
1034
- { publicKey: new Uint8Array(32).fill(0xbb), name: undefined },
1035
- ];
1036
- const mockProvider = createMockProvider({ accounts: rawAccounts });
1010
+ test("connect resolves to [] when neither productAccount nor dappName is set", async () => {
1011
+ // Caller asked us to pick accounts with no hints. We can't, so
1012
+ // resolve with an empty list rather than throwing.
1013
+ const mockProvider = createMockProvider({});
1037
1014
  const provider = new HostProvider({
1038
1015
  maxRetries: 1,
1039
1016
  loadSdk: () => Promise.resolve(createMockSdk(mockProvider)),
@@ -1042,11 +1019,7 @@ if (import.meta.vitest) {
1042
1019
 
1043
1020
  expect(result.ok).toBe(true);
1044
1021
  if (result.ok) {
1045
- expect(result.value).toHaveLength(2);
1046
- expect(result.value[0].name).toBe("Alice");
1047
- expect(result.value[0].source).toBe("host");
1048
- expect(result.value[0].publicKey).toEqual(rawAccounts[0].publicKey);
1049
- expect(result.value[1].name).toBeNull();
1022
+ expect(result.value).toEqual([]);
1050
1023
  }
1051
1024
  });
1052
1025
 
@@ -337,6 +337,26 @@ export class SignerManager {
337
337
 
338
338
  // ── Host-only: Product Account API ─────────────────────────────
339
339
 
340
+ /**
341
+ * Fetch the connected user's host identity.
342
+ *
343
+ * This uses the Host API identity permission path and is only available
344
+ * when connected through the host provider. The primary username can be
345
+ * used by higher-level helpers that need to bind an action to the user's
346
+ * DotNS / people username.
347
+ */
348
+ async getUserId(): Promise<Result<{ primaryUsername: string }, SignerError>> {
349
+ if (this.isDestroyed) return err(new DestroyedError());
350
+
351
+ const host = this.getHostProvider();
352
+ if (!host) {
353
+ return err(
354
+ new HostUnavailableError("User identity requires a host provider connection"),
355
+ );
356
+ }
357
+ return host.getUserId();
358
+ }
359
+
340
360
  /**
341
361
  * Get an app-scoped product account from the host.
342
362
  *
@@ -504,6 +524,7 @@ export class SignerManager {
504
524
  ss58Prefix: this.ss58Prefix,
505
525
  maxRetries: this.maxRetries,
506
526
  retryDelay: 500,
527
+ dappName: this.dappName,
507
528
  });
508
529
  case "dev":
509
530
  return new DevProvider({
@@ -889,4 +910,70 @@ if (import.meta.vitest) {
889
910
  manager.destroy();
890
911
  });
891
912
  });
913
+
914
+ describe("SignerManager.getUserId", () => {
915
+ test("returns HostUnavailableError when not connected via host (dev provider)", async () => {
916
+ const manager = new SignerManager({
917
+ createProvider: () => mockProvider(),
918
+ });
919
+ await manager.connect("dev");
920
+ await flush();
921
+
922
+ // Dev provider is active, not host — getUserId() should refuse
923
+ // rather than attempt a host RPC.
924
+ const result = await manager.getUserId();
925
+ expect(result.ok).toBe(false);
926
+ if (!result.ok) {
927
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
928
+ expect(result.error.message).toMatch(/host provider/i);
929
+ }
930
+ manager.destroy();
931
+ });
932
+
933
+ test("returns DestroyedError after destroy()", async () => {
934
+ const manager = new SignerManager({
935
+ createProvider: () => mockProvider(),
936
+ });
937
+ await manager.connect("dev");
938
+ await flush();
939
+ manager.destroy();
940
+
941
+ const result = await manager.getUserId();
942
+ expect(result.ok).toBe(false);
943
+ if (!result.ok) {
944
+ expect(result.error).toBeInstanceOf(DestroyedError);
945
+ }
946
+ });
947
+
948
+ test("delegates to HostProvider.getUserId when connected via host", async () => {
949
+ // Build a host-shaped fake provider that satisfies the SignerManager's
950
+ // `activeProvider === "host"` check and exposes a getUserId that
951
+ // resolves to a known username.
952
+ const fakeHostProvider = {
953
+ type: "host" as const,
954
+ connect: vi.fn().mockResolvedValue(ok([mockAccount()])),
955
+ disconnect: vi.fn(),
956
+ onStatusChange: vi.fn().mockReturnValue(() => {}),
957
+ onAccountsChange: vi.fn().mockReturnValue(() => {}),
958
+ getUserId: vi.fn().mockResolvedValue(ok({ primaryUsername: "alice.dot" })),
959
+ };
960
+
961
+ const manager = new SignerManager({
962
+ createProvider: (type) =>
963
+ type === "host"
964
+ ? (fakeHostProvider as unknown as SignerProvider)
965
+ : mockProvider(),
966
+ });
967
+ await manager.connect("host");
968
+ await flush();
969
+
970
+ const result = await manager.getUserId();
971
+ expect(result.ok).toBe(true);
972
+ if (result.ok) {
973
+ expect(result.value.primaryUsername).toBe("alice.dot");
974
+ }
975
+ expect(fakeHostProvider.getUserId).toHaveBeenCalledTimes(1);
976
+ manager.destroy();
977
+ });
978
+ });
892
979
  }