diff --git a/packages/blockchain-link/src/workers/solana/index.ts b/packages/blockchain-link/src/workers/solana/index.ts index d746aff5fc6..8df521b494d 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,6 +198,17 @@ 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)) { throw new Error( `Solana error code: ${error.context.__code}. Please try again or contact support.`, @@ -422,9 +435,10 @@ const getAccountInfo = async ( const getInfo = async (request: Request, isTestnet: boolean) => { const api = await request.connect(); + const { value: { blockhash: blockHash, lastValidBlockHeight: blockHeight }, - } = await api.rpc.getLatestBlockhash({ commitment: 'finalized' }).send(); + } = await api.rpc.getLatestBlockhash({ commitment: 'confirmed' }).send(); const serverInfo = { testnet: isTestnet, @@ -434,7 +448,7 @@ const getInfo = async (request: Request, isTestnet: boolea network: isTestnet ? 'dsol' : 'sol', url: api.clusterUrl, name: 'Solana', - version: (await api.rpc.getVersion().send())['solana-core'], + version: '1', // saving request api.rpc.getVersion().send(), version is not used anyways decimals: 9, }; diff --git a/packages/components/src/components/Badge/Badge.stories.tsx b/packages/components/src/components/Badge/Badge.stories.tsx index 38b0bb26787..055e69635fd 100644 --- a/packages/components/src/components/Badge/Badge.stories.tsx +++ b/packages/components/src/components/Badge/Badge.stories.tsx @@ -27,6 +27,7 @@ export const Badge: StoryObj = { 'primary', 'tertiary', 'destructive', + 'warning', undefined, ] satisfies BadgeProps['variant'][], }, diff --git a/packages/components/src/components/Badge/Badge.tsx b/packages/components/src/components/Badge/Badge.tsx index 191686547fc..e8e335b7e73 100644 --- a/packages/components/src/components/Badge/Badge.tsx +++ b/packages/components/src/components/Badge/Badge.tsx @@ -21,7 +21,7 @@ export type BadgeSize = Extract; export const allowedBadgeFrameProps = ['margin'] as const satisfies FramePropsKeys[]; type AllowedFrameProps = Pick; -export type BadgeVariant = Extract; +export type BadgeVariant = Extract; export type BadgeProps = AllowedFrameProps & { size?: BadgeSize; @@ -54,6 +54,7 @@ const mapVariantToBackgroundColor = ({ $variant, $onElevation, theme }: MapArgs) primary: 'backgroundPrimarySubtleOnElevation0', tertiary: `backgroundNeutralSubtleOnElevation${$onElevation ? 1 : 0}`, destructive: 'backgroundAlertRedSubtleOnElevation0', + warning: 'backgroundAlertYellowSubtleOnElevation0', }; return theme[colorMap[$variant]]; @@ -64,6 +65,7 @@ const mapVariantToTextColor = ({ $variant, theme }: MapArgs): CSSColor => { primary: 'textPrimaryDefault', tertiary: 'textSubdued', destructive: 'textAlertRed', + warning: 'textAlertYellow', }; return theme[colorMap[$variant]]; @@ -74,6 +76,7 @@ const mapVariantToIconColor = ({ $variant, theme }: MapArgs): CSSColor => { primary: 'iconPrimaryDefault', tertiary: 'iconSubdued', destructive: 'iconAlertRed', + warning: 'iconAlertYellow', }; return theme[colorMap[$variant]]; 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/actions/wallet/stake/stakeFormSolanaActions.ts b/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts index 596946f866a..d16a20aa0a3 100644 --- a/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts +++ b/packages/suite/src/actions/wallet/stake/stakeFormSolanaActions.ts @@ -195,11 +195,14 @@ export const signTransaction = !device || !transactionInfo || transactionInfo.type !== 'final' - ) + ) { return; + } const { account } = selectedAccount; - if (account.networkType !== 'solana') return; + if (account.networkType !== 'solana') { + return; + } const addressDisplayType = selectAddressDisplayType(getState()); const estimatedFee = { @@ -251,15 +254,20 @@ export const signTransaction = if (!signedTx.success) { // catch manual error from TransactionReviewModal - if (signedTx.payload.error === 'tx-cancelled') return; - dispatch( - notificationsActions.addToast({ - type: 'sign-tx-error', - error: signedTx.payload.error, - }), - ); - - return; + if (signedTx.payload.error === 'tx-cancelled') { + return; + } + + if (signedTx.payload.error !== 'tx-timeout') { + dispatch( + notificationsActions.addToast({ + type: 'sign-tx-error', + error: signedTx.payload.error, + }), + ); + } + + return signedTx; } txData.tx.txShim.addSignature(address(account.descriptor), signedTx.payload.signature); diff --git a/packages/suite/src/actions/wallet/stakeActions.ts b/packages/suite/src/actions/wallet/stakeActions.ts index 5904cf15b5e..e00b1d82a58 100644 --- a/packages/suite/src/actions/wallet/stakeActions.ts +++ b/packages/suite/src/actions/wallet/stakeActions.ts @@ -14,7 +14,7 @@ import { isSupportedSolStakingNetworkSymbol, tryGetAccountIdentity, } from '@suite-common/wallet-utils'; -import TrezorConnect from '@trezor/connect'; +import TrezorConnect, { Unsuccessful } from '@trezor/connect'; import { BigNumber } from '@trezor/utils/src/bigNumber'; import { Dispatch, GetState } from 'src/types/suite'; @@ -178,7 +178,7 @@ export const signTransaction = dispatch(modalActions.preserve()); // signTransaction by Trezor - let serializedTx: string | undefined; + let serializedTx: undefined | string | Unsuccessful; if (isSupportedEthStakingNetworkSymbol(account.symbol)) { serializedTx = await dispatch( stakeFormEthereumActions.signTransaction(formValues, enhancedTxInfo), @@ -191,7 +191,10 @@ export const signTransaction = ); } - if (!serializedTx) { + if (typeof serializedTx !== 'string') { + if (serializedTx?.payload?.error === 'tx-timeout') { + return; + } // close modal manually since UI.CLOSE_UI.WINDOW was blocked 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 3e9441aecd2..f51206f9e63 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx @@ -1,7 +1,17 @@ import { UserContextPayload } from '@suite-common/suite-types'; -import { cancelSignSendFormTransactionThunk, selectStake } from '@suite-common/wallet-core'; +import { + cancelSignSendFormTransactionThunk, + selectStake, + sendFormActions, + stakeActions, +} from '@suite-common/wallet-core'; +import { PrecomposedTransactionFinal } from '@suite-common/wallet-types'; -import { cancelSignTx as cancelSignStakingTx } from 'src/actions/wallet/stakeActions'; +import { signAndPushSendFormTransactionThunk } from 'src/actions/wallet/send/sendFormThunks'; +import { + cancelSignTx as cancelSignStakingTx, + signTransaction, +} from 'src/actions/wallet/stakeActions'; import { useDispatch, useSelector } from 'src/hooks/suite'; import { TransactionReviewModalContent } from './TransactionReviewModalContent'; @@ -19,6 +29,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); @@ -26,14 +37,39 @@ export const TransactionReviewModal = ({ type, decision }: TransactionReviewModa const txInfoState = isSend ? send : stake; const handleCancelSignTx = () => { - if (isSend) dispatch(cancelSignSendFormTransactionThunk()); - else dispatch(cancelSignStakingTx()); + if (isSend) { + dispatch(cancelSignSendFormTransactionThunk()); + } else { + dispatch(cancelSignStakingTx()); + } + }; + + const handleTryAgainSignTx = () => { + if (send.precomposedForm && send.precomposedTx) { + dispatch(sendFormActions.discardTransaction()); + dispatch( + signAndPushSendFormTransactionThunk({ + formState: send.precomposedForm, + precomposedTransaction: send.precomposedTx, + selectedAccount: selectedAccount.account, + }), + ); + } else if (stake.precomposedForm && stake.precomposedTx) { + dispatch(stakeActions.dispose()); + dispatch( + signTransaction( + stake.precomposedForm, + stake.precomposedTx as PrecomposedTransactionFinal, + ), + ); + } }; 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 a211c03acb8..e7523fb76ed 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx @@ -1,27 +1,40 @@ -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, + SerializedTx, StakeState, + selectAccounts, selectPrecomposedSendForm, selectSelectedDevice, selectSendFormReviewButtonRequestsCount, selectStakePrecomposedForm, } from '@suite-common/wallet-core'; -import { FormState, RbfTransactionType, StakeFormState } from '@suite-common/wallet-types'; +import { + FormState, + RbfTransactionType, + ReviewOutput, + StakeFormState, + StakeType, +} from '@suite-common/wallet-types'; import { constructTransactionReviewOutputs, + findAccountsByAddress, getTxStakeNameByDataHex, isRbfBumpFeeTransaction, isRbfCancelTransaction, isRbfTransaction, } from '@suite-common/wallet-utils'; -import { NewModal } from '@trezor/components'; +import { 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 { EventType, TransactionCreatedEvent, analytics } from '@trezor/suite-analytics'; +import { spacings } from '@trezor/theme'; import { Deferred } from '@trezor/utils'; import * as modalActions from 'src/actions/suite/modalActions'; @@ -33,10 +46,48 @@ import { getTransactionReviewModalActionText } from 'src/utils/suite/transaction import { TransactionReviewDetails } from './TransactionReviewDetails'; import { TransactionReviewOutputList } from './TransactionReviewOutputList/TransactionReviewOutputList'; +import { TransactionReviewOutputTimer } from './TransactionReviewOutputList/TransactionReviewOutputTimer'; import { TransactionReviewSummary } from './TransactionReviewSummary'; import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal'; +import { ExpiredTxValidity } from '../UserContextModal/TxDetailModal/ExpiredTxValidity'; import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed'; +const getTxValidityTimeoutInMs = (networkType?: NetworkType) => { + if (networkType === 'solana') { + // Blockhash required in Solana tx is valid for 1 minute. Leave 15 seconds for tx confirmation. + return 45 * 1000; + } + + return 0; +}; + +const hasTxValidityExpired = (deadline: number) => deadline <= Date.now(); + +const shouldShowTxValidityTimer = ( + deadline: number, + outputs: ReviewOutput[], + symbol: NetworkSymbol, + accounts: AccountsState, + buttonRequestsCount: number, + serializedTx: SerializedTx | undefined, + stakeType: StakeType | null, + shouldCheckTxTimeValidity: boolean, +) => { + if (!shouldCheckTxTimeValidity || hasTxValidityExpired(deadline)) { + return false; + } + + const firstOutput = outputs[0]; + const isInternalTransfer = + firstOutput?.type === 'address' && + findAccountsByAddress(symbol, firstOutput.value, accounts).length > 0; + + const isFirstStep = buttonRequestsCount <= 1; + const isStaking = stakeType && !serializedTx; + + return isInternalTransfer || !isFirstStep || serializedTx || isStaking; +}; + const isStakeState = (state: SendState | StakeState): state is StakeState => 'data' in state; const isStakeForm = (form: FormState | StakeFormState): form is StakeFormState => @@ -53,6 +104,7 @@ const mapRbfTypeToReporting: Record< type TransactionReviewModalContentProps = { decision: Deferred | undefined; txInfoState: SendState | StakeState; + tryAgainSignTx: () => void; cancelSignTx: () => void; isRbfConfirmedError?: boolean; }; @@ -60,16 +112,17 @@ type TransactionReviewModalContentProps = { export const TransactionReviewModalContent = ({ decision, txInfoState, + tryAgainSignTx, cancelSignTx, isRbfConfirmedError, }: TransactionReviewModalContentProps) => { const dispatch = useDispatch(); const account = useSelector(selectAccountIncludingChosenInTrading); + const accounts = useSelector(selectAccounts); const device = useSelector(selectSelectedDevice); const isActionAbortable = useSelector(selectIsActionAbortable); const [isSending, setIsSending] = useState(false); const [areDetailsVisible, setAreDetailsVisible] = useState(false); - const deviceModelInternal = device?.features?.internal_model; const { precomposedTx, serializedTx } = txInfoState; @@ -79,7 +132,34 @@ export const TransactionReviewModalContent = ({ : selectPrecomposedSendForm(state), ); - const isTradingAction = !!precomposedForm?.isTrading; + const shouldCheckTxTimeValidity = + account?.networkType === 'solana' && !precomposedForm?.isTrading; + + const createdTxTimestamp = txInfoState?.precomposedTx?.createdTimestamp || 0; + const deadline = createdTxTimestamp + getTxValidityTimeoutInMs(account?.networkType); + + // check if transaction is still valid + useEffect(() => { + if (!shouldCheckTxTimeValidity) { + return; + } + + const now = Date.now(); + const timeLeft = Math.max(deadline - now, 0); + let mounted = true; + + const timeoutId = setTimeout(() => { + if (mounted && !isSending) { + TrezorConnect.cancel('tx-timeout'); + } + }, timeLeft); + + return () => { + mounted = false; + clearTimeout(timeoutId); + }; + }, [deadline, isSending, shouldCheckTxTimeValidity]); + const isBumpFeeRbfAction = precomposedTx !== undefined && isRbfBumpFeeTransaction(precomposedTx); @@ -118,7 +198,7 @@ export const TransactionReviewModalContent = ({ .find(type => type) || null; const onCancel = () => { - if (isRbfConfirmedError) { + if (isRbfConfirmedError || shouldCheckTxTimeValidity) { dispatch(modalActions.onCancel()); } @@ -130,6 +210,19 @@ export const TransactionReviewModalContent = ({ const isCancelRbfAction = isRbfCancelTransaction(precomposedTx); + const isTxExpired = hasTxValidityExpired(deadline); + + const showTxValidityTimer = shouldShowTxValidityTimer( + deadline, + outputs, + symbol, + accounts, + buttonRequestsCount, + serializedTx, + stakeType, + shouldCheckTxTimeValidity, + ); + const actionLabel = getTransactionReviewModalActionText({ stakeType, isBumpFeeRbfAction, @@ -194,6 +287,14 @@ export const TransactionReviewModalContent = ({ reportTransactionCreatedEvent('downloaded'); }; + const handleTryAgain = (cancel: boolean) => { + if (cancel) { + TrezorConnect.cancel('tx-timeout'); + } + + tryAgainSignTx(); + }; + const BottomContent = () => { if (isRbfConfirmedError) { return ( @@ -203,6 +304,19 @@ export const TransactionReviewModalContent = ({ ); } + if (shouldCheckTxTimeValidity && isTxExpired && !isSending) { + return ( + <> + handleTryAgain(false)}> + + + + + + + ); + } + if (areDetailsVisible) { return null; } @@ -254,18 +368,26 @@ export const TransactionReviewModalContent = ({ ); } + if (shouldCheckTxTimeValidity && isTxExpired && !isSending) { + return ; + } + return ( - + + + ); }; @@ -287,15 +409,27 @@ export const TransactionReviewModalContent = ({ onBackClick={areDetailsVisible ? () => setAreDetailsVisible(false) : undefined} description={ !areDetailsVisible && ( - { - setAreDetailsVisible(!areDetailsVisible); - }} - stakeType={stakeType} - /> + + { + setAreDetailsVisible(!areDetailsVisible); + }} + stakeType={stakeType} + /> + {showTxValidityTimer && ( + + + + )} + ) } bottomContent={} diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx index b663001782b..8f206274cd2 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutput.tsx @@ -3,6 +3,7 @@ import { ReactNode } from 'react'; import { TranslationKey } from '@suite-common/intl-types'; import { NetworkSymbol, NetworkType, getNetworkDisplaySymbol } from '@suite-common/wallet-config'; import { BTC_LOCKTIME_VALUE } from '@suite-common/wallet-constants'; +import { selectAccounts } from '@suite-common/wallet-core'; import { ReviewOutput, StakeType } from '@suite-common/wallet-types'; import { findAccountsByAddress, isTestnet } from '@suite-common/wallet-utils'; import { BigNumber } from '@trezor/utils/src/bigNumber'; @@ -245,7 +246,7 @@ export const TransactionReviewOutput = ({ isTrading, }: TransactionReviewOutputProps) => { const { networkType, symbol } = account; - const accounts = useSelector(state => state.wallet.accounts); + const accounts = useSelector(selectAccounts); const { translationString } = useTranslation(); const isFiatVisible = ['fee', 'amount', 'gas', 'fee-replace', 'reduce-output'].includes(type) && 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..b7d785e86d0 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 @@ -5,7 +5,7 @@ import styled from 'styled-components'; import type { GeneralPrecomposedTransactionFinal } from '@suite-common/wallet-types'; import { ReviewOutput, StakeType } from '@suite-common/wallet-types'; import { findAccountsByAddress } from '@suite-common/wallet-utils'; -import { Banner, BulletList, Card, Column, H3, H4, Text } from '@trezor/components'; +import { BulletList, Card, Column, H3, H4 } from '@trezor/components'; import { spacings, spacingsPx } from '@trezor/theme'; import { Translation } from 'src/components/suite'; @@ -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; + deadline?: number; + onTryAgain: (close: boolean) => void; }; const getState = ( @@ -71,8 +74,10 @@ export const TransactionReviewOutputList = ({ buttonRequestsCount, isRbfAction, isTradingAction, - isSending, stakeType, + deadline, + onTryAgain, + isSending, }: TransactionReviewOutputListProps) => { const outputRefs = useRef<(HTMLDivElement | null)[]>([]); const totalOutputRef = useRef(null); @@ -80,7 +85,7 @@ export const TransactionReviewOutputList = ({ const { networkType, symbol } = account; const isMultirecipient = outputs.filter(({ type }) => type === 'address').length > 1; const isFirstOutputAddress = outputs[0].type === 'address'; - const isFirstStep = buttonRequestsCount === 1; + const isFirstStep = buttonRequestsCount <= 1; const isStaking = stakeType; const isInternalTransfer = isFirstOutputAddress && @@ -104,14 +109,24 @@ export const TransactionReviewOutputList = ({ isFirstStep && !isStaking && !isTradingAction && - !isInternalTransfer + !isInternalTransfer && + !signedTx ) { return ( - -

- -

+ + +

+ +

+ {networkType === 'solana' && deadline && ( + + )} +
)} - {isSending && networkType === 'solana' ? ( - - {chunks} }} - /> - - ) : null}
); }; 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 new file mode 100644 index 00000000000..49a7d8dd47d --- /dev/null +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx @@ -0,0 +1,80 @@ +import React from 'react'; + +import styled from 'styled-components'; + +import { Badge, Banner, Button, Text } from '@trezor/components'; + +import { CountdownTimer } from 'src/components/suite/CountdownTimer'; +import { Translation } from 'src/components/suite/Translation'; + +const TimerBox = styled.div` + font-variant-numeric: tabular-nums; +`; + +type TransactionReviewOutputTimerProps = { + deadline: number; + isMinimal?: boolean; + onTryAgain: (close: boolean) => void; + isSending?: boolean; +}; + +export const TransactionReviewOutputTimer = ({ + deadline, + isMinimal, + onTryAgain, + isSending, +}: TransactionReviewOutputTimerProps) => { + if (isMinimal) { + return ( + <> + + + + {isSending ? ( + + ) : ( + + )} + + + + ); + } + + return ( + onTryAgain(true)}> + + + } + > + + + + + + + + ); +}; diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/ConfirmStakeEthModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/ConfirmStakeEthModal.tsx index c1391d79b55..e6718f87f5b 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/ConfirmStakeEthModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/StakeModal/StakeEthForm/ConfirmStakeEthModal.tsx @@ -8,7 +8,7 @@ import { spacings } from '@trezor/theme'; import { HELP_CENTER_ETH_STAKING } from '@trezor/urls'; import { openModal } from 'src/actions/suite/modalActions'; -import { Translation, TrezorLink } from 'src/components/suite'; +import { Translation } from 'src/components/suite'; import { useDispatch, useSelector } from 'src/hooks/suite'; import { selectSelectedAccount } from 'src/reducers/wallet/selectedAccountReducer'; import { getDaysToAddToPoolInitial } from 'src/utils/suite/ethereumStaking'; @@ -80,19 +80,17 @@ export const ConfirmStakeEthModal = ({ }} /> - + + + + } + > ( - - {chunks} - - ), networkDisplaySymbol: getNetworkDisplaySymbol(account.symbol), }} /> diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredTxValidity.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredTxValidity.tsx new file mode 100644 index 00000000000..120ca975da0 --- /dev/null +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredTxValidity.tsx @@ -0,0 +1,34 @@ +import { NetworkSymbol, getNetwork } 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 'src/components/suite/Translation'; +import { TrezorLink } from 'src/components/suite/TrezorLink'; + +type ExpiredTxValidityProps = { + symbol: NetworkSymbol; +}; + +export const ExpiredTxValidity = ({ symbol }: ExpiredTxValidityProps) => { + const networkName = getNetwork(symbol).name; + + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 0ffaeba6dcf..fbdfe1af120 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -9149,6 +9149,28 @@ export default defineMessages({ id: 'TR_SOLANA_TX_CONFIRMATION_MAY_TAKE_UP_TO_1_MIN', defaultMessage: 'Transaction confirmation may take up to one (1) minute', }, + TR_TX_SEND_FAILED_TITLE: { + id: 'TR_TX_SEND_FAILED_TITLE', + defaultMessage: 'Send transaction failed', + }, + TR_TX_SEND_FAILED_DESCRIPTION: { + id: 'TR_TX_SEND_FAILED_DESCRIPTION', + defaultMessage: + '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_TX_CONFIRMATION_TIMER: { + id: 'TR_TX_CONFIRMATION_TIMER', + defaultMessage: '{value} left', + }, + TR_TX_CONFIRMATION_TIMER_SHORT: { + id: 'TR_TX_CONFIRMATION_TIMER_SHORT', + defaultMessage: '{value} left to confirm', + }, + TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION: { + id: 'TR_SOLANA_TX_CONFIRMATION_TIMER_DESCRIPTION', + defaultMessage: + 'Due to Solana network constraints you have <1 minute to confirm and send the transaction before it times out.', + }, TR_VIEW_ONLY_PROMO_YES: { id: 'TR_VIEW_ONLY_PROMO_YES', defaultMessage: 'Enable', diff --git a/packages/urls/src/urls.ts b/packages/urls/src/urls.ts index 9d49def5583..7384716792b 100644 --- a/packages/urls/src/urls.ts +++ b/packages/urls/src/urls.ts @@ -128,6 +128,9 @@ export const HELP_CENTER_REPLACE_BY_FEE_BITCOIN = 'https://trezor.io/learn/a/replace-by-fee-rbf-bitcoin'; export const HELP_CENTER_CANCEL_TRANSACTION: Url = 'https://trezor.io/support/a/can-i-cancel-or-reverse-a-transaction'; +// TODO: update this link when the article is ready +export const HELP_CENTER_SOL_SEND: Url = + 'https://trezor.io/learn/a/solana-sol-on-trezor-safe-5-trezor-safe-3-and-trezor-model-t'; export const INVITY_URL: Url = 'https://invity.io/'; export const INVITY_SCHEDULE_OF_FEES: Url = 'https://blog.invity.io/schedule-of-fees'; diff --git a/suite-common/wallet-core/src/send/sendFormThunks.ts b/suite-common/wallet-core/src/send/sendFormThunks.ts index 43a7e3c127e..f3f09e4b04f 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 ( @@ -457,11 +458,19 @@ 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 rejectWithValue({ + error: 'sign-transaction-timeout', + message: 'Signing process timed out.', + }); + } + + if (message === 'tx-cancelled') { return rejectWithValue({ error: 'sign-transaction-failed', message: 'User canceled the signing process.', }); + } dispatch( notificationsActions.addToast({ @@ -576,7 +585,10 @@ export const enhancePrecomposedTransactionThunk = createThunk< dispatch( sendFormActions.storePrecomposedTransaction({ formState: formValues, - precomposedTransaction: enhancedPrecomposedTransaction, + precomposedTransaction: { + ...enhancedPrecomposedTransaction, + createdTimestamp: new Date().getTime(), + }, }), ); diff --git a/suite-common/wallet-core/src/send/sendFormTypes.ts b/suite-common/wallet-core/src/send/sendFormTypes.ts index 5a8a856c36b..f465a8ec590 100644 --- a/suite-common/wallet-core/src/send/sendFormTypes.ts +++ b/suite-common/wallet-core/src/send/sendFormTypes.ts @@ -57,9 +57,19 @@ export type SignTransactionError = { message?: string; }; +export type SignTransactionTimeoutError = { + error: 'sign-transaction-timeout'; + errorCode?: CONNECT_ERRORS.ErrorCode; + message?: string; +}; + export type PushTransactionError = { error: 'push-transaction-failed'; metadata: Unsuccessful; }; -export type SendFormError = ComposeFeeLevelsError | SignTransactionError | PushTransactionError; +export type SendFormError = + | ComposeFeeLevelsError + | SignTransactionError + | SignTransactionTimeoutError + | PushTransactionError; diff --git a/suite-common/wallet-core/src/stake/stakeReducer.ts b/suite-common/wallet-core/src/stake/stakeReducer.ts index a228cb32f0a..fe3dcefd0fa 100644 --- a/suite-common/wallet-core/src/stake/stakeReducer.ts +++ b/suite-common/wallet-core/src/stake/stakeReducer.ts @@ -58,7 +58,10 @@ export const prepareStakeReducer = createReducerWithExtraDeps(stakeInitialState, builder .addCase(stakeActions.requestSignTransaction, (state, action) => { if (action.payload) { - state.precomposedTx = action.payload.transactionInfo; + state.precomposedTx = { + ...action.payload.transactionInfo, + createdTimestamp: new Date().getTime(), + }; // 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 '#' diff --git a/suite-common/wallet-types/src/transaction.ts b/suite-common/wallet-types/src/transaction.ts index 0898cf17365..315f85c9796 100644 --- a/suite-common/wallet-types/src/transaction.ts +++ b/suite-common/wallet-types/src/transaction.ts @@ -109,6 +109,7 @@ type PrecomposedTransactionBase = PrecomposedTransactionConnectResponseFinal & { estimatedFeeLimit?: string; token?: TokenInfo; isTokenKnown?: boolean; + createdTimestamp?: number; }; // base of PrecomposedTransactionFinal @@ -118,6 +119,7 @@ export type PrecomposedTransactionCardanoFinal = feeLimit?: string; estimatedFeeLimit?: string; token?: TokenInfo; + createdTimestamp?: number; }; export type RbfTransactionType = 'bump-fee' | 'cancel'; 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 (