@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/dist/index.d.ts +20 -0
- package/dist/index.js +59 -21
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
- package/src/errors.ts +2 -0
- package/src/index.ts +2 -0
- package/src/providers/dev.ts +2 -0
- package/src/providers/host.ts +199 -26
- package/src/providers/index.ts +2 -0
- package/src/providers/types.ts +2 -0
- package/src/retry.ts +2 -0
- package/src/signer-manager.ts +2 -0
- package/src/sleep.ts +2 -0
- package/src/types.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parity/product-sdk-signer",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
29
|
-
"@novasamatech/host-api": "^0.
|
|
28
|
+
"@novasamatech/host-api-wrapper": "^0.8.4",
|
|
29
|
+
"@novasamatech/host-api": "^0.8.4"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"tsup": "^8.
|
|
32
|
+
"tsup": "^8.5.1",
|
|
33
33
|
"typescript": "^5.9.3",
|
|
34
34
|
"vitest": "^3.1.4"
|
|
35
35
|
},
|
package/src/errors.ts
CHANGED
package/src/index.ts
CHANGED
package/src/providers/dev.ts
CHANGED
package/src/providers/host.ts
CHANGED
|
@@ -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.
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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", () => {
|
package/src/providers/index.ts
CHANGED
package/src/providers/types.ts
CHANGED
package/src/retry.ts
CHANGED
package/src/signer-manager.ts
CHANGED
package/src/sleep.ts
CHANGED