@bytezhang/ledger-adapter 0.0.3 → 0.0.5

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.
@@ -0,0 +1,406 @@
1
+ import { IHardwareWallet, IConnector, TransportType, IUiHandler, DeviceInfo, Response, ChainCapability, HardwareEventMap, DeviceEventListener, EvmGetAddressParams, EvmAddress, ProgressCallback, EvmGetPublicKeyParams, EvmPublicKey, EvmSignTxParams, EvmSignedTx, EvmSignMsgParams, EvmSignature, EvmSignTypedDataParams, BtcGetAddressParams, BtcAddress, BtcGetPublicKeyParams, BtcPublicKey, BtcSignTxParams, BtcSignedTx, BtcSignMsgParams, BtcSignature, SolGetAddressParams, SolAddress, SolGetPublicKeyParams, SolPublicKey, SolSignTxParams, SolSignedTx, SolSignMsgParams, SolSignature, DeviceDescriptor, DeviceChangeEvent, HardwareErrorCode } from '@bytezhang/hardware-wallet-core';
2
+
3
+ /**
4
+ * Ledger hardware wallet adapter that delegates to an IConnector.
5
+ *
6
+ * This is a thin translation layer that:
7
+ * - Accepts a pre-configured IConnector (transport decisions are made at connector creation time)
8
+ * - Translates IHardwareWallet method calls to connector.call() invocations
9
+ * - Maps connector results/errors to our Response<T> format with enriched error messages
10
+ * - Translates connector events to HardwareEventMap events
11
+ * - Integrates with IUiHandler for permission flows
12
+ */
13
+ declare class LedgerAdapter implements IHardwareWallet {
14
+ readonly vendor: "ledger";
15
+ private readonly connector;
16
+ private readonly emitter;
17
+ private _uiHandler;
18
+ private _discoveredDevices;
19
+ private _sessions;
20
+ constructor(connector: IConnector);
21
+ get activeTransport(): TransportType | null;
22
+ getAvailableTransports(): TransportType[];
23
+ switchTransport(_type: TransportType): Promise<void>;
24
+ setUiHandler(handler: Partial<IUiHandler>): void;
25
+ init(_config?: unknown): Promise<void>;
26
+ dispose(): Promise<void>;
27
+ searchDevices(): Promise<DeviceInfo[]>;
28
+ connectDevice(connectId: string): Promise<Response<string>>;
29
+ disconnectDevice(connectId: string): Promise<void>;
30
+ getDeviceInfo(connectId: string, deviceId: string): Promise<Response<DeviceInfo>>;
31
+ getSupportedChains(): ChainCapability[];
32
+ on<K extends keyof HardwareEventMap>(event: K, listener: (event: HardwareEventMap[K]) => void): void;
33
+ on(event: string, listener: DeviceEventListener): void;
34
+ off<K extends keyof HardwareEventMap>(event: K, listener: (event: HardwareEventMap[K]) => void): void;
35
+ off(event: string, listener: DeviceEventListener): void;
36
+ cancel(connectId: string): void;
37
+ evmGetAddress(connectId: string, _deviceId: string, params: EvmGetAddressParams): Promise<Response<EvmAddress>>;
38
+ evmGetAddresses(connectId: string, deviceId: string, params: EvmGetAddressParams[], onProgress?: ProgressCallback): Promise<Response<EvmAddress[]>>;
39
+ evmGetPublicKey(connectId: string, _deviceId: string, params: EvmGetPublicKeyParams): Promise<Response<EvmPublicKey>>;
40
+ evmSignTransaction(connectId: string, _deviceId: string, params: EvmSignTxParams): Promise<Response<EvmSignedTx>>;
41
+ evmSignMessage(connectId: string, _deviceId: string, params: EvmSignMsgParams): Promise<Response<EvmSignature>>;
42
+ evmSignTypedData(connectId: string, _deviceId: string, params: EvmSignTypedDataParams): Promise<Response<EvmSignature>>;
43
+ btcGetAddress(connectId: string, _deviceId: string, params: BtcGetAddressParams): Promise<Response<BtcAddress>>;
44
+ btcGetAddresses(connectId: string, deviceId: string, params: BtcGetAddressParams[], onProgress?: ProgressCallback): Promise<Response<BtcAddress[]>>;
45
+ btcGetPublicKey(connectId: string, _deviceId: string, params: BtcGetPublicKeyParams): Promise<Response<BtcPublicKey>>;
46
+ btcSignTransaction(connectId: string, _deviceId: string, params: BtcSignTxParams): Promise<Response<BtcSignedTx>>;
47
+ btcSignMessage(_connectId: string, _deviceId: string, _params: BtcSignMsgParams): Promise<Response<BtcSignature>>;
48
+ btcGetMasterFingerprint(connectId: string, _deviceId: string, params?: {
49
+ skipOpenApp?: boolean;
50
+ }): Promise<Response<{
51
+ masterFingerprint: string;
52
+ }>>;
53
+ solGetAddress(_connectId: string, _deviceId: string, _params: SolGetAddressParams): Promise<Response<SolAddress>>;
54
+ solGetAddresses(_connectId: string, _deviceId: string, _params: SolGetAddressParams[], _onProgress?: ProgressCallback): Promise<Response<SolAddress[]>>;
55
+ solGetPublicKey(_connectId: string, _deviceId: string, _params: SolGetPublicKeyParams): Promise<Response<SolPublicKey>>;
56
+ solSignTransaction(_connectId: string, _deviceId: string, _params: SolSignTxParams): Promise<Response<SolSignedTx>>;
57
+ solSignMessage(_connectId: string, _deviceId: string, _params: SolSignMsgParams): Promise<Response<SolSignature>>;
58
+ /**
59
+ * Ensure at least one device is connected and return a valid connectId.
60
+ *
61
+ * - If a session already exists for the given connectId, reuse it.
62
+ * - If ANY session exists (Ledger IDs are ephemeral), reuse it.
63
+ * - Otherwise: search → 1 device: auto-connect, multiple: ask user, 0: throw.
64
+ */
65
+ private static readonly MAX_DEVICE_RETRY;
66
+ private _deviceConnectResolve;
67
+ private static readonly DEVICE_CONNECT_TIMEOUT_MS;
68
+ /**
69
+ * Wait for user to connect and unlock device.
70
+ * Emits 'ui-request' event via the adapter's own emitter.
71
+ * The consumer (monorepo adapter wrapper) listens for this and shows UI.
72
+ * When user confirms, they call adapter.deviceConnectResponse() which resolves this promise.
73
+ * Times out after 60 seconds if no response is received.
74
+ */
75
+ private _waitForDeviceConnect;
76
+ /**
77
+ * Called by consumer to respond to ui-request-device-connect.
78
+ * type='confirm' → retry search, type='cancel' → abort.
79
+ */
80
+ deviceConnectResponse(type: 'confirm' | 'cancel'): void;
81
+ private ensureConnected;
82
+ private _connectFirstOrSelect;
83
+ /**
84
+ * Call the connector with automatic session resolution and disconnect retry.
85
+ *
86
+ * 1. Resolves a valid connectId via ensureConnected()
87
+ * 2. Looks up sessionId from _sessions
88
+ * 3. Calls connector.call()
89
+ * 4. On disconnect error: clears stale session, re-connects, retries once
90
+ */
91
+ private connectorCall;
92
+ /**
93
+ * Ensure device permission before proceeding.
94
+ * - No connectId (searchDevices): check environment-level permission
95
+ * - With connectId (business methods): check device-level permission
96
+ * If not granted, calls onDevicePermission so the consumer can request access.
97
+ */
98
+ private _ensureDevicePermission;
99
+ /**
100
+ * Convert a thrown error to a Response failure.
101
+ * Uses mapLedgerError to parse Ledger DMK error codes into HardwareErrorCode values.
102
+ */
103
+ private errorToFailure;
104
+ /**
105
+ * Generic batch call with progress reporting.
106
+ * If any single call fails, returns the failure immediately.
107
+ */
108
+ private batchCall;
109
+ private deviceConnectHandler;
110
+ private deviceDisconnectHandler;
111
+ private uiRequestHandler;
112
+ private uiEventHandler;
113
+ private registerEventListeners;
114
+ private unregisterEventListeners;
115
+ private handleUiEvent;
116
+ private connectorDeviceToDeviceInfo;
117
+ private extractDeviceInfoFromPayload;
118
+ private unknownDevice;
119
+ }
120
+
121
+ interface DmkDiscoveredDevice {
122
+ id: string;
123
+ deviceModel: {
124
+ id: string;
125
+ productName: string;
126
+ model: string;
127
+ name: string;
128
+ };
129
+ transport: string;
130
+ [key: string]: unknown;
131
+ }
132
+ interface IDmk {
133
+ startDiscovering(args?: {
134
+ transport?: string;
135
+ }): {
136
+ subscribe(observer: {
137
+ next: (device: DmkDiscoveredDevice) => void;
138
+ error?: (err: unknown) => void;
139
+ }): {
140
+ unsubscribe: () => void;
141
+ };
142
+ };
143
+ stopDiscovering(): void;
144
+ listenToAvailableDevices(args?: {
145
+ transport?: string;
146
+ }): {
147
+ subscribe(observer: {
148
+ next: (devices: DmkDiscoveredDevice[]) => void;
149
+ error?: (err: unknown) => void;
150
+ }): {
151
+ unsubscribe: () => void;
152
+ };
153
+ };
154
+ connect(params: {
155
+ device: DmkDiscoveredDevice;
156
+ }): Promise<string>;
157
+ disconnect(params: {
158
+ sessionId: string;
159
+ }): Promise<void>;
160
+ sendCommand(params: {
161
+ sessionId: string;
162
+ command: unknown;
163
+ }): Promise<unknown>;
164
+ close?(): void;
165
+ }
166
+ interface DeviceActionState$1<T> {
167
+ status: 'pending' | 'completed' | 'error';
168
+ output?: T;
169
+ error?: unknown;
170
+ intermediateValue?: {
171
+ requiredUserInteraction?: string;
172
+ [key: string]: unknown;
173
+ };
174
+ }
175
+ interface SignerEvmAddress {
176
+ address: string;
177
+ publicKey: string;
178
+ }
179
+ interface SignerEvmSignature {
180
+ r: string;
181
+ s: string;
182
+ v: number;
183
+ }
184
+ interface SignerBtcAddress {
185
+ address: string;
186
+ }
187
+ interface TransportProviderOptions {
188
+ logger?: unknown;
189
+ }
190
+ interface TransportProviderInstance {
191
+ dmk: IDmk;
192
+ dispose?: () => Promise<void>;
193
+ }
194
+ interface TransportProvider {
195
+ create(options?: TransportProviderOptions): TransportProviderInstance;
196
+ }
197
+
198
+ /**
199
+ * Manages device discovery, connection, and session tracking.
200
+ * Wraps DMK's Observable APIs into simpler imperative calls.
201
+ */
202
+ declare class LedgerDeviceManager {
203
+ private readonly _dmk;
204
+ private readonly _discovered;
205
+ private readonly _sessions;
206
+ private readonly _sessionToDevice;
207
+ private _listenSub;
208
+ constructor(dmk: IDmk);
209
+ /**
210
+ * One-shot enumeration: subscribe to listenToAvailableDevices,
211
+ * take the first emission, unsubscribe, return DeviceDescriptors.
212
+ */
213
+ enumerate(): Promise<DeviceDescriptor[]>;
214
+ /**
215
+ * Continuous listening: tracks device connect/disconnect via diffing.
216
+ */
217
+ listen(onChange: (event: DeviceChangeEvent) => void): void;
218
+ stopListening(): void;
219
+ /**
220
+ * Trigger browser device selection (WebHID requestDevice).
221
+ * Starts discovery for a short period, then stops.
222
+ */
223
+ requestDevice(timeoutMs?: number): Promise<void>;
224
+ /** Connect to a previously discovered device. Returns sessionId. */
225
+ connect(deviceId: string): Promise<string>;
226
+ /** Disconnect a session. */
227
+ disconnect(sessionId: string): Promise<void>;
228
+ getSessionId(deviceId: string): string | undefined;
229
+ getDeviceId(sessionId: string): string | undefined;
230
+ /** Get the underlying DMK instance (needed by SignerManager). */
231
+ getDmk(): IDmk;
232
+ dispose(): void;
233
+ }
234
+
235
+ /**
236
+ * SDK signer interface — duck-typed to avoid hard dependency on
237
+ * @ledgerhq/device-signer-kit-ethereum.
238
+ */
239
+ interface ISdkSignerEth {
240
+ getAddress(derivationPath: string, options?: {
241
+ checkOnDevice?: boolean;
242
+ }): unknown;
243
+ signTransaction(derivationPath: string, transaction: Uint8Array, options?: unknown): unknown;
244
+ signMessage(derivationPath: string, message: string): unknown;
245
+ signTypedData(derivationPath: string, data: unknown): unknown;
246
+ }
247
+ /**
248
+ * Wraps Ledger's SDK signer (Observable-based DeviceActions) into
249
+ * a simple async interface returning plain serializable data.
250
+ */
251
+ declare class SignerEth {
252
+ private readonly _sdk;
253
+ onInteraction?: (interaction: string) => void;
254
+ constructor(_sdk: ISdkSignerEth);
255
+ getAddress(derivationPath: string, options?: {
256
+ checkOnDevice?: boolean;
257
+ }): Promise<SignerEvmAddress>;
258
+ signTransaction(derivationPath: string, serializedTxHex: string): Promise<SignerEvmSignature>;
259
+ signMessage(derivationPath: string, message: string): Promise<SignerEvmSignature>;
260
+ signTypedData(derivationPath: string, data: unknown): Promise<SignerEvmSignature>;
261
+ }
262
+
263
+ type SignerEthBuilderFn = (args: {
264
+ dmk: IDmk;
265
+ sessionId: string;
266
+ }) => {
267
+ build(): unknown;
268
+ } | Promise<{
269
+ build(): unknown;
270
+ }>;
271
+ /**
272
+ * Manages per-sessionId SignerEth instances.
273
+ * Creates on demand, caches for reuse, invalidates on session change.
274
+ */
275
+ declare class SignerManager {
276
+ private readonly _cache;
277
+ private readonly _dmk;
278
+ private readonly _builderFn;
279
+ constructor(dmk: IDmk, builderFn?: SignerEthBuilderFn);
280
+ getOrCreate(sessionId: string): Promise<SignerEth>;
281
+ invalidate(sessionId: string): void;
282
+ clearAll(): void;
283
+ private static _defaultBuilder;
284
+ }
285
+
286
+ /**
287
+ * SDK BTC signer interface — duck-typed to avoid hard dependency on
288
+ * @ledgerhq/device-signer-kit-bitcoin.
289
+ */
290
+ interface ISdkSignerBtc {
291
+ getExtendedPublicKey(derivationPath: string, options?: {
292
+ checkOnDevice?: boolean;
293
+ }): unknown;
294
+ getWalletAddress(wallet: unknown, addressIndex: number, options?: {
295
+ checkOnDevice?: boolean;
296
+ change?: boolean;
297
+ }): unknown;
298
+ getMasterFingerprint(options?: {
299
+ skipOpenApp?: boolean;
300
+ }): unknown;
301
+ }
302
+ /**
303
+ * Wraps Ledger's BTC SDK signer (Observable-based DeviceActions) into
304
+ * a simple async interface returning plain serializable data.
305
+ */
306
+ declare class SignerBtc {
307
+ private readonly _sdk;
308
+ onInteraction?: (interaction: string) => void;
309
+ constructor(_sdk: ISdkSignerBtc);
310
+ getWalletAddress(wallet: unknown, addressIndex: number, options?: {
311
+ checkOnDevice?: boolean;
312
+ change?: boolean;
313
+ }): Promise<SignerBtcAddress>;
314
+ getExtendedPublicKey(derivationPath: string, options?: {
315
+ checkOnDevice?: boolean;
316
+ }): Promise<string>;
317
+ getMasterFingerprint(options?: {
318
+ skipOpenApp?: boolean;
319
+ }): Promise<Uint8Array>;
320
+ }
321
+
322
+ /** DeviceAction state emitted by DMK signer operations. */
323
+ interface DeviceActionState<T> {
324
+ status: 'pending' | 'completed' | 'error';
325
+ output?: T;
326
+ error?: unknown;
327
+ intermediateValue?: {
328
+ requiredUserInteraction?: string;
329
+ [key: string]: unknown;
330
+ };
331
+ }
332
+ /**
333
+ * Convert a DMK DeviceAction (Observable-based) into a Promise.
334
+ * Handles pending → completed/error state transitions and interaction callbacks.
335
+ */
336
+ declare function deviceActionToPromise<T>(action: {
337
+ observable: {
338
+ subscribe(observer: {
339
+ next: (value: DeviceActionState<T>) => void;
340
+ error?: (err: unknown) => void;
341
+ complete?: () => void;
342
+ }): {
343
+ unsubscribe: () => void;
344
+ };
345
+ };
346
+ }, onInteraction?: (interaction: string) => void): Promise<T>;
347
+
348
+ declare function registerTransport(type: string, provider: TransportProvider): void;
349
+ declare function unregisterTransport(type: string): void;
350
+ declare function getTransportProvider(type: string): TransportProvider | null;
351
+ declare function listRegisteredTransports(): string[];
352
+ declare function clearRegistry(): void;
353
+
354
+ interface AppManagerOptions {
355
+ waitMs?: number;
356
+ maxRetries?: number;
357
+ }
358
+ /**
359
+ * Orchestrates opening / closing Ledger on-device apps so that the
360
+ * correct signer application is running before any signing call.
361
+ */
362
+ declare class AppManager {
363
+ private readonly _dmk;
364
+ private readonly _waitMs;
365
+ private readonly _maxRetries;
366
+ constructor(dmk: IDmk, options?: AppManagerOptions);
367
+ /**
368
+ * Return the Ledger app name for a given chain ticker,
369
+ * or undefined if the chain is not supported.
370
+ */
371
+ static getAppName(chain: string): string | undefined;
372
+ /**
373
+ * Ensure the target app is open on the device identified by `sessionId`.
374
+ *
375
+ * Flow:
376
+ * 1. Check the currently running app.
377
+ * 2. If it is already the target, return immediately.
378
+ * 3. If a different app is running (not dashboard), close it first.
379
+ * 4. Open the target app.
380
+ * 5. Poll until the device confirms the target app is running.
381
+ */
382
+ ensureAppOpen(sessionId: string, targetAppName: string): Promise<void>;
383
+ private _getCurrentApp;
384
+ private _openApp;
385
+ private _closeCurrentApp;
386
+ /**
387
+ * Poll the device until the expected app is reported as running,
388
+ * or throw after `_maxRetries` attempts.
389
+ */
390
+ private _waitForApp;
391
+ private _isDashboard;
392
+ private _wait;
393
+ }
394
+
395
+ /** Check if an error (or any error in its chain) represents a locked Ledger device. */
396
+ declare function isDeviceLockedError(err: unknown): boolean;
397
+ /**
398
+ * Map a Ledger DMK error to a HardwareErrorCode and human-readable message
399
+ * with actionable recovery information for the caller.
400
+ */
401
+ declare function mapLedgerError(err: unknown): {
402
+ code: HardwareErrorCode;
403
+ message: string;
404
+ };
405
+
406
+ export { AppManager, type DeviceActionState$1 as DeviceActionState, type DmkDiscoveredDevice, type IDmk, LedgerAdapter, LedgerDeviceManager, SignerBtc, type SignerBtcAddress, SignerEth, type SignerEvmAddress, type SignerEvmSignature, SignerManager, type TransportProvider, type TransportProviderInstance, type TransportProviderOptions, clearRegistry, deviceActionToPromise, getTransportProvider, isDeviceLockedError, listRegisteredTransports, mapLedgerError, registerTransport, unregisterTransport };
package/dist/index.js CHANGED
@@ -158,7 +158,7 @@ function stripHex(hex) {
158
158
  function padHex64(hex) {
159
159
  return `0x${stripHex(hex).padStart(64, "0")}`;
160
160
  }
161
- var LedgerAdapter = class {
161
+ var _LedgerAdapter = class _LedgerAdapter {
162
162
  constructor(connector) {
163
163
  this.vendor = "ledger";
164
164
  this.emitter = new import_hardware_wallet_core2.TypedEventEmitter();
@@ -167,6 +167,8 @@ var LedgerAdapter = class {
167
167
  this._discoveredDevices = /* @__PURE__ */ new Map();
168
168
  // Session tracking: maps connectId -> sessionId
169
169
  this._sessions = /* @__PURE__ */ new Map();
170
+ // Pending device-connect resolve — set by _waitForDeviceConnect, resolved by uiResponse
171
+ this._deviceConnectResolve = null;
170
172
  // ---------------------------------------------------------------------------
171
173
  // Event translation
172
174
  // ---------------------------------------------------------------------------
@@ -490,16 +492,137 @@ var LedgerAdapter = class {
490
492
  async solSignMessage(_connectId, _deviceId, _params) {
491
493
  return (0, import_hardware_wallet_core2.failure)(import_hardware_wallet_core2.HardwareErrorCode.MethodNotSupported, "Solana signMessage is not supported on Ledger yet");
492
494
  }
493
- // ---------------------------------------------------------------------------
494
- // Private helpers
495
- // ---------------------------------------------------------------------------
496
495
  /**
497
- * Call the connector with session resolution.
498
- * Looks up sessionId from connectId, falls back to connectId itself.
496
+ * Wait for user to connect and unlock device.
497
+ * Emits 'ui-request' event via the adapter's own emitter.
498
+ * The consumer (monorepo adapter wrapper) listens for this and shows UI.
499
+ * When user confirms, they call adapter.deviceConnectResponse() which resolves this promise.
500
+ * Times out after 60 seconds if no response is received.
501
+ */
502
+ _waitForDeviceConnect(attempt) {
503
+ return new Promise((resolve, reject) => {
504
+ let settled = false;
505
+ const timer = setTimeout(() => {
506
+ if (!settled) {
507
+ settled = true;
508
+ this._deviceConnectResolve = null;
509
+ reject(new Error("Ledger device connect timed out after 60 seconds"));
510
+ }
511
+ }, _LedgerAdapter.DEVICE_CONNECT_TIMEOUT_MS);
512
+ this._deviceConnectResolve = (cancelled) => {
513
+ if (settled) return;
514
+ settled = true;
515
+ clearTimeout(timer);
516
+ this._deviceConnectResolve = null;
517
+ if (cancelled) {
518
+ reject(new Error("User cancelled Ledger connection"));
519
+ } else {
520
+ resolve();
521
+ }
522
+ };
523
+ this.emitter.emit("ui-request-device-connect", {
524
+ type: "ui-request-device-connect",
525
+ payload: {
526
+ message: "Please connect and unlock your Ledger device",
527
+ retryCount: attempt,
528
+ maxRetries: _LedgerAdapter.MAX_DEVICE_RETRY
529
+ }
530
+ });
531
+ });
532
+ }
533
+ /**
534
+ * Called by consumer to respond to ui-request-device-connect.
535
+ * type='confirm' → retry search, type='cancel' → abort.
536
+ */
537
+ deviceConnectResponse(type) {
538
+ if (this._deviceConnectResolve) {
539
+ this._deviceConnectResolve(type === "cancel");
540
+ }
541
+ }
542
+ async ensureConnected(connectId) {
543
+ if (connectId && this._sessions.has(connectId)) {
544
+ return connectId;
545
+ }
546
+ if (this._sessions.size > 0) {
547
+ return this._sessions.keys().next().value;
548
+ }
549
+ for (let attempt = 0; attempt < _LedgerAdapter.MAX_DEVICE_RETRY; attempt++) {
550
+ const devices = await this.searchDevices();
551
+ if (devices.length > 0) {
552
+ return this._connectFirstOrSelect(devices);
553
+ }
554
+ if (attempt < _LedgerAdapter.MAX_DEVICE_RETRY - 1) {
555
+ await this._waitForDeviceConnect(attempt + 1);
556
+ }
557
+ }
558
+ throw Object.assign(
559
+ new Error("No Ledger device found after multiple attempts. Please connect and unlock your device."),
560
+ { _tag: "DeviceNotRecognizedError" }
561
+ );
562
+ }
563
+ async _connectFirstOrSelect(devices) {
564
+ if (devices.length === 1) {
565
+ const result2 = await this.connectDevice(devices[0].connectId);
566
+ if (!result2.success) {
567
+ throw Object.assign(
568
+ new Error(result2.payload.error),
569
+ { _tag: "DeviceNotRecognizedError" }
570
+ );
571
+ }
572
+ return devices[0].connectId;
573
+ }
574
+ if (this._uiHandler?.onSelectDevice) {
575
+ const selectedConnectId = await this._uiHandler.onSelectDevice(devices);
576
+ const result2 = await this.connectDevice(selectedConnectId);
577
+ if (!result2.success) {
578
+ throw Object.assign(
579
+ new Error(result2.payload.error),
580
+ { _tag: "DeviceNotRecognizedError" }
581
+ );
582
+ }
583
+ return selectedConnectId;
584
+ }
585
+ const result = await this.connectDevice(devices[0].connectId);
586
+ if (!result.success) {
587
+ throw Object.assign(
588
+ new Error(result.payload.error),
589
+ { _tag: "DeviceNotRecognizedError" }
590
+ );
591
+ }
592
+ return devices[0].connectId;
593
+ }
594
+ /**
595
+ * Call the connector with automatic session resolution and disconnect retry.
596
+ *
597
+ * 1. Resolves a valid connectId via ensureConnected()
598
+ * 2. Looks up sessionId from _sessions
599
+ * 3. Calls connector.call()
600
+ * 4. On disconnect error: clears stale session, re-connects, retries once
499
601
  */
500
602
  async connectorCall(connectId, method, params) {
501
- const sessionId = this._sessions.get(connectId) ?? connectId;
502
- return this.connector.call(sessionId, method, params);
603
+ const resolvedConnectId = await this.ensureConnected(connectId);
604
+ const sessionId = this._sessions.get(resolvedConnectId);
605
+ if (!sessionId) {
606
+ throw Object.assign(
607
+ new Error("Auto-connect succeeded but no session found"),
608
+ { _tag: "DeviceSessionNotFound" }
609
+ );
610
+ }
611
+ try {
612
+ return await this.connector.call(sessionId, method, params);
613
+ } catch (err) {
614
+ if (isDeviceDisconnectedError(err)) {
615
+ this._sessions.delete(resolvedConnectId);
616
+ this._discoveredDevices.clear();
617
+ const retryConnectId = await this.ensureConnected();
618
+ const retrySessionId = this._sessions.get(retryConnectId);
619
+ if (!retrySessionId) {
620
+ throw err;
621
+ }
622
+ return this.connector.call(retrySessionId, method, params);
623
+ }
624
+ throw err;
625
+ }
503
626
  }
504
627
  /**
505
628
  * Ensure device permission before proceeding.
@@ -597,7 +720,8 @@ var LedgerAdapter = class {
597
720
  deviceId: device.deviceId,
598
721
  connectId: device.connectId,
599
722
  label: device.name,
600
- connectionType: "usb"
723
+ connectionType: "usb",
724
+ capabilities: device.capabilities
601
725
  };
602
726
  }
603
727
  extractDeviceInfoFromPayload(payload) {
@@ -622,6 +746,19 @@ var LedgerAdapter = class {
622
746
  };
623
747
  }
624
748
  };
749
+ // ---------------------------------------------------------------------------
750
+ // Private helpers
751
+ // ---------------------------------------------------------------------------
752
+ /**
753
+ * Ensure at least one device is connected and return a valid connectId.
754
+ *
755
+ * - If a session already exists for the given connectId, reuse it.
756
+ * - If ANY session exists (Ledger IDs are ephemeral), reuse it.
757
+ * - Otherwise: search → 1 device: auto-connect, multiple: ask user, 0: throw.
758
+ */
759
+ _LedgerAdapter.MAX_DEVICE_RETRY = 3;
760
+ _LedgerAdapter.DEVICE_CONNECT_TIMEOUT_MS = 6e4;
761
+ var LedgerAdapter = _LedgerAdapter;
625
762
 
626
763
  // src/device/LedgerDeviceManager.ts
627
764
  var LedgerDeviceManager = class {
@@ -640,13 +777,21 @@ var LedgerDeviceManager = class {
640
777
  */
641
778
  enumerate() {
642
779
  return new Promise((resolve) => {
643
- const sub = this._dmk.listenToAvailableDevices().subscribe({
780
+ let resolved = false;
781
+ let sub = null;
782
+ sub = this._dmk.listenToAvailableDevices().subscribe({
644
783
  next: (devices) => {
784
+ if (resolved) return;
785
+ resolved = true;
645
786
  this._discovered.clear();
646
787
  for (const d of devices) {
647
788
  this._discovered.set(d.id, d);
648
789
  }
649
- sub.unsubscribe();
790
+ if (sub) {
791
+ sub.unsubscribe();
792
+ } else {
793
+ Promise.resolve().then(() => sub?.unsubscribe());
794
+ }
650
795
  console.log("[LedgerDeviceManager] enumerate devices:", JSON.stringify(devices.map((d) => ({
651
796
  id: d.id,
652
797
  deviceModel: d.deviceModel,
@@ -655,7 +800,9 @@ var LedgerDeviceManager = class {
655
800
  resolve(devices.map((d) => ({ path: d.id, type: d.deviceModel.name })));
656
801
  },
657
802
  error: () => {
658
- sub.unsubscribe();
803
+ if (resolved) return;
804
+ resolved = true;
805
+ sub?.unsubscribe();
659
806
  resolve([]);
660
807
  }
661
808
  });