@polkadot-apps/signer 0.1.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.
Files changed (45) hide show
  1. package/dist/container.d.ts +11 -0
  2. package/dist/container.d.ts.map +1 -0
  3. package/dist/container.js +98 -0
  4. package/dist/container.js.map +1 -0
  5. package/dist/errors.d.ts +56 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +226 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +15 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +14 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/providers/dev.d.ts +39 -0
  14. package/dist/providers/dev.d.ts.map +1 -0
  15. package/dist/providers/dev.js +232 -0
  16. package/dist/providers/dev.js.map +1 -0
  17. package/dist/providers/extension.d.ts +46 -0
  18. package/dist/providers/extension.d.ts.map +1 -0
  19. package/dist/providers/extension.js +363 -0
  20. package/dist/providers/extension.js.map +1 -0
  21. package/dist/providers/host.d.ts +160 -0
  22. package/dist/providers/host.d.ts.map +1 -0
  23. package/dist/providers/host.js +724 -0
  24. package/dist/providers/host.js.map +1 -0
  25. package/dist/providers/types.d.ts +45 -0
  26. package/dist/providers/types.d.ts.map +1 -0
  27. package/dist/providers/types.js +2 -0
  28. package/dist/providers/types.js.map +1 -0
  29. package/dist/retry.d.ts +23 -0
  30. package/dist/retry.d.ts.map +1 -0
  31. package/dist/retry.js +197 -0
  32. package/dist/retry.js.map +1 -0
  33. package/dist/signer-manager.d.ts +168 -0
  34. package/dist/signer-manager.d.ts.map +1 -0
  35. package/dist/signer-manager.js +1447 -0
  36. package/dist/signer-manager.js.map +1 -0
  37. package/dist/sleep.d.ts +9 -0
  38. package/dist/sleep.d.ts.map +1 -0
  39. package/dist/sleep.js +85 -0
  40. package/dist/sleep.js.map +1 -0
  41. package/dist/types.d.ts +96 -0
  42. package/dist/types.d.ts.map +1 -0
  43. package/dist/types.js +71 -0
  44. package/dist/types.js.map +1 -0
  45. package/package.json +41 -0
