From 65579aac91a6a4a03c9d9c07ac242fd8b27ed520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hr=C3=A1ch?= Date: Thu, 20 Feb 2025 17:47:55 +0100 Subject: [PATCH] fixup! feat(suite): add timer to solana tx modal --- .../src/workers/solana/index.ts | 25 ++++--- .../TransactionReviewModal.tsx | 16 ++++ .../TransactionReviewModalContent.tsx | 75 +++++++++++++------ .../TransactionReviewOutputList.tsx | 21 +++++- .../TransactionReviewOutputTimer.tsx | 21 +++++- .../TxDetailModal/ExpiredBlockhash.tsx | 45 ++++++----- .../suite/src/hooks/useTransactionTimeout.tsx | 37 +++++++++ packages/suite/src/support/messages.ts | 10 ++- .../wallet-core/src/send/sendFormActions.ts | 1 + .../wallet-core/src/send/sendFormReducer.ts | 5 +- .../wallet-core/src/send/sendFormThunks.ts | 8 +- 11 files changed, 208 insertions(+), 56 deletions(-) create mode 100644 packages/suite/src/hooks/useTransactionTimeout.tsx diff --git a/packages/blockchain-link/src/workers/solana/index.ts b/packages/blockchain-link/src/workers/solana/index.ts index c4c201ece24..139c7376307 100644 --- a/packages/blockchain-link/src/workers/solana/index.ts +++ b/packages/blockchain-link/src/workers/solana/index.ts @@ -4,9 +4,11 @@ import { RpcMainnet, RpcSubscriptionsMainnet, SOLANA_ERROR__BLOCK_HEIGHT_EXCEEDED, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CONNECTION_CLOSED, SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_FAILED_TO_CONNECT, SOLANA_ERROR__RPC__TRANSPORT_HTTP_ERROR, + SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND, Signature, Slot, SolanaRpcApiMainnet, @@ -196,16 +198,21 @@ const pushTransaction = async (request: Request) = 'Solana backend connection failure. The backend might be inaccessible or the connection is unstable.', ); } + if ( + isSolanaError( + error, + SOLANA_ERROR__JSON_RPC__SERVER_ERROR_SEND_TRANSACTION_PREFLIGHT_FAILURE, + ) && + isSolanaError(error.cause, SOLANA_ERROR__TRANSACTION_ERROR__BLOCKHASH_NOT_FOUND) + ) { + throw new Error( + 'The transaction has expired because too much time passed between signing and sending. Please try again.', + ); + } if (isSolanaError(error)) { - if (error.context.__code === -32002) { - throw new Error( - 'The transaction has expired because too much time passed between signing and sending. Please try again.', - ); - } else { - throw new Error( - `Solana error code: ${error.context.__code}. Please try again or contact support.`, - ); - } + throw new Error( + `Solana error code: ${error.context.__code}. Please try again or contact support.`, + ); } throw error; } diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx index 3e9441aecd2..177c39f0709 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx @@ -1,6 +1,7 @@ import { UserContextPayload } from '@suite-common/suite-types'; import { cancelSignSendFormTransactionThunk, selectStake } from '@suite-common/wallet-core'; +import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sendFormThunks'; import { cancelSignTx as cancelSignStakingTx } from 'src/actions/wallet/stakeActions'; import { useDispatch, useSelector } from 'src/hooks/suite'; @@ -19,6 +20,7 @@ type TransactionReviewModalProps = export const TransactionReviewModal = ({ type, decision }: TransactionReviewModalProps) => { const send = useSelector(state => state.wallet.send); const stake = useSelector(selectStake); + const selectedAccount = useSelector(state => state.wallet.selectedAccount); const dispatch = useDispatch(); const isSend = Boolean(send?.precomposedTx); @@ -30,10 +32,24 @@ export const TransactionReviewModal = ({ type, decision }: TransactionReviewModa else dispatch(cancelSignStakingTx()); }; + const handleTryAgainSignTx = () => { + if (send.precomposedForm != null && send.precomposedTx != null) { + dispatch( + signAndPushSendFormTransactionThunk({ + formState: send.precomposedForm, + precomposedTransaction: send.precomposedTx, + selectedAccount: selectedAccount.account, + }), + ); + } + }; + return ( diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx index dd8ea823cf1..59fb4c1c62a 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx @@ -18,10 +18,10 @@ import { isRbfCancelTransaction, isRbfTransaction, } from '@suite-common/wallet-utils'; -import { Column, NewModal } from '@trezor/components'; +import { Button, Column, NewModal, Row } from '@trezor/components'; +import TrezorConnect from '@trezor/connect'; import { copyToClipboard, download } from '@trezor/dom-utils'; import { ConfirmOnDevice } from '@trezor/product-components'; -import { useTimer } from '@trezor/react-utils'; import { EventType, TransactionCreatedEvent, analytics } from '@trezor/suite-analytics'; import { spacings } from '@trezor/theme'; import { Deferred } from '@trezor/utils'; @@ -29,6 +29,7 @@ import { Deferred } from '@trezor/utils'; import * as modalActions from 'src/actions/suite/modalActions'; import { Translation } from 'src/components/suite'; import { useDispatch, useSelector } from 'src/hooks/suite'; +import { useTransactionTimeout } from 'src/hooks/useTransactionTimeout'; import { selectIsActionAbortable } from 'src/reducers/suite/suiteReducer'; import { selectAccountIncludingChosenInTrading } from 'src/reducers/wallet/selectedAccountReducer'; import { getTransactionReviewModalActionText } from 'src/utils/suite/transactionReview'; @@ -41,7 +42,7 @@ import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal'; import { ExpiredBlockhash } from '../UserContextModal/TxDetailModal/ExpiredBlockhash'; import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed'; -const SOLANA_TIMEOUT = 10; +const SOLANA_TX_TIMEOUT_SECONDS = 58; const isStakeState = (state: SendState | StakeState): state is StakeState => 'data' in state; @@ -57,15 +58,19 @@ const mapRbfTypeToReporting: Record< }; type TransactionReviewModalContentProps = { + timestamp?: number; decision: Deferred | undefined; txInfoState: SendState | StakeState; + tryAgainSignTx: () => void; cancelSignTx: () => void; isRbfConfirmedError?: boolean; }; export const TransactionReviewModalContent = ({ + timestamp = 0, decision, txInfoState, + tryAgainSignTx, cancelSignTx, isRbfConfirmedError, }: TransactionReviewModalContentProps) => { @@ -76,8 +81,9 @@ export const TransactionReviewModalContent = ({ const [isSending, setIsSending] = useState(false); const [areDetailsVisible, setAreDetailsVisible] = useState(false); - const timer = useTimer(); - const remainingTime = Math.max(0, SOLANA_TIMEOUT - timer.timeSpend.seconds); + const remainingTime = useTransactionTimeout(timestamp, SOLANA_TX_TIMEOUT_SECONDS, () => { + TrezorConnect.cancel('tx-timeout'); + }); const deviceModelInternal = device?.features?.internal_model; const { precomposedTx, serializedTx } = txInfoState; @@ -124,7 +130,7 @@ export const TransactionReviewModalContent = ({ : getTxStakeNameByDataHex(outputs[0]?.value); const onCancel = () => { - if (isRbfConfirmedError) { + if (isRbfConfirmedError || networkType === 'solana') { dispatch(modalActions.onCancel()); } @@ -202,6 +208,14 @@ export const TransactionReviewModalContent = ({ reportTransactionCreatedEvent('downloaded'); }; + const handleTryAgain = (cancel: boolean) => { + if (cancel) { + TrezorConnect.cancel('tx-timeout'); + } + + tryAgainSignTx(); + }; + const BottomContent = () => { if (isRbfConfirmedError) { return ( @@ -214,7 +228,7 @@ export const TransactionReviewModalContent = ({ if (isSolanaExpired) { return ( <> - + handleTryAgain(false)}> @@ -276,15 +290,11 @@ export const TransactionReviewModalContent = ({ } if (isSolanaExpired) { - return ; + return ; } return ( - {networkType === 'solana' && ( - - )} - ); @@ -318,15 +330,36 @@ export const TransactionReviewModalContent = ({ onBackClick={areDetailsVisible ? () => setAreDetailsVisible(false) : undefined} description={ !areDetailsVisible && ( - { - setAreDetailsVisible(!areDetailsVisible); - }} - stakeType={stakeType} - /> + + { + setAreDetailsVisible(!areDetailsVisible); + }} + stakeType={stakeType} + /> + {networkType === 'solana' && + !isSolanaExpired && + buttonRequestsCount != 1 && ( + + + + + )} + ) } bottomContent={} diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx index 85f21557ece..8e37d2ad9a7 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputList.tsx @@ -14,6 +14,7 @@ import type { Account } from 'src/types/wallet'; import { TransactionReviewOutput } from './TransactionReviewOutput'; import type { TransactionReviewOutputElementProps } from './TransactionReviewOutputElement'; +import { TransactionReviewOutputTimer } from './TransactionReviewOutputTimer'; import { TransactionReviewTotalOutput } from './TransactionReviewTotalOutput'; export type TransactionReviewOutputListProps = { @@ -26,6 +27,8 @@ export type TransactionReviewOutputListProps = { isTradingAction: boolean; isSending?: boolean; stakeType?: StakeType; + remainingTime?: number; + onTryAgain?: (close: boolean) => void; }; const getState = ( @@ -73,6 +76,8 @@ export const TransactionReviewOutputList = ({ isTradingAction, isSending, stakeType, + remainingTime, + onTryAgain, }: TransactionReviewOutputListProps) => { const outputRefs = useRef<(HTMLDivElement | null)[]>([]); const totalOutputRef = useRef(null); @@ -108,10 +113,18 @@ export const TransactionReviewOutputList = ({ ) { return ( - -

