diff --git a/packages/suite/src/actions/wallet/send/sendFormThunks.ts b/packages/suite/src/actions/wallet/send/sendFormThunks.ts index 37dd4476612..4cdb809847e 100644 --- a/packages/suite/src/actions/wallet/send/sendFormThunks.ts +++ b/packages/suite/src/actions/wallet/send/sendFormThunks.ts @@ -232,6 +232,11 @@ export const signAndPushSendFormTransactionThunk = createThunk( return; } + // Do not close the modal if the transaction signing timed out + if (signResponse.payload?.error === 'sign-transaction-timeout') { + return; + } + // Close the modal manually since UI.CLOSE_UI.WINDOW was // blocked by `modalActions.preserve` above. dispatch(modalActions.onCancel()); diff --git a/packages/suite/src/components/suite/CountdownTimer.tsx b/packages/suite/src/components/suite/CountdownTimer.tsx index 1967741247d..809e33287aa 100644 --- a/packages/suite/src/components/suite/CountdownTimer.tsx +++ b/packages/suite/src/components/suite/CountdownTimer.tsx @@ -85,6 +85,7 @@ export const CountdownTimer = ({ id={messageId} values={{ value: getValue(), + strong: chunks => {chunks}, firstValue, }} /> 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 177c39f0709..9922cd59c45 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx @@ -1,5 +1,9 @@ import { UserContextPayload } from '@suite-common/suite-types'; -import { cancelSignSendFormTransactionThunk, selectStake } from '@suite-common/wallet-core'; +import { + cancelSignSendFormTransactionThunk, + selectStake, + sendFormActions, +} from '@suite-common/wallet-core'; import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sendFormThunks'; import { cancelSignTx as cancelSignStakingTx } from 'src/actions/wallet/stakeActions'; @@ -34,6 +38,7 @@ export const TransactionReviewModal = ({ type, decision }: TransactionReviewModa const handleTryAgainSignTx = () => { if (send.precomposedForm != null && send.precomposedTx != null) { + dispatch(sendFormActions.discardTransaction()); dispatch( signAndPushSendFormTransactionThunk({ formState: send.precomposedForm, 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 59fb4c1c62a..f48f318a372 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx @@ -1,7 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { notificationsActions } from '@suite-common/toast-notifications'; +import { NetworkSymbol, NetworkType } from '@suite-common/wallet-config'; import { + AccountsState, DeviceRootState, SendState, StakeState, @@ -10,9 +12,15 @@ import { selectSendFormReviewButtonRequestsCount, selectStakePrecomposedForm, } from '@suite-common/wallet-core'; -import { FormState, RbfTransactionType, StakeFormState } from '@suite-common/wallet-types'; +import { + FormState, + RbfTransactionType, + ReviewOutput, + StakeFormState, +} from '@suite-common/wallet-types'; import { constructTransactionReviewOutputs, + findAccountsByAddress, getTxStakeNameByDataHex, isRbfBumpFeeTransaction, isRbfCancelTransaction, @@ -29,7 +37,6 @@ 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'; @@ -42,7 +49,32 @@ import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal'; import { ExpiredBlockhash } from '../UserContextModal/TxDetailModal/ExpiredBlockhash'; import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed'; -const SOLANA_TX_TIMEOUT_SECONDS = 58; +const SOLANA_TX_TIMEOUT_MS = 60 * 1000; + +const isSolanaExpired = (networkType: NetworkType, deadline: number) => + networkType === 'solana' && deadline <= Date.now(); + +const shouldShowSolanaTimer = ( + networkType: NetworkType, + deadline: number, + outputs: ReviewOutput[], + symbol: NetworkSymbol, + accounts: AccountsState, + buttonRequestsCount: number, +) => { + if (networkType !== 'solana' || isSolanaExpired(networkType, deadline)) { + return false; + } + + const firstOutput = outputs[0]; + const isInternalTransfer = + firstOutput?.type === 'address' && + findAccountsByAddress(symbol, firstOutput.value, accounts).length > 0; + + const isFirstStep = buttonRequestsCount === 1; + + return isInternalTransfer || !isFirstStep; +}; const isStakeState = (state: SendState | StakeState): state is StakeState => 'data' in state; @@ -76,14 +108,25 @@ export const TransactionReviewModalContent = ({ }: TransactionReviewModalContentProps) => { const dispatch = useDispatch(); const account = useSelector(selectAccountIncludingChosenInTrading); + const accounts = useSelector(state => state.wallet.accounts); const device = useSelector(selectSelectedDevice); const isActionAbortable = useSelector(selectIsActionAbortable); const [isSending, setIsSending] = useState(false); const [areDetailsVisible, setAreDetailsVisible] = useState(false); - const remainingTime = useTransactionTimeout(timestamp, SOLANA_TX_TIMEOUT_SECONDS, () => { - TrezorConnect.cancel('tx-timeout'); - }); + const deadline = timestamp + SOLANA_TX_TIMEOUT_MS; + + // check if transaction is still valid + useEffect(() => { + const now = Date.now(); + const timeLeft = Math.max(deadline - now, 0); + + const timeoutId = setTimeout(() => { + TrezorConnect.cancel('tx-timeout'); + }, timeLeft); + + return () => clearTimeout(timeoutId); + }, [deadline]); const deviceModelInternal = device?.features?.internal_model; const { precomposedTx, serializedTx } = txInfoState; @@ -142,7 +185,16 @@ export const TransactionReviewModalContent = ({ const isCancelRbfAction = isRbfCancelTransaction(precomposedTx); - const isSolanaExpired = networkType === 'solana' && remainingTime <= 0; + const solanaExpired = isSolanaExpired(networkType, deadline); + + const showSolanaTimer = shouldShowSolanaTimer( + networkType, + deadline, + outputs, + symbol, + accounts, + buttonRequestsCount, + ); const actionLabel = getTransactionReviewModalActionText({ stakeType, @@ -225,7 +277,7 @@ export const TransactionReviewModalContent = ({ ); } - if (isSolanaExpired) { + if (solanaExpired) { return ( <> handleTryAgain(false)}> @@ -289,7 +341,7 @@ export const TransactionReviewModalContent = ({ ); } - if (isSolanaExpired) { + if (solanaExpired) { return ; } @@ -305,7 +357,7 @@ export const TransactionReviewModalContent = ({ isTradingAction={isTradingAction} isSending={isSending} stakeType={stakeType || undefined} - remainingTime={remainingTime} + deadline={deadline} onTryAgain={handleTryAgain} /> @@ -340,25 +392,20 @@ export const TransactionReviewModalContent = ({ }} stakeType={stakeType} /> - {networkType === 'solana' && - !isSolanaExpired && - buttonRequestsCount != 1 && ( - - - - - )} + {showSolanaTimer && ( + + + + + )} ) } 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 8e37d2ad9a7..647662066c1 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 @@ -27,7 +27,7 @@ export type TransactionReviewOutputListProps = { isTradingAction: boolean; isSending?: boolean; stakeType?: StakeType; - remainingTime?: number; + deadline?: number; onTryAgain?: (close: boolean) => void; }; @@ -76,7 +76,7 @@ export const TransactionReviewOutputList = ({ isTradingAction, isSending, stakeType, - remainingTime, + deadline, onTryAgain, }: TransactionReviewOutputListProps) => { const outputRefs = useRef<(HTMLDivElement | null)[]>([]); @@ -118,9 +118,9 @@ export const TransactionReviewOutputList = ({

