From 0dc2c76dd9c67ac046b5fe68e78567de4acf5fec Mon Sep 17 00:00:00 2001 From: Edgar Khanzadian Date: Wed, 31 Jan 2024 22:46:16 +0400 Subject: [PATCH] refactor: reuse code for ledger signing in useLedgerSignTx --- .../ledger-bitcoin-sign-tx-container.tsx | 114 ++++++-------- .../ledger-sign-stacks-tx-container.tsx | 148 +++++++----------- .../tx-signing/use-ledger-sign-tx.ts | 84 ++++++++++ 3 files changed, 189 insertions(+), 157 deletions(-) create mode 100644 src/app/features/ledger/generic-flows/tx-signing/use-ledger-sign-tx.ts diff --git a/src/app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container.tsx b/src/app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container.tsx index 36168be2fb6..2ce87ffb6c1 100644 --- a/src/app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container.tsx +++ b/src/app/features/ledger/flows/bitcoin-tx-signing/ledger-bitcoin-sign-tx-container.tsx @@ -4,6 +4,7 @@ import { Route, useLocation } from 'react-router-dom'; import * as btc from '@scure/btc-signer'; import { hexToBytes } from '@stacks/common'; +import BitcoinApp from 'ledger-bitcoin'; import get from 'lodash.get'; import { BitcoinInputSigningConfig } from '@shared/crypto/bitcoin/signer-config'; @@ -14,18 +15,21 @@ import { useLocationStateWithCache } from '@app/common/hooks/use-location-state' import { useScrollLock } from '@app/common/hooks/use-scroll-lock'; import { appEvents } from '@app/common/publish-subscribe'; import { delay } from '@app/common/utils'; +import { ApproveSignLedgerBitcoinTx } from '@app/features/ledger/flows/bitcoin-tx-signing/steps/approve-bitcoin-sign-ledger-tx'; +import { ledgerSignTxRoutes } from '@app/features/ledger/generic-flows/tx-signing/ledger-sign-tx-route-generator'; import { LedgerTxSigningContext } from '@app/features/ledger/generic-flows/tx-signing/ledger-sign-tx.context'; +import { TxSigningFlow } from '@app/features/ledger/generic-flows/tx-signing/tx-signing-flow'; +import { useLedgerSignTx } from '@app/features/ledger/generic-flows/tx-signing/use-ledger-sign-tx'; +import { useLedgerAnalytics } from '@app/features/ledger/hooks/use-ledger-analytics.hook'; +import { useLedgerNavigate } from '@app/features/ledger/hooks/use-ledger-navigate'; +import { + connectLedgerBitcoinApp, + getBitcoinAppVersion, + isBitcoinAppOpen, +} from '@app/features/ledger/utils/bitcoin-ledger-utils'; import { useSignLedgerBitcoinTx } from '@app/store/accounts/blockchain/bitcoin/bitcoin.hooks'; import { useCurrentNetwork } from '@app/store/networks/networks.selectors'; -import { ledgerSignTxRoutes } from '../../generic-flows/tx-signing/ledger-sign-tx-route-generator'; -import { TxSigningFlow } from '../../generic-flows/tx-signing/tx-signing-flow'; -import { useLedgerAnalytics } from '../../hooks/use-ledger-analytics.hook'; -import { useLedgerNavigate } from '../../hooks/use-ledger-navigate'; -import { connectLedgerBitcoinApp, getBitcoinAppVersion } from '../../utils/bitcoin-ledger-utils'; -import { checkLockedDeviceError, useLedgerResponseState } from '../../utils/generic-ledger-utils'; -import { ApproveSignLedgerBitcoinTx } from './steps/approve-bitcoin-sign-ledger-tx'; - export const ledgerBitcoinTxSigningRoutes = ledgerSignTxRoutes({ component: , customRoutes: ( @@ -56,67 +60,51 @@ function LedgerSignBitcoinTxContainer() { useEffect(() => () => setUnsignedTransaction(null), []); - const [latestDeviceResponse, setLatestDeviceResponse] = useLedgerResponseState(); - - const [awaitingDeviceConnection, setAwaitingDeviceConnection] = useState(false); - - if (!inputsToSign) { - ledgerNavigate.cancelLedgerAction(); - toast.error('No input signing config defined'); - return null; - } - - const signTransaction = async () => { - setAwaitingDeviceConnection(true); - - try { - const bitcoinApp = await connectLedgerBitcoinApp(network.chain.bitcoin.bitcoinNetwork)(); + const chain = 'bitcoin' as const; - try { - const versionInfo = await getBitcoinAppVersion(bitcoinApp); - ledgerAnalytics.trackDeviceVersionInfo(versionInfo); - setAwaitingDeviceConnection(false); - setLatestDeviceResponse(versionInfo as any); - } catch (e) { - setLatestDeviceResponse(e as any); - logger.error('Unable to get Ledger app version info', e); - } + const { signTransaction, latestDeviceResponse, awaitingDeviceConnection } = + useLedgerSignTx({ + chain, + isAppOpen: isBitcoinAppOpen({ network: network.chain.bitcoin.bitcoinNetwork }), + getAppVersion: getBitcoinAppVersion, + connectApp: connectLedgerBitcoinApp(network.chain.bitcoin.bitcoinNetwork), + async signTransactionWithDevice(bitcoinApp) { + if (!inputsToSign) { + ledgerNavigate.cancelLedgerAction(); + toast.error('No input signing config defined'); + return; + } - ledgerNavigate.toDeviceBusyStep('Verifying public key on Ledger…'); + ledgerNavigate.toDeviceBusyStep('Verifying public key on Ledger…'); - ledgerNavigate.toConnectionSuccessStep('bitcoin'); - await delay(1200); - if (!unsignedTransaction) throw new Error('No unsigned tx'); - - ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: false }); - - try { - const btcTx = await signLedger(bitcoinApp, unsignedTransaction.toPSBT(), inputsToSign); - - if (!btcTx || !unsignedTransactionRaw) throw new Error('No tx returned'); - ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: true }); + ledgerNavigate.toConnectionSuccessStep('bitcoin'); await delay(1200); - appEvents.publish('ledgerBitcoinTxSigned', { - signedPsbt: btcTx, - unsignedPsbt: unsignedTransactionRaw, - }); - } catch (e) { - logger.error('Unable to sign tx with ledger', e); - ledgerAnalytics.transactionSignedOnLedgerRejected(); - ledgerNavigate.toOperationRejectedStep(); - } finally { - void bitcoinApp.transport.close(); - } - } catch (e) { - if (e instanceof Error && checkLockedDeviceError(e)) { - setLatestDeviceResponse({ deviceLocked: true } as any); - return; - } - } - }; + if (!unsignedTransaction) throw new Error('No unsigned tx'); + + ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: false }); + + try { + const btcTx = await signLedger(bitcoinApp, unsignedTransaction.toPSBT(), inputsToSign); + + if (!btcTx || !unsignedTransactionRaw) throw new Error('No tx returned'); + ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: true }); + await delay(1200); + appEvents.publish('ledgerBitcoinTxSigned', { + signedPsbt: btcTx, + unsignedPsbt: unsignedTransactionRaw, + }); + } catch (e) { + logger.error('Unable to sign tx with ledger', e); + ledgerAnalytics.transactionSignedOnLedgerRejected(); + ledgerNavigate.toOperationRejectedStep(); + } finally { + void bitcoinApp.transport.close(); + } + }, + }); const ledgerContextValue: LedgerTxSigningContext = { - chain: 'bitcoin', + chain, transaction: unsignedTransaction, signTransaction, latestDeviceResponse, diff --git a/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container.tsx b/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container.tsx index ee180ccc03d..b6de1dfef67 100644 --- a/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container.tsx +++ b/src/app/features/ledger/flows/stacks-tx-signing/ledger-sign-stacks-tx-container.tsx @@ -1,11 +1,10 @@ import { useEffect, useMemo, useState } from 'react'; -import { Route, useLocation, useNavigate } from 'react-router-dom'; +import { Route, useLocation } from 'react-router-dom'; import { deserializeTransaction } from '@stacks/transactions'; -import { LedgerError } from '@zondax/ledger-stacks'; +import StacksApp, { LedgerError } from '@zondax/ledger-stacks'; import get from 'lodash.get'; -import { logger } from '@shared/logger'; import { RouteUrls } from '@shared/route-urls'; import { useScrollLock } from '@app/common/hooks/use-scroll-lock'; @@ -16,9 +15,9 @@ import { createWaitForUserToSeeWarningScreen, } from '@app/features/ledger/generic-flows/tx-signing/ledger-sign-tx.context'; import { + connectLedgerStacksApp, getStacksAppVersion, - isVersionOfLedgerStacksAppWithContractPrincipalBug, - prepareLedgerDeviceStacksAppConnection, + isStacksAppOpen, signLedgerStacksTransaction, signStacksTransactionWithSignature, } from '@app/features/ledger/utils/stacks-ledger-utils'; @@ -26,10 +25,10 @@ import { useCurrentStacksAccount } from '@app/store/accounts/blockchain/stacks/s import { ledgerSignTxRoutes } from '../../generic-flows/tx-signing/ledger-sign-tx-route-generator'; import { TxSigningFlow } from '../../generic-flows/tx-signing/tx-signing-flow'; +import { useLedgerSignTx } from '../../generic-flows/tx-signing/use-ledger-sign-tx'; import { useLedgerAnalytics } from '../../hooks/use-ledger-analytics.hook'; import { useLedgerNavigate } from '../../hooks/use-ledger-navigate'; import { useVerifyMatchingLedgerStacksPublicKey } from '../../hooks/use-verify-matching-stacks-public-key'; -import { checkLockedDeviceError, useLedgerResponseState } from '../../utils/generic-ledger-utils'; import { ApproveSignLedgerStacksTx } from './steps/approve-sign-stacks-ledger-tx'; export const ledgerStacksTxSigningRoutes = ledgerSignTxRoutes({ @@ -41,7 +40,6 @@ export const ledgerStacksTxSigningRoutes = ledgerSignTxRoutes({ function LedgerSignStacksTxContainer() { const location = useLocation(); - const navigate = useNavigate(); const ledgerNavigate = useLedgerNavigate(); const ledgerAnalytics = useLedgerAnalytics(); useScrollLock(true); @@ -51,7 +49,7 @@ function LedgerSignStacksTxContainer() { const hasUserSkippedBuggyAppWarning = useMemo(() => createWaitForUserToSeeWarningScreen(), []); - const chain = 'stacks'; + const chain = 'stacks' as const; useEffect(() => { const tx = get(location.state, 'tx'); @@ -60,102 +58,64 @@ function LedgerSignStacksTxContainer() { useEffect(() => () => setUnsignedTx(null), []); - const [latestDeviceResponse, setLatestDeviceResponse] = useLedgerResponseState(); - - const [awaitingDeviceConnection, setAwaitingDeviceConnection] = useState(false); - - const signTransaction = async () => { - if (!account) return; + const { signTransaction, latestDeviceResponse, awaitingDeviceConnection } = + useLedgerSignTx({ + chain, + isAppOpen: isStacksAppOpen, + getAppVersion: getStacksAppVersion, + connectApp: connectLedgerStacksApp, + async signTransactionWithDevice(stacksApp) { + // TODO: need better handling + if (!account) return; + + ledgerNavigate.toDeviceBusyStep('Verifying public key on Ledger…'); + await verifyLedgerPublicKey(stacksApp); + ledgerNavigate.toConnectionSuccessStep('stacks'); + await delay(1000); + if (!unsignedTx) throw new Error('No unsigned tx'); + + ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: false }); + + const resp = await signLedgerStacksTransaction(stacksApp)( + Buffer.from(unsignedTx, 'hex'), + account.index + ); + + // Assuming here that public keys are wrong. Alternatively, we may want + // to proactively check the key before signing + if (resp.returnCode === LedgerError.DataIsInvalid) { + ledgerNavigate.toDevicePayloadInvalid(); + return; + } - const stacksApp = await prepareLedgerDeviceStacksAppConnection({ - setLoadingState: setAwaitingDeviceConnection, - onError(e) { - if (e instanceof Error && checkLockedDeviceError(e)) { - setLatestDeviceResponse({ deviceLocked: true } as any); + if (resp.returnCode === LedgerError.TransactionRejected) { + ledgerNavigate.toOperationRejectedStep(); + ledgerAnalytics.transactionSignedOnLedgerRejected(); return; } - ledgerNavigate.toErrorStep(chain); - }, - }); - try { - const versionInfo = await getStacksAppVersion(stacksApp); - ledgerAnalytics.trackDeviceVersionInfo(versionInfo); - setLatestDeviceResponse(versionInfo); + if (resp.returnCode !== LedgerError.NoErrors) { + throw new Error('Some other error'); + } - if (versionInfo.deviceLocked) { - setAwaitingDeviceConnection(false); - return; - } + ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: true }); - if (versionInfo.returnCode !== LedgerError.NoErrors) { - logger.error('Return code from device has error', versionInfo); - return; - } + await delay(1000); - if (isVersionOfLedgerStacksAppWithContractPrincipalBug(versionInfo)) { - navigate(RouteUrls.LedgerOutdatedAppWarning); - const response = await hasUserSkippedBuggyAppWarning.wait(); + const signedTx = signStacksTransactionWithSignature(unsignedTx, resp.signatureVRS); + ledgerAnalytics.transactionSignedOnLedgerSuccessfully(); - if (response === 'cancelled-operation') { - ledgerNavigate.cancelLedgerAction(); + try { + appEvents.publish('ledgerStacksTxSigned', { + unsignedTx, + signedTx, + }); + } catch (e) { + ledgerNavigate.toBroadcastErrorStep(e instanceof Error ? e.message : 'Unknown error'); return; } - } - - ledgerNavigate.toDeviceBusyStep('Verifying public key on Ledger…'); - await verifyLedgerPublicKey(stacksApp); - - ledgerNavigate.toConnectionSuccessStep('stacks'); - await delay(1000); - if (!unsignedTx) throw new Error('No unsigned tx'); - - ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: false }); - - const resp = await signLedgerStacksTransaction(stacksApp)( - Buffer.from(unsignedTx, 'hex'), - account.index - ); - - // Assuming here that public keys are wrong. Alternatively, we may want - // to proactively check the key before signing - if (resp.returnCode === LedgerError.DataIsInvalid) { - ledgerNavigate.toDevicePayloadInvalid(); - return; - } - - if (resp.returnCode === LedgerError.TransactionRejected) { - ledgerNavigate.toOperationRejectedStep(); - ledgerAnalytics.transactionSignedOnLedgerRejected(); - return; - } - - if (resp.returnCode !== LedgerError.NoErrors) { - throw new Error('Some other error'); - } - - ledgerNavigate.toAwaitingDeviceOperation({ hasApprovedOperation: true }); - - await delay(1000); - - const signedTx = signStacksTransactionWithSignature(unsignedTx, resp.signatureVRS); - ledgerAnalytics.transactionSignedOnLedgerSuccessfully(); - - try { - appEvents.publish('ledgerStacksTxSigned', { - unsignedTx, - signedTx, - }); - } catch (e) { - ledgerNavigate.toBroadcastErrorStep(e instanceof Error ? e.message : 'Unknown error'); - return; - } - } catch (e) { - ledgerNavigate.toDeviceDisconnectStep(); - } finally { - await stacksApp.transport.close(); - } - }; + }, + }); function closeAction() { appEvents.publish('ledgerStacksTxSigningCancelled', { unsignedTx: unsignedTx ?? '' }); diff --git a/src/app/features/ledger/generic-flows/tx-signing/use-ledger-sign-tx.ts b/src/app/features/ledger/generic-flows/tx-signing/use-ledger-sign-tx.ts new file mode 100644 index 00000000000..8cf2665a848 --- /dev/null +++ b/src/app/features/ledger/generic-flows/tx-signing/use-ledger-sign-tx.ts @@ -0,0 +1,84 @@ +import { useState } from 'react'; + +import StacksApp from '@zondax/ledger-stacks'; +import BitcoinApp from 'ledger-bitcoin'; + +import { SupportedBlockchains } from '@shared/constants'; + +import { delay } from '@app/common/utils'; + +import { useLedgerNavigate } from '../../hooks/use-ledger-navigate'; +import { BitcoinAppVersion } from '../../utils/bitcoin-ledger-utils'; +import { + LedgerConnectionErrors, + checkLockedDeviceError, + useLedgerResponseState, +} from '../../utils/generic-ledger-utils'; +import { StacksAppVersion } from '../../utils/stacks-ledger-utils'; + +interface UseLedgerSignTxArgs { + chain: SupportedBlockchains; + isAppOpen({ name }: { name: string }): boolean; + getAppVersion(app: App): Promise | Promise; + connectApp(): Promise; + onSuccess?(): void; + signTransactionWithDevice(app: App): Promise; +} + +export function useLedgerSignTx({ + chain, + isAppOpen, + getAppVersion, + connectApp, + onSuccess, + signTransactionWithDevice, +}: UseLedgerSignTxArgs) { + const [outdatedAppVersionWarning, setAppVersionOutdatedWarning] = useState(false); + const [latestDeviceResponse, setLatestDeviceResponse] = useLedgerResponseState(); + const [awaitingDeviceConnection, setAwaitingDeviceConnection] = useState(false); + const ledgerNavigate = useLedgerNavigate(); + + async function checkCorrectAppIsOpenWithFailState(app: App) { + const response = await getAppVersion(app); + + if (!isAppOpen(response)) { + setAwaitingDeviceConnection(false); + throw new Error(LedgerConnectionErrors.AppNotOpen); + } + return response; + } + + async function signTransactionImpl() { + let app; + try { + setLatestDeviceResponse({ deviceLocked: false } as any); + setAwaitingDeviceConnection(true); + app = await connectApp(); + await checkCorrectAppIsOpenWithFailState(app); + setAwaitingDeviceConnection(false); + ledgerNavigate.toConnectionSuccessStep(chain); + await delay(1250); + await signTransactionWithDevice(app); + onSuccess?.(); + } catch (e) { + setAwaitingDeviceConnection(false); + if (e instanceof Error && checkLockedDeviceError(e)) { + setLatestDeviceResponse({ deviceLocked: true } as any); + return; + } + + ledgerNavigate.toErrorStep(chain); + return app?.transport.close(); + } + } + + return { + signTransaction: signTransactionImpl, + outdatedAppVersionWarning, + setAppVersionOutdatedWarning, + latestDeviceResponse, + setLatestDeviceResponse, + awaitingDeviceConnection, + setAwaitingDeviceConnection, + }; +}