From 3ce4ba29a1f54633eab8a36064eb14fea385e67f Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Wed, 14 Jan 2026 23:00:22 +0800 Subject: [PATCH] feat: add ledger error handling --- .../src/errors.test.ts | 81 +++++++++ .../keyring-eth-ledger-bridge/src/errors.ts | 93 ++++++++++ .../keyring-eth-ledger-bridge/src/index.ts | 3 +- .../src/ledger-bridge.ts | 14 ++ .../src/ledger-error-handler.test.ts | 159 ++++++++++-------- .../src/ledger-error-handler.ts | 83 +++++---- .../src/ledger-hw-app.ts | 2 +- .../src/ledger-iframe-bridge.test.ts | 59 +++++++ .../src/ledger-iframe-bridge.ts | 23 ++- .../src/ledger-keyring.test.ts | 48 +++++- .../src/ledger-keyring.ts | 17 +- .../src/ledger-mobile-bridge.ts | 6 +- .../src/type.test.ts | 40 +++++ .../keyring-eth-ledger-bridge/tsconfig.json | 6 +- 14 files changed, 511 insertions(+), 123 deletions(-) create mode 100644 packages/keyring-eth-ledger-bridge/src/errors.test.ts create mode 100644 packages/keyring-eth-ledger-bridge/src/errors.ts create mode 100644 packages/keyring-eth-ledger-bridge/src/type.test.ts diff --git a/packages/keyring-eth-ledger-bridge/src/errors.test.ts b/packages/keyring-eth-ledger-bridge/src/errors.test.ts new file mode 100644 index 000000000..d78a4a190 --- /dev/null +++ b/packages/keyring-eth-ledger-bridge/src/errors.test.ts @@ -0,0 +1,81 @@ +import { + ErrorCode as ErrorCodeEnum, + Severity as SeverityEnum, + Category as CategoryEnum, + RetryStrategy as RetryStrategyEnum, + HardwareWalletError, +} from '@metamask/keyring-utils'; + +import { + createLedgerError, + isKnownLedgerError, + getLedgerErrorMapping, +} from './errors'; + +describe('createLedgerError', () => { + it('should create a HardwareWalletError from a known error code', () => { + const error = createLedgerError('0x6985'); + + expect(error).toBeInstanceOf(HardwareWalletError); + expect(error.message).toContain('User rejected'); + expect(error.code).toBe(ErrorCodeEnum.USER_CANCEL_001); + }); + + it('should create a HardwareWalletError with context', () => { + const error = createLedgerError('0x6985', 'during transaction signing'); + + expect(error).toBeInstanceOf(HardwareWalletError); + expect(error.message).toContain('User rejected'); + expect(error.message).toContain('(during transaction signing)'); + }); + + it('should create a fallback error for unknown error codes without context', () => { + const error = createLedgerError('0x9999'); + + expect(error).toBeInstanceOf(HardwareWalletError); + expect(error.message).toBe('Unknown Ledger error: 0x9999'); + expect(error.code).toBe(ErrorCodeEnum.UNKNOWN_001); + expect(error.severity).toBe(SeverityEnum.ERROR); + expect(error.category).toBe(CategoryEnum.UNKNOWN); + expect(error.retryStrategy).toBe(RetryStrategyEnum.NO_RETRY); + }); + + it('should create a fallback error for unknown error codes with context', () => { + const error = createLedgerError('0x9999', 'while doing something'); + + expect(error).toBeInstanceOf(HardwareWalletError); + expect(error.message).toBe( + 'Unknown Ledger error: 0x9999 (while doing something)', + ); + expect(error.code).toBe(ErrorCodeEnum.UNKNOWN_001); + }); +}); + +describe('isKnownLedgerError', () => { + it('should return true for known error codes', () => { + expect(isKnownLedgerError('0x6985')).toBe(true); + expect(isKnownLedgerError('0x5515')).toBe(true); + expect(isKnownLedgerError('0x6a80')).toBe(true); + }); + + it('should return false for unknown error codes', () => { + expect(isKnownLedgerError('0x9999')).toBe(false); + expect(isKnownLedgerError('0x0000')).toBe(false); + }); +}); + +describe('getLedgerErrorMapping', () => { + it('should return error mapping for known error codes', () => { + const mapping = getLedgerErrorMapping('0x6985'); + + expect(mapping).toBeDefined(); + expect(mapping?.customCode).toBe(ErrorCodeEnum.USER_CANCEL_001); + expect(mapping?.message).toContain('User rejected'); + }); + + it('should return undefined for unknown error codes', () => { + const mapping = getLedgerErrorMapping('0x9999'); + + expect(mapping).toBeUndefined(); + }); +}); diff --git a/packages/keyring-eth-ledger-bridge/src/errors.ts b/packages/keyring-eth-ledger-bridge/src/errors.ts new file mode 100644 index 000000000..519c90cfb --- /dev/null +++ b/packages/keyring-eth-ledger-bridge/src/errors.ts @@ -0,0 +1,93 @@ +import { + type ErrorCode, + type Severity, + type Category, + type RetryStrategy, + HardwareWalletError, + HARDWARE_MAPPINGS, + ErrorCode as ErrorCodeEnum, + Severity as SeverityEnum, + Category as CategoryEnum, + RetryStrategy as RetryStrategyEnum, +} from '@metamask/keyring-utils'; + +type LedgerErrorMapping = { + customCode: ErrorCode; + message: string; + severity: Severity; + category: Category; + retryStrategy: RetryStrategy; + userActionable: boolean; + userMessage?: string; +}; + +/** + * Factory function to create a HardwareWalletError from a Ledger error code. + * + * @param ledgerErrorCode - The Ledger error code (e.g., '0x6985', '0x5515') + * @param context - Optional additional context to append to the error message + * @returns A LedgerHardwareWalletError instance with mapped error details + */ +export function createLedgerError( + ledgerErrorCode: string, + context?: string, +): HardwareWalletError { + const mappings = HARDWARE_MAPPINGS.ledger.errorMappings as { + [key: string]: LedgerErrorMapping; + }; + const errorMapping = mappings[ledgerErrorCode]; + + if (errorMapping) { + const message = context + ? `${errorMapping.message} (${context})` + : errorMapping.message; + + return new HardwareWalletError(message, { + code: errorMapping.customCode, + severity: errorMapping.severity, + category: errorMapping.category, + retryStrategy: errorMapping.retryStrategy, + userActionable: errorMapping.userActionable, + userMessage: errorMapping.userMessage ?? '', + }); + } + + // Fallback for unknown error codes + const fallbackMessage = context + ? `Unknown Ledger error: ${ledgerErrorCode} (${context})` + : `Unknown Ledger error: ${ledgerErrorCode}`; + + return new HardwareWalletError(fallbackMessage, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + userActionable: false, + userMessage: '', + }); +} + +/** + * Checks if a Ledger error code exists in the error mappings. + * + * @param ledgerErrorCode - The Ledger error code to check + * @returns True if the error code is mapped, false otherwise + */ +export function isKnownLedgerError(ledgerErrorCode: string): boolean { + return ledgerErrorCode in HARDWARE_MAPPINGS.ledger.errorMappings; +} + +/** + * Gets the error mapping details for a Ledger error code without creating an error instance. + * + * @param ledgerErrorCode - The Ledger error code to look up + * @returns The error mapping details or undefined if not found + */ +export function getLedgerErrorMapping( + ledgerErrorCode: string, +): LedgerErrorMapping | undefined { + const mappings = HARDWARE_MAPPINGS.ledger.errorMappings as { + [key: string]: LedgerErrorMapping; + }; + return mappings[ledgerErrorCode]; +} diff --git a/packages/keyring-eth-ledger-bridge/src/index.ts b/packages/keyring-eth-ledger-bridge/src/index.ts index 2a3a93d8d..0f158cb4b 100644 --- a/packages/keyring-eth-ledger-bridge/src/index.ts +++ b/packages/keyring-eth-ledger-bridge/src/index.ts @@ -1,8 +1,9 @@ export * from './ledger-keyring'; -export * from './ledger-keyring-v2'; export * from './ledger-iframe-bridge'; export * from './ledger-mobile-bridge'; export type * from './ledger-bridge'; export * from './ledger-transport-middleware'; export type * from './type'; export * from './ledger-hw-app'; +export * from './errors'; +export * from './ledger-error-handler'; diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-bridge.ts b/packages/keyring-eth-ledger-bridge/src/ledger-bridge.ts index 4d52ec555..aacdc0079 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-bridge.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-bridge.ts @@ -25,6 +25,11 @@ export type LedgerSignTypedDataResponse = Awaited< ReturnType >; +export type GetAppNameAndVersionResponse = { + appName: string; + version: string; +}; + export type LedgerBridgeOptions = Record; export type LedgerBridge = { @@ -52,6 +57,8 @@ export type LedgerBridge = { getPublicKey(params: GetPublicKeyParams): Promise; + getAppNameAndVersion(): Promise; + deviceSignTransaction( params: LedgerSignTransactionParams, ): Promise; @@ -63,4 +70,11 @@ export type LedgerBridge = { deviceSignTypedData( params: LedgerSignTypedDataParams, ): Promise; + + /** + * Method to retrieve the name and version of the running application on the Ledger device. + * + * @returns An object containing appName and version. + */ + getAppNameAndVersion(): Promise; }; diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.test.ts index 50a2b4c09..01d1b6141 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.test.ts @@ -1,89 +1,86 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; +import { + ErrorCode, + Severity, + Category, + RetryStrategy, + HardwareWalletError, +} from '@metamask/keyring-utils'; import { handleLedgerTransportError } from './ledger-error-handler'; -import { LedgerStatusError } from './type'; -describe('handleLedgerTransportError', () => { - const fallbackMessage = 'Default error message'; - - /** - * Helper function to create a TransportStatusError-like object - * - * @param message - The error message - * @param statusCode - The status code - * @returns A TransportStatusError instance - */ - function createTransportStatusError( - message: string, - statusCode: number, - ): TransportStatusError { - const error = { - statusCode, - message, - name: 'TransportStatusError', - }; - Object.setPrototypeOf(error, TransportStatusError.prototype); - return error as TransportStatusError; - } +const fallbackMessage = 'Default error message'; - /** - * Helper function to test that handleLedgerTransportError throws a LedgerStatusError - * with expected properties - * - * @param error - The error to pass to handleLedgerTransportError - * @param expectedStatusCode - Expected status code of the thrown LedgerStatusError - * @param expectedMessage - Expected message of the thrown LedgerStatusError - * @returns True if all assertions pass - */ - function expectLedgerStatusError( - error: unknown, - expectedStatusCode: number, - expectedMessage: string, - ): boolean { - expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - LedgerStatusError, - ); +/** + * Helper function to create a TransportStatusError-like object + * + * @param message - The error message + * @param statusCode - The status code + * @returns A TransportStatusError instance + */ +function createTransportStatusError( + message: string, + statusCode: number, +): TransportStatusError { + const error = { + statusCode, + message, + name: 'TransportStatusError', + }; + Object.setPrototypeOf(error, TransportStatusError.prototype); + return error as TransportStatusError; +} - let thrownError: unknown; - try { - handleLedgerTransportError(error, fallbackMessage); - } catch (error_: unknown) { - thrownError = error_; - } - expect(thrownError).toBeInstanceOf(LedgerStatusError); - expect((thrownError as LedgerStatusError).statusCode).toBe( - expectedStatusCode, - ); - expect((thrownError as LedgerStatusError).message).toBe(expectedMessage); +/** + * Helper function to test that handleLedgerTransportError throws a HardwareWalletError + * with expected properties + * + * @param error - The error to pass to handleLedgerTransportError + * @param expectedMessage - Expected message of the thrown HardwareWalletError + * @returns True if all assertions pass + */ +function expectLedgerError(error: unknown, expectedMessage: string): boolean { + expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( + HardwareWalletError, + ); - return true; + let thrownError: unknown; + try { + handleLedgerTransportError(error, fallbackMessage); + } catch (error_: unknown) { + thrownError = error_; } + expect(thrownError).toBeInstanceOf(HardwareWalletError); + expect((thrownError as HardwareWalletError).message).toBe(expectedMessage); + return true; +} +describe('handleLedgerTransportError', () => { describe('when error is TransportStatusError', () => { it.each([ { tc: 'user rejection', inputMessage: 'User rejected', status: 0x6985, - expectedMessage: 'Ledger: User rejected the transaction', + expectedMessage: 'User rejected action on device', }, { tc: 'blind signing', inputMessage: 'Blind signing required', status: 0x6a80, - expectedMessage: 'Ledger: Blind signing must be enabled', + expectedMessage: 'Invalid data received', }, { tc: 'device locked', inputMessage: 'Device locked', status: 0x5515, - expectedMessage: 'Ledger: Device is locked. Unlock it to continue', + expectedMessage: 'Device is locked', }, { tc: 'app closed', inputMessage: 'App closed', status: 0x650f, - expectedMessage: 'Ledger: Ethereum app closed. Open it to unlock', + expectedMessage: 'App closed or connection issue', }, { tc: 'unknown status codes by preserving original message', @@ -95,9 +92,7 @@ describe('handleLedgerTransportError', () => { 'handles status code $status ($tc)', ({ inputMessage, status, expectedMessage }) => { const error = createTransportStatusError(inputMessage, status); - expect(expectLedgerStatusError(error, status, expectedMessage)).toBe( - true, - ); + expect(expectLedgerError(error, expectedMessage)).toBe(true); }, ); }); @@ -119,27 +114,49 @@ describe('handleLedgerTransportError', () => { expect(throwingFunction).toThrow(fallbackMessage); }); - it('re-throws Error instances as-is', () => { + it('wraps Error instances in HardwareWalletError', () => { const error = new Error('Original error message'); expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - error, + HardwareWalletError, ); - expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - error.message, + let thrownError: unknown; + try { + handleLedgerTransportError(error, fallbackMessage); + } catch (error_: unknown) { + thrownError = error_; + } + + expect(thrownError).toBeInstanceOf(HardwareWalletError); + expect((thrownError as HardwareWalletError).message).toBe( + 'Original error message', ); + expect((thrownError as HardwareWalletError).cause).toBe(error); }); - }); - describe('return type', () => { - it('has never return type (always throws)', () => { - type ReturnTypeIsNever any> = - ReturnType extends never ? true : false; + it('passes through HardwareWalletError instances', () => { + const ledgerError = new HardwareWalletError('Ledger error', { + code: ErrorCode.USER_CANCEL_001, + severity: Severity.ERROR, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + userMessage: '', + }); + + expect(() => + handleLedgerTransportError(ledgerError, fallbackMessage), + ).toThrow(ledgerError); + + let thrownError: unknown; + try { + handleLedgerTransportError(ledgerError, fallbackMessage); + } catch (error_: unknown) { + thrownError = error_; + } - const isNever: ReturnTypeIsNever = - true; - expect(isNever).toBe(true); + expect(thrownError).toBe(ledgerError); }); }); }); diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts index cb8b7466f..a6d43a8ce 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts @@ -1,51 +1,72 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; +import { + ErrorCode as ErrorCodeEnum, + Severity as SeverityEnum, + Category as CategoryEnum, + RetryStrategy as RetryStrategyEnum, + HardwareWalletError, +} from '@metamask/keyring-utils'; -import { LedgerStatusError } from './type'; +import { createLedgerError, isKnownLedgerError } from './errors'; /** * Central error handler for Ledger TransportStatusError instances. - * Converts common Ledger transport errors into user-friendly error messages. + * Converts Ledger transport errors into properly typed HardwareWalletError instances + * using the error mapping system. * * @param error - The error to handle * @param fallbackMessage - Default error message if no specific handling is found - * @throws Error with appropriate user-friendly message + * @throws HardwareWalletError with appropriate error details from mappings */ export function handleLedgerTransportError( error: unknown, fallbackMessage: string, ): never { if (error instanceof TransportStatusError) { - const transportError: TransportStatusError = error; + const statusCodeHex = `0x${error.statusCode.toString(16)}`; - throw new LedgerStatusError( - transportError.statusCode, - getTransportErrorMessageFrom(transportError), - ); + // Try to create error from known status code + if (isKnownLedgerError(statusCodeHex)) { + throw createLedgerError(statusCodeHex); + } + + // Unknown status code - create generic error with details + throw new HardwareWalletError(error.message, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + userActionable: false, + userMessage: '', + cause: error, + }); } - // For any other error (TransportStatusError not matching patterns or other errors) - throw error instanceof Error ? error : new Error(fallbackMessage); -} + // Handle HardwareWalletError - pass through + if (error instanceof HardwareWalletError) { + throw error; + } -/** - * Get the transport error message from the transport error. - * - * @param transportError - The transport error - * @returns The transport error message - */ -function getTransportErrorMessageFrom( - transportError: TransportStatusError, -): string { - switch (transportError.statusCode) { - case 0x6985: - return 'Ledger: User rejected the transaction'; - case 0x6a80: - return 'Ledger: Blind signing must be enabled'; - case 0x5515: - return 'Ledger: Device is locked. Unlock it to continue'; - case 0x650f: - return 'Ledger: Ethereum app closed. Open it to unlock'; - default: - return transportError.message; + // For any other error type + if (error instanceof Error) { + throw new HardwareWalletError(error.message, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + userActionable: false, + userMessage: '', + cause: error, + }); } + + // Unknown error type + throw new HardwareWalletError(fallbackMessage, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + userActionable: false, + userMessage: '', + }); } diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-hw-app.ts b/packages/keyring-eth-ledger-bridge/src/ledger-hw-app.ts index f3b07fc98..79fdb940d 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-hw-app.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-hw-app.ts @@ -1,7 +1,7 @@ import LedgerHwAppEth from '@ledgerhq/hw-app-eth'; import { Buffer } from 'buffer'; -import { GetAppNameAndVersionResponse } from './type'; +import type { GetAppNameAndVersionResponse } from './ledger-bridge'; export class MetaMaskLedgerHwAppEth extends LedgerHwAppEth diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.test.ts index c0c42a8d2..a30bb36cb 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.test.ts @@ -571,6 +571,65 @@ describe('LedgerIframeBridge', function () { // eslint-disable-next-line @typescript-eslint/unbound-method expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); }); + + describe('getAppNameAndVersion', function () { + it('sends and processes a successful ledger-get-app-name-and-version message', async function () { + const payload = { + appName: 'Ethereum', + version: '1.9.0', + }; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerGetAppNameAndVersion, + messageId: 1, + target: LEDGER_IFRAME_ID, + params: {}, + }); + + sendMessageToBridge(bridge, { + action: IFrameMessageAction.LedgerGetAppNameAndVersion, + messageId: 1, + success: true, + payload, + }); + }); + + const result = await bridge.getAppNameAndVersion(); + + expect(result).toBe(payload); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + + it('throws an error when a ledger-get-app-name-and-version message is not successful', async function () { + const errorMessage = 'Ledger Error'; + + stubKeyringIFramePostMessage(bridge, (message) => { + expect(message).toStrictEqual({ + action: IFrameMessageAction.LedgerGetAppNameAndVersion, + messageId: 1, + target: LEDGER_IFRAME_ID, + params: {}, + }); + + sendMessageToBridge(bridge, { + action: IFrameMessageAction.LedgerGetAppNameAndVersion, + messageId: 1, + success: false, + payload: { error: new Error(errorMessage) }, + }); + }); + + await expect(bridge.getAppNameAndVersion()).rejects.toThrow( + errorMessage, + ); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); + }); + }); }); describe('setOption', function () { diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.ts b/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.ts index 4bf7f1a47..a703e627c 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-iframe-bridge.ts @@ -1,6 +1,7 @@ import { createDeferredPromise, DeferredPromise } from '@metamask/utils'; import { + GetAppNameAndVersionResponse, GetPublicKeyParams, GetPublicKeyResponse, LedgerBridge, @@ -25,6 +26,7 @@ export enum IFrameMessageAction { LedgerSignTransaction = 'ledger-sign-transaction', LedgerSignPersonalMessage = 'ledger-sign-personal-message', LedgerSignTypedData = 'ledger-sign-typed-data', + LedgerGetAppNameAndVersion = 'ledger-get-app-name-and-version', } type IFrameMessageResponseStub< @@ -70,6 +72,10 @@ type LedgerSignTypedDataActionResponse = { action: IFrameMessageAction.LedgerSignTypedData; } & IFrameMessageResponseStub; +type LedgerGetAppNameAndVersionActionResponse = { + action: IFrameMessageAction.LedgerGetAppNameAndVersion; +} & IFrameMessageResponseStub; + export type IFrameMessageResponse = | LedgerConnectionChangeActionResponse | LedgerMakeAppActionResponse @@ -77,7 +83,8 @@ export type IFrameMessageResponse = | LedgerUnlockActionResponse | LedgerSignTransactionActionResponse | LedgerSignPersonalMessageActionResponse - | LedgerSignTypedDataActionResponse; + | LedgerSignTypedDataActionResponse + | LedgerGetAppNameAndVersionActionResponse; type IFrameMessage = { action: TAction; @@ -226,6 +233,13 @@ export class LedgerIframeBridge ); } + async getAppNameAndVersion(): Promise { + return this.#deviceActionMessage( + IFrameMessageAction.LedgerGetAppNameAndVersion, + {}, + ); + } + async #deviceActionMessage( action: IFrameMessageAction.LedgerUnlock, params: GetPublicKeyParams, @@ -246,17 +260,24 @@ export class LedgerIframeBridge params: LedgerSignTypedDataParams, ): Promise; + async #deviceActionMessage( + action: IFrameMessageAction.LedgerGetAppNameAndVersion, + params: Record, + ): Promise; + async #deviceActionMessage( ...[action, params]: | [IFrameMessageAction.LedgerUnlock, GetPublicKeyParams] | [IFrameMessageAction.LedgerSignTransaction, LedgerSignTransactionParams] | [IFrameMessageAction.LedgerSignPersonalMessage, LedgerSignMessageParams] | [IFrameMessageAction.LedgerSignTypedData, LedgerSignTypedDataParams] + | [IFrameMessageAction.LedgerGetAppNameAndVersion, Record] ): Promise< | GetPublicKeyResponse | LedgerSignTransactionResponse | LedgerSignMessageResponse | LedgerSignTypedDataResponse + | GetAppNameAndVersionResponse > { const response = await this.#sendMessage({ action, params }); diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts index 9df2a05ea..d2d06205b 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts @@ -801,7 +801,7 @@ describe('LedgerKeyring', function () { await expect( keyring.signTransaction(fakeAccounts[0], fakeTx), - ).rejects.toThrow('Ledger: User rejected the transaction'); + ).rejects.toThrow('User rejected action on device'); }); it('throws blind signing error when TransportStatusError with code 27264 is thrown', async function () { @@ -819,7 +819,7 @@ describe('LedgerKeyring', function () { await expect( keyring.signTransaction(fakeAccounts[0], fakeTx), - ).rejects.toThrow('Ledger: Blind signing must be enabled'); + ).rejects.toThrow('Invalid data received'); }); it('re-throws TransportStatusError with unknown status code', async function () { @@ -837,7 +837,7 @@ describe('LedgerKeyring', function () { await expect( keyring.signTransaction(fakeAccounts[0], fakeTx), - ).rejects.toThrow(transportError); + ).rejects.toThrow('Some other transport error'); }); }); @@ -928,7 +928,7 @@ describe('LedgerKeyring', function () { await expect( keyring.signPersonalMessage(fakeAccounts[0], 'some message'), - ).rejects.toThrow('Ledger: User rejected the transaction'); + ).rejects.toThrow('User rejected action on device'); }); it('re-throws TransportStatusError with unknown status code in signPersonalMessage', async function () { @@ -945,7 +945,7 @@ describe('LedgerKeyring', function () { await expect( keyring.signPersonalMessage(fakeAccounts[0], 'some message'), - ).rejects.toThrow(transportError); + ).rejects.toThrow('Some other transport error'); }); }); @@ -1312,7 +1312,7 @@ describe('LedgerKeyring', function () { keyring.signTypedData(fakeAccounts[15], fixtureData, { version: sigUtil.SignTypedDataVersion.V4, }), - ).rejects.toThrow('Ledger: User rejected the transaction'); + ).rejects.toThrow('User rejected action on device'); }); it('throws blind signing error when TransportStatusError with code 27264 is thrown in signTypedData', async function () { @@ -1330,7 +1330,7 @@ describe('LedgerKeyring', function () { keyring.signTypedData(fakeAccounts[15], fixtureData, { version: sigUtil.SignTypedDataVersion.V4, }), - ).rejects.toThrow('Ledger: Blind signing must be enabled'); + ).rejects.toThrow('Invalid data received'); }); it('re-throws TransportStatusError with unknown status code in signTypedData', async function () { @@ -1348,7 +1348,39 @@ describe('LedgerKeyring', function () { keyring.signTypedData(fakeAccounts[15], fixtureData, { version: sigUtil.SignTypedDataVersion.V4, }), - ).rejects.toThrow(transportError); + ).rejects.toThrow('Some other transport error'); + }); + }); + + describe('getAppNameAndVersion', function () { + it('returns app name and version from bridge', async function () { + const mockResponse = { + appName: 'Ethereum', + version: '1.9.0', + }; + jest + .spyOn(keyring.bridge, 'getAppNameAndVersion') + .mockResolvedValue(mockResponse); + + const result = await keyring.getAppNameAndVersion(); + + expect(result).toStrictEqual(mockResponse); + }); + + it('handles TransportStatusError when getting app name and version', async function () { + const transportError = { + statusCode: 27013, + message: 'Ledger device: (denied by the user?) (0x6985)', + name: 'TransportStatusError', + }; + Object.setPrototypeOf(transportError, TransportStatusError.prototype); + jest + .spyOn(keyring.bridge, 'getAppNameAndVersion') + .mockRejectedValue(transportError); + + await expect(keyring.getAppNameAndVersion()).rejects.toThrow( + 'User rejected action on device', + ); }); }); diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts index 62295534d..887e9296d 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts @@ -24,7 +24,11 @@ import { Buffer } from 'buffer'; import type OldEthJsTransaction from 'ethereumjs-tx'; import HDKey from 'hdkey'; -import { LedgerBridge, LedgerBridgeOptions } from './ledger-bridge'; +import { + GetAppNameAndVersionResponse, + LedgerBridge, + LedgerBridgeOptions, +} from './ledger-bridge'; import { handleLedgerTransportError } from './ledger-error-handler'; import { LedgerIframeBridgeOptions } from './ledger-iframe-bridge'; @@ -329,6 +333,17 @@ export class LedgerKeyring implements Keyring { return this.bridge.updateTransportMethod(transportType); } + async getAppNameAndVersion(): Promise { + try { + return await this.bridge.getAppNameAndVersion(); + } catch (error: unknown) { + return handleLedgerTransportError( + error, + 'Ledger: Unknown error while getting app name and version', + ); + } + } + // tx is an instance of the ethereumjs-transaction class. async signTransaction( address: Hex, diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts index 9e538159b..4b26e5934 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-mobile-bridge.ts @@ -1,6 +1,7 @@ import type Transport from '@ledgerhq/hw-transport'; import { + GetAppNameAndVersionResponse, GetPublicKeyParams, GetPublicKeyResponse, LedgerBridge, @@ -13,10 +14,7 @@ import { } from './ledger-bridge'; import { MetaMaskLedgerHwAppEth } from './ledger-hw-app'; import { TransportMiddleware } from './ledger-transport-middleware'; -import { - GetAppNameAndVersionResponse, - LedgerMobileBridgeOptions, -} from './type'; +import { LedgerMobileBridgeOptions } from './type'; // MobileBridge Type will always use LedgerBridge with LedgerMobileBridgeOptions export type MobileBridge = LedgerBridge & { diff --git a/packages/keyring-eth-ledger-bridge/src/type.test.ts b/packages/keyring-eth-ledger-bridge/src/type.test.ts new file mode 100644 index 000000000..6135ea00c --- /dev/null +++ b/packages/keyring-eth-ledger-bridge/src/type.test.ts @@ -0,0 +1,40 @@ +import { LedgerStatusError } from './type'; + +describe('LedgerStatusError', () => { + describe('constructor', () => { + it('should create an error with status code and message', () => { + const statusCode = 0x6985; + const message = 'User rejected the transaction'; + + const error = new LedgerStatusError(statusCode, message); + + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(LedgerStatusError); + expect(error.statusCode).toBe(statusCode); + expect(error.message).toBe(message); + }); + + it('should create an error with different status codes', () => { + const testCases = [ + { statusCode: 0x5515, message: 'Device is locked' }, + { statusCode: 0x650f, message: 'App closed' }, + { statusCode: 0x6a80, message: 'Invalid data' }, + ]; + + testCases.forEach(({ statusCode, message }) => { + const error = new LedgerStatusError(statusCode, message); + + expect(error.statusCode).toBe(statusCode); + expect(error.message).toBe(message); + expect(error).toBeInstanceOf(Error); + }); + }); + + it('should have Error as prototype', () => { + const error = new LedgerStatusError(0x6985, 'Test error'); + + expect(Object.getPrototypeOf(error)).toBe(LedgerStatusError.prototype); + expect(error instanceof Error).toBe(true); + }); + }); +}); diff --git a/packages/keyring-eth-ledger-bridge/tsconfig.json b/packages/keyring-eth-ledger-bridge/tsconfig.json index 396419fca..b46028b05 100644 --- a/packages/keyring-eth-ledger-bridge/tsconfig.json +++ b/packages/keyring-eth-ledger-bridge/tsconfig.json @@ -5,11 +5,7 @@ "exactOptionalPropertyTypes": false, "target": "es2017" }, - "references": [ - { "path": "../keyring-utils" }, - { "path": "../keyring-api" }, - { "path": "../account-api" } - ], + "references": [{ "path": "../keyring-utils" }], "include": ["./src"], "exclude": ["./dist/**/*"] }