From 58c4f2e49ffa7f3fba004008bd566333beea14c2 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Mon, 15 Dec 2025 23:14:59 +0800 Subject: [PATCH 1/8] feat: hardware errors --- .../keyring-eth-ledger-bridge/package.json | 1 + .../keyring-eth-ledger-bridge/src/errors.ts | 176 ++++ .../src/ledger-error-handler.test.ts | 56 +- .../src/ledger-error-handler.ts | 82 +- .../src/ledger-keyring.test.ts | 16 +- packages/keyring-eth-trezor/package.json | 2 +- packages/keyring-eth-trezor/src/errors.ts | 230 +++++ packages/keyring-eth-trezor/src/index.ts | 1 + .../keyring-utils/src/hardware-error-codes.ts | 86 ++ .../src/hardware-error-mappings.ts | 843 ++++++++++++++++++ packages/keyring-utils/src/hardware-error.ts | 232 +++++ .../src/hardware-errors-enums.ts | 121 +++ packages/keyring-utils/src/index.ts | 4 + 13 files changed, 1789 insertions(+), 61 deletions(-) create mode 100644 packages/keyring-eth-ledger-bridge/src/errors.ts create mode 100644 packages/keyring-eth-trezor/src/errors.ts create mode 100644 packages/keyring-utils/src/hardware-error-codes.ts create mode 100644 packages/keyring-utils/src/hardware-error-mappings.ts create mode 100644 packages/keyring-utils/src/hardware-error.ts create mode 100644 packages/keyring-utils/src/hardware-errors-enums.ts diff --git a/packages/keyring-eth-ledger-bridge/package.json b/packages/keyring-eth-ledger-bridge/package.json index 495a21932..8bc09f197 100644 --- a/packages/keyring-eth-ledger-bridge/package.json +++ b/packages/keyring-eth-ledger-bridge/package.json @@ -51,6 +51,7 @@ "@ethereumjs/util": "^9.1.0", "@ledgerhq/hw-app-eth": "^6.42.0", "@metamask/eth-sig-util": "^8.2.0", + "@metamask/keyring-utils": "workspace:^", "hdkey": "^2.1.0" }, "devDependencies": { 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..a1e7ac44a --- /dev/null +++ b/packages/keyring-eth-ledger-bridge/src/errors.ts @@ -0,0 +1,176 @@ +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'; + +export type LedgerHardwareWalletErrorOptions = { + code: ErrorCode; + severity: Severity; + category: Category; + retryStrategy: RetryStrategy; + cause?: Error; + ledgerCode?: string; +}; + +export class LedgerHardwareWalletError extends HardwareWalletError { + public readonly ledgerCode?: string; + + constructor(message: string, options: LedgerHardwareWalletErrorOptions) { + super(message, { + ...options, + userActionable: false, + userMessage: message, + }); + this.name = 'LedgerHardwareWalletError'; + this.ledgerCode = options.ledgerCode; + + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, LedgerHardwareWalletError.prototype); + } + + /** + * Creates a new error instance with an incremented retry count. + * + * @returns A new LedgerHardwareWalletError instance with the retry count incremented. + */ + override withIncrementedRetryCount(): LedgerHardwareWalletError { + const errorCause = + 'cause' in this && this.cause instanceof Error ? this.cause : undefined; + + return new LedgerHardwareWalletError(this.message, { + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + cause: errorCause, + ledgerCode: this.ledgerCode, + }); + } + + /** + * Creates a new error instance with additional metadata. + * + * @param _additionalMetadata - Additional metadata to merge with existing metadata. + * @returns A new LedgerHardwareWalletError instance with the updated metadata. + */ + override withMetadata( + _additionalMetadata: Record, + ): LedgerHardwareWalletError { + const errorCause = + 'cause' in this && this.cause instanceof Error ? this.cause : undefined; + + return new LedgerHardwareWalletError(this.message, { + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + cause: errorCause, + ledgerCode: this.ledgerCode, + }); + } + + /** + * Serializes the error to a JSON-compatible object. + * + * @returns A JSON-compatible object representing the error. + */ + override toJSON(): Record { + return { + ...super.toJSON(), + ledgerCode: this.ledgerCode, + }; + } +} + +type LedgerErrorMapping = { + customCode: ErrorCode; + message: string; + severity: Severity; + category: Category; + retryStrategy: RetryStrategy; + userActionable: boolean; + userMessage?: string; +}; + +/** + * Factory function to create a LedgerHardwareWalletError 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 + * + * @example + * ```typescript + * const error = createLedgerError('0x6985'); // User rejected action + * const errorWithContext = createLedgerError('0x6985', 'during transaction signing'); + * ``` + */ +export function createLedgerError( + ledgerErrorCode: string, + context?: string, +): LedgerHardwareWalletError { + 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 LedgerHardwareWalletError(message, { + code: errorMapping.customCode, + severity: errorMapping.severity, + category: errorMapping.category, + retryStrategy: errorMapping.retryStrategy, + ledgerCode: ledgerErrorCode, + }); + } + + // Fallback for unknown error codes + const fallbackMessage = context + ? `Unknown Ledger error: ${ledgerErrorCode} (${context})` + : `Unknown Ledger error: ${ledgerErrorCode}`; + + return new LedgerHardwareWalletError(fallbackMessage, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + ledgerCode: ledgerErrorCode, + }); +} + +/** + * 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/ledger-error-handler.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.test.ts index 50a2b4c09..98fce3139 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,7 +1,7 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; import { handleLedgerTransportError } from './ledger-error-handler'; -import { LedgerStatusError } from './type'; +import { LedgerHardwareWalletError } from './errors'; describe('handleLedgerTransportError', () => { const fallbackMessage = 'Default error message'; @@ -27,21 +27,21 @@ describe('handleLedgerTransportError', () => { } /** - * Helper function to test that handleLedgerTransportError throws a LedgerStatusError + * Helper function to test that handleLedgerTransportError throws a LedgerHardwareWalletError * 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 + * @param expectedLedgerCode - Expected ledger code of the thrown LedgerHardwareWalletError + * @param expectedMessage - Expected message of the thrown LedgerHardwareWalletError * @returns True if all assertions pass */ - function expectLedgerStatusError( + function expectLedgerError( error: unknown, - expectedStatusCode: number, + expectedLedgerCode: string, expectedMessage: string, ): boolean { expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - LedgerStatusError, + LedgerHardwareWalletError, ); let thrownError: unknown; @@ -50,11 +50,11 @@ describe('handleLedgerTransportError', () => { } catch (error_: unknown) { thrownError = error_; } - expect(thrownError).toBeInstanceOf(LedgerStatusError); - expect((thrownError as LedgerStatusError).statusCode).toBe( - expectedStatusCode, + expect(thrownError).toBeInstanceOf(LedgerHardwareWalletError); + expect((thrownError as LedgerHardwareWalletError).ledgerCode).toBe( + expectedLedgerCode, ); - expect((thrownError as LedgerStatusError).message).toBe(expectedMessage); + expect((thrownError as LedgerHardwareWalletError).message).toBe(expectedMessage); return true; } @@ -65,37 +65,42 @@ describe('handleLedgerTransportError', () => { tc: 'user rejection', inputMessage: 'User rejected', status: 0x6985, - expectedMessage: 'Ledger: User rejected the transaction', + expectedLedgerCode: '0x6985', + expectedMessage: 'User rejected action on device', }, { tc: 'blind signing', inputMessage: 'Blind signing required', status: 0x6a80, - expectedMessage: 'Ledger: Blind signing must be enabled', + expectedLedgerCode: '0x6a80', + expectedMessage: 'Invalid data received', }, { tc: 'device locked', inputMessage: 'Device locked', status: 0x5515, - expectedMessage: 'Ledger: Device is locked. Unlock it to continue', + expectedLedgerCode: '0x5515', + expectedMessage: 'Device is locked', }, { tc: 'app closed', inputMessage: 'App closed', status: 0x650f, - expectedMessage: 'Ledger: Ethereum app closed. Open it to unlock', + expectedLedgerCode: '0x650f', + expectedMessage: 'App closed or connection issue', }, { tc: 'unknown status codes by preserving original message', inputMessage: 'Unknown transport error', status: 0x9999, + expectedLedgerCode: '0x9999', expectedMessage: 'Unknown transport error', }, ])( 'handles status code $status ($tc)', - ({ inputMessage, status, expectedMessage }) => { + ({ inputMessage, status, expectedLedgerCode, expectedMessage }) => { const error = createTransportStatusError(inputMessage, status); - expect(expectLedgerStatusError(error, status, expectedMessage)).toBe( + expect(expectLedgerError(error, expectedLedgerCode, expectedMessage)).toBe( true, ); }, @@ -119,16 +124,25 @@ describe('handleLedgerTransportError', () => { expect(throwingFunction).toThrow(fallbackMessage); }); - it('re-throws Error instances as-is', () => { + it('wraps Error instances in LedgerHardwareWalletError', () => { const error = new Error('Original error message'); expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - error, + LedgerHardwareWalletError, ); - expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - error.message, + let thrownError: unknown; + try { + handleLedgerTransportError(error, fallbackMessage); + } catch (error_: unknown) { + thrownError = error_; + } + + expect(thrownError).toBeInstanceOf(LedgerHardwareWalletError); + expect((thrownError as LedgerHardwareWalletError).message).toBe( + 'Original error message', ); + expect((thrownError as LedgerHardwareWalletError).cause).toBe(error); }); }); 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..0428c3a4d 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,71 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; +import { + ErrorCode as ErrorCodeEnum, + Severity as SeverityEnum, + Category as CategoryEnum, + RetryStrategy as RetryStrategyEnum, +} from '@metamask/keyring-utils'; -import { LedgerStatusError } from './type'; +import { + createLedgerError, + isKnownLedgerError, + LedgerHardwareWalletError, +} 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 LedgerHardwareWalletError 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 LedgerHardwareWalletError with appropriate error details from mappings */ +// eslint-disable-next-line @typescript-eslint/no-throw-literal 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 LedgerHardwareWalletError(error.message, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + cause: error, + ledgerCode: statusCodeHex, + }); } - // For any other error (TransportStatusError not matching patterns or other errors) - throw error instanceof Error ? error : new Error(fallbackMessage); -} + // Handle LedgerHardwareWalletError - pass through + if (error instanceof LedgerHardwareWalletError) { + 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 LedgerHardwareWalletError(error.message, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + cause: error, + }); } + + // Unknown error type + throw new LedgerHardwareWalletError(fallbackMessage, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + }); } 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..7edac09b5 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,7 @@ describe('LedgerKeyring', function () { keyring.signTypedData(fakeAccounts[15], fixtureData, { version: sigUtil.SignTypedDataVersion.V4, }), - ).rejects.toThrow(transportError); + ).rejects.toThrow('Some other transport error'); }); }); diff --git a/packages/keyring-eth-trezor/package.json b/packages/keyring-eth-trezor/package.json index 91f1f0cee..9cd8c7de4 100644 --- a/packages/keyring-eth-trezor/package.json +++ b/packages/keyring-eth-trezor/package.json @@ -49,6 +49,7 @@ "@ethereumjs/tx": "^5.4.0", "@ethereumjs/util": "^9.1.0", "@metamask/eth-sig-util": "^8.2.0", + "@metamask/keyring-utils": "workspace:^", "@metamask/utils": "^11.1.0", "@trezor/connect-plugin-ethereum": "^9.0.5", "@trezor/connect-web": "^9.6.0", @@ -60,7 +61,6 @@ "@lavamoat/allow-scripts": "^3.2.1", "@lavamoat/preinstall-always-fail": "^2.1.0", "@metamask/auto-changelog": "^3.4.4", - "@metamask/keyring-utils": "workspace:^", "@ts-bridge/cli": "^0.6.3", "@types/ethereumjs-tx": "^1.0.1", "@types/hdkey": "^2.0.1", diff --git a/packages/keyring-eth-trezor/src/errors.ts b/packages/keyring-eth-trezor/src/errors.ts new file mode 100644 index 000000000..97f2cd010 --- /dev/null +++ b/packages/keyring-eth-trezor/src/errors.ts @@ -0,0 +1,230 @@ +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'; + +export type TrezorHardwareWalletErrorOptions = { + code: ErrorCode; + severity: Severity; + category: Category; + retryStrategy: RetryStrategy; + cause?: Error; + trezorCode?: string | number; +}; + +export class TrezorHardwareWalletError extends HardwareWalletError { + public readonly trezorCode?: string | number; + + constructor(message: string, options: TrezorHardwareWalletErrorOptions) { + super(message, { + ...options, + userActionable: false, + userMessage: message, + }); + this.name = 'TrezorHardwareWalletError'; + this.trezorCode = options.trezorCode; + + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, TrezorHardwareWalletError.prototype); + } + + /** + * Creates a new error instance with an incremented retry count. + * + * @returns A new TrezorHardwareWalletError instance with the retry count incremented. + */ + override withIncrementedRetryCount(): TrezorHardwareWalletError { + const errorCause = + 'cause' in this && this.cause instanceof Error ? this.cause : undefined; + + return new TrezorHardwareWalletError(this.message, { + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + cause: errorCause, + trezorCode: this.trezorCode, + }); + } + + /** + * Creates a new error instance with additional metadata. + * + * @param _additionalMetadata - Additional metadata to merge with existing metadata. + * @returns A new TrezorHardwareWalletError instance with the updated metadata. + */ + override withMetadata( + _additionalMetadata: Record, + ): TrezorHardwareWalletError { + const errorCause = + 'cause' in this && this.cause instanceof Error ? this.cause : undefined; + + return new TrezorHardwareWalletError(this.message, { + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + cause: errorCause, + trezorCode: this.trezorCode, + }); + } + + /** + * Serializes the error to a JSON-compatible object. + * + * @returns A JSON-compatible object representing the error. + */ + override toJSON(): Record { + return { + ...super.toJSON(), + trezorCode: this.trezorCode, + }; + } +} + +type TrezorErrorMappings = typeof HARDWARE_MAPPINGS.trezor.errorMapping; +type TrezorErrorMapping = TrezorErrorMappings[keyof TrezorErrorMappings]; + +/** + * Factory function to create a TrezorHardwareWalletError from a Trezor error code or name. + * + * @param trezorErrorCode - The Trezor error code (e.g., '1', '2', 'Init_NotInitialized') + * @param context - Optional additional context to append to the error message + * @returns A TrezorHardwareWalletError instance with mapped error details + * + * @example + * ```typescript + * const error = createTrezorError('4'); // Action cancelled by user + * const errorByName = createTrezorError('Init_NotInitialized'); + * const errorWithContext = createTrezorError('4', 'during transaction signing'); + * ``` + */ +export function createTrezorError( + trezorErrorCode: string | number, + context?: string, +): TrezorHardwareWalletError { + const codeKey = String(trezorErrorCode); + const mappings = HARDWARE_MAPPINGS.trezor.errorMapping as { + [key: string]: TrezorErrorMapping; + }; + const errorMapping = mappings[codeKey]; + + if (errorMapping) { + const message = context + ? `${errorMapping.message} (${context})` + : errorMapping.message; + + return new TrezorHardwareWalletError(message, { + code: errorMapping.customCode, + severity: errorMapping.severity, + category: errorMapping.category, + retryStrategy: errorMapping.retryStrategy, + trezorCode: trezorErrorCode, + }); + } + + // Fallback for unknown error codes + const fallbackMessage = context + ? `Unknown Trezor error: ${trezorErrorCode} (${context})` + : `Unknown Trezor error: ${trezorErrorCode}`; + + return new TrezorHardwareWalletError(fallbackMessage, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + trezorCode: trezorErrorCode, + }); +} + +/** + * Checks if a Trezor error code exists in the error mappings. + * + * @param trezorErrorCode - The Trezor error code to check + * @returns True if the error code is mapped, false otherwise + */ +export function isKnownTrezorError(trezorErrorCode: string | number): boolean { + return String(trezorErrorCode) in HARDWARE_MAPPINGS.trezor.errorMapping; +} + +/** + * Gets the error mapping details for a Trezor error code without creating an error instance. + * + * @param trezorErrorCode - The Trezor error code to look up + * @returns The error mapping details or undefined if not found + */ +export function getTrezorErrorMapping( + trezorErrorCode: string | number, +): TrezorErrorMapping | undefined { + const mappings = HARDWARE_MAPPINGS.trezor.errorMapping as { + [key: string]: TrezorErrorMapping; + }; + return mappings[String(trezorErrorCode)]; +} + +/** + * Creates a TrezorHardwareWalletError from a TrezorConnect response error. + * This helper extracts the error code/message from the response and creates an appropriate error. + * + * @param response - The TrezorConnect response object + * @param response.success - The success status of the response (must be false) + * @param response.payload - The payload object containing error information + * @param response.payload.error - The error message from Trezor + * @param response.payload.code - Optional error code from Trezor + * @param context - Optional additional context + * @returns A TrezorHardwareWalletError instance + * + * @example + * ```typescript + * const result = await TrezorConnect.ethereumGetAddress({ path: "m/44'/60'/0'/0/0" }); + * if (!result.success) { + * throw createTrezorErrorFromResponse(result); + * } + * ``` + */ +export function createTrezorErrorFromResponse( + response: { success: false; payload: { error: string; code?: string } }, + context?: string, +): TrezorHardwareWalletError { + const errorMessage = response.payload.error; + const errorCode = response.payload.code; + + // Try to find a matching error by code first + if (errorCode && isKnownTrezorError(errorCode)) { + return createTrezorError(errorCode, context); + } + + // Try to find a matching error by error message pattern + const mappings = HARDWARE_MAPPINGS.trezor.errorMapping as { + [key: string]: TrezorErrorMapping; + }; + + for (const [code, mapping] of Object.entries(mappings)) { + if ( + 'sdkMessage' in mapping && + mapping.sdkMessage && + errorMessage.includes(mapping.sdkMessage) + ) { + return createTrezorError(code, context); + } + } + + // If no specific mapping found, create a generic error + const message = context ? `${errorMessage} (${context})` : errorMessage; + + return new TrezorHardwareWalletError(message, { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + trezorCode: errorCode, + }); +} diff --git a/packages/keyring-eth-trezor/src/index.ts b/packages/keyring-eth-trezor/src/index.ts index 6cb20b9e7..35642fe7d 100644 --- a/packages/keyring-eth-trezor/src/index.ts +++ b/packages/keyring-eth-trezor/src/index.ts @@ -2,3 +2,4 @@ export * from './trezor-keyring'; export * from './onekey-keyring'; export type * from './trezor-bridge'; export * from './trezor-connect-bridge'; +export * from './errors'; diff --git a/packages/keyring-utils/src/hardware-error-codes.ts b/packages/keyring-utils/src/hardware-error-codes.ts new file mode 100644 index 000000000..2a1c4d568 --- /dev/null +++ b/packages/keyring-utils/src/hardware-error-codes.ts @@ -0,0 +1,86 @@ +// Authentication & Security +export const AUTH_PIN_001 = 'PIN invalid'; +export const AUTH_PIN_002 = 'PIN cancelled by user'; +export const AUTH_PIN_003 = 'PIN attempts remaining'; +export const AUTH_PIN_004 = 'PIN mismatch'; +export const AUTH_LOCK_001 = 'Device is locked'; +export const AUTH_LOCK_002 = 'Device blocked due to failed attempts'; +export const AUTH_SEC_001 = 'Security conditions not satisfied'; +export const AUTH_SEC_002 = 'Access rights insufficient'; +export const AUTH_WIPE_001 = 'Wipe code mismatch'; + +// User Action +export const USER_CANCEL_001 = 'User rejected action on device'; +export const USER_CANCEL_002 = 'User cancelled operation'; +export const USER_INPUT_001 = 'User input expected'; +export const USER_CONFIRM_001 = 'User confirmation required'; + +// Device State +export const DEVICE_STATE_001 = 'Device not initialized'; +export const DEVICE_STATE_002 = 'Device busy'; +export const DEVICE_STATE_003 = 'Device disconnected'; +export const DEVICE_STATE_004 = 'Device used elsewhere'; +export const DEVICE_STATE_005 = 'Device call in progress'; +export const DEVICE_DETECT_001 = 'Device not found'; +export const DEVICE_CAP_001 = 'Device missing required capability'; +export const DEVICE_CAP_002 = 'Device is BTC-only, operation not supported'; +export const DEVICE_MODE_001 = 'Invalid device mode'; + +// Connection & Transport +export const CONN_TRANSPORT_001 = 'Transport layer missing'; +export const CONN_CLOSED_001 = 'Connection closed unexpectedly'; +export const CONN_IFRAME_001 = 'Unable to establish iframe connection'; +export const CONN_SUITE_001 = 'Unable to connect to Suite'; +export const CONN_TIMEOUT_001 = 'Connection timeout'; +export const CONN_BLOCKED_001 = 'Connection blocked'; + +// Data & Validation +export const DATA_FORMAT_001 = 'Incorrect data length'; +export const DATA_FORMAT_002 = 'Invalid data received'; +export const DATA_FORMAT_003 = 'Invalid parameter'; +export const DATA_MISSING_001 = 'Missing critical parameter'; +export const DATA_VALIDATION_001 = 'Address mismatch'; +export const DATA_VALIDATION_002 = 'Invalid signature'; +export const DATA_NOTFOUND_001 = 'Referenced data not found'; +export const DATA_NOTFOUND_002 = 'File not found'; +export const DATA_NOTFOUND_003 = 'Coin not found'; + +// Cryptographic Operations +export const CRYPTO_SIGN_001 = 'Signature operation failed'; +export const CRYPTO_ALGO_001 = 'Algorithm not supported'; +export const CRYPTO_KEY_001 = 'Invalid key check value'; +export const CRYPTO_ENTROPY_001 = 'Entropy check failed'; + +// System & Internal +export const SYS_INTERNAL_001 = 'Internal device error'; +export const SYS_MEMORY_001 = 'Not enough memory'; +export const SYS_MEMORY_002 = 'Memory problem'; +export const SYS_FILE_001 = 'File system error'; +export const SYS_FILE_002 = 'Inconsistent file'; +export const SYS_LICENSE_001 = 'Licensing error'; +export const SYS_FIRMWARE_001 = 'Firmware error'; +export const SYS_FIRMWARE_002 = 'Firmware installation failed'; + +// Command & Protocol +export const PROTO_CMD_001 = 'Command not supported'; +export const PROTO_CMD_002 = 'Command incompatible'; +export const PROTO_CMD_003 = 'Unexpected message'; +export const PROTO_MSG_001 = 'Invalid APDU command'; +export const PROTO_PARAM_001 = 'Invalid command parameters'; + +// Configuration & Initialization +export const CONFIG_INIT_001 = 'Not initialized'; +export const CONFIG_INIT_002 = 'Already initialized'; +export const CONFIG_INIT_003 = 'Manifest missing'; +export const CONFIG_PERM_001 = 'Permissions not granted'; +export const CONFIG_METHOD_001 = 'Method not allowed'; + +// Transaction +export const TX_FUNDS_001 = 'Insufficient funds'; +export const TX_FAIL_001 = 'Transaction failed'; + +// Success +export const SUCCESS_000 = 'Operation successful'; + +// Unknown/Fallback +export const UNKNOWN_001 = 'Unknown error'; diff --git a/packages/keyring-utils/src/hardware-error-mappings.ts b/packages/keyring-utils/src/hardware-error-mappings.ts new file mode 100644 index 000000000..b5e4d1323 --- /dev/null +++ b/packages/keyring-utils/src/hardware-error-mappings.ts @@ -0,0 +1,843 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { + ErrorCode, + Severity, + Category, + RetryStrategy, +} from './hardware-errors-enums'; + +export const HARDWARE_MAPPINGS = { + ledger: { + vendorName: 'Ledger', + errorMappings: { + '0x9000': { + customCode: ErrorCode.SUCCESS_000, + message: 'Operation successful', + severity: Severity.INFO, + category: Category.SUCCESS, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6300': { + customCode: ErrorCode.AUTH_SEC_001, + message: 'Authentication failed', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: 'Authentication failed. Please verify your credentials.', + }, + '0x63c0': { + customCode: ErrorCode.AUTH_PIN_003, + message: 'PIN attempts remaining', + severity: Severity.WARNING, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Incorrect PIN. Please try again.', + }, + '0x6700': { + customCode: ErrorCode.DATA_FORMAT_001, + message: 'Incorrect data length', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6800': { + customCode: ErrorCode.DATA_MISSING_001, + message: 'Missing critical parameter', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6981': { + customCode: ErrorCode.PROTO_CMD_002, + message: 'Command incompatible with file structure', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6982': { + customCode: ErrorCode.AUTH_SEC_002, + message: 'Security conditions not satisfied', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Device is locked or access rights are insufficient. Please unlock your device.', + }, + '0x6985': { + customCode: ErrorCode.USER_CANCEL_001, + message: 'User rejected action on device', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Transaction was rejected. Please approve on your device to continue.', + }, + '0x6a80': { + customCode: ErrorCode.DATA_FORMAT_002, + message: 'Invalid data received', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6a84': { + customCode: ErrorCode.SYS_MEMORY_001, + message: 'Not enough memory space', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6a88': { + customCode: ErrorCode.DATA_NOTFOUND_001, + message: 'Referenced data not found', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6a89': { + customCode: ErrorCode.SYS_FILE_001, + message: 'File already exists', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6b00': { + customCode: ErrorCode.DATA_FORMAT_003, + message: 'Invalid parameter received', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6d00': { + customCode: ErrorCode.PROTO_CMD_001, + message: 'Instruction not supported', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6d02': { + customCode: ErrorCode.PROTO_MSG_001, + message: 'Unknown APDU command', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6e00': { + customCode: ErrorCode.PROTO_CMD_001, + message: 'Class not supported', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x6501': { + customCode: ErrorCode.PROTO_CMD_001, + message: 'Ethereum app specific error', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Please ensure the Ethereum app is open on your Ledger device.', + }, + '0x6f00': { + customCode: ErrorCode.SYS_INTERNAL_001, + message: 'Internal device error', + severity: Severity.CRITICAL, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + userMessage: 'An internal error occurred. Please report this issue.', + }, + '0x6f42': { + customCode: ErrorCode.SYS_LICENSE_001, + message: 'Licensing error', + severity: Severity.CRITICAL, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + userMessage: 'A licensing error occurred. Please contact support.', + }, + '0x6faa': { + customCode: ErrorCode.SYS_INTERNAL_001, + message: 'Device halted', + severity: Severity.CRITICAL, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + userMessage: + 'Device has halted. Please disconnect and reconnect your device.', + }, + '0x9240': { + customCode: ErrorCode.SYS_MEMORY_002, + message: 'Memory problem', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + }, + '0x9400': { + customCode: ErrorCode.SYS_FILE_001, + message: 'No elementary file selected', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9402': { + customCode: ErrorCode.DATA_FORMAT_003, + message: 'Invalid offset', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9404': { + customCode: ErrorCode.DATA_NOTFOUND_002, + message: 'File not found', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9408': { + customCode: ErrorCode.SYS_FILE_002, + message: 'Inconsistent file', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9484': { + customCode: ErrorCode.CRYPTO_ALGO_001, + message: 'Algorithm not supported', + severity: Severity.ERROR, + category: Category.CRYPTOGRAPHY, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9485': { + customCode: ErrorCode.CRYPTO_KEY_001, + message: 'Invalid key check value', + severity: Severity.ERROR, + category: Category.CRYPTOGRAPHY, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9802': { + customCode: ErrorCode.CONFIG_INIT_001, + message: 'Code not initialized', + severity: Severity.ERROR, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9804': { + customCode: ErrorCode.AUTH_SEC_002, + message: 'Access condition not fulfilled', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + }, + '0x9808': { + customCode: ErrorCode.AUTH_PIN_001, + message: 'Contradiction in secret code status', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9810': { + customCode: ErrorCode.SYS_INTERNAL_001, + message: 'Contradiction invalidation', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x9840': { + customCode: ErrorCode.AUTH_LOCK_002, + message: 'Code blocked', + severity: Severity.CRITICAL, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Your device is blocked due to too many failed attempts. Please follow device recovery procedures.', + }, + '0x9850': { + customCode: ErrorCode.SYS_INTERNAL_001, + message: 'Maximum value reached', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + '0x650f': { + customCode: ErrorCode.CONN_CLOSED_001, + message: 'App closed or connection issue', + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Connection lost or app closed. Please open the corresponding app on your Ledger device.', + }, + '0x5515': { + customCode: ErrorCode.AUTH_LOCK_001, + message: 'Device is locked', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: 'Please unlock your Ledger device to continue.', + }, + '0x5501': { + customCode: ErrorCode.USER_CANCEL_001, + message: 'User refused on device', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Transaction was rejected. Please approve on your device to continue.', + }, + }, + }, + trezor: { + vendorName: 'Trezor', + errorMapping: { + '1': { + customCode: ErrorCode.PROTO_CMD_003, + message: 'Unexpected message received', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + originalName: 'Failure_UnexpectedMessage', + }, + '2': { + customCode: ErrorCode.USER_CONFIRM_001, + message: 'Button confirmation required', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Please confirm the action on your Trezor device.', + originalName: 'Failure_ButtonExpected', + }, + '3': { + customCode: ErrorCode.DATA_FORMAT_002, + message: 'Data error', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + originalName: 'Failure_DataError', + }, + '4': { + customCode: ErrorCode.USER_CANCEL_002, + message: 'Action cancelled by user', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'You cancelled the operation. Please try again if this was unintentional.', + originalName: 'Failure_ActionCancelled', + }, + '5': { + customCode: ErrorCode.USER_INPUT_001, + message: 'PIN entry expected', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Please enter your PIN on the Trezor device.', + originalName: 'Failure_PinExpected', + }, + '6': { + customCode: ErrorCode.AUTH_PIN_002, + message: 'PIN cancelled by user', + severity: Severity.WARNING, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'PIN entry was cancelled. Please try again.', + originalName: 'Failure_PinCancelled', + }, + '7': { + customCode: ErrorCode.AUTH_PIN_001, + message: 'PIN invalid', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Incorrect PIN entered. Please try again.', + originalName: 'Failure_PinInvalid', + }, + '8': { + customCode: ErrorCode.CRYPTO_SIGN_001, + message: 'Invalid signature', + severity: Severity.ERROR, + category: Category.CRYPTOGRAPHY, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + originalName: 'Failure_InvalidSignature', + }, + '9': { + customCode: ErrorCode.SYS_INTERNAL_001, + message: 'Process error', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + userMessage: 'A processing error occurred. Please try again.', + originalName: 'Failure_ProcessError', + }, + '10': { + customCode: ErrorCode.TX_FUNDS_001, + message: 'Insufficient funds', + severity: Severity.ERROR, + category: Category.TRANSACTION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: 'Insufficient funds to complete this transaction.', + originalName: 'Failure_NotEnoughFunds', + }, + '11': { + customCode: ErrorCode.DEVICE_STATE_001, + message: 'Device not initialized', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Your Trezor device needs to be initialized. Please set it up first.', + originalName: 'Failure_NotInitialized', + }, + '12': { + customCode: ErrorCode.AUTH_PIN_004, + message: 'PIN mismatch', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'PINs do not match. Please try again.', + originalName: 'Failure_PinMismatch', + }, + '13': { + customCode: ErrorCode.AUTH_WIPE_001, + message: 'Wipe code mismatch', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Wipe codes do not match. Please try again.', + originalName: 'Failure_WipeCodeMismatch', + }, + '14': { + customCode: ErrorCode.DEVICE_STATE_002, + message: 'Invalid session', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + userMessage: 'Session expired. Please reconnect your device.', + originalName: 'Failure_InvalidSession', + }, + '15': { + customCode: ErrorCode.DEVICE_STATE_002, + message: 'Device busy', + severity: Severity.WARNING, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.EXPONENTIAL_BACKOFF, + userActionable: false, + userMessage: 'Device is busy. Please wait and try again.', + originalName: 'Failure_Busy', + }, + '99': { + customCode: ErrorCode.SYS_FIRMWARE_002, + message: 'Firmware installation failed', + severity: Severity.CRITICAL, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Firmware installation failed. Please contact Trezor support.', + originalName: 'Failure_FirmwareError', + }, + UNKNOWN: { + customCode: ErrorCode.UNKNOWN_001, + message: 'Unknown error', + severity: Severity.ERROR, + category: Category.UNKNOWN, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + userMessage: + 'An unknown error occurred. Please try again or contact support.', + originalName: 'Failure_UnknownCode', + }, + ENTROPY_CHECK: { + customCode: ErrorCode.CRYPTO_ENTROPY_001, + message: 'Entropy check failed', + severity: Severity.ERROR, + category: Category.CRYPTOGRAPHY, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + originalName: 'Failure_EntropyCheck', + }, + Init_NotInitialized: { + customCode: ErrorCode.CONFIG_INIT_001, + message: 'TrezorConnect not initialized', + severity: Severity.ERROR, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + sdkMessage: 'TrezorConnect not initialized', + }, + Init_AlreadyInitialized: { + customCode: ErrorCode.CONFIG_INIT_002, + message: 'TrezorConnect already initialized', + severity: Severity.WARNING, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + sdkMessage: 'TrezorConnect has been already initialized', + }, + Init_IframeBlocked: { + customCode: ErrorCode.CONN_BLOCKED_001, + message: 'Iframe blocked', + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Connection blocked. Please check your browser settings and allow iframes.', + sdkMessage: 'Iframe blocked', + }, + Init_IframeTimeout: { + customCode: ErrorCode.CONN_TIMEOUT_001, + message: 'Iframe connection timeout', + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + userMessage: + 'Connection timed out. Please check your internet connection and try again.', + sdkMessage: 'Iframe timeout', + }, + Init_ManifestMissing: { + customCode: ErrorCode.CONFIG_INIT_003, + message: 'Manifest not set', + severity: Severity.ERROR, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + sdkMessage: 'Manifest not set...', + }, + Popup_ConnectionMissing: { + customCode: ErrorCode.CONN_IFRAME_001, + message: 'Unable to establish connection with iframe', + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + userMessage: 'Connection failed. Please try again.', + sdkMessage: 'Unable to establish connection with iframe', + }, + Desktop_ConnectionMissing: { + customCode: ErrorCode.CONN_SUITE_001, + message: 'Unable to establish connection with Suite', + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Cannot connect to Trezor Suite. Please ensure Trezor Suite is running.', + sdkMessage: 'Unable to establish connection with Suite', + }, + Transport_Missing: { + customCode: ErrorCode.CONN_TRANSPORT_001, + message: 'Transport is missing', + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Transport layer not available. Please reconnect your device.', + sdkMessage: 'Transport is missing', + }, + Method_InvalidPackage: { + customCode: ErrorCode.CONFIG_METHOD_001, + message: 'Invalid package for browser environment', + severity: Severity.ERROR, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + sdkMessage: 'This package is not suitable to work with browser...', + }, + Method_InvalidParameter: { + customCode: ErrorCode.DATA_FORMAT_003, + message: 'Invalid method parameter', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + }, + Method_NotAllowed: { + customCode: ErrorCode.CONFIG_METHOD_001, + message: 'Method not allowed for this configuration', + severity: Severity.ERROR, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + sdkMessage: 'Method not allowed for this configuration', + }, + Method_PermissionsNotGranted: { + customCode: ErrorCode.CONFIG_PERM_001, + message: 'Permissions not granted', + severity: Severity.ERROR, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Required permissions were not granted. Please allow access to continue.', + sdkMessage: 'Permissions not granted', + }, + Method_Cancel: { + customCode: ErrorCode.USER_CANCEL_002, + message: 'Method cancelled by user', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Operation was cancelled.', + sdkMessage: 'Cancelled', + }, + Method_Interrupted: { + customCode: ErrorCode.USER_CANCEL_002, + message: 'Popup closed by user', + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Operation interrupted. The popup was closed.', + sdkMessage: 'Popup closed', + }, + Method_UnknownCoin: { + customCode: ErrorCode.DATA_NOTFOUND_003, + message: 'Coin not found', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: 'The requested cryptocurrency is not supported.', + sdkMessage: 'Coin not found', + }, + Method_AddressNotMatch: { + customCode: ErrorCode.DATA_VALIDATION_001, + message: 'Addresses do not match', + severity: Severity.ERROR, + category: Category.DATA_VALIDATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: 'Address verification failed. The addresses do not match.', + sdkMessage: 'Addresses do not match', + }, + Method_Discovery_BundleException: { + customCode: ErrorCode.SYS_INTERNAL_001, + message: 'Discovery bundle exception', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + }, + Method_Override: { + customCode: ErrorCode.CONFIG_METHOD_001, + message: 'Method override', + severity: Severity.WARNING, + category: Category.CONFIGURATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: false, + sdkMessage: 'override', + }, + Method_NoResponse: { + customCode: ErrorCode.PROTO_CMD_003, + message: 'Call resolved without response', + severity: Severity.ERROR, + category: Category.PROTOCOL, + retryStrategy: RetryStrategy.RETRY, + userActionable: false, + sdkMessage: 'Call resolved without response', + }, + Device_NotFound: { + customCode: ErrorCode.DEVICE_DETECT_001, + message: 'Device not found', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Trezor device not detected. Please connect your device and try again.', + sdkMessage: 'Device not found', + }, + Device_InitializeFailed: { + customCode: ErrorCode.DEVICE_STATE_001, + message: 'Device initialization failed', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Failed to initialize device. Please reconnect and try again.', + }, + Device_FwException: { + customCode: ErrorCode.SYS_FIRMWARE_001, + message: 'Firmware exception', + severity: Severity.ERROR, + category: Category.SYSTEM, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Firmware error detected. Please update your device firmware.', + }, + Device_ModeException: { + customCode: ErrorCode.DEVICE_MODE_001, + message: 'Device mode exception', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Device is in an incompatible mode. Please check your device settings.', + }, + Device_Disconnected: { + customCode: ErrorCode.DEVICE_STATE_003, + message: 'Device disconnected', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: + 'Device was disconnected. Please reconnect your Trezor device.', + sdkMessage: 'Device disconnected', + }, + Device_UsedElsewhere: { + customCode: ErrorCode.DEVICE_STATE_004, + message: 'Device is used in another window', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Your Trezor is being used in another window or application. Please close other connections.', + sdkMessage: 'Device is used in another window', + }, + Device_InvalidState: { + customCode: ErrorCode.AUTH_SEC_001, + message: 'Passphrase is incorrect', + severity: Severity.ERROR, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Incorrect passphrase. Please try again.', + sdkMessage: 'Passphrase is incorrect', + }, + Device_CallInProgress: { + customCode: ErrorCode.DEVICE_STATE_005, + message: 'Device call in progress', + severity: Severity.WARNING, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.EXPONENTIAL_BACKOFF, + userActionable: false, + userMessage: 'Another operation is in progress. Please wait.', + sdkMessage: 'Device call in progress', + }, + Device_MultipleNotSupported: { + customCode: ErrorCode.DEVICE_CAP_001, + message: 'Multiple devices are not supported', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Multiple devices detected. Please connect only one Trezor device.', + sdkMessage: 'Multiple devices are not supported', + }, + Device_MissingCapability: { + customCode: ErrorCode.DEVICE_CAP_001, + message: 'Device is missing required capability', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'Your device does not support this feature. A firmware update may be required.', + sdkMessage: 'Device is missing capability', + }, + Device_MissingCapabilityBtcOnly: { + customCode: ErrorCode.DEVICE_CAP_002, + message: 'Device is BTC-only, operation not supported', + severity: Severity.ERROR, + category: Category.DEVICE_STATE, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: + 'This operation is not supported on Bitcoin-only firmware.', + sdkMessage: 'Device is missing capability (BTC only)', + }, + }, + default: { + custom_code: ErrorCode.UNKNOWN_001, + message: 'Unknown Trezor error', + severity: Severity.ERROR, + category: Category.UNKNOWN, + retry_strategy: RetryStrategy.NO_RETRY, + userActionable: false, + userMessage: + 'An unexpected error occurred. Please try again or contact support.', + }, + error_patterns: [ + { + pattern: '^Failure_.*', + type: 'prefix', + description: 'Device failure codes', + defaultSeverity: Severity.ERROR, + }, + { + pattern: '^Init_.*', + type: 'prefix', + description: 'Initialization errors', + defaultSeverity: Severity.ERROR, + }, + { + pattern: '^Method_.*', + type: 'prefix', + description: 'Method invocation errors', + defaultSeverity: Severity.ERROR, + }, + { + pattern: '^Device_.*', + type: 'prefix', + description: 'Device state errors', + defaultSeverity: Severity.ERROR, + }, + ], + }, +}; diff --git a/packages/keyring-utils/src/hardware-error.ts b/packages/keyring-utils/src/hardware-error.ts new file mode 100644 index 000000000..78f277838 --- /dev/null +++ b/packages/keyring-utils/src/hardware-error.ts @@ -0,0 +1,232 @@ +import type { + ErrorCode, + Severity, + Category, + RetryStrategy, +} from './hardware-errors-enums'; + +/** + * Generates a unique error ID using timestamp and random values. + */ +function generateErrorId(): string { + const timestamp = Date.now().toString(36); + const randomPart = Math.random().toString(36).substring(2, 9); + return `err_${timestamp}_${randomPart}`; +} + +export type HardwareWalletErrorOptions = { + code: ErrorCode; + severity: Severity; + category: Category; + retryStrategy: RetryStrategy; + userActionable: boolean; + userMessage: string; + cause?: Error; + metadata?: Record; + documentationUrl?: string; + retryCount?: number; +}; + +export class HardwareWalletError extends Error { + public readonly id: string; + + public readonly code: ErrorCode; + + public readonly severity: Severity; + + public readonly category: Category; + + public readonly retryStrategy: RetryStrategy; + + public readonly userActionable: boolean; + + public readonly userMessage: string; + + public readonly timestamp: Date; + + public readonly metadata: Record | undefined; + + public readonly documentationUrl: string | undefined; + + public readonly retryCount: number; + + public readonly cause: Error | undefined; + + constructor(message: string, options: HardwareWalletErrorOptions) { + super(message); + this.name = 'HardwareWalletError'; + this.id = generateErrorId(); + this.code = options.code; + this.severity = options.severity; + this.category = options.category; + this.retryStrategy = options.retryStrategy; + this.userActionable = options.userActionable; + this.userMessage = options.userMessage; + this.timestamp = new Date(); + this.metadata = options.metadata; + this.documentationUrl = options.documentationUrl; + this.retryCount = options.retryCount ?? 0; + this.cause = options.cause; + + // Ensure proper prototype chain for instanceof checks + Object.setPrototypeOf(this, HardwareWalletError.prototype); + + // Capture stack trace if available + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } + + /** + * Checks if this error can be retried based on its retry strategy. + */ + isRetryable(): boolean { + return this.retryStrategy !== 'NO_RETRY'; + } + + /** + * Checks if this error is critical. + */ + isCritical(): boolean { + return this.severity === 'CRITICAL'; + } + + /** + * Checks if this error is a warning. + */ + isWarning(): boolean { + return this.severity === 'WARNING'; + } + + /** + * Checks if this error requires user action. + */ + requiresUserAction(): boolean { + return this.userActionable; + } + + /** + * Creates a new error instance with an incremented retry count. + */ + withIncrementedRetryCount(): HardwareWalletError { + const options: HardwareWalletErrorOptions = { + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + userActionable: this.userActionable, + userMessage: this.userMessage, + retryCount: this.retryCount + 1, + }; + + if (this.cause !== undefined) { + options.cause = this.cause; + } + if (this.metadata !== undefined) { + options.metadata = this.metadata; + } + if (this.documentationUrl !== undefined) { + options.documentationUrl = this.documentationUrl; + } + + return new HardwareWalletError(this.message, options); + } + + /** + * Creates a new error instance with additional metadata. + */ + withMetadata( + additionalMetadata: Record, + ): HardwareWalletError { + const options: HardwareWalletErrorOptions = { + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + userActionable: this.userActionable, + userMessage: this.userMessage, + metadata: { ...(this.metadata ?? {}), ...additionalMetadata }, + retryCount: this.retryCount, + }; + + if (this.cause !== undefined) { + options.cause = this.cause; + } + if (this.documentationUrl !== undefined) { + options.documentationUrl = this.documentationUrl; + } + + return new HardwareWalletError(this.message, options); + } + + /** + * Serializes the error to a JSON-compatible object. + * Note: The cause property is serialized if it exists. + */ + toJSON(): Record { + const json: Record = { + id: this.id, + name: this.name, + message: this.message, + code: this.code, + severity: this.severity, + category: this.category, + retryStrategy: this.retryStrategy, + userActionable: this.userActionable, + userMessage: this.userMessage, + timestamp: this.timestamp.toISOString(), + metadata: this.metadata, + documentationUrl: this.documentationUrl, + retryCount: this.retryCount, + stack: this.stack, + }; + + if (this.cause !== undefined) { + json.cause = { + name: this.cause.name, + message: this.cause.message, + stack: this.cause.stack, + }; + } + + return json; + } + + /** + * Returns a user-friendly string representation of the error. + */ + toString(): string { + return `${this.name} [${this.code}]: ${this.userMessage}`; + } + + /** + * Returns a detailed string representation for debugging. + */ + toDetailedString(): string { + const details = [ + `${this.name} [${this.code}]`, + `Message: ${this.message}`, + `User Message: ${this.userMessage}`, + `Severity: ${this.severity}`, + `Category: ${this.category}`, + `Retry Strategy: ${this.retryStrategy}`, + `User Actionable: ${this.userActionable}`, + `Timestamp: ${this.timestamp.toISOString()}`, + `Retry Count: ${this.retryCount}`, + ]; + + if (this.documentationUrl) { + details.push(`Documentation: ${this.documentationUrl}`); + } + + if (this.metadata && Object.keys(this.metadata).length > 0) { + details.push(`Metadata: ${JSON.stringify(this.metadata, null, 2)}`); + } + + if (this.cause !== undefined) { + details.push(`Caused by: ${this.cause.message}`); + } + + return details.join('\n'); + } +} diff --git a/packages/keyring-utils/src/hardware-errors-enums.ts b/packages/keyring-utils/src/hardware-errors-enums.ts new file mode 100644 index 000000000..249279ed2 --- /dev/null +++ b/packages/keyring-utils/src/hardware-errors-enums.ts @@ -0,0 +1,121 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Error Code Enum +export enum ErrorCode { + // Authentication & Security + AUTH_PIN_001 = 'AUTH_PIN_001', + AUTH_PIN_002 = 'AUTH_PIN_002', + AUTH_PIN_003 = 'AUTH_PIN_003', + AUTH_PIN_004 = 'AUTH_PIN_004', + AUTH_LOCK_001 = 'AUTH_LOCK_001', + AUTH_LOCK_002 = 'AUTH_LOCK_002', + AUTH_SEC_001 = 'AUTH_SEC_001', + AUTH_SEC_002 = 'AUTH_SEC_002', + AUTH_WIPE_001 = 'AUTH_WIPE_001', + + // User Action + USER_CANCEL_001 = 'USER_CANCEL_001', + USER_CANCEL_002 = 'USER_CANCEL_002', + USER_INPUT_001 = 'USER_INPUT_001', + USER_CONFIRM_001 = 'USER_CONFIRM_001', + + // Device State + DEVICE_STATE_001 = 'DEVICE_STATE_001', + DEVICE_STATE_002 = 'DEVICE_STATE_002', + DEVICE_STATE_003 = 'DEVICE_STATE_003', + DEVICE_STATE_004 = 'DEVICE_STATE_004', + DEVICE_STATE_005 = 'DEVICE_STATE_005', + DEVICE_DETECT_001 = 'DEVICE_DETECT_001', + DEVICE_CAP_001 = 'DEVICE_CAP_001', + DEVICE_CAP_002 = 'DEVICE_CAP_002', + DEVICE_MODE_001 = 'DEVICE_MODE_001', + + // Connection & Transport + CONN_TRANSPORT_001 = 'CONN_TRANSPORT_001', + CONN_CLOSED_001 = 'CONN_CLOSED_001', + CONN_IFRAME_001 = 'CONN_IFRAME_001', + CONN_SUITE_001 = 'CONN_SUITE_001', + CONN_TIMEOUT_001 = 'CONN_TIMEOUT_001', + CONN_BLOCKED_001 = 'CONN_BLOCKED_001', + + // Data & Validation + DATA_FORMAT_001 = 'DATA_FORMAT_001', + DATA_FORMAT_002 = 'DATA_FORMAT_002', + DATA_FORMAT_003 = 'DATA_FORMAT_003', + DATA_MISSING_001 = 'DATA_MISSING_001', + DATA_VALIDATION_001 = 'DATA_VALIDATION_001', + DATA_VALIDATION_002 = 'DATA_VALIDATION_002', + DATA_NOTFOUND_001 = 'DATA_NOTFOUND_001', + DATA_NOTFOUND_002 = 'DATA_NOTFOUND_002', + DATA_NOTFOUND_003 = 'DATA_NOTFOUND_003', + + // Cryptographic Operations + CRYPTO_SIGN_001 = 'CRYPTO_SIGN_001', + CRYPTO_ALGO_001 = 'CRYPTO_ALGO_001', + CRYPTO_KEY_001 = 'CRYPTO_KEY_001', + CRYPTO_ENTROPY_001 = 'CRYPTO_ENTROPY_001', + + // System & Internal + SYS_INTERNAL_001 = 'SYS_INTERNAL_001', + SYS_MEMORY_001 = 'SYS_MEMORY_001', + SYS_MEMORY_002 = 'SYS_MEMORY_002', + SYS_FILE_001 = 'SYS_FILE_001', + SYS_FILE_002 = 'SYS_FILE_002', + SYS_LICENSE_001 = 'SYS_LICENSE_001', + SYS_FIRMWARE_001 = 'SYS_FIRMWARE_001', + SYS_FIRMWARE_002 = 'SYS_FIRMWARE_002', + + // Command & Protocol + PROTO_CMD_001 = 'PROTO_CMD_001', + PROTO_CMD_002 = 'PROTO_CMD_002', + PROTO_CMD_003 = 'PROTO_CMD_003', + PROTO_MSG_001 = 'PROTO_MSG_001', + PROTO_PARAM_001 = 'PROTO_PARAM_001', + + // Configuration & Initialization + CONFIG_INIT_001 = 'CONFIG_INIT_001', + CONFIG_INIT_002 = 'CONFIG_INIT_002', + CONFIG_INIT_003 = 'CONFIG_INIT_003', + CONFIG_PERM_001 = 'CONFIG_PERM_001', + CONFIG_METHOD_001 = 'CONFIG_METHOD_001', + + // Transaction + TX_FUNDS_001 = 'TX_FUNDS_001', + TX_FAIL_001 = 'TX_FAIL_001', + + // Success + SUCCESS_000 = 'SUCCESS_000', + + // Unknown/Fallback + UNKNOWN_001 = 'UNKNOWN_001', +} + +// Severity Enum +export enum Severity { + INFO = 'INFO', + ERROR = 'ERROR', + WARNING = 'WARNING', + CRITICAL = 'CRITICAL', +} + +// Category Enum +export enum Category { + SUCCESS = 'SUCCESS', + AUTHENTICATION = 'AUTHENTICATION', + DATA_VALIDATION = 'DATA_VALIDATION', + PROTOCOL = 'PROTOCOL', + SYSTEM = 'SYSTEM', + CRYPTOGRAPHY = 'CRYPTOGRAPHY', + CONFIGURATION = 'CONFIGURATION', + CONNECTION = 'CONNECTION', + USER_ACTION = 'USER_ACTION', + DEVICE_STATE = 'DEVICE_STATE', + TRANSACTION = 'TRANSACTION', + UNKNOWN = 'UNKNOWN', +} + +// Retry Strategy Enum +export enum RetryStrategy { + NO_RETRY = 'NO_RETRY', + RETRY = 'RETRY', + EXPONENTIAL_BACKOFF = 'EXPONENTIAL_BACKOFF', +} diff --git a/packages/keyring-utils/src/index.ts b/packages/keyring-utils/src/index.ts index e84e6cc96..428d5609d 100644 --- a/packages/keyring-utils/src/index.ts +++ b/packages/keyring-utils/src/index.ts @@ -5,3 +5,7 @@ export * from './scopes'; export * from './superstruct'; export * from './JsonRpcRequest'; export type * from './keyring'; +export * from './hardware-errors-enums'; +export * from './hardware-error-mappings'; +export * from './hardware-error-codes'; +export * from './hardware-error'; From a13943f679a85b4e6f096f81eee689c212f79b5a Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 18 Dec 2025 17:57:50 +0800 Subject: [PATCH 2/8] feat: add getAppNameAndVersion --- .../keyring-eth-ledger-bridge/src/index.ts | 2 ++ .../src/ledger-bridge.ts | 12 ++++++++++ .../src/ledger-hw-app.ts | 2 +- .../src/ledger-iframe-bridge.ts | 23 ++++++++++++++++++- .../src/ledger-keyring.ts | 14 +++++++++++ .../src/ledger-mobile-bridge.ts | 6 ++--- .../keyring-eth-ledger-bridge/src/type.ts | 5 ---- 7 files changed, 53 insertions(+), 11 deletions(-) diff --git a/packages/keyring-eth-ledger-bridge/src/index.ts b/packages/keyring-eth-ledger-bridge/src/index.ts index c84c944fc..0f158cb4b 100644 --- a/packages/keyring-eth-ledger-bridge/src/index.ts +++ b/packages/keyring-eth-ledger-bridge/src/index.ts @@ -5,3 +5,5 @@ 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..92dc59b70 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 = { @@ -63,4 +68,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-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.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.ts b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts index 62295534d..282108a73 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts @@ -329,6 +329,20 @@ export class LedgerKeyring implements Keyring { return this.bridge.updateTransportMethod(transportType); } + async getAppNameAndVersion(): Promise<{ + appName: string; + version: string; + }> { + 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.ts b/packages/keyring-eth-ledger-bridge/src/type.ts index f72c09f8e..f8c9e48db 100644 --- a/packages/keyring-eth-ledger-bridge/src/type.ts +++ b/packages/keyring-eth-ledger-bridge/src/type.ts @@ -1,8 +1,3 @@ -export type GetAppNameAndVersionResponse = { - appName: string; - version: string; -}; - export type LedgerMobileBridgeOptions = Record; export class LedgerStatusError extends Error { From fc4696495b19490703b49a3c0b5d2c8bb225665a Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Mon, 5 Jan 2026 22:18:23 +0800 Subject: [PATCH 3/8] fix: lint --- .../src/ledger-error-handler.test.ts | 12 ++++--- .../src/ledger-error-handler.ts | 1 - packages/keyring-utils/src/hardware-error.ts | 35 ++++++++++++++----- 3 files changed, 33 insertions(+), 15 deletions(-) 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 98fce3139..fdf6e1148 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,7 +1,7 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; -import { handleLedgerTransportError } from './ledger-error-handler'; import { LedgerHardwareWalletError } from './errors'; +import { handleLedgerTransportError } from './ledger-error-handler'; describe('handleLedgerTransportError', () => { const fallbackMessage = 'Default error message'; @@ -54,7 +54,9 @@ describe('handleLedgerTransportError', () => { expect((thrownError as LedgerHardwareWalletError).ledgerCode).toBe( expectedLedgerCode, ); - expect((thrownError as LedgerHardwareWalletError).message).toBe(expectedMessage); + expect((thrownError as LedgerHardwareWalletError).message).toBe( + expectedMessage, + ); return true; } @@ -100,9 +102,9 @@ describe('handleLedgerTransportError', () => { 'handles status code $status ($tc)', ({ inputMessage, status, expectedLedgerCode, expectedMessage }) => { const error = createTransportStatusError(inputMessage, status); - expect(expectLedgerError(error, expectedLedgerCode, expectedMessage)).toBe( - true, - ); + expect( + expectLedgerError(error, expectedLedgerCode, expectedMessage), + ).toBe(true); }, ); }); 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 0428c3a4d..18ff70c68 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts @@ -21,7 +21,6 @@ import { * @param fallbackMessage - Default error message if no specific handling is found * @throws LedgerHardwareWalletError with appropriate error details from mappings */ -// eslint-disable-next-line @typescript-eslint/no-throw-literal export function handleLedgerTransportError( error: unknown, fallbackMessage: string, diff --git a/packages/keyring-utils/src/hardware-error.ts b/packages/keyring-utils/src/hardware-error.ts index 78f277838..149c39e40 100644 --- a/packages/keyring-utils/src/hardware-error.ts +++ b/packages/keyring-utils/src/hardware-error.ts @@ -1,12 +1,10 @@ -import type { - ErrorCode, - Severity, - Category, - RetryStrategy, -} from './hardware-errors-enums'; +import type { ErrorCode, Category } from './hardware-errors-enums'; +import { Severity, RetryStrategy } from './hardware-errors-enums'; /** * Generates a unique error ID using timestamp and random values. + * + * @returns A unique error ID string. */ function generateErrorId(): string { const timestamp = Date.now().toString(36); @@ -79,27 +77,35 @@ export class HardwareWalletError extends Error { /** * Checks if this error can be retried based on its retry strategy. + * + * @returns True if the error can be retried, false otherwise. */ isRetryable(): boolean { - return this.retryStrategy !== 'NO_RETRY'; + return this.retryStrategy !== RetryStrategy.NO_RETRY; } /** * Checks if this error is critical. + * + * @returns True if the error is critical, false otherwise. */ isCritical(): boolean { - return this.severity === 'CRITICAL'; + return this.severity === Severity.CRITICAL; } /** * Checks if this error is a warning. + * + * @returns True if the error is a warning, false otherwise. */ isWarning(): boolean { - return this.severity === 'WARNING'; + return this.severity === Severity.WARNING; } /** * Checks if this error requires user action. + * + * @returns True if the error requires user action, false otherwise. */ requiresUserAction(): boolean { return this.userActionable; @@ -107,6 +113,8 @@ export class HardwareWalletError extends Error { /** * Creates a new error instance with an incremented retry count. + * + * @returns A new HardwareWalletError instance with incremented retry count. */ withIncrementedRetryCount(): HardwareWalletError { const options: HardwareWalletErrorOptions = { @@ -134,6 +142,9 @@ export class HardwareWalletError extends Error { /** * Creates a new error instance with additional metadata. + * + * @param additionalMetadata - Additional metadata to merge with existing metadata. + * @returns A new HardwareWalletError instance with merged metadata. */ withMetadata( additionalMetadata: Record, @@ -162,6 +173,8 @@ export class HardwareWalletError extends Error { /** * Serializes the error to a JSON-compatible object. * Note: The cause property is serialized if it exists. + * + * @returns A JSON-compatible object representation of the error. */ toJSON(): Record { const json: Record = { @@ -194,6 +207,8 @@ export class HardwareWalletError extends Error { /** * Returns a user-friendly string representation of the error. + * + * @returns A user-friendly string representation of the error. */ toString(): string { return `${this.name} [${this.code}]: ${this.userMessage}`; @@ -201,6 +216,8 @@ export class HardwareWalletError extends Error { /** * Returns a detailed string representation for debugging. + * + * @returns A detailed string representation of the error for debugging. */ toDetailedString(): string { const details = [ From ec0b6995fae37e41f8d4d26dd7dea59d2eb8a4a5 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Jan 2026 12:30:21 +0800 Subject: [PATCH 4/8] fix: tests --- .../src/hardware-error-codes.test.ts | 283 +++++++++ .../src/hardware-error-mappings.test.ts | 557 ++++++++++++++++++ .../keyring-utils/src/hardware-error.test.ts | 481 +++++++++++++++ packages/keyring-utils/src/hardware-error.ts | 15 - 4 files changed, 1321 insertions(+), 15 deletions(-) create mode 100644 packages/keyring-utils/src/hardware-error-codes.test.ts create mode 100644 packages/keyring-utils/src/hardware-error-mappings.test.ts create mode 100644 packages/keyring-utils/src/hardware-error.test.ts diff --git a/packages/keyring-utils/src/hardware-error-codes.test.ts b/packages/keyring-utils/src/hardware-error-codes.test.ts new file mode 100644 index 000000000..2429b83e1 --- /dev/null +++ b/packages/keyring-utils/src/hardware-error-codes.test.ts @@ -0,0 +1,283 @@ +import * as errorCodes from './hardware-error-codes'; + +describe('hardware-error-codes', () => { + describe('exports', () => { + it('should export all error code constants', () => { + expect(Object.keys(errorCodes).length).toBeGreaterThan(0); + }); + + it('should export all constants as strings', () => { + Object.values(errorCodes).forEach((value) => { + expect(typeof value).toBe('string'); + }); + }); + }); + + describe('Authentication & Security codes', () => { + it('should have PIN error codes', () => { + expect(errorCodes.AUTH_PIN_001).toBe('PIN invalid'); + expect(errorCodes.AUTH_PIN_002).toBe('PIN cancelled by user'); + expect(errorCodes.AUTH_PIN_003).toBe('PIN attempts remaining'); + expect(errorCodes.AUTH_PIN_004).toBe('PIN mismatch'); + }); + + it('should have lock error codes', () => { + expect(errorCodes.AUTH_LOCK_001).toBe('Device is locked'); + expect(errorCodes.AUTH_LOCK_002).toBe( + 'Device blocked due to failed attempts', + ); + }); + + it('should have security error codes', () => { + expect(errorCodes.AUTH_SEC_001).toBe('Security conditions not satisfied'); + expect(errorCodes.AUTH_SEC_002).toBe('Access rights insufficient'); + }); + + it('should have wipe code error', () => { + expect(errorCodes.AUTH_WIPE_001).toBe('Wipe code mismatch'); + }); + }); + + describe('User Action codes', () => { + it('should have cancel error codes', () => { + expect(errorCodes.USER_CANCEL_001).toBe('User rejected action on device'); + expect(errorCodes.USER_CANCEL_002).toBe('User cancelled operation'); + }); + + it('should have user input codes', () => { + expect(errorCodes.USER_INPUT_001).toBe('User input expected'); + expect(errorCodes.USER_CONFIRM_001).toBe('User confirmation required'); + }); + }); + + describe('Device State codes', () => { + it('should have device state error codes', () => { + expect(errorCodes.DEVICE_STATE_001).toBe('Device not initialized'); + expect(errorCodes.DEVICE_STATE_002).toBe('Device busy'); + expect(errorCodes.DEVICE_STATE_003).toBe('Device disconnected'); + expect(errorCodes.DEVICE_STATE_004).toBe('Device used elsewhere'); + expect(errorCodes.DEVICE_STATE_005).toBe('Device call in progress'); + }); + + it('should have device detection code', () => { + expect(errorCodes.DEVICE_DETECT_001).toBe('Device not found'); + }); + + it('should have device capability codes', () => { + expect(errorCodes.DEVICE_CAP_001).toBe( + 'Device missing required capability', + ); + expect(errorCodes.DEVICE_CAP_002).toBe( + 'Device is BTC-only, operation not supported', + ); + }); + + it('should have device mode code', () => { + expect(errorCodes.DEVICE_MODE_001).toBe('Invalid device mode'); + }); + }); + + describe('Connection & Transport codes', () => { + it('should have transport error code', () => { + expect(errorCodes.CONN_TRANSPORT_001).toBe('Transport layer missing'); + }); + + it('should have connection error codes', () => { + expect(errorCodes.CONN_CLOSED_001).toBe('Connection closed unexpectedly'); + expect(errorCodes.CONN_IFRAME_001).toBe( + 'Unable to establish iframe connection', + ); + expect(errorCodes.CONN_SUITE_001).toBe('Unable to connect to Suite'); + expect(errorCodes.CONN_TIMEOUT_001).toBe('Connection timeout'); + expect(errorCodes.CONN_BLOCKED_001).toBe('Connection blocked'); + }); + }); + + describe('Data & Validation codes', () => { + it('should have data format error codes', () => { + expect(errorCodes.DATA_FORMAT_001).toBe('Incorrect data length'); + expect(errorCodes.DATA_FORMAT_002).toBe('Invalid data received'); + expect(errorCodes.DATA_FORMAT_003).toBe('Invalid parameter'); + }); + + it('should have data missing code', () => { + expect(errorCodes.DATA_MISSING_001).toBe('Missing critical parameter'); + }); + + it('should have data validation codes', () => { + expect(errorCodes.DATA_VALIDATION_001).toBe('Address mismatch'); + expect(errorCodes.DATA_VALIDATION_002).toBe('Invalid signature'); + }); + + it('should have data not found codes', () => { + expect(errorCodes.DATA_NOTFOUND_001).toBe('Referenced data not found'); + expect(errorCodes.DATA_NOTFOUND_002).toBe('File not found'); + expect(errorCodes.DATA_NOTFOUND_003).toBe('Coin not found'); + }); + }); + + describe('Cryptographic Operations codes', () => { + it('should have crypto error codes', () => { + expect(errorCodes.CRYPTO_SIGN_001).toBe('Signature operation failed'); + expect(errorCodes.CRYPTO_ALGO_001).toBe('Algorithm not supported'); + expect(errorCodes.CRYPTO_KEY_001).toBe('Invalid key check value'); + expect(errorCodes.CRYPTO_ENTROPY_001).toBe('Entropy check failed'); + }); + }); + + describe('System & Internal codes', () => { + it('should have internal error code', () => { + expect(errorCodes.SYS_INTERNAL_001).toBe('Internal device error'); + }); + + it('should have memory error codes', () => { + expect(errorCodes.SYS_MEMORY_001).toBe('Not enough memory'); + expect(errorCodes.SYS_MEMORY_002).toBe('Memory problem'); + }); + + it('should have file system error codes', () => { + expect(errorCodes.SYS_FILE_001).toBe('File system error'); + expect(errorCodes.SYS_FILE_002).toBe('Inconsistent file'); + }); + + it('should have license error code', () => { + expect(errorCodes.SYS_LICENSE_001).toBe('Licensing error'); + }); + + it('should have firmware error codes', () => { + expect(errorCodes.SYS_FIRMWARE_001).toBe('Firmware error'); + expect(errorCodes.SYS_FIRMWARE_002).toBe('Firmware installation failed'); + }); + }); + + describe('Command & Protocol codes', () => { + it('should have command error codes', () => { + expect(errorCodes.PROTO_CMD_001).toBe('Command not supported'); + expect(errorCodes.PROTO_CMD_002).toBe('Command incompatible'); + expect(errorCodes.PROTO_CMD_003).toBe('Unexpected message'); + }); + + it('should have protocol message codes', () => { + expect(errorCodes.PROTO_MSG_001).toBe('Invalid APDU command'); + expect(errorCodes.PROTO_PARAM_001).toBe('Invalid command parameters'); + }); + }); + + describe('Configuration & Initialization codes', () => { + it('should have initialization error codes', () => { + expect(errorCodes.CONFIG_INIT_001).toBe('Not initialized'); + expect(errorCodes.CONFIG_INIT_002).toBe('Already initialized'); + expect(errorCodes.CONFIG_INIT_003).toBe('Manifest missing'); + }); + + it('should have permission error code', () => { + expect(errorCodes.CONFIG_PERM_001).toBe('Permissions not granted'); + }); + + it('should have method error code', () => { + expect(errorCodes.CONFIG_METHOD_001).toBe('Method not allowed'); + }); + }); + + describe('Transaction codes', () => { + it('should have transaction error codes', () => { + expect(errorCodes.TX_FUNDS_001).toBe('Insufficient funds'); + expect(errorCodes.TX_FAIL_001).toBe('Transaction failed'); + }); + }); + + describe('Special codes', () => { + it('should have success code', () => { + expect(errorCodes.SUCCESS_000).toBe('Operation successful'); + }); + + it('should have unknown error code', () => { + expect(errorCodes.UNKNOWN_001).toBe('Unknown error'); + }); + }); + + describe('code uniqueness', () => { + it('should have unique error code identifiers', () => { + const codeNames = Object.keys(errorCodes); + const uniqueNames = new Set(codeNames); + expect(uniqueNames.size).toBe(codeNames.length); + }); + + it('should have unique error code messages', () => { + const codeValues = Object.values(errorCodes); + const uniqueValues = new Set(codeValues); + expect(uniqueValues.size).toBe(codeValues.length); + }); + }); + + describe('naming conventions', () => { + it('should follow naming pattern with underscores and numbers', () => { + const codeNames = Object.keys(errorCodes); + // Pattern: WORD_NNN or WORD_WORD_NNN (allows one or more words followed by numbers) + const pattern = /^[A-Z]+(_[A-Z0-9]+)*_\d+$/u; + + codeNames.forEach((name) => { + expect(name).toMatch(pattern); + }); + }); + + it('should have sequential numbering within categories', () => { + const categories: Record = {}; + + Object.keys(errorCodes).forEach((code) => { + const prefix = code.substring(0, code.lastIndexOf('_')); + if (!categories[prefix]) { + categories[prefix] = []; + } + categories[prefix].push(code); + }); + + // Check that each category has at least one code + Object.values(categories).forEach((codes) => { + expect(codes.length).toBeGreaterThan(0); + }); + }); + }); + + describe('error message quality', () => { + it('should have descriptive error messages', () => { + Object.values(errorCodes).forEach((message) => { + expect(message.length).toBeGreaterThan(5); + // Messages should start with a capital letter or be all caps + expect(message[0]).toMatch(/[A-Z]/u); + }); + }); + + it('should not end with punctuation', () => { + Object.values(errorCodes).forEach((message) => { + expect(message).not.toMatch(/[.!?]$/u); + }); + }); + }); + + describe('category coverage', () => { + it('should have error codes for all major categories', () => { + const codeNames = Object.keys(errorCodes); + + const categories = [ + 'AUTH', + 'USER', + 'DEVICE', + 'CONN', + 'DATA', + 'CRYPTO', + 'SYS', + 'PROTO', + 'CONFIG', + 'TX', + ]; + + categories.forEach((category) => { + const hasCategoryCode = codeNames.some((code) => + code.startsWith(category), + ); + expect(hasCategoryCode).toBe(true); + }); + }); + }); +}); diff --git a/packages/keyring-utils/src/hardware-error-mappings.test.ts b/packages/keyring-utils/src/hardware-error-mappings.test.ts new file mode 100644 index 000000000..c155e724d --- /dev/null +++ b/packages/keyring-utils/src/hardware-error-mappings.test.ts @@ -0,0 +1,557 @@ +import { HARDWARE_MAPPINGS } from './hardware-error-mappings'; +import { + ErrorCode, + Severity, + Category, + RetryStrategy, +} from './hardware-errors-enums'; + +describe('HARDWARE_MAPPINGS', () => { + describe('structure', () => { + it('should have ledger and trezor vendors', () => { + expect(HARDWARE_MAPPINGS).toHaveProperty('ledger'); + expect(HARDWARE_MAPPINGS).toHaveProperty('trezor'); + }); + + it('should have vendor names', () => { + expect(HARDWARE_MAPPINGS.ledger.vendorName).toBe('Ledger'); + expect(HARDWARE_MAPPINGS.trezor.vendorName).toBe('Trezor'); + }); + }); + + describe('Ledger mappings', () => { + const { errorMappings } = HARDWARE_MAPPINGS.ledger; + + it('should have errorMappings object', () => { + expect(errorMappings).toBeDefined(); + expect(typeof errorMappings).toBe('object'); + }); + + describe('success codes', () => { + it('should map 0x9000 to success', () => { + const mapping = errorMappings['0x9000']; + expect(mapping).toBeDefined(); + expect(mapping.customCode).toBe(ErrorCode.SUCCESS_000); + expect(mapping.severity).toBe(Severity.INFO); + expect(mapping.category).toBe(Category.SUCCESS); + expect(mapping.retryStrategy).toBe(RetryStrategy.NO_RETRY); + expect(mapping.userActionable).toBe(false); + }); + }); + + describe('authentication errors', () => { + it('should map 0x6300 to authentication failed', () => { + const mapping = errorMappings['0x6300']; + expect(mapping.customCode).toBe(ErrorCode.AUTH_SEC_001); + expect(mapping.severity).toBe(Severity.ERROR); + expect(mapping.category).toBe(Category.AUTHENTICATION); + expect(mapping.userActionable).toBe(true); + expect(mapping.userMessage).toBeDefined(); + }); + + it('should map 0x63c0 to PIN attempts remaining', () => { + const mapping = errorMappings['0x63c0']; + expect(mapping.customCode).toBe(ErrorCode.AUTH_PIN_003); + expect(mapping.severity).toBe(Severity.WARNING); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + }); + + it('should map 0x5515 to device locked', () => { + const mapping = errorMappings['0x5515']; + expect(mapping.customCode).toBe(ErrorCode.AUTH_LOCK_001); + expect(mapping.severity).toBe(Severity.ERROR); + expect(mapping.userActionable).toBe(true); + expect(mapping.userMessage).toContain('unlock'); + }); + + it('should map 0x9840 to device blocked', () => { + const mapping = errorMappings['0x9840']; + expect(mapping.customCode).toBe(ErrorCode.AUTH_LOCK_002); + expect(mapping.severity).toBe(Severity.CRITICAL); + expect(mapping.retryStrategy).toBe(RetryStrategy.NO_RETRY); + }); + }); + + describe('user action errors', () => { + it('should map 0x6985 to user rejected', () => { + const mapping = errorMappings['0x6985']; + expect(mapping.customCode).toBe(ErrorCode.USER_CANCEL_001); + expect(mapping.severity).toBe(Severity.WARNING); + expect(mapping.category).toBe(Category.USER_ACTION); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + expect(mapping.userActionable).toBe(true); + }); + + it('should map 0x5501 to user refused', () => { + const mapping = errorMappings['0x5501']; + expect(mapping.customCode).toBe(ErrorCode.USER_CANCEL_001); + expect(mapping.severity).toBe(Severity.WARNING); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + }); + }); + + describe('data validation errors', () => { + it('should map 0x6700 to incorrect data length', () => { + const mapping = errorMappings['0x6700']; + expect(mapping.customCode).toBe(ErrorCode.DATA_FORMAT_001); + expect(mapping.category).toBe(Category.DATA_VALIDATION); + expect(mapping.userActionable).toBe(false); + }); + + it('should map 0x6a80 to invalid data', () => { + const mapping = errorMappings['0x6a80']; + expect(mapping.customCode).toBe(ErrorCode.DATA_FORMAT_002); + expect(mapping.category).toBe(Category.DATA_VALIDATION); + }); + + it('should map 0x6b00 to invalid parameter', () => { + const mapping = errorMappings['0x6b00']; + expect(mapping.customCode).toBe(ErrorCode.DATA_FORMAT_003); + expect(mapping.severity).toBe(Severity.ERROR); + }); + }); + + describe('protocol errors', () => { + it('should map 0x6981 to command incompatible', () => { + const mapping = errorMappings['0x6981']; + expect(mapping.customCode).toBe(ErrorCode.PROTO_CMD_002); + expect(mapping.category).toBe(Category.PROTOCOL); + }); + + it('should map 0x6d00 to instruction not supported', () => { + const mapping = errorMappings['0x6d00']; + expect(mapping.customCode).toBe(ErrorCode.PROTO_CMD_001); + expect(mapping.category).toBe(Category.PROTOCOL); + }); + + it('should map 0x6d02 to unknown APDU command', () => { + const mapping = errorMappings['0x6d02']; + expect(mapping.customCode).toBe(ErrorCode.PROTO_MSG_001); + expect(mapping.category).toBe(Category.PROTOCOL); + }); + }); + + describe('system errors', () => { + it('should map 0x6f00 to internal device error', () => { + const mapping = errorMappings['0x6f00']; + expect(mapping.customCode).toBe(ErrorCode.SYS_INTERNAL_001); + expect(mapping.severity).toBe(Severity.CRITICAL); + expect(mapping.category).toBe(Category.SYSTEM); + }); + + it('should map 0x6a84 to not enough memory', () => { + const mapping = errorMappings['0x6a84']; + expect(mapping.customCode).toBe(ErrorCode.SYS_MEMORY_001); + expect(mapping.category).toBe(Category.SYSTEM); + }); + + it('should map 0x6faa to device halted', () => { + const mapping = errorMappings['0x6faa']; + expect(mapping.customCode).toBe(ErrorCode.SYS_INTERNAL_001); + expect(mapping.severity).toBe(Severity.CRITICAL); + expect(mapping.userMessage).toContain('disconnect and reconnect'); + }); + }); + + describe('connection errors', () => { + it('should map 0x650f to connection issue', () => { + const mapping = errorMappings['0x650f']; + expect(mapping.customCode).toBe(ErrorCode.CONN_CLOSED_001); + expect(mapping.category).toBe(Category.CONNECTION); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + }); + }); + + describe('cryptographic errors', () => { + it('should map 0x9484 to algorithm not supported', () => { + const mapping = errorMappings['0x9484']; + expect(mapping.customCode).toBe(ErrorCode.CRYPTO_ALGO_001); + expect(mapping.category).toBe(Category.CRYPTOGRAPHY); + }); + + it('should map 0x9485 to invalid key check value', () => { + const mapping = errorMappings['0x9485']; + expect(mapping.customCode).toBe(ErrorCode.CRYPTO_KEY_001); + expect(mapping.category).toBe(Category.CRYPTOGRAPHY); + }); + }); + + it('should have valid structure for all mappings', () => { + Object.entries(errorMappings).forEach(([_, mapping]) => { + expect(mapping).toHaveProperty('customCode'); + expect(mapping).toHaveProperty('message'); + expect(mapping).toHaveProperty('severity'); + expect(mapping).toHaveProperty('category'); + expect(mapping).toHaveProperty('retryStrategy'); + expect(mapping).toHaveProperty('userActionable'); + + expect(Object.values(ErrorCode)).toContain(mapping.customCode); + expect(Object.values(Severity)).toContain(mapping.severity); + expect(Object.values(Category)).toContain(mapping.category); + expect(Object.values(RetryStrategy)).toContain(mapping.retryStrategy); + expect(typeof mapping.userActionable).toBe('boolean'); + expect(typeof mapping.message).toBe('string'); + }); + }); + + it('should have valid userMessage when present', () => { + const mappingsWithUserMessage = Object.values(errorMappings).filter( + (mapping): mapping is typeof mapping & { userMessage: string } => + 'userMessage' in mapping && + typeof mapping.userMessage === 'string' && + mapping.userMessage.length > 0, + ); + expect(mappingsWithUserMessage.length).toBeGreaterThan(0); + mappingsWithUserMessage.forEach((mapping) => { + expect(typeof mapping.userMessage).toBe('string'); + expect(mapping.userMessage.length).toBeGreaterThan(0); + }); + }); + }); + + describe('Trezor mappings', () => { + const { errorMapping } = HARDWARE_MAPPINGS.trezor; + + it('should have errorMapping object', () => { + expect(errorMapping).toBeDefined(); + expect(typeof errorMapping).toBe('object'); + }); + + describe('failure codes', () => { + it('should map code 1 to unexpected message', () => { + const mapping = errorMapping['1']; + expect(mapping.customCode).toBe(ErrorCode.PROTO_CMD_003); + expect(mapping.severity).toBe(Severity.ERROR); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + expect(mapping.originalName).toBe('Failure_UnexpectedMessage'); + }); + + it('should map code 4 to action cancelled', () => { + const mapping = errorMapping['4']; + expect(mapping.customCode).toBe(ErrorCode.USER_CANCEL_002); + expect(mapping.category).toBe(Category.USER_ACTION); + expect(mapping.userActionable).toBe(true); + expect(mapping.originalName).toBe('Failure_ActionCancelled'); + }); + + it('should map code 10 to insufficient funds', () => { + const mapping = errorMapping['10']; + expect(mapping.customCode).toBe(ErrorCode.TX_FUNDS_001); + expect(mapping.category).toBe(Category.TRANSACTION); + expect(mapping.originalName).toBe('Failure_NotEnoughFunds'); + }); + + it('should map code 99 to firmware error', () => { + const mapping = errorMapping['99']; + expect(mapping.customCode).toBe(ErrorCode.SYS_FIRMWARE_002); + expect(mapping.severity).toBe(Severity.CRITICAL); + expect(mapping.originalName).toBe('Failure_FirmwareError'); + }); + }); + + describe('PIN errors', () => { + it('should map code 5 to PIN expected', () => { + const mapping = errorMapping['5']; + expect(mapping.customCode).toBe(ErrorCode.USER_INPUT_001); + expect(mapping.category).toBe(Category.USER_ACTION); + expect(mapping.originalName).toBe('Failure_PinExpected'); + }); + + it('should map code 7 to PIN invalid', () => { + const mapping = errorMapping['7']; + expect(mapping.customCode).toBe(ErrorCode.AUTH_PIN_001); + expect(mapping.category).toBe(Category.AUTHENTICATION); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + expect(mapping.originalName).toBe('Failure_PinInvalid'); + }); + + it('should map code 12 to PIN mismatch', () => { + const mapping = errorMapping['12']; + expect(mapping.customCode).toBe(ErrorCode.AUTH_PIN_004); + expect(mapping.originalName).toBe('Failure_PinMismatch'); + }); + }); + + describe('device state errors', () => { + it('should map code 11 to device not initialized', () => { + const mapping = errorMapping['11']; + expect(mapping.customCode).toBe(ErrorCode.DEVICE_STATE_001); + expect(mapping.category).toBe(Category.DEVICE_STATE); + expect(mapping.originalName).toBe('Failure_NotInitialized'); + }); + + it('should map code 15 to device busy', () => { + const mapping = errorMapping['15']; + expect(mapping.customCode).toBe(ErrorCode.DEVICE_STATE_002); + expect(mapping.severity).toBe(Severity.WARNING); + expect(mapping.retryStrategy).toBe(RetryStrategy.EXPONENTIAL_BACKOFF); + expect(mapping.originalName).toBe('Failure_Busy'); + }); + + it('should map Device_Disconnected to device disconnected', () => { + const mapping = errorMapping.Device_Disconnected; + expect(mapping.customCode).toBe(ErrorCode.DEVICE_STATE_003); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + expect(mapping.sdkMessage).toBe('Device disconnected'); + }); + + it('should map Device_UsedElsewhere correctly', () => { + const mapping = errorMapping.Device_UsedElsewhere; + expect(mapping.customCode).toBe(ErrorCode.DEVICE_STATE_004); + expect(mapping.retryStrategy).toBe(RetryStrategy.NO_RETRY); + expect(mapping.userMessage).toContain('another window'); + }); + }); + + describe('initialization errors', () => { + it('should map Init_NotInitialized', () => { + const mapping = errorMapping.Init_NotInitialized; + expect(mapping.customCode).toBe(ErrorCode.CONFIG_INIT_001); + expect(mapping.category).toBe(Category.CONFIGURATION); + expect(mapping.sdkMessage).toBe('TrezorConnect not initialized'); + }); + + it('should map Init_AlreadyInitialized', () => { + const mapping = errorMapping.Init_AlreadyInitialized; + expect(mapping.customCode).toBe(ErrorCode.CONFIG_INIT_002); + expect(mapping.severity).toBe(Severity.WARNING); + }); + + it('should map Init_ManifestMissing', () => { + const mapping = errorMapping.Init_ManifestMissing; + expect(mapping.customCode).toBe(ErrorCode.CONFIG_INIT_003); + expect(mapping.category).toBe(Category.CONFIGURATION); + }); + }); + + describe('connection errors', () => { + it('should map Init_IframeBlocked', () => { + const mapping = errorMapping.Init_IframeBlocked; + expect(mapping.customCode).toBe(ErrorCode.CONN_BLOCKED_001); + expect(mapping.category).toBe(Category.CONNECTION); + expect(mapping.userMessage).toContain('browser settings'); + }); + + it('should map Init_IframeTimeout', () => { + const mapping = errorMapping.Init_IframeTimeout; + expect(mapping.customCode).toBe(ErrorCode.CONN_TIMEOUT_001); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + }); + + it('should map Transport_Missing', () => { + const mapping = errorMapping.Transport_Missing; + expect(mapping.customCode).toBe(ErrorCode.CONN_TRANSPORT_001); + expect(mapping.category).toBe(Category.CONNECTION); + }); + }); + + describe('method errors', () => { + it('should map Method_InvalidParameter', () => { + const mapping = errorMapping.Method_InvalidParameter; + expect(mapping.customCode).toBe(ErrorCode.DATA_FORMAT_003); + expect(mapping.category).toBe(Category.DATA_VALIDATION); + }); + + it('should map Method_Cancel', () => { + const mapping = errorMapping.Method_Cancel; + expect(mapping.customCode).toBe(ErrorCode.USER_CANCEL_002); + expect(mapping.category).toBe(Category.USER_ACTION); + expect(mapping.retryStrategy).toBe(RetryStrategy.RETRY); + }); + + it('should map Method_UnknownCoin', () => { + const mapping = errorMapping.Method_UnknownCoin; + expect(mapping.customCode).toBe(ErrorCode.DATA_NOTFOUND_003); + expect(mapping.userMessage).toContain('not supported'); + }); + }); + + describe('device capability errors', () => { + it('should map Device_MissingCapability', () => { + const mapping = errorMapping.Device_MissingCapability; + expect(mapping.customCode).toBe(ErrorCode.DEVICE_CAP_001); + expect(mapping.userMessage).toContain('firmware update'); + }); + + it('should map Device_MissingCapabilityBtcOnly', () => { + const mapping = errorMapping.Device_MissingCapabilityBtcOnly; + expect(mapping.customCode).toBe(ErrorCode.DEVICE_CAP_002); + expect(mapping.userMessage).toContain('Bitcoin-only'); + }); + }); + + describe('special codes', () => { + it('should have UNKNOWN fallback', () => { + const mapping = errorMapping.UNKNOWN; + expect(mapping.customCode).toBe(ErrorCode.UNKNOWN_001); + expect(mapping.category).toBe(Category.UNKNOWN); + expect(mapping.originalName).toBe('Failure_UnknownCode'); + }); + + it('should map ENTROPY_CHECK', () => { + const mapping = errorMapping.ENTROPY_CHECK; + expect(mapping.customCode).toBe(ErrorCode.CRYPTO_ENTROPY_001); + expect(mapping.category).toBe(Category.CRYPTOGRAPHY); + expect(mapping.originalName).toBe('Failure_EntropyCheck'); + }); + }); + + it('should have valid structure for all mappings', () => { + Object.entries(errorMapping).forEach(([_code, mapping]) => { + expect(mapping).toHaveProperty('customCode'); + expect(mapping).toHaveProperty('message'); + expect(mapping).toHaveProperty('severity'); + expect(mapping).toHaveProperty('category'); + expect(mapping).toHaveProperty('retryStrategy'); + expect(mapping).toHaveProperty('userActionable'); + + expect(Object.values(ErrorCode)).toContain(mapping.customCode); + expect(Object.values(Severity)).toContain(mapping.severity); + expect(Object.values(Category)).toContain(mapping.category); + expect(Object.values(RetryStrategy)).toContain(mapping.retryStrategy); + expect(typeof mapping.userActionable).toBe('boolean'); + expect(typeof mapping.message).toBe('string'); + }); + }); + + it('should have valid optional fields when present', () => { + const mappingsWithUserMessage = Object.values(errorMapping).filter( + (mapping): mapping is typeof mapping & { userMessage: string } => + 'userMessage' in mapping && + typeof mapping.userMessage === 'string' && + mapping.userMessage.length > 0, + ); + mappingsWithUserMessage.forEach((mapping) => { + expect(typeof mapping.userMessage).toBe('string'); + expect(mapping.userMessage.length).toBeGreaterThan(0); + }); + + const mappingsWithOriginalName = Object.values(errorMapping).filter( + (mapping): mapping is typeof mapping & { originalName: string } => + 'originalName' in mapping && + typeof mapping.originalName === 'string' && + mapping.originalName.length > 0, + ); + mappingsWithOriginalName.forEach((mapping) => { + expect(typeof mapping.originalName).toBe('string'); + expect(mapping.originalName.length).toBeGreaterThan(0); + }); + + const mappingsWithSdkMessage = Object.values(errorMapping).filter( + (mapping): mapping is typeof mapping & { sdkMessage: string } => + 'sdkMessage' in mapping && + typeof mapping.sdkMessage === 'string' && + mapping.sdkMessage.length > 0, + ); + mappingsWithSdkMessage.forEach((mapping) => { + expect(typeof mapping.sdkMessage).toBe('string'); + expect(mapping.sdkMessage.length).toBeGreaterThan(0); + }); + }); + }); + + describe('Trezor default and patterns', () => { + it('should have default error mapping', () => { + const { default: defaultMapping } = HARDWARE_MAPPINGS.trezor; + expect(defaultMapping).toBeDefined(); + expect(defaultMapping.custom_code).toBe(ErrorCode.UNKNOWN_001); + expect(defaultMapping.category).toBe(Category.UNKNOWN); + }); + + it('should have error_patterns array', () => { + const { error_patterns: errorPatterns } = HARDWARE_MAPPINGS.trezor; + expect(Array.isArray(errorPatterns)).toBe(true); + expect(errorPatterns.length).toBeGreaterThan(0); + }); + + it('should have valid pattern structure', () => { + const { error_patterns: errorPatterns } = HARDWARE_MAPPINGS.trezor; + errorPatterns.forEach((pattern) => { + expect(pattern).toHaveProperty('pattern'); + expect(pattern).toHaveProperty('type'); + expect(pattern).toHaveProperty('description'); + expect(pattern).toHaveProperty('defaultSeverity'); + expect(typeof pattern.pattern).toBe('string'); + expect(typeof pattern.type).toBe('string'); + expect(typeof pattern.description).toBe('string'); + expect(Object.values(Severity)).toContain(pattern.defaultSeverity); + }); + }); + + it('should have patterns for common error prefixes', () => { + const { error_patterns: errorPatterns } = HARDWARE_MAPPINGS.trezor; + const patterns = errorPatterns.map((patternObj) => patternObj.pattern); + expect(patterns).toContain('^Failure_.*'); + expect(patterns).toContain('^Init_.*'); + expect(patterns).toContain('^Method_.*'); + expect(patterns).toContain('^Device_.*'); + }); + }); + + describe('consistency checks', () => { + it('should have unique error codes within each vendor', () => { + const ledgerCodes = Object.values(HARDWARE_MAPPINGS.ledger.errorMappings); + const ledgerCustomCodes = ledgerCodes.map( + (mapping) => mapping.customCode, + ); + expect(ledgerCustomCodes.length).toBeGreaterThan(0); + + const trezorCodes = Object.values(HARDWARE_MAPPINGS.trezor.errorMapping); + const trezorCustomCodes = trezorCodes.map( + (mapping) => mapping.customCode, + ); + expect(trezorCustomCodes.length).toBeGreaterThan(0); + }); + + it('should have user messages for user-actionable errors', () => { + const ledgerMappings = Object.values( + HARDWARE_MAPPINGS.ledger.errorMappings, + ).filter( + (mapping): mapping is typeof mapping & { userMessage: string } => + mapping.userActionable && + mapping.severity !== Severity.INFO && + 'userMessage' in mapping && + typeof mapping.userMessage === 'string' && + mapping.userMessage.length > 0, + ); + + ledgerMappings.forEach((mapping) => { + expect(mapping.userMessage).toBeDefined(); + expect(mapping.userMessage.length).toBeGreaterThan(0); + }); + + const trezorMappings = Object.values( + HARDWARE_MAPPINGS.trezor.errorMapping, + ).filter( + (mapping): mapping is typeof mapping & { userMessage: string } => + mapping.userActionable && + mapping.severity !== Severity.INFO && + 'userMessage' in mapping && + typeof mapping.userMessage === 'string' && + mapping.userMessage.length > 0, + ); + + trezorMappings.forEach((mapping) => { + expect(mapping.userMessage).toBeDefined(); + expect(mapping.userMessage.length).toBeGreaterThan(0); + }); + }); + + it('should use NO_RETRY for critical errors', () => { + const allMappings = [ + ...Object.values(HARDWARE_MAPPINGS.ledger.errorMappings), + ...Object.values(HARDWARE_MAPPINGS.trezor.errorMapping), + ]; + + const criticalMappings = allMappings.filter( + (mapping) => mapping.severity === Severity.CRITICAL, + ); + + criticalMappings.forEach((mapping) => { + expect([RetryStrategy.NO_RETRY, RetryStrategy.RETRY]).toContain( + mapping.retryStrategy, + ); + }); + }); + }); +}); diff --git a/packages/keyring-utils/src/hardware-error.test.ts b/packages/keyring-utils/src/hardware-error.test.ts new file mode 100644 index 000000000..c8f43fcda --- /dev/null +++ b/packages/keyring-utils/src/hardware-error.test.ts @@ -0,0 +1,481 @@ +import { HardwareWalletError } from './hardware-error'; +import { + ErrorCode, + Severity, + Category, + RetryStrategy, +} from './hardware-errors-enums'; + +describe('HardwareWalletError', () => { + const mockOptions = { + code: ErrorCode.USER_CANCEL_001, + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Transaction was rejected', + }; + + describe('constructor', () => { + it('should create an error with required properties', () => { + const error = new HardwareWalletError('Test error', mockOptions); + + expect(error.message).toBe('Test error'); + expect(error.name).toBe('HardwareWalletError'); + expect(error.code).toBe(ErrorCode.USER_CANCEL_001); + expect(error.severity).toBe(Severity.WARNING); + expect(error.category).toBe(Category.USER_ACTION); + expect(error.retryStrategy).toBe(RetryStrategy.RETRY); + expect(error.userActionable).toBe(true); + expect(error.userMessage).toBe('Transaction was rejected'); + expect(error.retryCount).toBe(0); + }); + + it('should generate a unique error ID', () => { + const error1 = new HardwareWalletError('Test error 1', mockOptions); + const error2 = new HardwareWalletError('Test error 2', mockOptions); + + expect(error1.id).toBeDefined(); + expect(error2.id).toBeDefined(); + expect(error1.id).not.toBe(error2.id); + expect(error1.id).toMatch(/^err_[a-z0-9]+_[a-z0-9]+$/u); + }); + + it('should set timestamp to current date', () => { + const before = new Date(); + const error = new HardwareWalletError('Test error', mockOptions); + const after = new Date(); + + expect(error.timestamp.getTime()).toBeGreaterThanOrEqual( + before.getTime(), + ); + expect(error.timestamp.getTime()).toBeLessThanOrEqual(after.getTime()); + }); + + it('should set optional properties when provided', () => { + const cause = new Error('Original error'); + const metadata = { deviceId: '12345', attempt: 1 }; + + const error = new HardwareWalletError('Test error', { + ...mockOptions, + cause, + metadata, + retryCount: 3, + }); + + expect(error.cause).toBe(cause); + expect(error.metadata).toStrictEqual(metadata); + expect(error.retryCount).toBe(3); + }); + + it('should default retryCount to 0 when not provided', () => { + const error = new HardwareWalletError('Test error', mockOptions); + expect(error.retryCount).toBe(0); + }); + + it('should work with instanceof checks', () => { + const error = new HardwareWalletError('Test error', mockOptions); + expect(error instanceof HardwareWalletError).toBe(true); + expect(error instanceof Error).toBe(true); + }); + + it('should capture stack trace', () => { + const error = new HardwareWalletError('Test error', mockOptions); + expect(error.stack).toBeDefined(); + expect(error.stack).toContain('HardwareWalletError'); + }); + }); + + describe('isRetryable', () => { + it('should return true for RETRY strategy', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + retryStrategy: RetryStrategy.RETRY, + }); + expect(error.isRetryable()).toBe(true); + }); + + it('should return true for EXPONENTIAL_BACKOFF strategy', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + retryStrategy: RetryStrategy.EXPONENTIAL_BACKOFF, + }); + expect(error.isRetryable()).toBe(true); + }); + + it('should return false for NO_RETRY strategy', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + retryStrategy: RetryStrategy.NO_RETRY, + }); + expect(error.isRetryable()).toBe(false); + }); + }); + + describe('isCritical', () => { + it('should return true for CRITICAL severity', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + severity: Severity.CRITICAL, + }); + expect(error.isCritical()).toBe(true); + }); + + it('should return false for non-CRITICAL severity', () => { + const severities = [Severity.ERROR, Severity.WARNING, Severity.INFO]; + severities.forEach((severity) => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + severity, + }); + expect(error.isCritical()).toBe(false); + }); + }); + }); + + describe('isWarning', () => { + it('should return true for WARNING severity', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + severity: Severity.WARNING, + }); + expect(error.isWarning()).toBe(true); + }); + + it('should return false for non-WARNING severity', () => { + const severities = [Severity.ERROR, Severity.CRITICAL, Severity.INFO]; + severities.forEach((severity) => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + severity, + }); + expect(error.isWarning()).toBe(false); + }); + }); + }); + + describe('requiresUserAction', () => { + it('should return true when userActionable is true', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + userActionable: true, + }); + expect(error.requiresUserAction()).toBe(true); + }); + + it('should return false when userActionable is false', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + userActionable: false, + }); + expect(error.requiresUserAction()).toBe(false); + }); + }); + + describe('withIncrementedRetryCount', () => { + it('should create a new error with incremented retry count', () => { + const originalError = new HardwareWalletError('Test error', { + ...mockOptions, + retryCount: 2, + }); + + const newError = originalError.withIncrementedRetryCount(); + + expect(newError.retryCount).toBe(3); + expect(originalError.retryCount).toBe(2); // Original unchanged + expect(newError).not.toBe(originalError); // New instance + }); + + it('should preserve all other properties', () => { + const cause = new Error('Original error'); + const metadata = { deviceId: '12345' }; + + const originalError = new HardwareWalletError('Test error', { + ...mockOptions, + cause, + metadata, + }); + + const newError = originalError.withIncrementedRetryCount(); + + expect(newError.message).toBe(originalError.message); + expect(newError.code).toBe(originalError.code); + expect(newError.severity).toBe(originalError.severity); + expect(newError.category).toBe(originalError.category); + expect(newError.retryStrategy).toBe(originalError.retryStrategy); + expect(newError.userActionable).toBe(originalError.userActionable); + expect(newError.userMessage).toBe(originalError.userMessage); + expect(newError.cause).toBe(originalError.cause); + expect(newError.metadata).toStrictEqual(originalError.metadata); + }); + + it('should work when optional properties are undefined', () => { + const originalError = new HardwareWalletError('Test error', mockOptions); + const newError = originalError.withIncrementedRetryCount(); + + expect(newError.retryCount).toBe(1); + expect(newError.cause).toBeUndefined(); + expect(newError.metadata).toBeUndefined(); + }); + }); + + describe('withMetadata', () => { + it('should create a new error with additional metadata', () => { + const originalMetadata = { deviceId: '12345' }; + const originalError = new HardwareWalletError('Test error', { + ...mockOptions, + metadata: originalMetadata, + }); + + const additionalMetadata = { attempt: 1, timestamp: Date.now() }; + const newError = originalError.withMetadata(additionalMetadata); + + expect(newError.metadata).toStrictEqual({ + ...originalMetadata, + ...additionalMetadata, + }); + expect(originalError.metadata).toStrictEqual(originalMetadata); // Original unchanged + expect(newError).not.toBe(originalError); // New instance + }); + + it('should create metadata when original has none', () => { + const originalError = new HardwareWalletError('Test error', mockOptions); + const metadata = { deviceId: '12345' }; + const newError = originalError.withMetadata(metadata); + + expect(newError.metadata).toStrictEqual(metadata); + }); + + it('should override existing metadata keys', () => { + const originalError = new HardwareWalletError('Test error', { + ...mockOptions, + metadata: { key: 'old', other: 'value' }, + }); + + const newError = originalError.withMetadata({ key: 'new' }); + + expect(newError.metadata).toStrictEqual({ key: 'new', other: 'value' }); + }); + + it('should preserve all other properties', () => { + const cause = new Error('Original error'); + + const originalError = new HardwareWalletError('Test error', { + ...mockOptions, + cause, + retryCount: 5, + }); + + const newError = originalError.withMetadata({ extra: 'data' }); + + expect(newError.message).toBe(originalError.message); + expect(newError.code).toBe(originalError.code); + expect(newError.severity).toBe(originalError.severity); + expect(newError.category).toBe(originalError.category); + expect(newError.retryStrategy).toBe(originalError.retryStrategy); + expect(newError.userActionable).toBe(originalError.userActionable); + expect(newError.userMessage).toBe(originalError.userMessage); + expect(newError.cause).toBe(originalError.cause); + expect(newError.retryCount).toBe(originalError.retryCount); + }); + }); + + describe('toJSON', () => { + it('should serialize all properties to JSON', () => { + const cause = new Error('Original error'); + cause.stack = 'Error stack trace'; + const metadata = { deviceId: '12345' }; + + const error = new HardwareWalletError('Test error', { + ...mockOptions, + cause, + metadata, + retryCount: 3, + }); + + const json = error.toJSON(); + + expect(json.id).toBe(error.id); + expect(json.name).toBe('HardwareWalletError'); + expect(json.message).toBe('Test error'); + expect(json.code).toBe(ErrorCode.USER_CANCEL_001); + expect(json.severity).toBe(Severity.WARNING); + expect(json.category).toBe(Category.USER_ACTION); + expect(json.retryStrategy).toBe(RetryStrategy.RETRY); + expect(json.userActionable).toBe(true); + expect(json.userMessage).toBe('Transaction was rejected'); + expect(json.timestamp).toBe(error.timestamp.toISOString()); + expect(json.metadata).toStrictEqual(metadata); + expect(json.retryCount).toBe(3); + expect(json.stack).toBeDefined(); + }); + + it('should serialize cause when present', () => { + const cause = new Error('Original error'); + cause.stack = 'Error stack trace'; + + const error = new HardwareWalletError('Test error', { + ...mockOptions, + cause, + }); + + const json = error.toJSON(); + + expect(json.cause).toStrictEqual({ + name: 'Error', + message: 'Original error', + stack: 'Error stack trace', + }); + }); + + it('should not include cause when not present', () => { + const error = new HardwareWalletError('Test error', mockOptions); + const json = error.toJSON(); + + expect(json.cause).toBeUndefined(); + }); + + it('should handle undefined optional properties', () => { + const error = new HardwareWalletError('Test error', mockOptions); + const json = error.toJSON(); + + expect(json.metadata).toBeUndefined(); + expect(json.cause).toBeUndefined(); + }); + }); + + describe('toString', () => { + it('should return a user-friendly string representation', () => { + const error = new HardwareWalletError('Test error', mockOptions); + const result = error.toString(); + + expect(result).toBe( + 'HardwareWalletError [USER_CANCEL_001]: Transaction was rejected', + ); + }); + + it('should work with different error codes and messages', () => { + const error = new HardwareWalletError('Internal error', { + ...mockOptions, + code: ErrorCode.SYS_INTERNAL_001, + userMessage: 'An internal error occurred', + }); + const result = error.toString(); + + expect(result).toBe( + 'HardwareWalletError [SYS_INTERNAL_001]: An internal error occurred', + ); + }); + }); + + describe('toDetailedString', () => { + it('should return a detailed string with all information', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + retryCount: 2, + }); + + const result = error.toDetailedString(); + + expect(result).toContain('HardwareWalletError [USER_CANCEL_001]'); + expect(result).toContain('Message: Test error'); + expect(result).toContain('User Message: Transaction was rejected'); + expect(result).toContain('Severity: WARNING'); + expect(result).toContain('Category: USER_ACTION'); + expect(result).toContain('Retry Strategy: RETRY'); + expect(result).toContain('User Actionable: true'); + expect(result).toContain('Timestamp:'); + expect(result).toContain('Retry Count: 2'); + }); + + it('should include metadata when present', () => { + const metadata = { deviceId: '12345', attempt: 1 }; + const error = new HardwareWalletError('Test error', { + ...mockOptions, + metadata, + }); + + const result = error.toDetailedString(); + expect(result).toContain('Metadata:'); + expect(result).toContain('"deviceId": "12345"'); + expect(result).toContain('"attempt": 1'); + }); + + it('should include cause when present', () => { + const cause = new Error('Original error'); + const error = new HardwareWalletError('Test error', { + ...mockOptions, + cause, + }); + + const result = error.toDetailedString(); + expect(result).toContain('Caused by: Original error'); + }); + + it('should not include optional fields when not present', () => { + const error = new HardwareWalletError('Test error', mockOptions); + const result = error.toDetailedString(); + + expect(result).not.toContain('Metadata:'); + expect(result).not.toContain('Caused by:'); + }); + + it('should not include metadata section when metadata is empty', () => { + const error = new HardwareWalletError('Test error', { + ...mockOptions, + metadata: {}, + }); + + const result = error.toDetailedString(); + expect(result).not.toContain('Metadata:'); + }); + }); + + describe('error scenarios', () => { + it('should handle critical authentication errors', () => { + const error = new HardwareWalletError('Device blocked', { + code: ErrorCode.AUTH_LOCK_002, + severity: Severity.CRITICAL, + category: Category.AUTHENTICATION, + retryStrategy: RetryStrategy.NO_RETRY, + userActionable: true, + userMessage: 'Device is blocked due to too many failed attempts', + }); + + expect(error.isCritical()).toBe(true); + expect(error.isRetryable()).toBe(false); + expect(error.requiresUserAction()).toBe(true); + }); + + it('should handle retryable connection errors', () => { + const error = new HardwareWalletError('Connection timeout', { + code: ErrorCode.CONN_TIMEOUT_001, + severity: Severity.ERROR, + category: Category.CONNECTION, + retryStrategy: RetryStrategy.EXPONENTIAL_BACKOFF, + userActionable: false, + userMessage: 'Connection timed out', + }); + + expect(error.isCritical()).toBe(false); + expect(error.isRetryable()).toBe(true); + expect(error.requiresUserAction()).toBe(false); + }); + + it('should handle user action warnings', () => { + const error = new HardwareWalletError('User confirmation required', { + code: ErrorCode.USER_CONFIRM_001, + severity: Severity.WARNING, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.RETRY, + userActionable: true, + userMessage: 'Please confirm the action on your device', + }); + + expect(error.isWarning()).toBe(true); + expect(error.isCritical()).toBe(false); + expect(error.isRetryable()).toBe(true); + expect(error.requiresUserAction()).toBe(true); + }); + }); +}); diff --git a/packages/keyring-utils/src/hardware-error.ts b/packages/keyring-utils/src/hardware-error.ts index 149c39e40..78332612a 100644 --- a/packages/keyring-utils/src/hardware-error.ts +++ b/packages/keyring-utils/src/hardware-error.ts @@ -21,7 +21,6 @@ export type HardwareWalletErrorOptions = { userMessage: string; cause?: Error; metadata?: Record; - documentationUrl?: string; retryCount?: number; }; @@ -44,8 +43,6 @@ export class HardwareWalletError extends Error { public readonly metadata: Record | undefined; - public readonly documentationUrl: string | undefined; - public readonly retryCount: number; public readonly cause: Error | undefined; @@ -62,7 +59,6 @@ export class HardwareWalletError extends Error { this.userMessage = options.userMessage; this.timestamp = new Date(); this.metadata = options.metadata; - this.documentationUrl = options.documentationUrl; this.retryCount = options.retryCount ?? 0; this.cause = options.cause; @@ -133,9 +129,6 @@ export class HardwareWalletError extends Error { if (this.metadata !== undefined) { options.metadata = this.metadata; } - if (this.documentationUrl !== undefined) { - options.documentationUrl = this.documentationUrl; - } return new HardwareWalletError(this.message, options); } @@ -163,9 +156,6 @@ export class HardwareWalletError extends Error { if (this.cause !== undefined) { options.cause = this.cause; } - if (this.documentationUrl !== undefined) { - options.documentationUrl = this.documentationUrl; - } return new HardwareWalletError(this.message, options); } @@ -189,7 +179,6 @@ export class HardwareWalletError extends Error { userMessage: this.userMessage, timestamp: this.timestamp.toISOString(), metadata: this.metadata, - documentationUrl: this.documentationUrl, retryCount: this.retryCount, stack: this.stack, }; @@ -232,10 +221,6 @@ export class HardwareWalletError extends Error { `Retry Count: ${this.retryCount}`, ]; - if (this.documentationUrl) { - details.push(`Documentation: ${this.documentationUrl}`); - } - if (this.metadata && Object.keys(this.metadata).length > 0) { details.push(`Metadata: ${JSON.stringify(this.metadata, null, 2)}`); } From 2a0671b0cd71b6935d6f9208590de0ef4bd01ea9 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Jan 2026 12:32:35 +0800 Subject: [PATCH 5/8] fix: tests --- .../src/ledger-error-handler.test.ts | 116 ++++++++---------- .../src/ledger-keyring.test.ts | 38 ++++++ .../src/ledger-keyring.ts | 11 +- 3 files changed, 96 insertions(+), 69 deletions(-) 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 fdf6e1148..7a3699702 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 @@ -3,64 +3,63 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; import { LedgerHardwareWalletError } from './errors'; import { handleLedgerTransportError } from './ledger-error-handler'; -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 LedgerHardwareWalletError - * with expected properties - * - * @param error - The error to pass to handleLedgerTransportError - * @param expectedLedgerCode - Expected ledger code of the thrown LedgerHardwareWalletError - * @param expectedMessage - Expected message of the thrown LedgerHardwareWalletError - * @returns True if all assertions pass - */ - function expectLedgerError( - error: unknown, - expectedLedgerCode: string, - expectedMessage: string, - ): boolean { - expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - LedgerHardwareWalletError, - ); +/** + * 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(LedgerHardwareWalletError); - expect((thrownError as LedgerHardwareWalletError).ledgerCode).toBe( - expectedLedgerCode, - ); - expect((thrownError as LedgerHardwareWalletError).message).toBe( - expectedMessage, - ); +/** + * Helper function to test that handleLedgerTransportError throws a LedgerHardwareWalletError + * with expected properties + * + * @param error - The error to pass to handleLedgerTransportError + * @param expectedLedgerCode - Expected ledger code of the thrown LedgerHardwareWalletError + * @param expectedMessage - Expected message of the thrown LedgerHardwareWalletError + * @returns True if all assertions pass + */ +function expectLedgerError( + error: unknown, + expectedLedgerCode: string, + expectedMessage: string, +): boolean { + expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( + LedgerHardwareWalletError, + ); - return true; + let thrownError: unknown; + try { + handleLedgerTransportError(error, fallbackMessage); + } catch (error_: unknown) { + thrownError = error_; } + expect(thrownError).toBeInstanceOf(LedgerHardwareWalletError); + expect((thrownError as LedgerHardwareWalletError).ledgerCode).toBe( + expectedLedgerCode, + ); + expect((thrownError as LedgerHardwareWalletError).message).toBe( + expectedMessage, + ); + return true; +} +describe('handleLedgerTransportError', () => { describe('when error is TransportStatusError', () => { it.each([ { @@ -147,15 +146,4 @@ describe('handleLedgerTransportError', () => { expect((thrownError as LedgerHardwareWalletError).cause).toBe(error); }); }); - - describe('return type', () => { - it('has never return type (always throws)', () => { - type ReturnTypeIsNever any> = - ReturnType extends never ? true : false; - - const isNever: ReturnTypeIsNever = - true; - expect(isNever).toBe(true); - }); - }); }); 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 7edac09b5..72ebf7fb4 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts @@ -611,6 +611,44 @@ describe('LedgerKeyring', function () { }); }); + describe('getAppNameAndVersion', function () { + it('returns the app name and version from the bridge', async function () { + const expectedResponse = { + appName: 'Ethereum', + version: '1.9.0', + }; + jest + .spyOn(bridge, 'getAppNameAndVersion') + .mockResolvedValue(expectedResponse); + + const result = await keyring.getAppNameAndVersion(); + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(bridge.getAppNameAndVersion).toHaveBeenCalledTimes(1); + expect(result).toStrictEqual(expectedResponse); + }); + + it('throws an error when the bridge getAppNameAndVersion method throws an Error', async function () { + jest + .spyOn(bridge, 'getAppNameAndVersion') + .mockRejectedValue(new Error('Connection failed')); + + await expect(keyring.getAppNameAndVersion()).rejects.toThrow( + 'Connection failed', + ); + }); + + it('throws the default error when the bridge getAppNameAndVersion method throws a non-Error object', async function () { + jest + .spyOn(bridge, 'getAppNameAndVersion') + .mockRejectedValue('some error'); + + await expect(keyring.getAppNameAndVersion()).rejects.toThrow( + 'Ledger: Unknown error while getting app name and version', + ); + }); + }); + describe('signTransaction', function () { describe('using old versions of ethereumjs/tx', function () { it('passes serialized transaction to ledger and return signed tx', async function () { diff --git a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.ts index 282108a73..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,10 +333,7 @@ export class LedgerKeyring implements Keyring { return this.bridge.updateTransportMethod(transportType); } - async getAppNameAndVersion(): Promise<{ - appName: string; - version: string; - }> { + async getAppNameAndVersion(): Promise { try { return await this.bridge.getAppNameAndVersion(); } catch (error: unknown) { From a42f23982bda530bd756b01c1858685c3a1f2589 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Jan 2026 13:00:48 +0800 Subject: [PATCH 6/8] fix: add tests --- .../keyring-eth-ledger-bridge/jest.config.js | 8 +- .../src/errors.test.ts | 273 ++++++++++++++++++ .../src/ledger-error-handler.test.ts | 29 ++ .../src/ledger-iframe-bridge.test.ts | 58 ++++ .../src/ledger-keyring.test.ts | 70 ++--- .../src/type.test.ts | 40 +++ 6 files changed, 436 insertions(+), 42 deletions(-) create mode 100644 packages/keyring-eth-ledger-bridge/src/errors.test.ts create mode 100644 packages/keyring-eth-ledger-bridge/src/type.test.ts diff --git a/packages/keyring-eth-ledger-bridge/jest.config.js b/packages/keyring-eth-ledger-bridge/jest.config.js index f20625df1..a4132edfd 100644 --- a/packages/keyring-eth-ledger-bridge/jest.config.js +++ b/packages/keyring-eth-ledger-bridge/jest.config.js @@ -23,10 +23,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 91.89, - functions: 97.93, - lines: 97.28, - statements: 97.31, + branches: 92.35, + functions: 98.09, + lines: 97.47, + statements: 97.49, }, }, }); 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..a16e5cbda --- /dev/null +++ b/packages/keyring-eth-ledger-bridge/src/errors.test.ts @@ -0,0 +1,273 @@ +import { + ErrorCode as ErrorCodeEnum, + Severity as SeverityEnum, + Category as CategoryEnum, + RetryStrategy as RetryStrategyEnum, +} from '@metamask/keyring-utils'; + +import { + LedgerHardwareWalletError, + createLedgerError, + isKnownLedgerError, + getLedgerErrorMapping, +} from './errors'; + +describe('LedgerHardwareWalletError', () => { + describe('constructor', () => { + it('should create an error with all properties', () => { + const error = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.USER_CANCEL_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.USER_ACTION, + retryStrategy: RetryStrategyEnum.NO_RETRY, + ledgerCode: '0x6985', + }); + + expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error.message).toBe('Test error'); + expect(error.code).toBe(ErrorCodeEnum.USER_CANCEL_001); + expect(error.severity).toBe(SeverityEnum.ERROR); + expect(error.category).toBe(CategoryEnum.USER_ACTION); + expect(error.retryStrategy).toBe(RetryStrategyEnum.NO_RETRY); + expect(error.ledgerCode).toBe('0x6985'); + expect(error.name).toBe('LedgerHardwareWalletError'); + }); + + it('should create an error with a cause', () => { + const cause = new Error('Original error'); + const error = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + cause, + }); + + expect(error.cause).toBe(cause); + }); + + it('should create an error without ledgerCode', () => { + const error = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + }); + + expect(error.ledgerCode).toBeUndefined(); + }); + }); + + describe('withIncrementedRetryCount', () => { + it('should create a new error instance with the same properties', () => { + const originalError = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.AUTH_LOCK_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.AUTHENTICATION, + retryStrategy: RetryStrategyEnum.RETRY, + ledgerCode: '0x5515', + }); + + const newError = originalError.withIncrementedRetryCount(); + + expect(newError).toBeInstanceOf(LedgerHardwareWalletError); + expect(newError).not.toBe(originalError); + expect(newError.message).toBe(originalError.message); + expect(newError.code).toBe(originalError.code); + expect(newError.severity).toBe(originalError.severity); + expect(newError.category).toBe(originalError.category); + expect(newError.retryStrategy).toBe(originalError.retryStrategy); + expect(newError.ledgerCode).toBe(originalError.ledgerCode); + }); + + it('should preserve the cause when creating a new error', () => { + const cause = new Error('Original cause'); + const originalError = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.AUTH_LOCK_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.AUTHENTICATION, + retryStrategy: RetryStrategyEnum.RETRY, + cause, + ledgerCode: '0x5515', + }); + + const newError = originalError.withIncrementedRetryCount(); + + expect(newError.cause).toBe(cause); + }); + + it('should handle error without cause', () => { + const originalError = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.AUTH_LOCK_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.AUTHENTICATION, + retryStrategy: RetryStrategyEnum.RETRY, + ledgerCode: '0x5515', + }); + + const newError = originalError.withIncrementedRetryCount(); + + expect(newError.cause).toBeUndefined(); + }); + }); + + describe('withMetadata', () => { + it('should create a new error instance with the same properties', () => { + const originalError = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.AUTH_LOCK_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.AUTHENTICATION, + retryStrategy: RetryStrategyEnum.RETRY, + ledgerCode: '0x5515', + }); + + const metadata = { additionalInfo: 'test data' }; + const newError = originalError.withMetadata(metadata); + + expect(newError).toBeInstanceOf(LedgerHardwareWalletError); + expect(newError).not.toBe(originalError); + expect(newError.message).toBe(originalError.message); + expect(newError.code).toBe(originalError.code); + expect(newError.severity).toBe(originalError.severity); + expect(newError.category).toBe(originalError.category); + expect(newError.retryStrategy).toBe(originalError.retryStrategy); + expect(newError.ledgerCode).toBe(originalError.ledgerCode); + }); + + it('should preserve the cause when creating a new error', () => { + const cause = new Error('Original cause'); + const originalError = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.AUTH_LOCK_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.AUTHENTICATION, + retryStrategy: RetryStrategyEnum.RETRY, + cause, + ledgerCode: '0x5515', + }); + + const metadata = { additionalInfo: 'test data' }; + const newError = originalError.withMetadata(metadata); + + expect(newError.cause).toBe(cause); + }); + + it('should handle error without cause', () => { + const originalError = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.AUTH_LOCK_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.AUTHENTICATION, + retryStrategy: RetryStrategyEnum.RETRY, + ledgerCode: '0x5515', + }); + + const metadata = { additionalInfo: 'test data' }; + const newError = originalError.withMetadata(metadata); + + expect(newError.cause).toBeUndefined(); + }); + }); + + describe('toJSON', () => { + it('should serialize the error to JSON including ledgerCode', () => { + const error = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.USER_CANCEL_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.USER_ACTION, + retryStrategy: RetryStrategyEnum.NO_RETRY, + ledgerCode: '0x6985', + }); + + const json = error.toJSON(); + + expect(json).toHaveProperty('ledgerCode', '0x6985'); + expect(json).toHaveProperty('message', 'Test error'); + expect(json).toHaveProperty('code', ErrorCodeEnum.USER_CANCEL_001); + }); + + it('should serialize the error to JSON without ledgerCode when not provided', () => { + const error = new LedgerHardwareWalletError('Test error', { + code: ErrorCodeEnum.UNKNOWN_001, + severity: SeverityEnum.ERROR, + category: CategoryEnum.UNKNOWN, + retryStrategy: RetryStrategyEnum.NO_RETRY, + }); + + const json = error.toJSON(); + + expect(json).toHaveProperty('ledgerCode', undefined); + expect(json).toHaveProperty('message', 'Test error'); + }); + }); +}); + +describe('createLedgerError', () => { + it('should create a LedgerHardwareWalletError from a known error code', () => { + const error = createLedgerError('0x6985'); + + expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error.message).toContain('User rejected'); + expect(error.ledgerCode).toBe('0x6985'); + expect(error.code).toBe(ErrorCodeEnum.USER_CANCEL_001); + }); + + it('should create a LedgerHardwareWalletError with context', () => { + const error = createLedgerError('0x6985', 'during transaction signing'); + + expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error.message).toContain('User rejected'); + expect(error.message).toContain('(during transaction signing)'); + expect(error.ledgerCode).toBe('0x6985'); + }); + + it('should create a fallback error for unknown error codes without context', () => { + const error = createLedgerError('0x9999'); + + expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error.message).toBe('Unknown Ledger error: 0x9999'); + expect(error.ledgerCode).toBe('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(LedgerHardwareWalletError); + expect(error.message).toBe( + 'Unknown Ledger error: 0x9999 (while doing something)', + ); + expect(error.ledgerCode).toBe('0x9999'); + 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/ledger-error-handler.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.test.ts index 7a3699702..b43babe2a 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,4 +1,10 @@ import { TransportStatusError } from '@ledgerhq/hw-transport'; +import { + ErrorCode, + Severity, + Category, + RetryStrategy, +} from '@metamask/keyring-utils'; import { LedgerHardwareWalletError } from './errors'; import { handleLedgerTransportError } from './ledger-error-handler'; @@ -145,5 +151,28 @@ describe('handleLedgerTransportError', () => { ); expect((thrownError as LedgerHardwareWalletError).cause).toBe(error); }); + + it('passes through LedgerHardwareWalletError instances', () => { + const ledgerError = new LedgerHardwareWalletError('Ledger error', { + code: ErrorCode.USER_CANCEL_001, + severity: Severity.ERROR, + category: Category.USER_ACTION, + retryStrategy: RetryStrategy.NO_RETRY, + ledgerCode: '0x6985', + }); + + expect(() => + handleLedgerTransportError(ledgerError, fallbackMessage), + ).toThrow(ledgerError); + + let thrownError: unknown; + try { + handleLedgerTransportError(ledgerError, fallbackMessage); + } catch (error_: unknown) { + thrownError = error_; + } + + expect(thrownError).toBe(ledgerError); + }); }); }); 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..81e68fc69 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,64 @@ 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-keyring.test.ts b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts index 72ebf7fb4..d2d06205b 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-keyring.test.ts @@ -611,44 +611,6 @@ describe('LedgerKeyring', function () { }); }); - describe('getAppNameAndVersion', function () { - it('returns the app name and version from the bridge', async function () { - const expectedResponse = { - appName: 'Ethereum', - version: '1.9.0', - }; - jest - .spyOn(bridge, 'getAppNameAndVersion') - .mockResolvedValue(expectedResponse); - - const result = await keyring.getAppNameAndVersion(); - - // eslint-disable-next-line @typescript-eslint/unbound-method - expect(bridge.getAppNameAndVersion).toHaveBeenCalledTimes(1); - expect(result).toStrictEqual(expectedResponse); - }); - - it('throws an error when the bridge getAppNameAndVersion method throws an Error', async function () { - jest - .spyOn(bridge, 'getAppNameAndVersion') - .mockRejectedValue(new Error('Connection failed')); - - await expect(keyring.getAppNameAndVersion()).rejects.toThrow( - 'Connection failed', - ); - }); - - it('throws the default error when the bridge getAppNameAndVersion method throws a non-Error object', async function () { - jest - .spyOn(bridge, 'getAppNameAndVersion') - .mockRejectedValue('some error'); - - await expect(keyring.getAppNameAndVersion()).rejects.toThrow( - 'Ledger: Unknown error while getting app name and version', - ); - }); - }); - describe('signTransaction', function () { describe('using old versions of ethereumjs/tx', function () { it('passes serialized transaction to ledger and return signed tx', async function () { @@ -1390,6 +1352,38 @@ describe('LedgerKeyring', function () { }); }); + 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', + ); + }); + }); + describe('destroy', function () { it('calls the destroy bridge method', async function () { jest.spyOn(keyring.bridge, 'destroy').mockResolvedValue(undefined); 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); + }); + }); +}); From 97074268816c0f5e0bffd0c4514f05162f5bb104 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Jan 2026 13:17:56 +0800 Subject: [PATCH 7/8] fix: lint --- .../keyring-eth-ledger-bridge/src/ledger-iframe-bridge.test.ts | 1 + 1 file changed, 1 insertion(+) 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 81e68fc69..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 @@ -629,6 +629,7 @@ describe('LedgerIframeBridge', function () { // eslint-disable-next-line @typescript-eslint/unbound-method expect(bridge.iframe?.contentWindow?.postMessage).toHaveBeenCalled(); }); + }); }); describe('setOption', function () { From 1b6ed30e00d81dec7ad66c7dc1cf66636dbbdd04 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Jan 2026 21:53:29 +0800 Subject: [PATCH 8/8] fix: use default hardware error --- .../src/errors.test.ts | 206 +----------------- .../keyring-eth-ledger-bridge/src/errors.ts | 91 +------- .../src/ledger-error-handler.test.ts | 52 ++--- .../src/ledger-error-handler.ts | 28 +-- 4 files changed, 47 insertions(+), 330 deletions(-) diff --git a/packages/keyring-eth-ledger-bridge/src/errors.test.ts b/packages/keyring-eth-ledger-bridge/src/errors.test.ts index a16e5cbda..d78a4a190 100644 --- a/packages/keyring-eth-ledger-bridge/src/errors.test.ts +++ b/packages/keyring-eth-ledger-bridge/src/errors.test.ts @@ -3,228 +3,37 @@ import { Severity as SeverityEnum, Category as CategoryEnum, RetryStrategy as RetryStrategyEnum, + HardwareWalletError, } from '@metamask/keyring-utils'; import { - LedgerHardwareWalletError, createLedgerError, isKnownLedgerError, getLedgerErrorMapping, } from './errors'; -describe('LedgerHardwareWalletError', () => { - describe('constructor', () => { - it('should create an error with all properties', () => { - const error = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.USER_CANCEL_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.USER_ACTION, - retryStrategy: RetryStrategyEnum.NO_RETRY, - ledgerCode: '0x6985', - }); - - expect(error).toBeInstanceOf(LedgerHardwareWalletError); - expect(error.message).toBe('Test error'); - expect(error.code).toBe(ErrorCodeEnum.USER_CANCEL_001); - expect(error.severity).toBe(SeverityEnum.ERROR); - expect(error.category).toBe(CategoryEnum.USER_ACTION); - expect(error.retryStrategy).toBe(RetryStrategyEnum.NO_RETRY); - expect(error.ledgerCode).toBe('0x6985'); - expect(error.name).toBe('LedgerHardwareWalletError'); - }); - - it('should create an error with a cause', () => { - const cause = new Error('Original error'); - const error = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.UNKNOWN_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.UNKNOWN, - retryStrategy: RetryStrategyEnum.NO_RETRY, - cause, - }); - - expect(error.cause).toBe(cause); - }); - - it('should create an error without ledgerCode', () => { - const error = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.UNKNOWN_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.UNKNOWN, - retryStrategy: RetryStrategyEnum.NO_RETRY, - }); - - expect(error.ledgerCode).toBeUndefined(); - }); - }); - - describe('withIncrementedRetryCount', () => { - it('should create a new error instance with the same properties', () => { - const originalError = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.AUTH_LOCK_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.AUTHENTICATION, - retryStrategy: RetryStrategyEnum.RETRY, - ledgerCode: '0x5515', - }); - - const newError = originalError.withIncrementedRetryCount(); - - expect(newError).toBeInstanceOf(LedgerHardwareWalletError); - expect(newError).not.toBe(originalError); - expect(newError.message).toBe(originalError.message); - expect(newError.code).toBe(originalError.code); - expect(newError.severity).toBe(originalError.severity); - expect(newError.category).toBe(originalError.category); - expect(newError.retryStrategy).toBe(originalError.retryStrategy); - expect(newError.ledgerCode).toBe(originalError.ledgerCode); - }); - - it('should preserve the cause when creating a new error', () => { - const cause = new Error('Original cause'); - const originalError = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.AUTH_LOCK_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.AUTHENTICATION, - retryStrategy: RetryStrategyEnum.RETRY, - cause, - ledgerCode: '0x5515', - }); - - const newError = originalError.withIncrementedRetryCount(); - - expect(newError.cause).toBe(cause); - }); - - it('should handle error without cause', () => { - const originalError = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.AUTH_LOCK_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.AUTHENTICATION, - retryStrategy: RetryStrategyEnum.RETRY, - ledgerCode: '0x5515', - }); - - const newError = originalError.withIncrementedRetryCount(); - - expect(newError.cause).toBeUndefined(); - }); - }); - - describe('withMetadata', () => { - it('should create a new error instance with the same properties', () => { - const originalError = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.AUTH_LOCK_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.AUTHENTICATION, - retryStrategy: RetryStrategyEnum.RETRY, - ledgerCode: '0x5515', - }); - - const metadata = { additionalInfo: 'test data' }; - const newError = originalError.withMetadata(metadata); - - expect(newError).toBeInstanceOf(LedgerHardwareWalletError); - expect(newError).not.toBe(originalError); - expect(newError.message).toBe(originalError.message); - expect(newError.code).toBe(originalError.code); - expect(newError.severity).toBe(originalError.severity); - expect(newError.category).toBe(originalError.category); - expect(newError.retryStrategy).toBe(originalError.retryStrategy); - expect(newError.ledgerCode).toBe(originalError.ledgerCode); - }); - - it('should preserve the cause when creating a new error', () => { - const cause = new Error('Original cause'); - const originalError = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.AUTH_LOCK_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.AUTHENTICATION, - retryStrategy: RetryStrategyEnum.RETRY, - cause, - ledgerCode: '0x5515', - }); - - const metadata = { additionalInfo: 'test data' }; - const newError = originalError.withMetadata(metadata); - - expect(newError.cause).toBe(cause); - }); - - it('should handle error without cause', () => { - const originalError = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.AUTH_LOCK_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.AUTHENTICATION, - retryStrategy: RetryStrategyEnum.RETRY, - ledgerCode: '0x5515', - }); - - const metadata = { additionalInfo: 'test data' }; - const newError = originalError.withMetadata(metadata); - - expect(newError.cause).toBeUndefined(); - }); - }); - - describe('toJSON', () => { - it('should serialize the error to JSON including ledgerCode', () => { - const error = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.USER_CANCEL_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.USER_ACTION, - retryStrategy: RetryStrategyEnum.NO_RETRY, - ledgerCode: '0x6985', - }); - - const json = error.toJSON(); - - expect(json).toHaveProperty('ledgerCode', '0x6985'); - expect(json).toHaveProperty('message', 'Test error'); - expect(json).toHaveProperty('code', ErrorCodeEnum.USER_CANCEL_001); - }); - - it('should serialize the error to JSON without ledgerCode when not provided', () => { - const error = new LedgerHardwareWalletError('Test error', { - code: ErrorCodeEnum.UNKNOWN_001, - severity: SeverityEnum.ERROR, - category: CategoryEnum.UNKNOWN, - retryStrategy: RetryStrategyEnum.NO_RETRY, - }); - - const json = error.toJSON(); - - expect(json).toHaveProperty('ledgerCode', undefined); - expect(json).toHaveProperty('message', 'Test error'); - }); - }); -}); - describe('createLedgerError', () => { - it('should create a LedgerHardwareWalletError from a known error code', () => { + it('should create a HardwareWalletError from a known error code', () => { const error = createLedgerError('0x6985'); - expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error).toBeInstanceOf(HardwareWalletError); expect(error.message).toContain('User rejected'); - expect(error.ledgerCode).toBe('0x6985'); expect(error.code).toBe(ErrorCodeEnum.USER_CANCEL_001); }); - it('should create a LedgerHardwareWalletError with context', () => { + it('should create a HardwareWalletError with context', () => { const error = createLedgerError('0x6985', 'during transaction signing'); - expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error).toBeInstanceOf(HardwareWalletError); expect(error.message).toContain('User rejected'); expect(error.message).toContain('(during transaction signing)'); - expect(error.ledgerCode).toBe('0x6985'); }); it('should create a fallback error for unknown error codes without context', () => { const error = createLedgerError('0x9999'); - expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error).toBeInstanceOf(HardwareWalletError); expect(error.message).toBe('Unknown Ledger error: 0x9999'); - expect(error.ledgerCode).toBe('0x9999'); expect(error.code).toBe(ErrorCodeEnum.UNKNOWN_001); expect(error.severity).toBe(SeverityEnum.ERROR); expect(error.category).toBe(CategoryEnum.UNKNOWN); @@ -234,11 +43,10 @@ describe('createLedgerError', () => { it('should create a fallback error for unknown error codes with context', () => { const error = createLedgerError('0x9999', 'while doing something'); - expect(error).toBeInstanceOf(LedgerHardwareWalletError); + expect(error).toBeInstanceOf(HardwareWalletError); expect(error.message).toBe( 'Unknown Ledger error: 0x9999 (while doing something)', ); - expect(error.ledgerCode).toBe('0x9999'); expect(error.code).toBe(ErrorCodeEnum.UNKNOWN_001); }); }); diff --git a/packages/keyring-eth-ledger-bridge/src/errors.ts b/packages/keyring-eth-ledger-bridge/src/errors.ts index a1e7ac44a..2ad7c6e14 100644 --- a/packages/keyring-eth-ledger-bridge/src/errors.ts +++ b/packages/keyring-eth-ledger-bridge/src/errors.ts @@ -11,85 +11,6 @@ import { RetryStrategy as RetryStrategyEnum, } from '@metamask/keyring-utils'; -export type LedgerHardwareWalletErrorOptions = { - code: ErrorCode; - severity: Severity; - category: Category; - retryStrategy: RetryStrategy; - cause?: Error; - ledgerCode?: string; -}; - -export class LedgerHardwareWalletError extends HardwareWalletError { - public readonly ledgerCode?: string; - - constructor(message: string, options: LedgerHardwareWalletErrorOptions) { - super(message, { - ...options, - userActionable: false, - userMessage: message, - }); - this.name = 'LedgerHardwareWalletError'; - this.ledgerCode = options.ledgerCode; - - // Ensure proper prototype chain for instanceof checks - Object.setPrototypeOf(this, LedgerHardwareWalletError.prototype); - } - - /** - * Creates a new error instance with an incremented retry count. - * - * @returns A new LedgerHardwareWalletError instance with the retry count incremented. - */ - override withIncrementedRetryCount(): LedgerHardwareWalletError { - const errorCause = - 'cause' in this && this.cause instanceof Error ? this.cause : undefined; - - return new LedgerHardwareWalletError(this.message, { - code: this.code, - severity: this.severity, - category: this.category, - retryStrategy: this.retryStrategy, - cause: errorCause, - ledgerCode: this.ledgerCode, - }); - } - - /** - * Creates a new error instance with additional metadata. - * - * @param _additionalMetadata - Additional metadata to merge with existing metadata. - * @returns A new LedgerHardwareWalletError instance with the updated metadata. - */ - override withMetadata( - _additionalMetadata: Record, - ): LedgerHardwareWalletError { - const errorCause = - 'cause' in this && this.cause instanceof Error ? this.cause : undefined; - - return new LedgerHardwareWalletError(this.message, { - code: this.code, - severity: this.severity, - category: this.category, - retryStrategy: this.retryStrategy, - cause: errorCause, - ledgerCode: this.ledgerCode, - }); - } - - /** - * Serializes the error to a JSON-compatible object. - * - * @returns A JSON-compatible object representing the error. - */ - override toJSON(): Record { - return { - ...super.toJSON(), - ledgerCode: this.ledgerCode, - }; - } -} - type LedgerErrorMapping = { customCode: ErrorCode; message: string; @@ -116,7 +37,7 @@ type LedgerErrorMapping = { export function createLedgerError( ledgerErrorCode: string, context?: string, -): LedgerHardwareWalletError { +): HardwareWalletError { const mappings = HARDWARE_MAPPINGS.ledger.errorMappings as { [key: string]: LedgerErrorMapping; }; @@ -127,12 +48,13 @@ export function createLedgerError( ? `${errorMapping.message} (${context})` : errorMapping.message; - return new LedgerHardwareWalletError(message, { + return new HardwareWalletError(message, { code: errorMapping.customCode, severity: errorMapping.severity, category: errorMapping.category, retryStrategy: errorMapping.retryStrategy, - ledgerCode: ledgerErrorCode, + userActionable: errorMapping.userActionable, + userMessage: errorMapping.userMessage ?? '', }); } @@ -141,12 +63,13 @@ export function createLedgerError( ? `Unknown Ledger error: ${ledgerErrorCode} (${context})` : `Unknown Ledger error: ${ledgerErrorCode}`; - return new LedgerHardwareWalletError(fallbackMessage, { + return new HardwareWalletError(fallbackMessage, { code: ErrorCodeEnum.UNKNOWN_001, severity: SeverityEnum.ERROR, category: CategoryEnum.UNKNOWN, retryStrategy: RetryStrategyEnum.NO_RETRY, - ledgerCode: ledgerErrorCode, + userActionable: false, + userMessage: '', }); } 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 b43babe2a..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 @@ -4,9 +4,9 @@ import { Severity, Category, RetryStrategy, + HardwareWalletError, } from '@metamask/keyring-utils'; -import { LedgerHardwareWalletError } from './errors'; import { handleLedgerTransportError } from './ledger-error-handler'; const fallbackMessage = 'Default error message'; @@ -32,21 +32,16 @@ function createTransportStatusError( } /** - * Helper function to test that handleLedgerTransportError throws a LedgerHardwareWalletError + * Helper function to test that handleLedgerTransportError throws a HardwareWalletError * with expected properties * * @param error - The error to pass to handleLedgerTransportError - * @param expectedLedgerCode - Expected ledger code of the thrown LedgerHardwareWalletError - * @param expectedMessage - Expected message of the thrown LedgerHardwareWalletError + * @param expectedMessage - Expected message of the thrown HardwareWalletError * @returns True if all assertions pass */ -function expectLedgerError( - error: unknown, - expectedLedgerCode: string, - expectedMessage: string, -): boolean { +function expectLedgerError(error: unknown, expectedMessage: string): boolean { expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - LedgerHardwareWalletError, + HardwareWalletError, ); let thrownError: unknown; @@ -55,13 +50,8 @@ function expectLedgerError( } catch (error_: unknown) { thrownError = error_; } - expect(thrownError).toBeInstanceOf(LedgerHardwareWalletError); - expect((thrownError as LedgerHardwareWalletError).ledgerCode).toBe( - expectedLedgerCode, - ); - expect((thrownError as LedgerHardwareWalletError).message).toBe( - expectedMessage, - ); + expect(thrownError).toBeInstanceOf(HardwareWalletError); + expect((thrownError as HardwareWalletError).message).toBe(expectedMessage); return true; } @@ -72,44 +62,37 @@ describe('handleLedgerTransportError', () => { tc: 'user rejection', inputMessage: 'User rejected', status: 0x6985, - expectedLedgerCode: '0x6985', expectedMessage: 'User rejected action on device', }, { tc: 'blind signing', inputMessage: 'Blind signing required', status: 0x6a80, - expectedLedgerCode: '0x6a80', expectedMessage: 'Invalid data received', }, { tc: 'device locked', inputMessage: 'Device locked', status: 0x5515, - expectedLedgerCode: '0x5515', expectedMessage: 'Device is locked', }, { tc: 'app closed', inputMessage: 'App closed', status: 0x650f, - expectedLedgerCode: '0x650f', expectedMessage: 'App closed or connection issue', }, { tc: 'unknown status codes by preserving original message', inputMessage: 'Unknown transport error', status: 0x9999, - expectedLedgerCode: '0x9999', expectedMessage: 'Unknown transport error', }, ])( 'handles status code $status ($tc)', - ({ inputMessage, status, expectedLedgerCode, expectedMessage }) => { + ({ inputMessage, status, expectedMessage }) => { const error = createTransportStatusError(inputMessage, status); - expect( - expectLedgerError(error, expectedLedgerCode, expectedMessage), - ).toBe(true); + expect(expectLedgerError(error, expectedMessage)).toBe(true); }, ); }); @@ -131,11 +114,11 @@ describe('handleLedgerTransportError', () => { expect(throwingFunction).toThrow(fallbackMessage); }); - it('wraps Error instances in LedgerHardwareWalletError', () => { + it('wraps Error instances in HardwareWalletError', () => { const error = new Error('Original error message'); expect(() => handleLedgerTransportError(error, fallbackMessage)).toThrow( - LedgerHardwareWalletError, + HardwareWalletError, ); let thrownError: unknown; @@ -145,20 +128,21 @@ describe('handleLedgerTransportError', () => { thrownError = error_; } - expect(thrownError).toBeInstanceOf(LedgerHardwareWalletError); - expect((thrownError as LedgerHardwareWalletError).message).toBe( + expect(thrownError).toBeInstanceOf(HardwareWalletError); + expect((thrownError as HardwareWalletError).message).toBe( 'Original error message', ); - expect((thrownError as LedgerHardwareWalletError).cause).toBe(error); + expect((thrownError as HardwareWalletError).cause).toBe(error); }); - it('passes through LedgerHardwareWalletError instances', () => { - const ledgerError = new LedgerHardwareWalletError('Ledger error', { + 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, - ledgerCode: '0x6985', + userActionable: false, + userMessage: '', }); expect(() => 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 18ff70c68..a6d43a8ce 100644 --- a/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts +++ b/packages/keyring-eth-ledger-bridge/src/ledger-error-handler.ts @@ -4,22 +4,19 @@ import { Severity as SeverityEnum, Category as CategoryEnum, RetryStrategy as RetryStrategyEnum, + HardwareWalletError, } from '@metamask/keyring-utils'; -import { - createLedgerError, - isKnownLedgerError, - LedgerHardwareWalletError, -} from './errors'; +import { createLedgerError, isKnownLedgerError } from './errors'; /** * Central error handler for Ledger TransportStatusError instances. - * Converts Ledger transport errors into properly typed LedgerHardwareWalletError instances + * 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 LedgerHardwareWalletError with appropriate error details from mappings + * @throws HardwareWalletError with appropriate error details from mappings */ export function handleLedgerTransportError( error: unknown, @@ -34,37 +31,42 @@ export function handleLedgerTransportError( } // Unknown status code - create generic error with details - throw new LedgerHardwareWalletError(error.message, { + throw new HardwareWalletError(error.message, { code: ErrorCodeEnum.UNKNOWN_001, severity: SeverityEnum.ERROR, category: CategoryEnum.UNKNOWN, retryStrategy: RetryStrategyEnum.NO_RETRY, + userActionable: false, + userMessage: '', cause: error, - ledgerCode: statusCodeHex, }); } - // Handle LedgerHardwareWalletError - pass through - if (error instanceof LedgerHardwareWalletError) { + // Handle HardwareWalletError - pass through + if (error instanceof HardwareWalletError) { throw error; } // For any other error type if (error instanceof Error) { - throw new LedgerHardwareWalletError(error.message, { + 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 LedgerHardwareWalletError(fallbackMessage, { + throw new HardwareWalletError(fallbackMessage, { code: ErrorCodeEnum.UNKNOWN_001, severity: SeverityEnum.ERROR, category: CategoryEnum.UNKNOWN, retryStrategy: RetryStrategyEnum.NO_RETRY, + userActionable: false, + userMessage: '', }); }