- -

+ + +

+ +

+ {networkType === 'solana' && remainingTime != null && ( + + )} +
void; }; export const TransactionReviewOutputTimer = ({ isMinimal, remainingTime, + onTryAgain, }: TransactionReviewOutputTimerProps) => { if (isMinimal) { return ( @@ -42,6 +54,13 @@ export const TransactionReviewOutputTimer = ({ + {onTryAgain && ( + + onTryAgain(true)}> + + + + )} ); diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.tsx index 694f57ecc44..2fe7c2f1e85 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.tsx @@ -1,25 +1,34 @@ +import { NetworkSymbol, getNetworkDisplaySymbolName } from '@suite-common/wallet-config'; import { Box, Card, Column, IconCircle, Text } from '@trezor/components'; import { spacings } from '@trezor/theme'; import { HELP_CENTER_SOL_SEND } from '@trezor/urls'; -import { Translation } from '../../../../Translation'; -import { TrezorLink } from '../../../../TrezorLink'; +import { Translation } from 'src/components/suite/Translation'; +import { TrezorLink } from 'src/components/suite/TrezorLink'; -export const ExpiredBlockhash = () => ( - - - - - +type ExpiredBlockhashProps = { + symbol: NetworkSymbol; +}; - - - - +export const ExpiredBlockhash = ({ symbol }: ExpiredBlockhashProps) => { + const networkName = getNetworkDisplaySymbolName(symbol); - - - - - -); + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/suite/src/hooks/useTransactionTimeout.tsx b/packages/suite/src/hooks/useTransactionTimeout.tsx new file mode 100644 index 00000000000..64c2c0041b0 --- /dev/null +++ b/packages/suite/src/hooks/useTransactionTimeout.tsx @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react'; + +const getRemainingTime = (timestamp: number, timeoutSeconds: number) => { + const elapsedTime = Math.floor((Date.now() - timestamp) / 1000); + + return Math.max(0, timeoutSeconds - elapsedTime); +}; + +export const useTransactionTimeout = ( + timestamp: number, + timeoutSeconds: number, + onTimeout?: () => void, +) => { + const [remainingTime, setRemainingTime] = useState(() => + getRemainingTime(timestamp, timeoutSeconds), + ); + + const handleTimeout = useCallback(() => { + onTimeout?.(); + }, [onTimeout]); + + useEffect(() => { + const intervalId = setInterval(() => { + const newRemainingTime = getRemainingTime(timestamp, timeoutSeconds); + setRemainingTime(newRemainingTime); + + if (newRemainingTime === 0) { + clearInterval(intervalId); + handleTimeout(); + } + }, 1000); + + return () => clearInterval(intervalId); + }, [timestamp, timeoutSeconds, handleTimeout]); + + return remainingTime; +}; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index deb89bc655b..684b9a4963a 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -3124,6 +3124,10 @@ export default defineMessages({ defaultMessage: 'South', id: 'TR_SOUTH', }, + TR_AGAIN: { + defaultMessage: 'Again', + id: 'TR_AGAIN', + }, TR_START_AGAIN: { defaultMessage: 'Start again', description: 'Button text', @@ -9135,7 +9139,7 @@ export default defineMessages({ TR_SOLANA_TX_SEND_FAILED_DESCRIPTION: { id: 'TR_SOLANA_TX_SEND_FAILED_DESCRIPTION', defaultMessage: - 'The time to sign a Solana transaction is limited. It could no longer be submitted because it timed out and is no longer valid.', + 'The time to sign a {networkName} transaction is limited. It could no longer be submitted because it timed out and is no longer valid.', }, TR_SOLANA_TX_CONFIRMATION_TIMER: { id: 'TR_SOLANA_TX_CONFIRMATION_TIMER', @@ -9150,6 +9154,10 @@ export default defineMessages({ defaultMessage: 'Due to Solana constraints, you have ~1 minute to check everything before your transaction times out.', }, + TR_SOLANA_TX_CONFIRMATION_TIMER_AGAIN: { + id: 'TR_SOLANA_TX_CONFIRMATION_TIMER_AGAIN', + defaultMessage: 'Start over', + }, TR_VIEW_ONLY_PROMO_YES: { id: 'TR_VIEW_ONLY_PROMO_YES', defaultMessage: 'Enable', diff --git a/suite-common/wallet-core/src/send/sendFormActions.ts b/suite-common/wallet-core/src/send/sendFormActions.ts index 921d242c699..59e6f870337 100644 --- a/suite-common/wallet-core/src/send/sendFormActions.ts +++ b/suite-common/wallet-core/src/send/sendFormActions.ts @@ -30,6 +30,7 @@ const storePrecomposedTransaction = createAction( (payload: { formState: FormState; precomposedTransaction: GeneralPrecomposedTransactionFinal; + timestamp: number; }) => ({ payload, }), diff --git a/suite-common/wallet-core/src/send/sendFormReducer.ts b/suite-common/wallet-core/src/send/sendFormReducer.ts index 5f86d3fa20f..fe3577a870e 100644 --- a/suite-common/wallet-core/src/send/sendFormReducer.ts +++ b/suite-common/wallet-core/src/send/sendFormReducer.ts @@ -33,6 +33,7 @@ export type SendState = { precomposedForm?: FormState; // Used to pass the form state to the review modal. Holds similar data as drafts, but drafts are not used in RBF form. signedTx?: BlockbookTransaction; serializedTx?: SerializedTx; // Hexadecimal representation of signed transaction (payload for TrezorConnect.pushTransaction). + timestamp?: number; }; export const initialState: SendState = { @@ -40,6 +41,7 @@ export const initialState: SendState = { precomposedTx: undefined, serializedTx: undefined, signedTx: undefined, + timestamp: undefined, }; export type SendRootState = { @@ -67,13 +69,14 @@ export const prepareSendFormReducer = createReducerWithExtraDeps(initialState, ( }) .addCase( sendFormActions.storePrecomposedTransaction, - (state, { payload: { precomposedTransaction, formState } }) => { + (state, { payload: { precomposedTransaction, formState, timestamp } }) => { state.precomposedTx = precomposedTransaction; // Deep-cloning to prevent buggy interaction between react-hook-form and immer, see https://github.com/orgs/react-hook-form/discussions/3715#discussioncomment-2151458 // Otherwise, whenever the outputs fieldArray is updated after the form draft or precomposedForm is saved, there is na error: // TypeError: Cannot assign to read only property of object '#' // This might not be necessary in the future when the dependencies are upgraded. state.precomposedForm = cloneObject(formState); + state.timestamp = timestamp; }, ) .addCase( diff --git a/suite-common/wallet-core/src/send/sendFormThunks.ts b/suite-common/wallet-core/src/send/sendFormThunks.ts index 43a7e3c127e..4cdd2793ab4 100644 --- a/suite-common/wallet-core/src/send/sendFormThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormThunks.ts @@ -457,11 +457,16 @@ export const signTransactionThunk = createThunk< if (isRejected(response) || !response?.payload) { // catch manual error from TransactionReviewModal const message = response?.payload?.message ?? 'unknown-error'; - if (message === 'tx-cancelled') + if (message === 'tx-timeout') { + return; + } + + if (message === 'tx-cancelled') { return rejectWithValue({ error: 'sign-transaction-failed', message: 'User canceled the signing process.', }); + } dispatch( notificationsActions.addToast({ @@ -577,6 +582,7 @@ export const enhancePrecomposedTransactionThunk = createThunk< sendFormActions.storePrecomposedTransaction({ formState: formValues, precomposedTransaction: enhancedPrecomposedTransaction, + timestamp: new Date().getTime(), }), );