@@ -0,0 +1,1447 @@
1
+ import { createLogger } from "@polkadot-apps/logger";
2
+ import { AccountNotFoundError, DestroyedError, HostDisconnectedError, HostUnavailableError, SigningFailedError, SignerError, } from "./errors.js";
3
+ import { isInsideContainer } from "./container.js";
4
+ import { DevProvider } from "./providers/dev.js";
5
+ import { ExtensionProvider } from "./providers/extension.js";
6
+ import { HostProvider } from "./providers/host.js";
7
+ import { withRetry } from "./retry.js";
8
+ import { err, ok } from "./types.js";
9
+ const log = createLogger("signer");
10
+ const DEFAULT_HOST_TIMEOUT = 10_000;
11
+ const DEFAULT_EXTENSION_TIMEOUT = 1_000;
12
+ const DEFAULT_MAX_RETRIES = 3;
13
+ const DEFAULT_SS58_PREFIX = 42;
14
+ const DEFAULT_DAPP_NAME = "polkadot-app";
15
+ // Auto-reconnect settings for host disconnect events
16
+ const RECONNECT_MAX_ATTEMPTS = 5;
17
+ const RECONNECT_INITIAL_DELAY = 1_000;
18
+ const RECONNECT_MAX_DELAY = 15_000;
19
+ function persistenceStorageKey(dappName) {
20
+ return `polkadot-apps:signer:${dappName}:selectedAccount`;
21
+ }
22
+ /* @integration */
23
+ /**
24
+ * Auto-detect the best available persistence adapter.
25
+ *
26
+ * Prefers hostLocalStorage (product-sdk) when inside a container because
27
+ * sandboxed iframes may not share localStorage with the host application.
28
+ * Falls back to browser localStorage in standalone environments.
29
+ */
30
+ async function detectPersistence() {
31
+ // Try host storage first (container environment)
32
+ if (isInsideContainer()) {
33
+ try {
34
+ const sdk = await import("@novasamatech/product-sdk");
35
+ if (sdk.hostLocalStorage) {
36
+ log.debug("using hostLocalStorage for persistence");
37
+ return {
38
+ getItem: (key) => sdk.hostLocalStorage.readString(key),
39
+ setItem: (key, value) => sdk.hostLocalStorage.writeString(key, value),
40
+ removeItem: (key) => sdk.hostLocalStorage.writeString(key, ""),
41
+ };
42
+ }
43
+ }
44
+ catch {
45
+ // product-sdk not available — fall through to localStorage
46
+ }
47
+ }
48
+ // Fall back to browser localStorage
49
+ try {
50
+ if (typeof globalThis.localStorage !== "undefined") {
51
+ return globalThis.localStorage;
52
+ }
53
+ }
54
+ catch {
55
+ // localStorage may throw in some environments (e.g. sandboxed iframes)
56
+ }
57
+ return null;
58
+ }
59
+ function initialState() {
60
+ return {
61
+ status: "disconnected",
62
+ accounts: [],
63
+ selectedAccount: null,
64
+ activeProvider: null,
65
+ error: null,
66
+ };
67
+ }
68
+ /**
69
+ * Core orchestrator for signer management.
70
+ *
71
+ * Manages account discovery and signer creation across multiple providers
72
+ * (Host API, browser extensions, dev accounts). Framework-agnostic —
73
+ * use the subscribe() pattern to integrate with React, Vue, or any framework.
74
+ *
75
+ * @example
76
+ * ```ts
77
+ * const manager = new SignerManager();
78
+ * manager.subscribe(state => console.log(state.status));
79
+ *
80
+ * // Auto-detect: tries Host API first, then browser extensions
81
+ * await manager.connect();
82
+ *
83
+ * // Or connect to a specific provider
84
+ * await manager.connect("dev");
85
+ *
86
+ * // Select account and get signer
87
+ * manager.selectAccount("5GrwvaEF...");
88
+ * const signer = manager.getSigner();
89
+ * ```
90
+ */
91
+ export class SignerManager {
92
+ state;
93
+ provider = null;
94
+ subscribers = new Set();
95
+ cleanups = [];
96
+ isDestroyed = false;
97
+ reconnectController = null;
98
+ connectController = null;
99
+ ss58Prefix;
100
+ hostTimeout;
101
+ extensionTimeout;
102
+ maxRetries;
103
+ providerFactory;
104
+ dappName;
105
+ persistenceOption;
106
+ resolvedPersistence;
107
+ constructor(options) {
108
+ this.ss58Prefix = options?.ss58Prefix ?? DEFAULT_SS58_PREFIX;
109
+ this.hostTimeout = options?.hostTimeout ?? DEFAULT_HOST_TIMEOUT;
110
+ this.extensionTimeout = options?.extensionTimeout ?? DEFAULT_EXTENSION_TIMEOUT;
111
+ this.maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
112
+ this.providerFactory = options?.createProvider;
113
+ this.dappName = options?.dappName ?? DEFAULT_DAPP_NAME;
114
+ // null = disabled, undefined = auto-detect, AccountPersistence = explicit
115
+ this.persistenceOption = options?.persistence;
116
+ this.resolvedPersistence = options?.persistence;
117
+ this.state = initialState();
118
+ }
119
+ async getPersistence() {
120
+ if (this.persistenceOption === null)
121
+ return null;
122
+ if (this.persistenceOption !== undefined)
123
+ return this.persistenceOption;
124
+ // Auto-detect (lazy, cached)
125
+ if (this.resolvedPersistence === undefined) {
126
+ this.resolvedPersistence = await detectPersistence();
127
+ }
128
+ return this.resolvedPersistence ?? null;
129
+ }
130
+ /** Get a snapshot of the current state. */
131
+ getState() {
132
+ return this.state;
133
+ }
134
+ /**
135
+ * Subscribe to state changes. The callback fires on every state mutation.
136
+ * Returns an unsubscribe function.
137
+ */
138
+ subscribe(callback) {
139
+ this.subscribers.add(callback);
140
+ return () => {
141
+ this.subscribers.delete(callback);
142
+ };
143
+ }
144
+ /**
145
+ * Connect to a provider.
146
+ *
147
+ * If no provider type is specified, runs environment-aware auto-detection:
148
+ *
149
+ * **Inside a container** (iframe/webview):
150
+ * 1. Try direct Host API connection (preferred, idiomatic path)
151
+ * 2. If host fails, try Spektr extension injection as fallback
152
+ * 3. If both fail, return error — no further fallback
153
+ *
154
+ * **Outside a container** (standalone browser):
155
+ * 1. Try browser extensions directly
156
+ * 2. If fails, return error — no host attempt
157
+ *
158
+ * When connecting to a specific provider, it is used directly.
159
+ */
160
+ async connect(providerType) {
161
+ if (this.isDestroyed) {
162
+ return err(new DestroyedError());
163
+ }
164
+ // Cancel any in-flight connection or reconnect attempt
165
+ this.cancelConnect();
166
+ this.cancelReconnect();
167
+ this.connectController = new AbortController();
168
+ const signal = this.connectController.signal;
169
+ // Clean up previous connection
170
+ this.disconnectInternal();
171
+ this.setState({ status: "connecting", error: null });
172
+ if (providerType) {
173
+ // When explicitly requesting extension inside a container, inject
174
+ // Spektr first so the host wallet appears as a browser extension.
175
+ if (providerType === "extension" && isInsideContainer()) {
176
+ await HostProvider.injectSpektr();
177
+ }
178
+ return this.connectToProvider(providerType, signal);
179
+ }
180
+ return this.autoDetect(signal);
181
+ }
182
+ /** Disconnect from the current provider and reset state. */
183
+ disconnect() {
184
+ this.cancelConnect();
185
+ this.cancelReconnect();
186
+ this.disconnectInternal();
187
+ this.setState(initialState());
188
+ log.info("disconnected");
189
+ }
190
+ /**
191
+ * Select an account by address.
192
+ * Returns the account on success, or ACCOUNT_NOT_FOUND error.
193
+ */
194
+ selectAccount(address) {
195
+ if (this.isDestroyed) {
196
+ return err(new DestroyedError());
197
+ }
198
+ const account = this.state.accounts.find((a) => a.address === address);
199
+ if (!account) {
200
+ log.warn("account not found", { address });
201
+ return err(new AccountNotFoundError(address));
202
+ }
203
+ this.setState({ selectedAccount: account });
204
+ this.persistAccount(address);
205
+ log.debug("account selected", { address });
206
+ return ok(account);
207
+ }
208
+ /**
209
+ * Get the PolkadotSigner for the currently selected account.
210
+ * Returns null if no account is selected or manager is disconnected.
211
+ */
212
+ getSigner() {
213
+ return this.state.selectedAccount?.getSigner() ?? null;
214
+ }
215
+ /**
216
+ * Sign arbitrary bytes with the currently selected account.
217
+ *
218
+ * Convenience wrapper around `PolkadotSigner.signBytes` — useful for
219
+ * master key derivation, message signing, and proof generation without
220
+ * constructing a full transaction.
221
+ *
222
+ * Returns a SIGNING_FAILED error if no account is selected or signing fails.
223
+ */
224
+ async signRaw(data) {
225
+ if (this.isDestroyed) {
226
+ return err(new DestroyedError());
227
+ }
228
+ const signer = this.getSigner();
229
+ if (!signer) {
230
+ return err(new SigningFailedError(null, "No account selected"));
231
+ }
232
+ try {
233
+ const signature = await signer.signBytes(data);
234
+ return ok(signature);
235
+ }
236
+ catch (cause) {
237
+ log.error("signRaw failed", { cause });
238
+ return err(new SigningFailedError(cause));
239
+ }
240
+ }
241
+ // ── Host-only: Product Account API ─────────────────────────────
242
+ /**
243
+ * Get an app-scoped product account from the host.
244
+ *
245
+ * Product accounts are derived by the host wallet for each app, identified
246
+ * by `dotNsIdentifier` (e.g., "mark3t.dot"). Only available when connected
247
+ * via the host provider — returns HOST_UNAVAILABLE otherwise.
248
+ *
249
+ * @example
250
+ * ```ts
251
+ * const result = await manager.getProductAccount("myapp.dot");
252
+ * if (result.ok) {
253
+ * const signer = result.value.getSigner();
254
+ * }
255
+ * ```
256
+ */
257
+ async getProductAccount(dotNsIdentifier, derivationIndex = 0) {
258
+ if (this.isDestroyed)
259
+ return err(new DestroyedError());
260
+ const host = this.getHostProvider();
261
+ if (!host) {
262
+ return err(new HostUnavailableError("Product accounts require a host provider connection"));
263
+ }
264
+ return host.getProductAccount(dotNsIdentifier, derivationIndex);
265
+ }
266
+ /**
267
+ * Get a contextual alias for a product account via Ring VRF.
268
+ *
269
+ * Aliases prove account membership in a ring without revealing which
270
+ * account produced the alias. Only available when connected via the host
271
+ * provider — returns HOST_UNAVAILABLE otherwise.
272
+ */
273
+ async getProductAccountAlias(dotNsIdentifier, derivationIndex = 0) {
274
+ if (this.isDestroyed)
275
+ return err(new DestroyedError());
276
+ const host = this.getHostProvider();
277
+ if (!host) {
278
+ return err(new HostUnavailableError("Product account aliases require a host provider connection"));
279
+ }
280
+ return host.getProductAccountAlias(dotNsIdentifier, derivationIndex);
281
+ }
282
+ /**
283
+ * Create a Ring VRF proof for anonymous operations.
284
+ *
285
+ * Proves that the signer is a member of the ring at the given location
286
+ * without revealing which member. Only available when connected via the
287
+ * host provider — returns HOST_UNAVAILABLE otherwise.
288
+ */
289
+ async createRingVRFProof(dotNsIdentifier, derivationIndex, location, message) {
290
+ if (this.isDestroyed)
291
+ return err(new DestroyedError());
292
+ const host = this.getHostProvider();
293
+ if (!host) {
294
+ return err(new HostUnavailableError("Ring VRF proofs require a host provider connection"));
295
+ }
296
+ return host.createRingVRFProof(dotNsIdentifier, derivationIndex, location, message);
297
+ }
298
+ /**
299
+ * List available browser extensions.
300
+ *
301
+ * Async because extensions inject into `window.injectedWeb3` asynchronously
302
+ * after page load. Uses the same injection wait as the extension provider.
303
+ */
304
+ async getAvailableExtensions() {
305
+ try {
306
+ const api = await this.loadExtensionApi();
307
+ return api.getInjectedExtensions();
308
+ }
309
+ catch {
310
+ return [];
311
+ }
312
+ }
313
+ /**
314
+ * Destroy the manager and release all resources.
315
+ * After calling destroy(), the manager is unusable.
316
+ */
317
+ destroy() {
318
+ if (this.isDestroyed)
319
+ return;
320
+ this.isDestroyed = true;
321
+ this.cancelConnect();
322
+ this.cancelReconnect();
323
+ this.disconnectInternal();
324
+ this.subscribers.clear();
325
+ this.state = initialState();
326
+ log.info("manager destroyed");
327
+ }
328
+ // ── Private ──────────────────────────────────────────────────────
329
+ /**
330
+ * Environment-aware auto-detection.
331
+ *
332
+ * Inside a container: direct Host API is the preferred, idiomatic path.
333
+ * If that fails, Spektr extension injection is tried as a fallback.
334
+ * Outside a container: browser extensions are the only viable path.
335
+ */
336
+ async autoDetect(signal) {
337
+ const inContainer = isInsideContainer();
338
+ log.info("auto-detecting provider", { inContainer });
339
+ if (inContainer) {
340
+ return this.autoDetectContainer(signal);
341
+ }
342
+ return this.autoDetectStandalone(signal);
343
+ }
344
+ /**
345
+ * Container path: Host API (preferred) → Spektr injection (fallback) → error.
346
+ *
347
+ * The direct Host API is the idiomatic path for container environments.
348
+ * Spektr injection is a compatibility fallback that makes the host wallet
349
+ * appear as a browser extension via `window.injectedWeb3`.
350
+ */
351
+ async autoDetectContainer(signal) {
352
+ // Apply hostTimeout to the host connection attempt
353
+ const hostSignal = signal
354
+ ? AbortSignal.any([signal, AbortSignal.timeout(this.hostTimeout)])
355
+ : AbortSignal.timeout(this.hostTimeout);
356
+ const hostResult = await this.connectToProvider("host", hostSignal);
357
+ if (hostResult.ok) {
358
+ return hostResult;
359
+ }
360
+ log.info("direct host connection failed, trying Spektr injection fallback", {
361
+ error: hostResult.error,
362
+ });
363
+ // Spektr injection fallback: inject host wallet as browser extension
364
+ const injected = await HostProvider.injectSpektr();
365
+ if (injected) {
366
+ log.info("Spektr injected, connecting via extension provider");
367
+ const extResult = await this.connectToProvider("extension", signal);
368
+ if (extResult.ok) {
369
+ return extResult;
370
+ }
371
+ log.warn("Spektr injection succeeded but extension connection failed", {
372
+ error: extResult.error,
373
+ });
374
+ }
375
+ else {
376
+ log.warn("Spektr injection failed");
377
+ }
378
+ // All container paths failed
379
+ this.setState({
380
+ status: "disconnected",
381
+ error: hostResult.error,
382
+ });
383
+ return hostResult;
384
+ }
385
+ /** Standalone path: browser extensions only. */
386
+ async autoDetectStandalone(signal) {
387
+ const extResult = await this.connectToProvider("extension", signal);
388
+ if (extResult.ok) {
389
+ return extResult;
390
+ }
391
+ log.warn("no browser extensions available");
392
+ this.setState({
393
+ status: "disconnected",
394
+ error: extResult.error,
395
+ });
396
+ return extResult;
397
+ }
398
+ async connectToProvider(type, signal) {
399
+ const provider = this.createProvider(type);
400
+ const result = await provider.connect(signal);
401
+ if (!result.ok) {
402
+ provider.disconnect();
403
+ this.setState({ status: "disconnected", error: result.error });
404
+ return result;
405
+ }
406
+ // Success — set up provider
407
+ this.provider = provider;
408
+ // Wire status change listener
409
+ const statusUnsub = provider.onStatusChange((status) => {
410
+ this.handleProviderStatusChange(status);
411
+ });
412
+ this.cleanups.push(statusUnsub);
413
+ // Wire account change listener
414
+ const accountUnsub = provider.onAccountsChange((accounts) => {
415
+ this.setState({
416
+ accounts,
417
+ // Clear selected if no longer in list
418
+ selectedAccount: accounts.find((a) => a.address === this.state.selectedAccount?.address) ?? null,
419
+ });
420
+ });
421
+ this.cleanups.push(accountUnsub);
422
+ const accounts = result.value;
423
+ // Try to restore persisted account selection
424
+ const persisted = await this.loadPersistedAccount();
425
+ const restoredAccount = persisted ? accounts.find((a) => a.address === persisted) : null;
426
+ const selectedAccount = restoredAccount ?? (accounts.length > 0 ? accounts[0] : null);
427
+ this.setState({
428
+ status: "connected",
429
+ accounts,
430
+ activeProvider: type,
431
+ selectedAccount,
432
+ error: null,
433
+ });
434
+ if (selectedAccount) {
435
+ this.persistAccount(selectedAccount.address);
436
+ }
437
+ log.info("connected", { provider: type, accounts: accounts.length });
438
+ return result;
439
+ }
440
+ createProvider(type) {
441
+ if (this.providerFactory) {
442
+ return this.providerFactory(type);
443
+ }
444
+ switch (type) {
445
+ case "host":
446
+ return new HostProvider({
447
+ ss58Prefix: this.ss58Prefix,
448
+ maxRetries: this.maxRetries,
449
+ retryDelay: 500,
450
+ });
451
+ case "extension":
452
+ return new ExtensionProvider({
453
+ injectionWait: this.extensionTimeout,
454
+ dappName: this.dappName,
455
+ });
456
+ case "dev":
457
+ return new DevProvider({
458
+ ss58Prefix: this.ss58Prefix,
459
+ });
460
+ }
461
+ }
462
+ /* @integration */
463
+ handleProviderStatusChange(status) {
464
+ if (status === "disconnected" && this.state.status === "connected") {
465
+ log.warn("provider disconnected, attempting reconnect");
466
+ this.attemptReconnect();
467
+ }
468
+ }
469
+ /* @integration */
470
+ attemptReconnect() {
471
+ this.cancelReconnect();
472
+ const providerType = this.state.activeProvider;
473
+ if (!providerType)
474
+ return;
475
+ this.reconnectController = new AbortController();
476
+ const signal = this.reconnectController.signal;
477
+ this.setState({ status: "connecting" });
478
+ withRetry(async () => {
479
+ if (signal.aborted) {
480
+ return err(new HostDisconnectedError("Reconnect cancelled"));
481
+ }
482
+ this.disconnectInternal();
483
+ const provider = this.createProvider(providerType);
484
+ // Compose hostTimeout with reconnect signal for host providers
485
+ const connectSignal = providerType === "host"
486
+ ? AbortSignal.any([signal, AbortSignal.timeout(this.hostTimeout)])
487
+ : signal;
488
+ const result = await provider.connect(connectSignal);
489
+ if (!result.ok)
490
+ return result;
491
+ // Re-wire provider
492
+ this.provider = provider;
493
+ const statusUnsub = provider.onStatusChange((s) => this.handleProviderStatusChange(s));
494
+ this.cleanups.push(statusUnsub);
495
+ const accountUnsub = provider.onAccountsChange((accounts) => {
496
+ this.setState({
497
+ accounts,
498
+ selectedAccount: accounts.find((a) => a.address === this.state.selectedAccount?.address) ?? null,
499
+ });
500
+ });
501
+ this.cleanups.push(accountUnsub);
502
+ const accounts = result.value;
503
+ this.setState({
504
+ status: "connected",
505
+ accounts,
506
+ activeProvider: providerType,
507
+ selectedAccount: accounts.find((a) => a.address === this.state.selectedAccount?.address) ??
508
+ (accounts.length > 0 ? accounts[0] : null),
509
+ error: null,
510
+ });
511
+ log.info("reconnected", { provider: providerType });
512
+ return result;
513
+ }, {
514
+ maxAttempts: RECONNECT_MAX_ATTEMPTS,
515
+ initialDelay: RECONNECT_INITIAL_DELAY,
516
+ maxDelay: RECONNECT_MAX_DELAY,
517
+ signal,
518
+ })
519
+ .then(async (result) => {
520
+ if (!result.ok && !signal.aborted) {
521
+ log.warn("reconnect to original provider failed, trying auto-detect");
522
+ const fallback = await this.autoDetect();
523
+ if (!fallback.ok) {
524
+ log.error("all reconnect attempts failed", { error: fallback.error });
525
+ this.setState({
526
+ status: "disconnected",
527
+ error: new HostDisconnectedError("Reconnect failed after all retries"),
528
+ });
529
+ }
530
+ }
531
+ })
532
+ .catch((cause) => {
533
+ log.error("unexpected reconnect error", { cause });
534
+ this.setState({
535
+ status: "disconnected",
536
+ error: new HostDisconnectedError("Reconnect failed unexpectedly"),
537
+ });
538
+ });
539
+ }
540
+ /** Returns the underlying HostProvider if connected via host, or null otherwise. */
541
+ getHostProvider() {
542
+ if (this.provider && this.state.activeProvider === "host") {
543
+ return this.provider;
544
+ }
545
+ return null;
546
+ }
547
+ cancelConnect() {
548
+ if (this.connectController) {
549
+ this.connectController.abort();
550
+ this.connectController = null;
551
+ }
552
+ }
553
+ cancelReconnect() {
554
+ if (this.reconnectController) {
555
+ this.reconnectController.abort();
556
+ this.reconnectController = null;
557
+ }
558
+ }
559
+ disconnectInternal() {
560
+ for (const cleanup of this.cleanups) {
561
+ cleanup();
562
+ }
563
+ this.cleanups = [];
564
+ if (this.provider) {
565
+ this.provider.disconnect();
566
+ this.provider = null;
567
+ }
568
+ }
569
+ persistAccount(address) {
570
+ void this.getPersistence()
571
+ .then((p) => {
572
+ if (p) {
573
+ const key = persistenceStorageKey(this.dappName);
574
+ return Promise.resolve(p.setItem(key, address));
575
+ }
576
+ })
577
+ .catch(() => {
578
+ log.debug("failed to persist selected account");
579
+ });
580
+ }
581
+ async loadPersistedAccount() {
582
+ try {
583
+ const p = await this.getPersistence();
584
+ if (!p)
585
+ return null;
586
+ const key = persistenceStorageKey(this.dappName);
587
+ const value = await Promise.resolve(p.getItem(key));
588
+ // Treat empty strings as null (hostLocalStorage uses writeString("") for deletion)
589
+ return value || null;
590
+ }
591
+ catch {
592
+ log.debug("failed to load persisted account");
593
+ return null;
594
+ }
595
+ }
596
+ async loadExtensionApi() {
597
+ const { getInjectedExtensions, connectInjectedExtension } = await import("polkadot-api/pjs-signer");
598
+ return { getInjectedExtensions, connectInjectedExtension };
599
+ }
600
+ setState(patch) {
601
+ this.state = { ...this.state, ...patch };
602
+ for (const subscriber of this.subscribers) {
603
+ subscriber(this.state);
604
+ }
605
+ }
606
+ }
607
+ if (import.meta.vitest) {
608
+ const { test, expect, describe, vi, beforeEach, afterEach } = import.meta.vitest;
609
+ const { HostUnavailableError, ExtensionNotFoundError } = await import("./errors.js");
610
+ beforeEach(() => {
611
+ vi.useFakeTimers();
612
+ });
613
+ afterEach(() => {
614
+ vi.useRealTimers();
615
+ vi.restoreAllMocks();
616
+ });
617
+ describe("SignerManager", () => {
618
+ test("initial state is disconnected with empty accounts", () => {
619
+ const manager = new SignerManager({ persistence: null });
620
+ const state = manager.getState();
621
+ expect(state.status).toBe("disconnected");
622
+ expect(state.accounts).toEqual([]);
623
+ expect(state.selectedAccount).toBeNull();
624
+ expect(state.activeProvider).toBeNull();
625
+ expect(state.error).toBeNull();
626
+ manager.destroy();
627
+ });
628
+ test("subscribe fires on state changes and unsubscribe works", async () => {
629
+ const manager = new SignerManager({ persistence: null });
630
+ const states = [];
631
+ const unsub = manager.subscribe((s) => states.push({ ...s }));
632
+ // Trigger a state change via connect("dev")
633
+ await manager.connect("dev");
634
+ expect(states.length).toBeGreaterThan(0);
635
+ expect(states[0].status).toBe("connecting");
636
+ // Unsubscribe
637
+ unsub();
638
+ const countBefore = states.length;
639
+ manager.disconnect();
640
+ expect(states.length).toBe(countBefore); // no new events
641
+ manager.destroy();
642
+ });
643
+ test("connect('dev') populates accounts and selects first", async () => {
644
+ const manager = new SignerManager({ persistence: null });
645
+ const result = await manager.connect("dev");
646
+ expect(result.ok).toBe(true);
647
+ if (result.ok) {
648
+ expect(result.value.length).toBe(6);
649
+ expect(result.value[0].name).toBe("Alice");
650
+ }
651
+ const state = manager.getState();
652
+ expect(state.status).toBe("connected");
653
+ expect(state.accounts.length).toBe(6);
654
+ expect(state.selectedAccount?.name).toBe("Alice");
655
+ expect(state.activeProvider).toBe("dev");
656
+ expect(state.error).toBeNull();
657
+ manager.destroy();
658
+ });
659
+ test("selectAccount updates selectedAccount", async () => {
660
+ const manager = new SignerManager({ persistence: null });
661
+ await manager.connect("dev");
662
+ const bobAddress = manager.getState().accounts[1].address;
663
+ const result = manager.selectAccount(bobAddress);
664
+ expect(result.ok).toBe(true);
665
+ if (result.ok) {
666
+ expect(result.value.name).toBe("Bob");
667
+ }
668
+ expect(manager.getState().selectedAccount?.name).toBe("Bob");
669
+ manager.destroy();
670
+ });
671
+ test("selectAccount returns ACCOUNT_NOT_FOUND for unknown address", async () => {
672
+ const manager = new SignerManager({ persistence: null });
673
+ await manager.connect("dev");
674
+ const result = manager.selectAccount("5NonExistentAddress");
675
+ expect(result.ok).toBe(false);
676
+ if (!result.ok) {
677
+ expect(result.error).toBeInstanceOf(AccountNotFoundError);
678
+ }
679
+ manager.destroy();
680
+ });
681
+ test("getSigner returns signer of selected account", async () => {
682
+ const manager = new SignerManager({ persistence: null });
683
+ await manager.connect("dev");
684
+ const signer = manager.getSigner();
685
+ expect(signer).not.toBeNull();
686
+ expect(signer.publicKey).toEqual(manager.getState().selectedAccount?.publicKey);
687
+ manager.destroy();
688
+ });
689
+ test("getSigner returns null when no account selected", () => {
690
+ const manager = new SignerManager({ persistence: null });
691
+ expect(manager.getSigner()).toBeNull();
692
+ manager.destroy();
693
+ });
694
+ test("disconnect resets state", async () => {
695
+ const manager = new SignerManager({ persistence: null });
696
+ await manager.connect("dev");
697
+ manager.disconnect();
698
+ const state = manager.getState();
699
+ expect(state.status).toBe("disconnected");
700
+ expect(state.accounts).toEqual([]);
701
+ expect(state.selectedAccount).toBeNull();
702
+ expect(state.activeProvider).toBeNull();
703
+ manager.destroy();
704
+ });
705
+ test("destroy prevents further operations", async () => {
706
+ const manager = new SignerManager({ persistence: null });
707
+ manager.destroy();
708
+ const result = await manager.connect("dev");
709
+ expect(result.ok).toBe(false);
710
+ if (!result.ok) {
711
+ expect(result.error).toBeInstanceOf(DestroyedError);
712
+ }
713
+ const selectResult = manager.selectAccount("x");
714
+ expect(selectResult.ok).toBe(false);
715
+ if (!selectResult.ok) {
716
+ expect(selectResult.error).toBeInstanceOf(DestroyedError);
717
+ }
718
+ });
719
+ test("destroy is idempotent", () => {
720
+ const manager = new SignerManager({ persistence: null });
721
+ manager.destroy();
722
+ manager.destroy(); // should not throw
723
+ });
724
+ test("multiple subscribers all receive updates", async () => {
725
+ const manager = new SignerManager({ persistence: null });
726
+ const states1 = [];
727
+ const states2 = [];
728
+ manager.subscribe((s) => states1.push(s.status));
729
+ manager.subscribe((s) => states2.push(s.status));
730
+ await manager.connect("dev");
731
+ expect(states1).toEqual(states2);
732
+ expect(states1.length).toBeGreaterThan(0);
733
+ manager.destroy();
734
+ });
735
+ test("connect cleans up previous connection", async () => {
736
+ const manager = new SignerManager({ persistence: null });
737
+ await manager.connect("dev");
738
+ expect(manager.getState().accounts.length).toBe(6);
739
+ // Connect again with different options
740
+ await manager.connect("dev");
741
+ expect(manager.getState().status).toBe("connected");
742
+ expect(manager.getState().accounts.length).toBe(6);
743
+ manager.destroy();
744
+ });
745
+ test("state transitions through full lifecycle", async () => {
746
+ const manager = new SignerManager({ persistence: null });
747
+ const statuses = [];
748
+ manager.subscribe((s) => statuses.push(s.status));
749
+ await manager.connect("dev");
750
+ manager.disconnect();
751
+ expect(statuses).toEqual([
752
+ "connecting", // connect() begins
753
+ "connected", // connect() succeeds
754
+ "disconnected", // disconnect() called
755
+ ]);
756
+ manager.destroy();
757
+ });
758
+ test("auto-detect outside container goes directly to extensions", async () => {
759
+ // In Node env, isInsideContainer() returns false
760
+ const callOrder = [];
761
+ const mockExtAccounts = [
762
+ {
763
+ address: "5ExtAddr",
764
+ h160Address: "0x0000000000000000000000000000000000000000",
765
+ publicKey: new Uint8Array(32).fill(0xee),
766
+ name: "Ext Account",
767
+ source: "extension",
768
+ getSigner: () => {
769
+ return { publicKey: new Uint8Array(32).fill(0xee) };
770
+ },
771
+ },
772
+ ];
773
+ const manager = new SignerManager({
774
+ createProvider: (type) => {
775
+ if (type === "host") {
776
+ return {
777
+ type: "host",
778
+ connect: async () => {
779
+ callOrder.push("host");
780
+ return err(new HostUnavailableError());
781
+ },
782
+ disconnect: () => { },
783
+ onStatusChange: () => () => { },
784
+ onAccountsChange: () => () => { },
785
+ };
786
+ }
787
+ return {
788
+ type: "extension",
789
+ connect: async () => {
790
+ callOrder.push("extension");
791
+ return ok(mockExtAccounts);
792
+ },
793
+ disconnect: () => { },
794
+ onStatusChange: () => () => { },
795
+ onAccountsChange: () => () => { },
796
+ };
797
+ },
798
+ persistence: null,
799
+ });
800
+ const result = await manager.connect();
801
+ // Outside container: extension only, no host attempt
802
+ expect(callOrder).toEqual(["extension"]);
803
+ expect(result.ok).toBe(true);
804
+ if (result.ok) {
805
+ expect(result.value[0].name).toBe("Ext Account");
806
+ }
807
+ expect(manager.getState().activeProvider).toBe("extension");
808
+ manager.destroy();
809
+ });
810
+ test("auto-detect outside container returns extension error when none found", async () => {
811
+ const callOrder = [];
812
+ const manager = new SignerManager({
813
+ createProvider: (type) => ({
814
+ type,
815
+ connect: async () => {
816
+ callOrder.push(type);
817
+ if (type === "host")
818
+ return err(new HostUnavailableError());
819
+ return err(new ExtensionNotFoundError("*", "No extensions"));
820
+ },
821
+ disconnect: () => { },
822
+ onStatusChange: () => () => { },
823
+ onAccountsChange: () => () => { },
824
+ }),
825
+ persistence: null,
826
+ });
827
+ const result = await manager.connect();
828
+ // Outside container: extension only
829
+ expect(callOrder).toEqual(["extension"]);
830
+ expect(result.ok).toBe(false);
831
+ if (!result.ok) {
832
+ expect(result.error).toBeInstanceOf(ExtensionNotFoundError);
833
+ }
834
+ manager.destroy();
835
+ });
836
+ test("createProvider passes dappName to extension provider", async () => {
837
+ // Verify via createProvider factory that dappName is captured.
838
+ // The factory receives the type; we verify the default factory builds
839
+ // an ExtensionProvider with the configured dappName by inspecting the
840
+ // actual createProvider code path.
841
+ let receivedType;
842
+ const manager = new SignerManager({
843
+ createProvider: (type) => {
844
+ receivedType = type;
845
+ return {
846
+ type,
847
+ connect: async () => ok([]),
848
+ disconnect: () => { },
849
+ onStatusChange: () => () => { },
850
+ onAccountsChange: () => () => { },
851
+ };
852
+ },
853
+ persistence: null,
854
+ dappName: "my-custom-app",
855
+ });
856
+ await manager.connect("extension");
857
+ expect(receivedType).toBe("extension");
858
+ // The actual dappName forwarding is verified by reading the source:
859
+ // createProvider("extension") calls new ExtensionProvider({ dappName: this.dappName })
860
+ // This test ensures the factory is invoked correctly for the extension type.
861
+ manager.destroy();
862
+ });
863
+ test("concurrent connect: second call succeeds and manager is connected", async () => {
864
+ const manager = new SignerManager({
865
+ persistence: null,
866
+ });
867
+ // First connect starts
868
+ const promise1 = manager.connect("dev");
869
+ // Second connect starts immediately (cancels first via AbortController)
870
+ const promise2 = manager.connect("dev");
871
+ const [, result2] = await Promise.all([promise1, promise2]);
872
+ // Second connect should succeed
873
+ expect(result2.ok).toBe(true);
874
+ // Manager should be in connected state from second connect
875
+ expect(manager.getState().status).toBe("connected");
876
+ manager.destroy();
877
+ });
878
+ // ── Product account delegation tests ──────────────────────
879
+ test("getProductAccount returns HOST_UNAVAILABLE when not connected via host", async () => {
880
+ const manager = new SignerManager({ persistence: null });
881
+ await manager.connect("dev");
882
+ const result = await manager.getProductAccount("myapp.dot");
883
+ expect(result.ok).toBe(false);
884
+ if (!result.ok) {
885
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
886
+ }
887
+ manager.destroy();
888
+ });
889
+ test("getProductAccount returns DESTROYED after destroy", async () => {
890
+ const manager = new SignerManager({ persistence: null });
891
+ manager.destroy();
892
+ const result = await manager.getProductAccount("myapp.dot");
893
+ expect(result.ok).toBe(false);
894
+ if (!result.ok) {
895
+ expect(result.error).toBeInstanceOf(DestroyedError);
896
+ }
897
+ });
898
+ test("getProductAccount delegates to host provider when connected via host", async () => {
899
+ const mockProductAccount = {
900
+ address: "5Product",
901
+ h160Address: "0x0000000000000000000000000000000000000000",
902
+ publicKey: new Uint8Array(32).fill(0xdd),
903
+ name: "Product",
904
+ source: "host",
905
+ getSigner: () => ({
906
+ publicKey: new Uint8Array(32).fill(0xdd),
907
+ }),
908
+ };
909
+ const mockHost = {
910
+ type: "host",
911
+ connect: async () => ok([mockProductAccount]),
912
+ disconnect: () => { },
913
+ onStatusChange: () => () => { },
914
+ onAccountsChange: () => () => { },
915
+ getProductAccount: async () => ok(mockProductAccount),
916
+ getProductAccountAlias: async () => ok({ context: new Uint8Array(32), alias: new Uint8Array(64) }),
917
+ createRingVRFProof: async () => ok(new Uint8Array(128)),
918
+ };
919
+ const manager = new SignerManager({
920
+ createProvider: () => mockHost,
921
+ persistence: null,
922
+ });
923
+ await manager.connect("host");
924
+ const result = await manager.getProductAccount("myapp.dot");
925
+ expect(result.ok).toBe(true);
926
+ if (result.ok) {
927
+ expect(result.value.address).toBe("5Product");
928
+ }
929
+ manager.destroy();
930
+ });
931
+ test("getProductAccountAlias returns HOST_UNAVAILABLE when connected via extension", async () => {
932
+ const manager = new SignerManager({ persistence: null });
933
+ await manager.connect("dev");
934
+ const result = await manager.getProductAccountAlias("myapp.dot");
935
+ expect(result.ok).toBe(false);
936
+ if (!result.ok) {
937
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
938
+ }
939
+ manager.destroy();
940
+ });
941
+ test("createRingVRFProof returns HOST_UNAVAILABLE when not connected via host", async () => {
942
+ const manager = new SignerManager({ persistence: null });
943
+ await manager.connect("dev");
944
+ const result = await manager.createRingVRFProof("myapp.dot", 0, { genesisHash: "0x00", ringRootHash: "0x01" }, new Uint8Array([1]));
945
+ expect(result.ok).toBe(false);
946
+ if (!result.ok) {
947
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
948
+ }
949
+ manager.destroy();
950
+ });
951
+ // ── Container auto-detect tests ──────────────────────────
952
+ test("auto-detect inside container: host succeeds", async () => {
953
+ // Mock isInsideContainer to return true
954
+ const containerModule = await import("./container.js");
955
+ const spy = vi.spyOn(containerModule, "isInsideContainer").mockReturnValue(true);
956
+ const mockAccounts = [
957
+ {
958
+ address: "5HostAddr",
959
+ h160Address: "0x0000000000000000000000000000000000000001",
960
+ publicKey: new Uint8Array(32).fill(0x11),
961
+ name: "Host Account",
962
+ source: "host",
963
+ getSigner: () => ({
964
+ publicKey: new Uint8Array(32).fill(0x11),
965
+ }),
966
+ },
967
+ ];
968
+ const manager = new SignerManager({
969
+ createProvider: (type) => ({
970
+ type,
971
+ connect: async () => ok(mockAccounts),
972
+ disconnect: () => { },
973
+ onStatusChange: () => () => { },
974
+ onAccountsChange: () => () => { },
975
+ }),
976
+ persistence: null,
977
+ });
978
+ const result = await manager.connect();
979
+ expect(result.ok).toBe(true);
980
+ expect(manager.getState().activeProvider).toBe("host");
981
+ spy.mockRestore();
982
+ manager.destroy();
983
+ });
984
+ test("auto-detect inside container: host fails, Spektr injection + extension succeeds", async () => {
985
+ const containerModule = await import("./container.js");
986
+ const spy = vi.spyOn(containerModule, "isInsideContainer").mockReturnValue(true);
987
+ // Mock HostProvider.injectSpektr to succeed
988
+ const spektrSpy = vi.spyOn(HostProvider, "injectSpektr").mockResolvedValue(true);
989
+ const callOrder = [];
990
+ const mockExtAccounts = [
991
+ {
992
+ address: "5Ext",
993
+ h160Address: "0x0000000000000000000000000000000000000002",
994
+ publicKey: new Uint8Array(32).fill(0x22),
995
+ name: "Ext",
996
+ source: "extension",
997
+ getSigner: () => ({
998
+ publicKey: new Uint8Array(32).fill(0x22),
999
+ }),
1000
+ },
1001
+ ];
1002
+ const manager = new SignerManager({
1003
+ createProvider: (type) => {
1004
+ callOrder.push(type);
1005
+ if (type === "host") {
1006
+ return {
1007
+ type: "host",
1008
+ connect: async () => err(new HostUnavailableError()),
1009
+ disconnect: () => { },
1010
+ onStatusChange: () => () => { },
1011
+ onAccountsChange: () => () => { },
1012
+ };
1013
+ }
1014
+ return {
1015
+ type: "extension",
1016
+ connect: async () => ok(mockExtAccounts),
1017
+ disconnect: () => { },
1018
+ onStatusChange: () => () => { },
1019
+ onAccountsChange: () => () => { },
1020
+ };
1021
+ },
1022
+ persistence: null,
1023
+ });
1024
+ const result = await manager.connect();
1025
+ // Host tried first, then extension after Spektr injection
1026
+ expect(callOrder).toEqual(["host", "extension"]);
1027
+ expect(result.ok).toBe(true);
1028
+ expect(manager.getState().activeProvider).toBe("extension");
1029
+ spy.mockRestore();
1030
+ spektrSpy.mockRestore();
1031
+ manager.destroy();
1032
+ });
1033
+ test("auto-detect inside container: all paths fail returns host error", async () => {
1034
+ const containerModule = await import("./container.js");
1035
+ const spy = vi.spyOn(containerModule, "isInsideContainer").mockReturnValue(true);
1036
+ const spektrSpy = vi.spyOn(HostProvider, "injectSpektr").mockResolvedValue(false);
1037
+ const manager = new SignerManager({
1038
+ createProvider: (type) => ({
1039
+ type,
1040
+ connect: async () => err(new HostUnavailableError()),
1041
+ disconnect: () => { },
1042
+ onStatusChange: () => () => { },
1043
+ onAccountsChange: () => () => { },
1044
+ }),
1045
+ persistence: null,
1046
+ });
1047
+ const result = await manager.connect();
1048
+ expect(result.ok).toBe(false);
1049
+ if (!result.ok) {
1050
+ expect(result.error).toBeInstanceOf(HostUnavailableError);
1051
+ }
1052
+ spy.mockRestore();
1053
+ spektrSpy.mockRestore();
1054
+ manager.destroy();
1055
+ });
1056
+ // ── Reconnect tests ─────────────────────────────────────
1057
+ test("reconnect on provider disconnect", async () => {
1058
+ let statusCallback;
1059
+ let connectCallCount = 0;
1060
+ const manager = new SignerManager({
1061
+ createProvider: () => {
1062
+ connectCallCount++;
1063
+ return {
1064
+ type: "dev",
1065
+ connect: async () => ok([
1066
+ {
1067
+ address: "5Test",
1068
+ h160Address: "0x0000000000000000000000000000000000000000",
1069
+ publicKey: new Uint8Array(32),
1070
+ name: "Test",
1071
+ source: "dev",
1072
+ getSigner: () => ({
1073
+ publicKey: new Uint8Array(32),
1074
+ }),
1075
+ },
1076
+ ]),
1077
+ disconnect: () => { },
1078
+ onStatusChange: (cb) => {
1079
+ statusCallback = cb;
1080
+ return () => { };
1081
+ },
1082
+ onAccountsChange: () => () => { },
1083
+ };
1084
+ },
1085
+ persistence: null,
1086
+ });
1087
+ await manager.connect("dev");
1088
+ expect(manager.getState().status).toBe("connected");
1089
+ expect(connectCallCount).toBe(1);
1090
+ // Simulate provider disconnect — triggers reconnect
1091
+ statusCallback("disconnected");
1092
+ // Allow reconnect retry to complete
1093
+ await vi.advanceTimersByTimeAsync(2000);
1094
+ // Should have attempted reconnect (at least one more connect call)
1095
+ expect(connectCallCount).toBeGreaterThan(1);
1096
+ manager.destroy();
1097
+ });
1098
+ test("connect() cancels in-flight reconnect", async () => {
1099
+ let statusCallback;
1100
+ let connectCallCount = 0;
1101
+ const manager = new SignerManager({
1102
+ createProvider: () => {
1103
+ connectCallCount++;
1104
+ return {
1105
+ type: "dev",
1106
+ connect: async () => ok([
1107
+ {
1108
+ address: "5Test",
1109
+ h160Address: "0x0000000000000000000000000000000000000000",
1110
+ publicKey: new Uint8Array(32),
1111
+ name: "Test",
1112
+ source: "dev",
1113
+ getSigner: () => ({
1114
+ publicKey: new Uint8Array(32),
1115
+ }),
1116
+ },
1117
+ ]),
1118
+ disconnect: () => { },
1119
+ onStatusChange: (cb) => {
1120
+ statusCallback = cb;
1121
+ return () => { };
1122
+ },
1123
+ onAccountsChange: () => () => { },
1124
+ };
1125
+ },
1126
+ persistence: null,
1127
+ });
1128
+ await manager.connect("dev");
1129
+ // Trigger reconnect
1130
+ statusCallback("disconnected");
1131
+ // Immediately call connect() — should cancel the reconnect
1132
+ await manager.connect("dev");
1133
+ expect(manager.getState().status).toBe("connected");
1134
+ manager.destroy();
1135
+ });
1136
+ test("connect('extension') injects Spektr inside container", async () => {
1137
+ const containerModule = await import("./container.js");
1138
+ const containerSpy = vi
1139
+ .spyOn(containerModule, "isInsideContainer")
1140
+ .mockReturnValue(true);
1141
+ const spektrSpy = vi.spyOn(HostProvider, "injectSpektr").mockResolvedValue(true);
1142
+ const manager = new SignerManager({
1143
+ createProvider: (type) => ({
1144
+ type,
1145
+ connect: async () => err(new ExtensionNotFoundError("*", "No extensions")),
1146
+ disconnect: () => { },
1147
+ onStatusChange: () => () => { },
1148
+ onAccountsChange: () => () => { },
1149
+ }),
1150
+ persistence: null,
1151
+ });
1152
+ await manager.connect("extension");
1153
+ // Spektr injection should have been attempted
1154
+ expect(spektrSpy).toHaveBeenCalled();
1155
+ containerSpy.mockRestore();
1156
+ spektrSpy.mockRestore();
1157
+ manager.destroy();
1158
+ });
1159
+ test("handleProviderStatusChange ignores non-disconnect events", async () => {
1160
+ let statusCallback;
1161
+ const manager = new SignerManager({
1162
+ createProvider: () => ({
1163
+ type: "dev",
1164
+ connect: async () => ok([
1165
+ {
1166
+ address: "5Test",
1167
+ h160Address: "0x0000000000000000000000000000000000000000",
1168
+ publicKey: new Uint8Array(32),
1169
+ name: "Test",
1170
+ source: "dev",
1171
+ getSigner: () => ({
1172
+ publicKey: new Uint8Array(32),
1173
+ }),
1174
+ },
1175
+ ]),
1176
+ disconnect: () => { },
1177
+ onStatusChange: (cb) => {
1178
+ statusCallback = cb;
1179
+ return () => { };
1180
+ },
1181
+ onAccountsChange: () => () => { },
1182
+ }),
1183
+ persistence: null,
1184
+ });
1185
+ await manager.connect("dev");
1186
+ // "connected" status change should NOT trigger reconnect
1187
+ statusCallback("connected");
1188
+ expect(manager.getState().status).toBe("connected");
1189
+ manager.destroy();
1190
+ });
1191
+ test("connectToProvider cleans up on failure", async () => {
1192
+ const disconnectSpy = vi.fn();
1193
+ const manager = new SignerManager({
1194
+ createProvider: () => ({
1195
+ type: "dev",
1196
+ connect: async () => err(new HostUnavailableError("test failure")),
1197
+ disconnect: disconnectSpy,
1198
+ onStatusChange: () => () => { },
1199
+ onAccountsChange: () => () => { },
1200
+ }),
1201
+ persistence: null,
1202
+ });
1203
+ const result = await manager.connect("dev");
1204
+ expect(result.ok).toBe(false);
1205
+ expect(disconnectSpy).toHaveBeenCalled();
1206
+ expect(manager.getState().status).toBe("disconnected");
1207
+ manager.destroy();
1208
+ });
1209
+ test("onAccountsChange updates state and clears selected if removed", async () => {
1210
+ let accountsCallback;
1211
+ const mockAccount = {
1212
+ address: "5Test",
1213
+ h160Address: "0x0000000000000000000000000000000000000000",
1214
+ publicKey: new Uint8Array(32),
1215
+ name: "Test",
1216
+ source: "dev",
1217
+ getSigner: () => ({
1218
+ publicKey: new Uint8Array(32),
1219
+ }),
1220
+ };
1221
+ const manager = new SignerManager({
1222
+ createProvider: () => ({
1223
+ type: "dev",
1224
+ connect: async () => ok([mockAccount]),
1225
+ disconnect: () => { },
1226
+ onStatusChange: () => () => { },
1227
+ onAccountsChange: (cb) => {
1228
+ accountsCallback = cb;
1229
+ return () => { };
1230
+ },
1231
+ }),
1232
+ persistence: null,
1233
+ });
1234
+ await manager.connect("dev");
1235
+ expect(manager.getState().selectedAccount?.address).toBe("5Test");
1236
+ // Fire account change with empty list — selected account should be cleared
1237
+ accountsCallback([]);
1238
+ expect(manager.getState().accounts).toEqual([]);
1239
+ expect(manager.getState().selectedAccount).toBeNull();
1240
+ manager.destroy();
1241
+ });
1242
+ test("default createProvider builds HostProvider for 'host' type", async () => {
1243
+ vi.useRealTimers();
1244
+ // No createProvider override — exercises the default switch case
1245
+ const manager = new SignerManager({
1246
+ persistence: null,
1247
+ maxRetries: 1,
1248
+ hostTimeout: 500,
1249
+ });
1250
+ // connect("host") uses the real HostProvider which fails in Node
1251
+ // (either HOST_UNAVAILABLE if SDK missing, or HOST_REJECTED if SDK present but no host)
1252
+ const result = await manager.connect("host");
1253
+ expect(result.ok).toBe(false);
1254
+ if (!result.ok) {
1255
+ expect(result.error).toBeInstanceOf(SignerError);
1256
+ }
1257
+ manager.destroy();
1258
+ vi.useFakeTimers();
1259
+ });
1260
+ test("default createProvider builds ExtensionProvider for 'extension' type", async () => {
1261
+ vi.useRealTimers();
1262
+ const manager = new SignerManager({
1263
+ persistence: null,
1264
+ extensionTimeout: 0,
1265
+ });
1266
+ // connect("extension") uses the real ExtensionProvider which fails in Node
1267
+ const result = await manager.connect("extension");
1268
+ expect(result.ok).toBe(false);
1269
+ if (!result.ok) {
1270
+ expect(result.error).toBeInstanceOf(ExtensionNotFoundError);
1271
+ }
1272
+ manager.destroy();
1273
+ vi.useFakeTimers();
1274
+ });
1275
+ test("loadPersistedAccount treats empty string as null", async () => {
1276
+ const storage = new Map();
1277
+ storage.set("polkadot-apps:signer:test-app:selectedAccount", "");
1278
+ const persistence = {
1279
+ getItem: (key) => storage.get(key) ?? null,
1280
+ setItem: (key, value) => {
1281
+ storage.set(key, value);
1282
+ },
1283
+ removeItem: (key) => {
1284
+ storage.delete(key);
1285
+ },
1286
+ };
1287
+ const manager = new SignerManager({ persistence, dappName: "test-app" });
1288
+ await manager.connect("dev");
1289
+ // Empty string should be treated as null — first account (Alice) selected
1290
+ expect(manager.getState().selectedAccount?.name).toBe("Alice");
1291
+ manager.destroy();
1292
+ });
1293
+ // ── Persistence tests ──────────────────────────────────────
1294
+ test("persists selected account and restores on reconnect", async () => {
1295
+ const storage = new Map();
1296
+ const persistence = {
1297
+ getItem: (key) => storage.get(key) ?? null,
1298
+ setItem: (key, value) => {
1299
+ storage.set(key, value);
1300
+ },
1301
+ removeItem: (key) => {
1302
+ storage.delete(key);
1303
+ },
1304
+ };
1305
+ const manager = new SignerManager({ persistence, dappName: "test-app" });
1306
+ await manager.connect("dev");
1307
+ // Alice is auto-selected first
1308
+ expect(manager.getState().selectedAccount?.name).toBe("Alice");
1309
+ // Select Bob
1310
+ const bobAddr = manager.getState().accounts[1].address;
1311
+ manager.selectAccount(bobAddr);
1312
+ expect(manager.getState().selectedAccount?.name).toBe("Bob");
1313
+ // Allow fire-and-forget persist to complete
1314
+ await vi.advanceTimersByTimeAsync(0);
1315
+ // Verify persistence wrote Bob's address
1316
+ expect(storage.get("polkadot-apps:signer:test-app:selectedAccount")).toBe(bobAddr);
1317
+ // Reconnect — should restore Bob
1318
+ manager.disconnect();
1319
+ await manager.connect("dev");
1320
+ expect(manager.getState().selectedAccount?.name).toBe("Bob");
1321
+ manager.destroy();
1322
+ });
1323
+ test("persistence null disables account saving", async () => {
1324
+ const manager = new SignerManager({ persistence: null });
1325
+ await manager.connect("dev");
1326
+ const bobAddr = manager.getState().accounts[1].address;
1327
+ manager.selectAccount(bobAddr);
1328
+ // Reconnect — should default to first (Alice), not Bob
1329
+ manager.disconnect();
1330
+ await manager.connect("dev");
1331
+ expect(manager.getState().selectedAccount?.name).toBe("Alice");
1332
+ manager.destroy();
1333
+ });
1334
+ test("persistence failure is gracefully handled", async () => {
1335
+ const persistence = {
1336
+ getItem: () => {
1337
+ throw new Error("storage unavailable");
1338
+ },
1339
+ setItem: () => {
1340
+ throw new Error("storage unavailable");
1341
+ },
1342
+ removeItem: () => { },
1343
+ };
1344
+ const manager = new SignerManager({ persistence });
1345
+ // Should not throw despite persistence failures
1346
+ await manager.connect("dev");
1347
+ expect(manager.getState().selectedAccount?.name).toBe("Alice");
1348
+ manager.destroy();
1349
+ });
1350
+ // ── signRaw tests ──────────────────────────────────────────
1351
+ test("signRaw returns SIGNING_FAILED when no account selected", async () => {
1352
+ const manager = new SignerManager({ persistence: null });
1353
+ const result = await manager.signRaw(new Uint8Array([1, 2, 3]));
1354
+ expect(result.ok).toBe(false);
1355
+ if (!result.ok) {
1356
+ expect(result.error).toBeInstanceOf(SigningFailedError);
1357
+ }
1358
+ manager.destroy();
1359
+ });
1360
+ test("signRaw returns DESTROYED after destroy", async () => {
1361
+ const manager = new SignerManager({ persistence: null });
1362
+ manager.destroy();
1363
+ const result = await manager.signRaw(new Uint8Array([1]));
1364
+ expect(result.ok).toBe(false);
1365
+ if (!result.ok) {
1366
+ expect(result.error).toBeInstanceOf(DestroyedError);
1367
+ }
1368
+ });
1369
+ test("signRaw delegates to signer.signBytes on selected account", async () => {
1370
+ const mockSignature = new Uint8Array(64).fill(0xab);
1371
+ const manager = new SignerManager({
1372
+ persistence: null,
1373
+ createProvider: () => ({
1374
+ type: "dev",
1375
+ connect: async () => ok([
1376
+ {
1377
+ address: "5Test",
1378
+ publicKey: new Uint8Array(32),
1379
+ name: "Test",
1380
+ source: "dev",
1381
+ getSigner: () => ({
1382
+ publicKey: new Uint8Array(32),
1383
+ signBytes: async () => mockSignature,
1384
+ signTx: async () => new Uint8Array(0),
1385
+ }),
1386
+ },
1387
+ ]),
1388
+ disconnect: () => { },
1389
+ onStatusChange: () => () => { },
1390
+ onAccountsChange: () => () => { },
1391
+ }),
1392
+ });
1393
+ await manager.connect("dev");
1394
+ const result = await manager.signRaw(new Uint8Array([1, 2, 3]));
1395
+ expect(result.ok).toBe(true);
1396
+ if (result.ok) {
1397
+ expect(result.value).toEqual(mockSignature);
1398
+ }
1399
+ manager.destroy();
1400
+ });
1401
+ test("signRaw returns SIGNING_FAILED when signer throws", async () => {
1402
+ const manager = new SignerManager({
1403
+ persistence: null,
1404
+ createProvider: () => ({
1405
+ type: "dev",
1406
+ connect: async () => ok([
1407
+ {
1408
+ address: "5Test",
1409
+ publicKey: new Uint8Array(32),
1410
+ name: "Test",
1411
+ source: "dev",
1412
+ getSigner: () => ({
1413
+ publicKey: new Uint8Array(32),
1414
+ signBytes: async () => {
1415
+ throw new Error("hardware wallet disconnected");
1416
+ },
1417
+ signTx: async () => new Uint8Array(0),
1418
+ }),
1419
+ },
1420
+ ]),
1421
+ disconnect: () => { },
1422
+ onStatusChange: () => () => { },
1423
+ onAccountsChange: () => () => { },
1424
+ }),
1425
+ });
1426
+ await manager.connect("dev");
1427
+ const result = await manager.signRaw(new Uint8Array([1]));
1428
+ expect(result.ok).toBe(false);
1429
+ if (!result.ok) {
1430
+ expect(result.error).toBeInstanceOf(SigningFailedError);
1431
+ if (result.error instanceof SigningFailedError) {
1432
+ expect(result.error.message).toContain("hardware wallet disconnected");
1433
+ }
1434
+ }
1435
+ manager.destroy();
1436
+ });
1437
+ // ── getAvailableExtensions tests ───────────────────────────
1438
+ test("getAvailableExtensions returns empty in Node env", async () => {
1439
+ const manager = new SignerManager({ persistence: null });
1440
+ // In Node, no window.injectedWeb3 exists, so it returns empty
1441
+ const extensions = await manager.getAvailableExtensions();
1442
+ expect(extensions).toEqual([]);
1443
+ manager.destroy();
1444
+ });
1445
+ });
1446
+ }
1447
+ //# sourceMappingURL=signer-manager.js.map