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