- {networkType === 'solana' && remainingTime != null && ( + {networkType === 'solana' && deadline && ( )} diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx index 08774a8cae6..6ae6ef9917e 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx @@ -5,6 +5,7 @@ import styled from 'styled-components'; import { Badge, Banner, Row, Text } from '@trezor/components'; import { spacings } from '@trezor/theme'; +import { CountdownTimer } from 'src/components/suite/CountdownTimer'; import { Translation } from 'src/components/suite/Translation'; const TimerBox = styled.div` @@ -21,26 +22,25 @@ const RetryLink = styled.a` `; type TransactionReviewOutputTimerProps = { - remainingTime: number; + deadline: number; isMinimal?: boolean; onTryAgain?: (close: boolean) => void; }; export const TransactionReviewOutputTimer = ({ + deadline, isMinimal, - remainingTime, onTryAgain, }: TransactionReviewOutputTimerProps) => { if (isMinimal) { return ( - {chunks}, - }} + @@ -48,10 +48,15 @@ export const TransactionReviewOutputTimer = ({ } return ( - + - + {onTryAgain && ( diff --git a/packages/suite/src/hooks/useTransactionTimeout.tsx b/packages/suite/src/hooks/useTransactionTimeout.tsx deleted file mode 100644 index 64c2c0041b0..00000000000 --- a/packages/suite/src/hooks/useTransactionTimeout.tsx +++ /dev/null @@ -1,37 +0,0 @@ -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 684b9a4963a..8f93bcd8207 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -9143,11 +9143,11 @@ export default defineMessages({ }, TR_SOLANA_TX_CONFIRMATION_TIMER: { id: 'TR_SOLANA_TX_CONFIRMATION_TIMER', - defaultMessage: '{remainingTime} seconds left', + defaultMessage: '{value} left', }, TR_SOLANA_TX_CONFIRMATION_TIMER_SHORT: { id: 'TR_SOLANA_TX_CONFIRMATION_TIMER_SHORT', - defaultMessage: '{remainingTime}s left to confirm', + defaultMessage: '{value} left to confirm', }, TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION: { id: 'TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION', diff --git a/suite-common/wallet-core/src/send/sendFormThunks.ts b/suite-common/wallet-core/src/send/sendFormThunks.ts index 4cdd2793ab4..773bbd9b643 100644 --- a/suite-common/wallet-core/src/send/sendFormThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormThunks.ts @@ -64,6 +64,7 @@ import { ComposeFeeLevelsError, PushTransactionError, SignTransactionError, + SignTransactionTimeoutError, } from './sendFormTypes'; import { accountsActions } from '../accounts/accountsActions'; import { selectAccountByKey } from '../accounts/accountsReducer'; @@ -399,7 +400,7 @@ export const signTransactionThunk = createThunk< precomposedTransaction: PrecomposedTransactionFinal | PrecomposedTransactionFinalCardano; selectedAccount: Account; }, - { rejectValue: SignTransactionError | undefined } + { rejectValue: SignTransactionError | SignTransactionTimeoutError | undefined } >( `${SEND_MODULE_PREFIX}/signTransactionThunk`, async ( @@ -458,7 +459,10 @@ export const signTransactionThunk = createThunk< // catch manual error from TransactionReviewModal const message = response?.payload?.message ?? 'unknown-error'; if (message === 'tx-timeout') { - return; + return rejectWithValue({ + error: 'sign-transaction-timeout', + message: 'Signing process timed out.', + }); } if (message === 'tx-cancelled') { diff --git a/suite-common/wallet-core/src/send/sendFormTypes.ts b/suite-common/wallet-core/src/send/sendFormTypes.ts index 5a8a856c36b..319c0bbf38c 100644 --- a/suite-common/wallet-core/src/send/sendFormTypes.ts +++ b/suite-common/wallet-core/src/send/sendFormTypes.ts @@ -57,6 +57,11 @@ export type SignTransactionError = { message?: string; }; +export type SignTransactionTimeoutError = { + error: 'sign-transaction-timeout'; + message?: string; +}; + export type PushTransactionError = { error: 'push-transaction-failed'; metadata: Unsuccessful; diff --git a/suite-native/module-send/src/sendFormThunks.ts b/suite-native/module-send/src/sendFormThunks.ts index 370ae66929c..e11b6fdf24e 100644 --- a/suite-native/module-send/src/sendFormThunks.ts +++ b/suite-native/module-send/src/sendFormThunks.ts @@ -5,6 +5,7 @@ import { createThunk } from '@suite-common/redux-utils'; import { getNetwork } from '@suite-common/wallet-config'; import { SignTransactionError, + SignTransactionTimeoutError, composeSendFormTransactionFeeLevelsThunk, deviceActions, enhancePrecomposedTransactionThunk, @@ -39,7 +40,7 @@ export const signTransactionNativeThunk = createThunk< feeLevel: GeneralPrecomposedTransactionFinal; tokenContract?: TokenAddress; }, - { rejectValue: SignTransactionError | undefined } + { rejectValue: SignTransactionError | SignTransactionTimeoutError | undefined } >( `${SEND_MODULE_PREFIX}/signTransactionNativeThunk`, async (