@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.
- package/dist/container.d.ts +11 -0
- package/dist/container.d.ts.map +1 -0
- package/dist/container.js +98 -0
- package/dist/container.js.map +1 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +226 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/dev.d.ts +39 -0
- package/dist/providers/dev.d.ts.map +1 -0
- package/dist/providers/dev.js +232 -0
- package/dist/providers/dev.js.map +1 -0
- package/dist/providers/extension.d.ts +46 -0
- package/dist/providers/extension.d.ts.map +1 -0
- package/dist/providers/extension.js +363 -0
- package/dist/providers/extension.js.map +1 -0
- package/dist/providers/host.d.ts +160 -0
- package/dist/providers/host.d.ts.map +1 -0
- package/dist/providers/host.js +724 -0
- package/dist/providers/host.js.map +1 -0
- package/dist/providers/types.d.ts +45 -0
- package/dist/providers/types.d.ts.map +1 -0
- package/dist/providers/types.js +2 -0
- package/dist/providers/types.js.map +1 -0
- package/dist/retry.d.ts +23 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +197 -0
- package/dist/retry.js.map +1 -0
- package/dist/signer-manager.d.ts +168 -0
- package/dist/signer-manager.d.ts.map +1 -0
- package/dist/signer-manager.js +1447 -0
- package/dist/signer-manager.js.map +1 -0
- package/dist/sleep.d.ts +9 -0
- package/dist/sleep.d.ts.map +1 -0
- package/dist/sleep.js +85 -0
- package/dist/sleep.js.map +1 -0
- package/dist/types.d.ts +96 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +71 -0
- package/dist/types.js.map +1 -0
- 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
|