diff --git a/packages/connect-popup/e2e/tests/passphrase.test.ts b/packages/connect-popup/e2e/tests/passphrase.test.ts index 79905d4ad20..0d6b8dccb39 100644 --- a/packages/connect-popup/e2e/tests/passphrase.test.ts +++ b/packages/connect-popup/e2e/tests/passphrase.test.ts @@ -175,7 +175,7 @@ test('introduce passphrase in popup and device rejects it', async () => { await waitAndClick(popup, ['@connect-ui/error-close-button']); - await explorerPage.waitForSelector('text=Failure_ActionCancelled'); + await explorerPage.waitForSelector('text=Cancelled'); }); test('introduce passphrase successfully next time should not ask for it', async () => { diff --git a/packages/connect-popup/e2e/tests/popup-close.test.ts b/packages/connect-popup/e2e/tests/popup-close.test.ts index cc2ca225f7a..d4aff73d8d9 100644 --- a/packages/connect-popup/e2e/tests/popup-close.test.ts +++ b/packages/connect-popup/e2e/tests/popup-close.test.ts @@ -174,7 +174,7 @@ test(`device dialog canceled ON DEVICE by user`, async ({ page, context }) => { await popupClosedPromise; - await explorerPage.waitForSelector('text=Failure_ActionCancelled'); + await explorerPage.waitForSelector('text=Cancelled'); }); test(`device disconnected during device interaction`, async ({ page, context }) => { diff --git a/packages/connect/src/device/Device.ts b/packages/connect/src/device/Device.ts index 1472622e0e5..b7a460c7e69 100644 --- a/packages/connect/src/device/Device.ts +++ b/packages/connect/src/device/Device.ts @@ -165,7 +165,7 @@ export class Device extends TypedEmitter { private keepTransportSession = false; public commands?: DeviceCommands; - private cancelableAction?: (err?: Error) => Promise; + private cancelableAction?: (err?: string) => Promise; private loaded = false; @@ -462,7 +462,7 @@ export class Device extends TypedEmitter { } setCancelableAction(callback: NonNullable) { - this.cancelableAction = (e?: Error) => + this.cancelableAction = (e?: string) => callback(e) .catch(e2 => { _log.debug('cancelableAction error', e2); @@ -479,7 +479,7 @@ export class Device extends TypedEmitter { async interruptionFromUser(error: Error) { _log.debug('interruptionFromUser'); - await this.cancelableAction?.(error); + await this.cancelableAction?.(error.toString()); await this.commands?.cancel(); if (this.runPromise) { diff --git a/packages/connect/src/device/DeviceCommands.ts b/packages/connect/src/device/DeviceCommands.ts index b5fb0aa69d9..12a22b9df28 100644 --- a/packages/connect/src/device/DeviceCommands.ts +++ b/packages/connect/src/device/DeviceCommands.ts @@ -3,6 +3,7 @@ import { MessagesSchema as Messages } from '@trezor/protobuf'; import { Assert } from '@trezor/schema-utils'; import { Session, Transport } from '@trezor/transport'; +import { ReadWriteError } from '@trezor/transport/src/transports/abstract'; import { createTimeoutPromise, versionUtils } from '@trezor/utils'; import { ERRORS } from '../constants'; @@ -31,16 +32,6 @@ type DefaultPayloadMessage = TypedCallResponseMap[keyof MessageType]; const logger = initLog('DeviceCommands'); -const assertType = (res: DefaultPayloadMessage, resType: MessageKey | MessageKey[]) => { - const splitResTypes = Array.isArray(resType) ? resType : resType.split('|'); - if (!splitResTypes.includes(res.type)) { - throw ERRORS.TypedError( - 'Runtime', - `assertType: Response of unexpected type: ${res.type}. Should be ${resType}`, - ); - } -}; - const filterForLog = (type: string, msg: any) => { const blacklist: { [key: string]: Record | string } = { PassphraseAck: { @@ -294,44 +285,6 @@ export class DeviceCommands { return this._getAddress(); } - // Sends an async message to the opened device. - private async call( - type: MessageKey, - msg: DefaultPayloadMessage['message'] = {}, - ): Promise { - logger.debug('Sending', type, filterForLog(type, msg)); - - this.callPromise = this.transport.call({ - session: this.transportSession, - name: type, - data: msg, - protocol: this.device.protocol, - }); - - const res = await this.callPromise; - - this.callPromise = undefined; - if (!res.success) { - logger.warn( - 'Received error', - res.error, - // res.message is not propagated to higher levels, only logged here. webusb/node-bridge may return message with additional information - res.message, - ); - throw new Error(res.error); - } - - logger.debug( - 'Received', - res.payload.type, - filterForLog(res.payload.type, res.payload.message), - ); - - // TODO: https://github.com/trezor/trezor-suite/issues/5301 - // @ts-expect-error - return res.payload; - } - typedCall( type: T, resType: R, @@ -353,43 +306,103 @@ export class DeviceCommands { // Assert message type // msg is allowed to be undefined for some calls, in that case the schema is an empty object Assert(Messages.MessageType.properties[type], msg ?? {}); + const response = await this._commonCall(type, msg); - try { - assertType(response, resType); - } catch (error) { - // handle possible race condition - // Bridge may have some unread message in buffer, read it - const abortController = new AbortController(); - const timeout = setTimeout(() => { - abortController.abort(); - }, 500); - - await this.transport - .receive({ - session: this.transportSession, - protocol: this.device.protocol, - signal: abortController.signal, - }) - .finally(() => { - clearTimeout(timeout); - }); - // throw error anyway, next call should be resolved properly - throw error; + + if (!response.success) { + if (response.isTransportError) { + // todo: at this point we have 2 options: + // A - throw { cause: 'transport-error' } plus update to ES2022 + // B - rework also implementations of typedCall so that they never throw + throw new Error(response.error); + } else { + throw new Error(response.message); + } } - return response; + const splitResTypes = Array.isArray(resType) ? resType : resType.split('|'); + if (splitResTypes.includes(response.payload.type)) { + return response.payload; + } + // handle possible race condition + // Bridge may have some unread message in buffer, read it + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + }, 500); + + await this.transport + .receive({ + session: this.transportSession, + protocol: this.device.protocol, + signal: abortController.signal, + }) + .finally(() => { + clearTimeout(timeout); + }); + + // throw error anyway, next call should be resolved properly + + throw ERRORS.TypedError( + 'Runtime', + `assertType: Response of unexpected type: ${response.payload.type}. Should be ${resType}`, + ); } - async _commonCall(type: MessageKey, msg?: DefaultPayloadMessage['message']) { + async _commonCall( + type: MessageKey, + msg: DefaultPayloadMessage['message'] = {}, + ): Promise< + | { success: true; payload: DefaultPayloadMessage } + | { success: false; error: any; message: string; isTransportError: false } + | { success: false; error: ReadWriteError; message?: string; isTransportError: true } + > { if (this.disposed) { throw ERRORS.TypedError('Runtime', 'typedCall: DeviceCommands already disposed'); } - const resp = await this.call(type, msg); - return this._filterCommonTypes(resp); + logger.debug('Sending', type, filterForLog(type, msg)); + + this.callPromise = this.transport.call({ + session: this.transportSession, + name: type, + data: msg, + protocol: this.device.protocol, + }); + + const res = await this.callPromise; + this.callPromise = undefined; + + if (!res.success) { + logger.warn( + 'Received error', + res.error, + // res.message is not propagated to higher levels, only logged here. webusb/node-bridge may return message with additional information + res.message, + ); + + return { + ...res, + isTransportError: true, + }; + } + + logger.debug( + 'Received', + res.payload.type, + filterForLog(res.payload.type, res.payload.message), + ); + + return this._filterCommonTypes(res.payload as DefaultPayloadMessage); } - _filterCommonTypes(res: DefaultPayloadMessage): Promise { + _filterCommonTypes( + res: DefaultPayloadMessage, + ): Promise< + | { success: true; payload: DefaultPayloadMessage } + | { success: false; error: any; message: string; isTransportError: false } + | { success: false; error: ReadWriteError; message?: string; isTransportError: true } + > { this.device.clearCancelableAction(); if (res.type === 'Failure') { @@ -409,16 +422,17 @@ export class DeviceCommands { } // pass code and message from firmware error - return Promise.reject( - new ERRORS.TrezorError( - (code as any) || 'Failure_UnknownCode', - message || 'Failure_UnknownMessage', - ), - ); + return Promise.resolve({ + success: false, + // todo: check renaming error vs code + error: code || 'Failure_UnknownCode', + message: message || 'Failure_UnknownMessage', + isTransportError: false, + }); } if (res.type === 'Features') { - return Promise.resolve(res); + return Promise.resolve({ success: true, payload: res }); } if (res.type === 'ButtonRequest') { @@ -434,38 +448,52 @@ export class DeviceCommands { } if (res.type === 'PinMatrixRequest') { - return promptPin(this.device, res.message.type).then( - pin => - this._commonCall('PinMatrixAck', { pin }).then(response => { + return promptPin(this.device, res.message.type).then(promptRes => { + if (!promptRes.success) { + return promptRes; + } + + return this._commonCall('PinMatrixAck', { pin: promptRes.payload }).then( + response => { + if (!response.success) { + return response; + } if (!this.device.features.unlocked) { // reload features to after successful PIN return this.device.getFeatures().then(() => response); } return response; - }), - error => Promise.reject(error), - ); + }, + ); + }); } + // { value, passphraseOnDevice } if (res.type === 'PassphraseRequest') { - return promptPassphrase(this.device).then( - ({ value, passphraseOnDevice }) => - !passphraseOnDevice - ? this._commonCall('PassphraseAck', { passphrase: value.normalize('NFKD') }) - : this._commonCall('PassphraseAck', { on_device: true }), - error => Promise.reject(error), - ); + return promptPassphrase(this.device).then(promptRes => { + if (!promptRes.success) { + return promptRes; + } + const { value, passphraseOnDevice } = promptRes.payload; + + return !passphraseOnDevice + ? this._commonCall('PassphraseAck', { passphrase: value.normalize('NFKD') }) + : this._commonCall('PassphraseAck', { on_device: true }); + }); } if (res.type === 'WordRequest') { - return promptWord(this.device, res.message.type).then( - word => this._commonCall('WordAck', { word }), - error => Promise.reject(error), - ); + return promptWord(this.device, res.message.type).then(promptRes => { + if (!promptRes.success) { + return promptRes; + } + + return this._commonCall('WordAck', { word: promptRes.payload }); + }); } - return Promise.resolve(res); + return Promise.resolve({ success: true, payload: res }); } private async _getAddress() { diff --git a/packages/connect/src/device/prompts.ts b/packages/connect/src/device/prompts.ts index 878c9bd7133..4b86ff9d98d 100644 --- a/packages/connect/src/device/prompts.ts +++ b/packages/connect/src/device/prompts.ts @@ -1,10 +1,10 @@ import { Messages, TRANSPORT_ERROR } from '@trezor/transport'; +import { ReadWriteError } from '@trezor/transport/src/transports/abstract'; -import { ERRORS } from '../constants'; import { DEVICE } from '../events'; import type { Device, DeviceEvents } from './Device'; -export type PromptCallback = (response: T | null, error?: Error) => void; +export type PromptCallback = (response: T | null, error?: string) => void; type PromptEvents = typeof DEVICE.PIN | typeof DEVICE.PASSPHRASE | typeof DEVICE.WORD; // infer all args of Device.emit but one (callback) @@ -40,20 +40,39 @@ export const cancelPrompt = (device: Device, expectResponse = true) => { return expectResponse ? device.transport.call(cancelArgs) : device.transport.send(cancelArgs); }; -const prompt = (event: E, ...[device, ...args]: DeviceEventArgs) => +type PromptReturnType = Promise< + | { success: true; payload: NonNullable>[0]> } + | ({ success: false } & ( + | { error: string; message: string; isTransportError: false } + | { error: ReadWriteError; isTransportError: true } + )) +>; + +const prompt = ( + event: E, + ...[device, ...args]: DeviceEventArgs +): PromptReturnType => // return non nullable first arg of PromptCallback - new Promise>[0]>>((resolve, reject) => { - const cancelAndReject = (error?: Error) => - cancelPrompt(device).then(onCancel => - reject( - error || - new Error( - onCancel.success - ? (onCancel.payload?.message.message as string) - : onCancel.error, - ), - ), - ); + new Promise(resolve => { + const cancelAndReject = (error?: string) => + cancelPrompt(device).then(cancelResponse => { + if (cancelResponse.success) { + return resolve({ + success: false, + error: error || (cancelResponse.payload?.message.message as string), + message: cancelResponse.payload?.message.message as string, + isTransportError: false, + }); + } + + resolve({ + success: false, + error: error || cancelResponse.error, + // @ts-expect-error todo todo + + isTransportError: true, + }); + }); if (device.listenerCount(event) > 0) { device.setCancelableAction(cancelAndReject); @@ -63,7 +82,7 @@ const prompt = (event: E, ...[device, ...args]: DeviceEv if (error || response == null) { cancelAndReject(error); } else { - resolve(response); + resolve({ success: true, payload: response }); } }; @@ -75,7 +94,7 @@ const prompt = (event: E, ...[device, ...args]: DeviceEv } else { // this may happen in case communication is out of sync. consider: // reload app, send GetFeatures, read PassphraseRequest (from previous session) - cancelAndReject(ERRORS.TypedError('Runtime', `${event} callback not configured`)); + cancelAndReject(`${event} callback not configured`); } }); export const promptPassphrase = (device: Device) => prompt(DEVICE.PASSPHRASE, device); diff --git a/packages/transport/src/transports/abstract.ts b/packages/transport/src/transports/abstract.ts index 6393c9a9108..10d61697867 100644 --- a/packages/transport/src/transports/abstract.ts +++ b/packages/transport/src/transports/abstract.ts @@ -58,7 +58,7 @@ export const isTransportInstance = (transport?: AbstractTransport) => { const getKey = ({ path, product }: Descriptor) => `${path}${product}`; -type ReadWriteError = +export type ReadWriteError = | typeof ERRORS.HTTP_ERROR | typeof ERRORS.WRONG_RESULT_TYPE | typeof ERRORS.OTHER_CALL_IN_PROGRESS