diff --git a/packages/connect-examples/electron-example/package.json b/packages/connect-examples/electron-example/package.json index 656f925c6..1b36f1ab8 100644 --- a/packages/connect-examples/electron-example/package.json +++ b/packages/connect-examples/electron-example/package.json @@ -2,7 +2,7 @@ "name": "hardware-example", "productName": "HardwareExample", "executableName": "onekey-hardware-example", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "author": "OneKey", "description": "End-to-end encrypted workspaces for teams", "main": "dist/index.js", @@ -22,7 +22,7 @@ "ts:check": "yarn tsc --noEmit" }, "dependencies": { - "@onekeyfe/hd-transport-electron": "1.1.27-alpha.3", + "@onekeyfe/hd-transport-electron": "1.1.27-alpha.4", "@stoprocent/noble": "2.3.16", "debug": "4.3.4", "electron-is-dev": "^3.0.1", diff --git a/packages/connect-examples/expo-example/package.json b/packages/connect-examples/expo-example/package.json index 68afec412..297ae7e57 100644 --- a/packages/connect-examples/expo-example/package.json +++ b/packages/connect-examples/expo-example/package.json @@ -1,6 +1,6 @@ { "name": "expo-example", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "scripts": { "start": "cross-env CONNECT_SRC=https://localhost:8087/ yarn expo start --dev-client", "android": "yarn expo run:android", @@ -19,10 +19,10 @@ "@noble/ed25519": "^2.1.0", "@noble/hashes": "^1.3.3", "@noble/secp256k1": "^1.7.1", - "@onekeyfe/hd-ble-sdk": "1.1.27-alpha.3", - "@onekeyfe/hd-common-connect-sdk": "1.1.27-alpha.3", - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-web-sdk": "1.1.27-alpha.3", + "@onekeyfe/hd-ble-sdk": "1.1.27-alpha.4", + "@onekeyfe/hd-common-connect-sdk": "1.1.27-alpha.4", + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-web-sdk": "1.1.27-alpha.4", "@onekeyfe/react-native-ble-utils": "^0.1.3", "@polkadot/util-crypto": "13.1.1", "@react-native-async-storage/async-storage": "1.21.0", diff --git a/packages/connect-examples/expo-playground/package.json b/packages/connect-examples/expo-playground/package.json index 2adcc21a1..c4dfe97ce 100644 --- a/packages/connect-examples/expo-playground/package.json +++ b/packages/connect-examples/expo-playground/package.json @@ -1,6 +1,6 @@ { "name": "onekey-hardware-playground", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "private": true, "sideEffects": [ "app/utils/shim.js", @@ -17,9 +17,9 @@ }, "dependencies": { "@noble/hashes": "^1.8.0", - "@onekeyfe/hd-common-connect-sdk": "1.1.27-alpha.3", - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", + "@onekeyfe/hd-common-connect-sdk": "1.1.27-alpha.4", + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", diff --git a/packages/core/package.json b/packages/core/package.json index 53586b086..960f2a6ff 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-core", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "Core processes and APIs for communicating with OneKey hardware devices.", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", @@ -25,8 +25,8 @@ "url": "https://github.com/OneKeyHQ/hardware-js-sdk/issues" }, "dependencies": { - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4", "axios": "1.15.2", "bignumber.js": "^9.0.2", "bytebuffer": "^5.0.1", diff --git a/packages/hd-ble-sdk/package.json b/packages/hd-ble-sdk/package.json index aeb9f8865..601fbb395 100644 --- a/packages/hd-ble-sdk/package.json +++ b/packages/hd-ble-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-ble-sdk", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "ISC", @@ -20,8 +20,8 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-react-native": "1.1.27-alpha.3" + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-react-native": "1.1.27-alpha.4" } } diff --git a/packages/hd-cli/package.json b/packages/hd-cli/package.json index 023fd416d..0e5183c15 100644 --- a/packages/hd-cli/package.json +++ b/packages/hd-cli/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hardware-cli", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "OneKey hardware wallet CLI for testing device communication", "author": "OneKey", "license": "Apache-2.0", @@ -30,10 +30,10 @@ "test": "jest" }, "dependencies": { - "@onekeyfe/hd-common-connect-sdk": "1.1.27-alpha.3", - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-usb": "1.1.27-alpha.3", + "@onekeyfe/hd-common-connect-sdk": "1.1.27-alpha.4", + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-usb": "1.1.27-alpha.4", "commander": "^12.0.0" } } diff --git a/packages/hd-common-connect-sdk/package.json b/packages/hd-common-connect-sdk/package.json index e6357b92e..0f7afa27c 100644 --- a/packages/hd-common-connect-sdk/package.json +++ b/packages/hd-common-connect-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-common-connect-sdk", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "ISC", @@ -20,12 +20,12 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-emulator": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-http": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-lowlevel": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-usb": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-web-device": "1.1.27-alpha.3" + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-emulator": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-http": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-lowlevel": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-usb": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-web-device": "1.1.27-alpha.4" } } diff --git a/packages/hd-transport-electron/package.json b/packages/hd-transport-electron/package.json index 5f5b06f67..2cac25ed0 100644 --- a/packages/hd-transport-electron/package.json +++ b/packages/hd-transport-electron/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-electron", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "MIT", @@ -25,9 +25,9 @@ "electron-log": ">=4.0.0" }, "dependencies": { - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3", + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4", "@stoprocent/noble": "2.3.16", "p-retry": "^4.6.2" }, diff --git a/packages/hd-transport-emulator/package.json b/packages/hd-transport-emulator/package.json index 75e157e0a..a2d2adff0 100644 --- a/packages/hd-transport-emulator/package.json +++ b/packages/hd-transport-emulator/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-emulator", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "hardware emulator transport", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", @@ -24,8 +24,8 @@ "url": "https://github.com/OneKeyHQ/hardware-js-sdk/issues" }, "dependencies": { - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4", "axios": "1.15.2", "secure-json-parse": "^4.0.0" } diff --git a/packages/hd-transport-http/package.json b/packages/hd-transport-http/package.json index 06714a621..973d493a4 100644 --- a/packages/hd-transport-http/package.json +++ b/packages/hd-transport-http/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-http", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "hardware http transport", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", @@ -24,8 +24,8 @@ "url": "https://github.com/OneKeyHQ/hardware-js-sdk/issues" }, "dependencies": { - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4", "axios": "1.15.2", "secure-json-parse": "^4.0.0" } diff --git a/packages/hd-transport-lowlevel/package.json b/packages/hd-transport-lowlevel/package.json index a84679162..718ddd71d 100644 --- a/packages/hd-transport-lowlevel/package.json +++ b/packages/hd-transport-lowlevel/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-lowlevel", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "MIT", "main": "dist/index.js", @@ -19,7 +19,7 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3" + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4" } } diff --git a/packages/hd-transport-react-native/package.json b/packages/hd-transport-react-native/package.json index d01fc5a37..a64a4cb89 100644 --- a/packages/hd-transport-react-native/package.json +++ b/packages/hd-transport-react-native/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-react-native", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "MIT", "main": "dist/index.js", @@ -19,9 +19,9 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3", + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4", "@onekeyfe/react-native-ble-utils": "^0.1.4", "react-native-ble-plx": "3.5.1" } diff --git a/packages/hd-transport-usb/package.json b/packages/hd-transport-usb/package.json index 4a9d467d5..ea27f8bc8 100644 --- a/packages/hd-transport-usb/package.json +++ b/packages/hd-transport-usb/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-usb", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "OneKey hardware wallet direct USB transport plugin (libusb)", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "MIT", @@ -20,8 +20,8 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4", "bytebuffer": "^5.0.1", "usb": "^2.14.0" } diff --git a/packages/hd-transport-web-device/package.json b/packages/hd-transport-web-device/package.json index 88255edfb..bbfa5f02e 100644 --- a/packages/hd-transport-web-device/package.json +++ b/packages/hd-transport-web-device/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport-web-device", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "MIT", @@ -20,11 +20,11 @@ "lint:fix": "eslint . --fix" }, "dependencies": { - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport": "1.1.27-alpha.3" + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport": "1.1.27-alpha.4" }, "devDependencies": { - "@onekeyfe/hd-transport-electron": "1.1.27-alpha.3", + "@onekeyfe/hd-transport-electron": "1.1.27-alpha.4", "@types/w3c-web-usb": "^1.0.6", "@types/web-bluetooth": "^0.0.17" } diff --git a/packages/hd-transport/package.json b/packages/hd-transport/package.json index 9e78b2316..91fabd115 100644 --- a/packages/hd-transport/package.json +++ b/packages/hd-transport/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-transport", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "Transport layer abstractions and utilities for OneKey hardware SDK.", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", diff --git a/packages/hd-web-sdk/package.json b/packages/hd-web-sdk/package.json index cd00238f3..f3b4eb7f7 100644 --- a/packages/hd-web-sdk/package.json +++ b/packages/hd-web-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-web-sdk", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "author": "OneKey", "homepage": "https://github.com/OneKeyHQ/hardware-js-sdk#readme", "license": "ISC", @@ -21,10 +21,10 @@ }, "dependencies": { "@onekeyfe/cross-inpage-provider-core": "2.2.67", - "@onekeyfe/hd-core": "1.1.27-alpha.3", - "@onekeyfe/hd-shared": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-http": "1.1.27-alpha.3", - "@onekeyfe/hd-transport-web-device": "1.1.27-alpha.3" + "@onekeyfe/hd-core": "1.1.27-alpha.4", + "@onekeyfe/hd-shared": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-http": "1.1.27-alpha.4", + "@onekeyfe/hd-transport-web-device": "1.1.27-alpha.4" }, "devDependencies": { "@babel/plugin-proposal-optional-chaining": "^7.17.12", diff --git a/packages/hwk-adapter-core/package.json b/packages/hwk-adapter-core/package.json index 25086b638..bcd12a20c 100644 --- a/packages/hwk-adapter-core/package.json +++ b/packages/hwk-adapter-core/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hwk-adapter-core", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "Shared types and utilities for OneKey hardware wallet kit", "author": "OneKey", "license": "MIT", diff --git a/packages/hwk-adapter-core/src/__tests__/core-types.test.ts b/packages/hwk-adapter-core/src/__tests__/core-types.test.ts index df6ad1403..769bf3c45 100644 --- a/packages/hwk-adapter-core/src/__tests__/core-types.test.ts +++ b/packages/hwk-adapter-core/src/__tests__/core-types.test.ts @@ -33,6 +33,7 @@ describe('HardwareErrorCode contract', () => { expect(HardwareErrorCode.DeviceNotInitialized).toBe(10104); expect(HardwareErrorCode.DeviceInBootloader).toBe(10105); expect(HardwareErrorCode.DeviceMismatch).toBe(10106); + expect(HardwareErrorCode.DeviceOneDeviceOnly).toBe(10109); }); it('firmware (10200-10299)', () => { diff --git a/packages/hwk-adapter-core/src/types/errors.ts b/packages/hwk-adapter-core/src/types/errors.ts index ca751d27c..895fc9ae1 100644 --- a/packages/hwk-adapter-core/src/types/errors.ts +++ b/packages/hwk-adapter-core/src/types/errors.ts @@ -43,6 +43,8 @@ export enum HardwareErrorCode { DeviceAppStuck = 10107, /** Vendor (Ledger / Trezor) doesn't support the chain at all. */ ChainNotSupported = 10108, + /** Current operation supports only one connected device. */ + DeviceOneDeviceOnly = 10109, // --- 10200s Firmware --- FirmwareTooOld = 10200, @@ -129,6 +131,7 @@ export const ORPHAN_ELIGIBLE_ERROR_CODES: number[] = [ HardwareErrorCode.DeviceDisconnected, HardwareErrorCode.DeviceMismatch, HardwareErrorCode.DeviceAppStuck, + HardwareErrorCode.DeviceOneDeviceOnly, HardwareErrorCode.TransportError, HardwareErrorCode.DevicePermissionDenied, HardwareErrorCode.BlePairingTimeout, diff --git a/packages/hwk-adapter-core/src/utils/errorMessages.ts b/packages/hwk-adapter-core/src/utils/errorMessages.ts index fc3bd0d05..1ff3d0d77 100644 --- a/packages/hwk-adapter-core/src/utils/errorMessages.ts +++ b/packages/hwk-adapter-core/src/utils/errorMessages.ts @@ -16,6 +16,8 @@ export function enrichErrorMessage(code: HardwareErrorCode, originalMessage: str return `${originalMessage}. Please reconnect the device and try again.`; case HardwareErrorCode.DeviceLocked: return `${originalMessage}. Please unlock your device and try again.`; + case HardwareErrorCode.DeviceOneDeviceOnly: + return `${originalMessage}. Disconnect the extra device and try again.`; case HardwareErrorCode.UserRejected: return `${originalMessage}. The request was rejected on the device.`; case HardwareErrorCode.WrongApp: diff --git a/packages/hwk-ledger-adapter/package.json b/packages/hwk-ledger-adapter/package.json index c52be7731..b6f90c941 100644 --- a/packages/hwk-ledger-adapter/package.json +++ b/packages/hwk-ledger-adapter/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hwk-ledger-adapter", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "Ledger hardware wallet adapter for OneKey", "author": "OneKey", "license": "MIT", @@ -50,7 +50,7 @@ "@ledgerhq/device-signer-kit-solana": "^1.7.0", "@ledgerhq/hw-app-trx": "^6.34.1", "@ledgerhq/hw-transport": "^6.34.1", - "@onekeyfe/hwk-adapter-core": "1.1.27-alpha.3", + "@onekeyfe/hwk-adapter-core": "1.1.27-alpha.4", "bitcoinjs-lib": "npm:@onekeyfe/bitcoinjs-lib@7.0.1" }, "devDependencies": { diff --git a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts index 1f87131e2..d2e4fb2dc 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/LedgerAdapter.test.ts @@ -1090,15 +1090,20 @@ describe('LedgerAdapter', () => { ); }); - it('should retry with fresh connection on disconnect error', async () => { + it('should retry with a recovered USB connectId when fingerprint verification is available', async () => { + const expectedAddress = '0x1111111111111111111111111111111111111111'; + const expectedFingerprint = deriveDeviceFingerprint(expectedAddress); + // First: establish a session await adapter.connectDevice('dev-1'); // Simulate disconnect error on first call, success on retry connector.call + .mockResolvedValueOnce({ address: expectedAddress }) .mockRejectedValueOnce( Object.assign(new Error('session not found'), { _tag: 'DeviceSessionNotFound' }) ) + .mockResolvedValueOnce({ address: expectedAddress }) .mockResolvedValueOnce({ address: '0xRETRY' }); // After disconnect, searchDevices returns a new device ID (DMK regenerates UUIDs) @@ -1117,7 +1122,7 @@ describe('LedgerAdapter', () => { }, }); - const result = await adapter.evmGetAddress('dev-1', '', { + const result = await adapter.evmGetAddress('dev-1', expectedFingerprint, { path: "m/44'/60'/0'/0/0", showOnDevice: false, }); @@ -1136,6 +1141,29 @@ describe('LedgerAdapter', () => { ); }); + it('should fail closed when a USB target misses and no device fingerprint is available', async () => { + await adapter.connectDevice('dev-1'); + + connector.call.mockRejectedValueOnce( + Object.assign(new Error('session not found'), { _tag: 'DeviceSessionNotFound' }) + ); + connector.searchDevices.mockResolvedValueOnce([ + { connectId: 'dev-new', deviceId: 'dev-new', name: 'Nano X', model: 'nanoX' }, + ]); + + const result = await adapter.evmGetAddress('dev-1', '', { + path: "m/44'/60'/0'/0/0", + showOnDevice: false, + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.payload.code).toBe(HardwareErrorCode.DeviceNotFound); + expect(result.payload.error).toContain('Target Ledger unavailable: dev-1'); + } + expect(connector.connect).not.toHaveBeenCalledWith('dev-new'); + }); + it('should reconnect the original target after timeout reset', async () => { const expectedAddress = '0x1111111111111111111111111111111111111111'; const expectedFingerprint = deriveDeviceFingerprint(expectedAddress); @@ -1151,7 +1179,6 @@ describe('LedgerAdapter', () => { .mockResolvedValueOnce({ signature: '0xSIGNED' }); connector.searchDevices.mockResolvedValueOnce([ - { connectId: 'dev-new', deviceId: 'dev-new', name: 'Nano S', model: 'nanoS' }, { connectId: 'dev-1', deviceId: 'dev-1', name: 'Nano X', model: 'nanoX' }, ]); connector.connect.mockResolvedValueOnce({ @@ -1173,7 +1200,6 @@ describe('LedgerAdapter', () => { expect(result.success).toBe(true); expect(connector.connect).toHaveBeenLastCalledWith('dev-1'); - expect(connector.connect).not.toHaveBeenCalledWith('dev-new'); expect(connector.call).toHaveBeenLastCalledWith( 'session-target', 'evmSignMessage', @@ -1240,7 +1266,7 @@ describe('LedgerAdapter', () => { ); }); - it('should auto-select first device when multiple devices found and handleSelectDevice is off (default)', async () => { + it('should reject multiple USB devices instead of auto-selecting the first one', async () => { connector.searchDevices.mockResolvedValueOnce([ { connectId: 'dev-A', deviceId: 'dev-A', name: 'Nano X', model: 'nanoX' }, { connectId: 'dev-B', deviceId: 'dev-B', name: 'Nano S', model: 'nanoS' }, @@ -1258,32 +1284,20 @@ describe('LedgerAdapter', () => { }); connector.call.mockResolvedValueOnce({ address: '0xFALLBACK' }); - // No UI handler set — should fall back to first device const result = await adapter.evmGetAddress('', '', { path: "m/44'/60'/0'/0/0", showOnDevice: false, }); - expect(result.success).toBe(true); - expect(connector.connect).toHaveBeenCalledWith('dev-A'); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.payload.code).toBe(HardwareErrorCode.DeviceOneDeviceOnly); + expect(result.payload.error).toContain('Multiple Ledger USB devices are connected'); + } + expect(connector.connect).not.toHaveBeenCalled(); }); - it('should capture a synchronous select-device response from the UI handler', async () => { - const selectAdapter = new LedgerAdapter(connector, { handleSelectDevice: true }); - selectAdapter.on(UI_REQUEST.REQUEST_DEVICE_PERMISSION, () => { - selectAdapter.uiResponse({ - type: UI_RESPONSE.RECEIVE_DEVICE_PERMISSION, - payload: { granted: true }, - }); - }); - selectAdapter.on(UI_REQUEST.REQUEST_SELECT_DEVICE, event => { - const selected = event.payload.devices.find(d => d.connectId === 'dev-B'); - selectAdapter.uiResponse({ - type: UI_RESPONSE.RECEIVE_SELECT_DEVICE, - payload: { sdkConnectId: selected?.connectId ?? 'dev-B' }, - }); - }); - + it('connects the explicitly targeted device even when multiple USB devices are present', async () => { connector.searchDevices.mockResolvedValueOnce([ { connectId: 'dev-A', deviceId: 'dev-A', name: 'Nano X', model: 'nanoX' }, { connectId: 'dev-B', deviceId: 'dev-B', name: 'Nano S', model: 'nanoS' }, @@ -1299,15 +1313,16 @@ describe('LedgerAdapter', () => { connectionType: 'usb', }, }); - connector.call.mockResolvedValueOnce({ address: '0xSELECTED' }); + connector.call.mockResolvedValueOnce({ address: '0xTARGET', publicKey: '0xpk' }); - const result = await selectAdapter.evmGetAddress('', '', { + const result = await adapter.evmGetAddress('dev-B', '', { path: "m/44'/60'/0'/0/0", showOnDevice: false, }); expect(result.success).toBe(true); expect(connector.connect).toHaveBeenCalledWith('dev-B'); + expect(connector.connect).not.toHaveBeenCalledWith('dev-A'); }); it('should reject BLE business calls with an empty connectId instead of auto-selecting a device', async () => { diff --git a/packages/hwk-ledger-adapter/src/__tests__/LedgerConnectorBase.test.ts b/packages/hwk-ledger-adapter/src/__tests__/LedgerConnectorBase.test.ts index ab6305811..38671db88 100644 --- a/packages/hwk-ledger-adapter/src/__tests__/LedgerConnectorBase.test.ts +++ b/packages/hwk-ledger-adapter/src/__tests__/LedgerConnectorBase.test.ts @@ -3,11 +3,11 @@ import { HardwareErrorCode } from '@onekeyfe/hwk-adapter-core'; import { LedgerConnectorBase } from '../connector/LedgerConnectorBase'; import { ERROR_TAG } from '../errors'; -import type { DeviceDescriptor } from '@onekeyfe/hwk-adapter-core'; +import type { ConnectionType, DeviceDescriptor } from '@onekeyfe/hwk-adapter-core'; -class BleSearchConnector extends LedgerConnectorBase { - constructor(private readonly descriptors: DeviceDescriptor[]) { - super(async () => ({}), { connectionType: 'ble', dmk: {} as any }); +class SearchConnector extends LedgerConnectorBase { + constructor(private readonly descriptors: DeviceDescriptor[], connectionType: ConnectionType) { + super(async () => ({}), { connectionType, dmk: {} as any }); } protected override async _discoverDescriptors(): Promise { @@ -61,15 +61,18 @@ describe('LedgerConnectorBase error wrapping', () => { describe('LedgerConnectorBase BLE discovery', () => { it('allows transport ids as BLE connectId even when they are not four-character names', async () => { - const connector = new BleSearchConnector([ - { - path: 'D5:75:7D:4B:51:E8', - name: 'Nano X 123', - bleName: 'A58F', - transport: 'RN_BLE', - type: 'nanoX', - }, - ]); + const connector = new SearchConnector( + [ + { + path: 'D5:75:7D:4B:51:E8', + name: 'Nano X 123', + bleName: 'A58F', + transport: 'RN_BLE', + type: 'nanoX', + }, + ], + 'ble' + ); await expect(connector.searchDevices()).resolves.toEqual([ expect.objectContaining({ @@ -82,6 +85,30 @@ describe('LedgerConnectorBase BLE discovery', () => { }); }); +describe('LedgerConnectorBase USB discovery', () => { + const usbDescriptors: DeviceDescriptor[] = [ + { + path: 'usb-path-a', + name: 'Ledger Stax', + transport: 'WEB-HID', + type: 'stax', + }, + { + path: 'usb-path-b', + name: 'Ledger Nano Gen5', + transport: 'WEB-HID', + type: 'apex', + }, + ]; + + it('returns all USB devices (single-device restriction is enforced on auto-connect, not discovery)', async () => { + const connector = new SearchConnector(usbDescriptors, 'usb'); + + const devices = await connector.searchDevices(); + + expect(devices.map(d => d.connectId)).toEqual(['usb-path-a', 'usb-path-b']); + }); +}); describe('LedgerConnectorBase BLE direct-connect gate', () => { const TARGET_PATH = 'D5:75:7D:4B:51:E8'; @@ -100,9 +127,18 @@ describe('LedgerConnectorBase BLE direct-connect gate', () => { } function setupConnector(requirePreFlightScan: boolean) { + // _watchSessionState now propagates subscribe failures (so missing + // subscriptions can't silently leave ghost entries in the adapter's + // _sessions map). Provide a no-op observable so happy-path connect() + // tests don't trip on the new strict behavior. + const fakeDmk = { + getDeviceSessionState: jest.fn().mockReturnValue({ + subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + }), + }; const connector = new LedgerConnectorBase(async () => ({}), { connectionType: 'ble', - dmk: {} as any, + dmk: fakeDmk as any, requirePreFlightScan, }); return connector; diff --git a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts index 4d36fe560..0683555a0 100644 --- a/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts +++ b/packages/hwk-ledger-adapter/src/adapter/LedgerAdapter.ts @@ -15,6 +15,7 @@ import { import { ERROR_TAG, + createMultipleUsbLedgerDevicesError, isConnectionLevelError, isDeviceDisconnectedError, isDeviceLockedError, @@ -75,15 +76,6 @@ import type { UiResponseEvent, } from '@onekeyfe/hwk-adapter-core'; -export interface LedgerAdapterOptions { - /** - * `true` — emit `REQUEST_SELECT_DEVICE` on multi-device discovery and await - * `uiResponse({ type: RECEIVE_SELECT_DEVICE, payload: { sdkConnectId } })`. - * `false` (default) — silently pick the first device. - */ - handleSelectDevice?: boolean; -} - /** * Result of `_verifyDeviceFingerprint`. * @@ -95,6 +87,12 @@ type IFingerprintVerifyResult = | { success: true } | { success: false; expected: string; actual: string }; +type ConnectorCallFingerprint = { + chain: ChainForFingerprint; + deviceId: string; + skipFingerprint: boolean; +}; + // Fingerprints are deterministic 16-char hashes of fixed testnet paths, // not secrets — safe to log. function formatDeviceMismatchError(expected: string, actual: string): string { @@ -123,8 +121,6 @@ export class LedgerAdapter implements IHardwareWallet { private readonly emitter = new TypedEventEmitter(); - private readonly _handleSelectDevice: boolean; - private _discoveredDevices = new Map(); private _sessions = new Map(); @@ -149,9 +145,8 @@ export class LedgerAdapter implements IHardwareWallet { // Shared across concurrent callers — only `cancel()` aborts. private _doConnectAbortController: AbortController | null = null; - constructor(connector: IConnector, options?: LedgerAdapterOptions) { + constructor(connector: IConnector) { this.connector = connector; - this._handleSelectDevice = options?.handleSelectDevice ?? false; this._jobQueue = new DeviceJobQueue(); this.registerEventListeners(); } @@ -221,12 +216,8 @@ export class LedgerAdapter implements IHardwareWallet { const devices = await this.connector.searchDevices(); - // Replace cache with this round's raw result. DMK paths used as - // connectId on USB are ephemeral (new UUID after each replug), so - // incremental writes leave stale entries pointing at devices DMK no - // longer recognizes — the visible symptom is the same physical device - // appearing twice in the discovered list, one with a real name and one - // with the 'Ledger' placeholder from a connect-time event. + // Replace cache with this round's raw result. DMK paths used as connectId + // on USB are ephemeral — incremental writes leave stale entries. this._discoveredDevices.clear(); for (const d of devices) { if (d.connectId) { @@ -234,7 +225,6 @@ export class LedgerAdapter implements IHardwareWallet { } } - // If no devices found, ensure permission (no connectId = search context) if (this._discoveredDevices.size === 0) { await this._ensureDevicePermission(); } @@ -247,6 +237,20 @@ export class LedgerAdapter implements IHardwareWallet { return Array.from(this._discoveredDevices.values()); } + // USB single-session invariant: evict all sessions, best-effort (see connectDevice). + private async _evictAllSessions(): Promise { + if (this._sessions.size === 0) return; + const stale = [...this._sessions.values()]; + this._sessions.clear(); + for (const sid of stale) { + try { + await this.connector.disconnect(sid); + } catch { + // best-effort + } + } + } + // Layer 2 retry budget after connection-class error. Each round delegates // to Layer 1 (which owns the unlock prompt). Layer 2 never emits UI itself. private static readonly MAX_BUSINESS_RETRY_BUDGET = 3; @@ -279,6 +283,12 @@ export class LedgerAdapter implements IHardwareWallet { }); } + // USB single-session invariant: evict any stale session before opening + // a new one — guards ensureConnected's ambient fallback against ghosts. + if (!isLedgerBleConnectionType(this.connector.connectionType)) { + await this._evictAllSessions(); + } + await this._ensureDevicePermission(connectId); const session = await this.connector.connect(connectId); @@ -671,7 +681,7 @@ export class LedgerAdapter implements IHardwareWallet { * * - If a session already exists for the given connectId, reuse it. * - If ANY session exists (Ledger IDs are ephemeral), reuse it. - * - Otherwise: search → 1 device: auto-connect, multiple: ask user, 0: throw. + * - Otherwise: search → exactly 1 USB device auto-connects; multiple or none throws. */ // Mutex for ensureConnected — prevents concurrent calls from establishing duplicate connections private _connectingPromise: Promise | null = null; @@ -807,7 +817,8 @@ export class LedgerAdapter implements IHardwareWallet { // doesn't kill caller B's await. private async ensureConnected( connectId: string | undefined, - signal: AbortSignal + signal: AbortSignal, + allowUsbEphemeralFallback = false ): Promise { if (signal.aborted) LedgerAdapter._throwIfAborted(signal); @@ -818,7 +829,20 @@ export class LedgerAdapter implements IHardwareWallet { } if (connectId && this._sessions.has(connectId)) return connectId; - if (this._sessions.size > 0) { + // Ambient fallback only when the caller gave no target. On an explicit + // connectId miss, re-resolve THAT device via _doConnect — never route to + // the first session entry (a stale entry for B once made A's calls hit B). + if (!connectId && this._sessions.size > 0) { + // Fail loud rather than route to a wrong device; connectDevice evicts + // first, so size>1 here should be impossible. + if (!isLedgerBleConnectionType(this.connector.connectionType) && this._sessions.size > 1) { + throw Object.assign( + new Error( + 'Ledger USB session invariant violated: more than one session is active. Please reconnect the device.' + ), + { code: HardwareErrorCode.DeviceOneDeviceOnly } + ); + } return this._sessions.keys().next().value as string; } @@ -827,7 +851,7 @@ export class LedgerAdapter implements IHardwareWallet { const innerSignal = this._doConnectAbortController.signal; this._connectingPromise = (async () => { try { - return await this._doConnect(innerSignal, connectId); + return await this._doConnect(innerSignal, connectId, allowUsbEphemeralFallback); } finally { this._connectingPromise = null; this._doConnectAbortController = null; @@ -841,7 +865,11 @@ export class LedgerAdapter implements IHardwareWallet { // Layer 1 main loop — the ONLY place in SDK that emits unlock dialog. // Bounded by MAX_DOCONNECT_CONFIRMS — after N Confirms with no progress, // throw DeviceNotFound so the user is kicked out of the loop. - private async _doConnect(internalSignal: AbortSignal, targetConnectId?: string): Promise { + private async _doConnect( + internalSignal: AbortSignal, + targetConnectId?: string, + allowUsbEphemeralFallback = false + ): Promise { if (isLedgerBleConnectionType(this.connector.connectionType) && targetConnectId) { try { return await this._connectDeviceOrThrow(targetConnectId); @@ -892,7 +920,11 @@ export class LedgerAdapter implements IHardwareWallet { if (devices.length > 0) { try { - return await this._connectFirstOrSelect(devices, targetConnectId); + return await this._connectFirstOrSelect( + devices, + targetConnectId, + allowUsbEphemeralFallback + ); } catch (err) { // PairingFailure / DeviceMismatch / unclassified → throw out, no // Layer 1 retry. PairingRefused = user declined system pair prompt @@ -936,22 +968,47 @@ export class LedgerAdapter implements IHardwareWallet { private async _connectFirstOrSelect( devices: DeviceInfo[], - targetConnectId?: string + targetConnectId?: string, + allowUsbEphemeralFallback = false ): Promise { if (targetConnectId) { const target = devices.find( d => d.connectId === targetConnectId || d.deviceId === targetConnectId ); - if (!target) { - const err = Object.assign(new Error(`Target Ledger unavailable: ${targetConnectId}`), { - code: HardwareErrorCode.DeviceNotFound, - }) as Error & { _tag?: string }; - if (isLedgerBleConnectionType(this.connector.connectionType)) { - err._tag = ERROR_TAG.DeviceNotAdvertising; - } - throw err; + if (target) { + return this._connectDeviceOrThrow(target.connectId); + } + + // Decision: a stale USB target + fresh search returning exactly one + // Ledger is not proof that the sole Ledger is the original device. It + // might be A after a replug (safe to recover), or B after A was unplugged + // (wrong-device risk). + // + // We only take this recovery path when the business call supplied a + // stable wallet fingerprint (`deviceId`) and did not opt out of + // fingerprint checks. The connection is then provisional: the caller must + // run `_verifyDeviceFingerprintWithSession` before sending the real APDU. + // Calls without that identity check fail closed with DeviceNotFound so + // the host can ask the user to reconnect/select again. + if ( + !isLedgerBleConnectionType(this.connector.connectionType) && + devices.length === 1 && + allowUsbEphemeralFallback + ) { + debugLog( + `[LedgerAdapter] target ${targetConnectId} not in fresh enumeration; ` + + `accepting sole USB device ${devices[0].connectId} for fingerprint-verified recovery` + ); + return this._connectDeviceOrThrow(devices[0].connectId); } - return this._connectDeviceOrThrow(target.connectId); + + const err = Object.assign(new Error(`Target Ledger unavailable: ${targetConnectId}`), { + code: HardwareErrorCode.DeviceNotFound, + }) as Error & { _tag?: string }; + if (isLedgerBleConnectionType(this.connector.connectionType)) { + err._tag = ERROR_TAG.DeviceNotAdvertising; + } + throw err; } if (isLedgerBleConnectionType(this.connector.connectionType)) { @@ -960,10 +1017,19 @@ export class LedgerAdapter implements IHardwareWallet { }); } - const chosenConnectId = - devices.length === 1 ? devices[0].connectId : await this._chooseDeviceFromList(devices); + // No target + multiple USB devices: refuse to auto-pick by ephemeral + // connectId (the multi-device drift bug). Caller must specify which device. + if (devices.length > 1) { + throw createMultipleUsbLedgerDevicesError(); + } - return this._connectDeviceOrThrow(chosenConnectId); + if (devices.length !== 1) { + throw Object.assign(new Error('Ledger device not found.'), { + code: HardwareErrorCode.DeviceNotFound, + }); + } + + return this._connectDeviceOrThrow(devices[0].connectId); } private async _connectDeviceOrThrow(chosenConnectId: string): Promise { @@ -982,53 +1048,6 @@ export class LedgerAdapter implements IHardwareWallet { return chosenConnectId; } - private async _chooseDeviceFromList(devices: DeviceInfo[]): Promise { - if (!this._handleSelectDevice) { - debugLog( - `[DMK] Multiple Ledger devices found (${devices.length}); handleSelectDevice=false, picking first (${devices[0].connectId}).` - ); - return devices[0].connectId; - } - - let response: { sdkConnectId: string } | undefined; - try { - const waitPromise = this._uiRegistry.wait<{ sdkConnectId: string }>( - UI_REQUEST.REQUEST_SELECT_DEVICE - ); - this.emitter.emit(UI_REQUEST.REQUEST_SELECT_DEVICE, { - type: UI_REQUEST.REQUEST_SELECT_DEVICE, - payload: { devices }, - }); - response = await waitPromise; - } catch (err) { - // Defensive: same-type re-fire of REQUEST_SELECT_DEVICE could preempt - // this wait. Surface as UserAborted — _doConnect's catch only treats - // Locked / NotAdvertising / Disconnected as recoverable; raw - // UiRequestPreempted would escape the retry loop. - if ((err as { _tag?: string })?._tag === UI_REQUEST_PREEMPTED_TAG) { - this.emitter.emit(UI_REQUEST.CLOSE_UI_WINDOW, { - type: UI_REQUEST.CLOSE_UI_WINDOW, - payload: {}, - }); - throw Object.assign(new Error('Device selection superseded'), { - _tag: ERROR_TAG.UserAborted, - code: HardwareErrorCode.UserAborted, - cause: err, - }); - } - throw err; - } - - const chosen = devices.find(d => d.connectId === response?.sdkConnectId); - if (!chosen) { - throw Object.assign( - new Error(`Selected sdkConnectId '${response?.sdkConnectId}' not in discovered list`), - { _tag: ERROR_TAG.DeviceNotRecognized } - ); - } - return chosen.connectId; - } - /** * Call the connector with automatic session resolution and disconnect retry. * @@ -1041,11 +1060,7 @@ export class LedgerAdapter implements IHardwareWallet { connectId: string, method: string, params: unknown, - fingerprint?: { - chain: ChainForFingerprint; - deviceId: string; - skipFingerprint: boolean; - }, + fingerprint?: ConnectorCallFingerprint, permissionDeviceId?: string ): Promise { debugLog('[LedgerAdapter] connectorCall:', method, 'connectId:', connectId || '(empty)'); @@ -1109,11 +1124,7 @@ export class LedgerAdapter implements IHardwareWallet { method: string, params: unknown, signal: AbortSignal, - fingerprint?: { - chain: ChainForFingerprint; - deviceId: string; - skipFingerprint: boolean; - }, + fingerprint?: ConnectorCallFingerprint, permissionDeviceId?: string ): Promise { LedgerAdapter._throwIfAborted(signal); @@ -1135,11 +1146,17 @@ export class LedgerAdapter implements IHardwareWallet { effectiveParams = gatedParams; } + const allowUsbEphemeralFallback = !!fingerprint?.deviceId && !fingerprint.skipFingerprint; + // Wrap ensureConnected in _abortable so an abort during device discovery / // user-connect UI wait rejects this caller immediately. The underlying // _doConnect / _connectingPromise is shared across callers and continues // running — other concurrent callers aren't affected. - const resolvedConnectId = await this.ensureConnected(connectId, signal); + const resolvedConnectId = await this.ensureConnected( + connectId, + signal, + allowUsbEphemeralFallback + ); const sessionId = this._sessions.get(resolvedConnectId); if (!sessionId) { throw Object.assign(new Error('Auto-connect succeeded but no session found'), { @@ -1269,10 +1286,14 @@ export class LedgerAdapter implements IHardwareWallet { // from APDU 0x5515 (rare hardware-direct), and _doConnect's // dialog still covers it via the same UI request. - const retryConnectId = isLedgerBleConnectionType(this.connector.connectionType) - ? resolvedConnectId - : undefined; - const reConnectId = await this.ensureConnected(retryConnectId, signal); + // Preserve connectId across retry to prevent multi-device drift. + // USB replug fallback is allowed only when this call will verify + // the device fingerprint before sending the business APDU. + const reConnectId = await this.ensureConnected( + resolvedConnectId, + signal, + allowUsbEphemeralFallback + ); const reSessionId = this._sessions.get(reConnectId); if (!reSessionId) throw lastErr; @@ -1360,20 +1381,18 @@ export class LedgerAdapter implements IHardwareWallet { params: unknown, signal: AbortSignal, originalErr: unknown, - fingerprint?: { - chain: ChainForFingerprint; - deviceId: string; - skipFingerprint: boolean; - } + fingerprint?: ConnectorCallFingerprint ): Promise { await this._sleepAbortable(LedgerAdapter.STUCK_APP_RETRY_DELAY_MS, signal); - // BLE: keep the same connectId so we don't re-prompt the user to pair. - // USB: pass undefined; ensureConnected enumerates fresh. - const retryTargetConnectId = isLedgerBleConnectionType(this.connector.connectionType) - ? resolvedConnectId - : undefined; - const retryConnectId = await this.ensureConnected(retryTargetConnectId, signal); + // Preserve connectId across retry (APDU 6901 didn't disconnect, so it's + // still valid). If a USB replug changed the connectId, recover only when + // the call has a fingerprint check queued immediately after reconnect. + const retryConnectId = await this.ensureConnected( + resolvedConnectId, + signal, + !!fingerprint?.deviceId && !fingerprint.skipFingerprint + ); const retrySessionId = this._sessions.get(retryConnectId); if (!retrySessionId) throw originalErr; @@ -1427,18 +1446,19 @@ export class LedgerAdapter implements IHardwareWallet { params: unknown, signal: AbortSignal, originalErr: unknown, - fingerprint?: { - chain: ChainForFingerprint; - deviceId: string; - skipFingerprint: boolean; - } + fingerprint?: ConnectorCallFingerprint ): Promise { this.connector.reset(); this._sessions.clear(); this._discoveredDevices.clear(); this._connectingPromise = null; - const retryConnectId = await this.ensureConnected(targetConnectId, signal); + const allowUsbEphemeralFallback = !!fingerprint?.deviceId && !fingerprint.skipFingerprint; + const retryConnectId = await this.ensureConnected( + targetConnectId, + signal, + allowUsbEphemeralFallback + ); const retrySessionId = this._sessions.get(retryConnectId); if (!retrySessionId) { throw originalErr; @@ -1480,7 +1500,11 @@ export class LedgerAdapter implements IHardwareWallet { this._sessions.clear(); this._discoveredDevices.clear(); this._connectingPromise = null; - const finalConnectId = await this.ensureConnected(targetConnectId, signal); + const finalConnectId = await this.ensureConnected( + targetConnectId, + signal, + allowUsbEphemeralFallback + ); const finalSessionId = this._sessions.get(finalConnectId); if (!finalSessionId) { throw originalErr; diff --git a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts index 503929367..5d0526dd0 100644 --- a/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts +++ b/packages/hwk-ledger-adapter/src/connector/LedgerConnectorBase.ts @@ -315,6 +315,12 @@ export class LedgerConnectorBase implements IConnector { // --------------------------------------------------------------------------- // IConnector -- Device discovery + // + // Discovery never throws on multiple USB devices — it returns the full list. + // The "connect exactly one device" rule is enforced upstream: an explicit + // connectId connects that device (or fails as not-found), and only the + // no-connectId USB path rejects when more than one device is present + // (see LedgerAdapter._connectFirstOrSelect). // --------------------------------------------------------------------------- async searchDevices(): Promise { @@ -466,7 +472,19 @@ export class LedgerConnectorBase implements IConnector { } throw err; } - this._watchSessionState(sessionId, externalConnectId); + try { + this._watchSessionState(sessionId, externalConnectId); + } catch (subErr) { + // Subscription failure is fatal (see _watchSessionState) — disconnect + // the just-created DMK session so connect()'s catch wraps it normally. + debugLog('[DMK] state subscription failed during connect; disconnecting session:', subErr); + try { + await dm.disconnect(sessionId); + } catch { + // best-effort cleanup + } + throw subErr; + } const info = dm.getDiscoveredDeviceInfo(path); const session: ConnectorSession = { sessionId, @@ -562,8 +580,7 @@ export class LedgerConnectorBase implements IConnector { * `LedgerAdapter._sessions` map would hold a dead session entry until * the next call hit `DeviceSessionNotFound`. * - * Best-effort: any error subscribing is swallowed so a flaky DMK - * doesn't break the connect path. + * Subscribe failure is fatal — see the inline note at the subscribe call. */ private _watchSessionState(sessionId: string, externalConnectId: string): void { const dmk = this._dmk; @@ -576,29 +593,30 @@ export class LedgerConnectorBase implements IConnector { // ignore } } - try { - const sub = dmk.getDeviceSessionState({ sessionId }).subscribe({ - next: (state: { deviceStatus?: string }) => { - // String-compare against DeviceStatus.NOT_CONNECTED ("NOT CONNECTED") - // to avoid pulling the runtime enum import (kept type-only for - // Metro/RN compatibility — see _importLedgerKit). - if (state?.deviceStatus === 'NOT CONNECTED') { - this._handleAutonomousDisconnect(sessionId, externalConnectId); - } - }, - error: () => { - // DMK closed the observable abnormally — treat as disconnect. + // Subscribe failure is fatal: without this subscription we can't detect + // autonomous disconnect (USB unplug, BLE drop, sleep), which leaks ghost + // entries into the adapter's _sessions map and can route subsequent calls + // to the wrong device. Let the error propagate so connect() cleans up the + // just-created DMK session and fails loudly. + const sub = dmk.getDeviceSessionState({ sessionId }).subscribe({ + next: (state: { deviceStatus?: string }) => { + // String-compare against DeviceStatus.NOT_CONNECTED ("NOT CONNECTED") + // to avoid pulling the runtime enum import (kept type-only for + // Metro/RN compatibility — see _importLedgerKit). + if (state?.deviceStatus === 'NOT CONNECTED') { this._handleAutonomousDisconnect(sessionId, externalConnectId); - }, - complete: () => { - // Observable completed — session is gone from DMK's POV. - this._handleAutonomousDisconnect(sessionId, externalConnectId); - }, - }); - this._sessionStateSubs.set(sessionId, sub); - } catch (err) { - debugLog('[DMK] _watchSessionState subscribe failed:', err); - } + } + }, + error: () => { + // DMK closed the observable abnormally — treat as disconnect. + this._handleAutonomousDisconnect(sessionId, externalConnectId); + }, + complete: () => { + // Observable completed — session is gone from DMK's POV. + this._handleAutonomousDisconnect(sessionId, externalConnectId); + }, + }); + this._sessionStateSubs.set(sessionId, sub); } private _unwatchSessionState(sessionId: string): void { diff --git a/packages/hwk-ledger-adapter/src/errors.ts b/packages/hwk-ledger-adapter/src/errors.ts index bf87c6172..25d128691 100644 --- a/packages/hwk-ledger-adapter/src/errors.ts +++ b/packages/hwk-ledger-adapter/src/errors.ts @@ -2,6 +2,15 @@ import { HardwareErrorCode, enrichErrorMessage } from '@onekeyfe/hwk-adapter-cor import type { Failure } from '@onekeyfe/hwk-adapter-core'; +export const MULTIPLE_USB_LEDGER_DEVICES_ERROR_MESSAGE = + 'Multiple Ledger USB devices are connected. Please connect only one Ledger device and try again.'; + +export function createMultipleUsbLedgerDevicesError(): Error & { code: HardwareErrorCode } { + return Object.assign(new Error(MULTIPLE_USB_LEDGER_DEVICES_ERROR_MESSAGE), { + code: HardwareErrorCode.DeviceOneDeviceOnly, + }); +} + // `_tag` survives errorToFailure → re-throw so SDK classifiers keep working. export type LedgerFailure = Omit & { payload: Failure['payload'] & { appName?: string; _tag?: string }; diff --git a/packages/hwk-ledger-connector-ble/package.json b/packages/hwk-ledger-connector-ble/package.json index fcd70b82a..5d03faa8e 100644 --- a/packages/hwk-ledger-connector-ble/package.json +++ b/packages/hwk-ledger-connector-ble/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hwk-ledger-connector-ble", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "IConnector implementation for Ledger hardware wallets via React Native BLE", "author": "OneKey", "license": "MIT", @@ -51,8 +51,8 @@ "@ledgerhq/device-signer-kit-ethereum": "^1.9.0", "@ledgerhq/device-signer-kit-solana": "^1.7.0", "@ledgerhq/device-transport-kit-react-native-ble": "^1.0.0", - "@onekeyfe/hwk-adapter-core": "1.1.27-alpha.3", - "@onekeyfe/hwk-ledger-adapter": "1.1.27-alpha.3" + "@onekeyfe/hwk-adapter-core": "1.1.27-alpha.4", + "@onekeyfe/hwk-ledger-adapter": "1.1.27-alpha.4" }, "peerDependencies": { "react-native": "*" diff --git a/packages/hwk-ledger-connector-webhid/package.json b/packages/hwk-ledger-connector-webhid/package.json index 96e982423..a83a52e9a 100644 --- a/packages/hwk-ledger-connector-webhid/package.json +++ b/packages/hwk-ledger-connector-webhid/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hwk-ledger-connector-webhid", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "IConnector implementation for Ledger hardware wallets via WebHID (DMK)", "author": "OneKey", "license": "MIT", @@ -49,8 +49,8 @@ "@ledgerhq/device-signer-kit-ethereum": "^1.9.0", "@ledgerhq/device-signer-kit-solana": "^1.7.0", "@ledgerhq/device-transport-kit-web-hid": "^1.0.0", - "@onekeyfe/hwk-adapter-core": "1.1.27-alpha.3", - "@onekeyfe/hwk-ledger-adapter": "1.1.27-alpha.3" + "@onekeyfe/hwk-adapter-core": "1.1.27-alpha.4", + "@onekeyfe/hwk-ledger-adapter": "1.1.27-alpha.4" }, "devDependencies": { "rimraf": "^5.0.0", diff --git a/packages/shared/package.json b/packages/shared/package.json index 5b8fe3d6e..4fe5e548a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/hd-shared", - "version": "1.1.27-alpha.3", + "version": "1.1.27-alpha.4", "description": "Hardware SDK's shared tool library", "keywords": [ "Hardware-SDK",