From 39ada5d8ea0a784ace4b0063da2cd95606cea7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hr=C3=A1ch?= Date: Mon, 17 Feb 2025 14:34:05 +0100 Subject: [PATCH] feat(suite): add timer to solana tx modal --- .../src/workers/solana/index.ts | 13 ++ .../src/components/Badge/Badge.stories.tsx | 1 + .../components/src/components/Badge/Badge.tsx | 5 +- .../suite-data/files/translations/en.json | 2 +- .../src/actions/wallet/send/sendFormThunks.ts | 5 + .../wallet/stake/stakeFormSolanaActions.ts | 30 +-- .../suite/src/actions/wallet/stakeActions.ts | 9 +- .../src/components/suite/CountdownTimer.tsx | 1 + .../TransactionReviewModal.tsx | 45 ++++- .../TransactionReviewModalContent.tsx | 171 +++++++++++++++--- .../TransactionReviewOutputList.tsx | 39 ++-- .../TransactionReviewOutputTimer.tsx | 78 ++++++++ .../StakeEthForm/ConfirmStakeEthModal.tsx | 20 +- .../TxDetailModal/ExpiredBlockhash.tsx | 34 ++++ packages/suite/src/support/messages.ts | 28 ++- packages/urls/src/urls.ts | 3 + .../wallet-core/src/send/sendFormReducer.ts | 5 +- .../wallet-core/src/send/sendFormThunks.ts | 13 +- .../wallet-core/src/send/sendFormTypes.ts | 12 +- .../wallet-core/src/stake/stakeReducer.ts | 5 +- suite-common/wallet-types/src/transaction.ts | 2 + .../module-send/src/sendFormThunks.ts | 3 +- 22 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.tsx diff --git a/packages/blockchain-link/src/workers/solana/index.ts b/packages/blockchain-link/src/workers/solana/index.ts index d746aff5fc6..cf13d010b11 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.`, 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-data/files/translations/en.json b/packages/suite-data/files/translations/en.json index 4d59975c8c3..af1a9d2a79c 100644 --- a/packages/suite-data/files/translations/en.json +++ b/packages/suite-data/files/translations/en.json @@ -1681,7 +1681,7 @@ "TR_STAKE_ETH_REWARDS_EARN": "Your rewards also earn. Keep them staked and watch your {networkDisplaySymbol} rewards soar.", "TR_STAKE_ETH_REWARDS_EARN_APY": "Your {networkDisplaySymbol} rewards also earn the APY rate. Keep your funds staked or add more to increase your rewards.", "TR_STAKE_ETH_SEE_MONEY_DANCE": "Watch your money dance", - "TR_STAKE_ETH_WILL_BE_BLOCKED": "Your {networkDisplaySymbol} will be blocked during this period and can't be canceled. Learn more", + "TR_STAKE_ETH_WILL_BE_BLOCKED": "Your {networkDisplaySymbol} will be blocked during this period and can't be canceled.", "TR_STAKE_EVERSTAKE_MANAGES": "Everstake maintains and protects your staked {networkDisplaySymbol} with their smart contracts, infrastructure, and technology.", "TR_STAKE_EXPECTED_REWARDS": "Expected rewards per 1 epoch (up to {count, plural, one {# day} other {# days}})", "TR_STAKE_INSTANT": "Instant", 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..e347b7e2681 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,40 @@ 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..a39b6a94e1f 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,39 @@ -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, 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 +45,42 @@ 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 { ExpiredBlockhash } from '../UserContextModal/TxDetailModal/ExpiredBlockhash'; import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed'; +const SOLANA_TX_TIMEOUT_MS = 40 * 1000; + +const isSolanaBlockhashExpired = (networkType: NetworkType, deadline: number) => + networkType === 'solana' && deadline <= Date.now(); + +const shouldShowSolanaTimer = ( + networkType: NetworkType, + deadline: number, + outputs: ReviewOutput[], + symbol: NetworkSymbol, + accounts: AccountsState, + buttonRequestsCount: number, + serializedTx: SerializedTx | undefined, + stakeType: StakeType | null, +) => { + if (networkType !== 'solana' || isSolanaBlockhashExpired(networkType, 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 => @@ -51,25 +95,50 @@ 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) => { 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 deadline = timestamp + SOLANA_TX_TIMEOUT_MS; + + // check if transaction is still valid + useEffect(() => { + 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]); + const deviceModelInternal = device?.features?.internal_model; const { precomposedTx, serializedTx } = txInfoState; @@ -118,7 +187,7 @@ export const TransactionReviewModalContent = ({ .find(type => type) || null; const onCancel = () => { - if (isRbfConfirmedError) { + if (isRbfConfirmedError || networkType === 'solana') { dispatch(modalActions.onCancel()); } @@ -130,6 +199,19 @@ export const TransactionReviewModalContent = ({ const isCancelRbfAction = isRbfCancelTransaction(precomposedTx); + const hasSolanaBlockhashExpired = isSolanaBlockhashExpired(networkType, deadline); + + const showSolanaTimer = shouldShowSolanaTimer( + networkType, + deadline, + outputs, + symbol, + accounts, + buttonRequestsCount, + serializedTx, + stakeType, + ); + const actionLabel = getTransactionReviewModalActionText({ stakeType, isBumpFeeRbfAction, @@ -194,6 +276,14 @@ export const TransactionReviewModalContent = ({ reportTransactionCreatedEvent('downloaded'); }; + const handleTryAgain = (cancel: boolean) => { + if (cancel) { + TrezorConnect.cancel('tx-timeout'); + } + + tryAgainSignTx(); + }; + const BottomContent = () => { if (isRbfConfirmedError) { return ( @@ -203,6 +293,19 @@ export const TransactionReviewModalContent = ({ ); } + if (hasSolanaBlockhashExpired && !isSending) { + return ( + <> + handleTryAgain(false)}> + + + + + + + ); + } + if (areDetailsVisible) { return null; } @@ -254,18 +357,26 @@ export const TransactionReviewModalContent = ({ ); } + if (hasSolanaBlockhashExpired) { + return ; + } + return ( - + + + ); }; @@ -287,15 +398,27 @@ export const TransactionReviewModalContent = ({ onBackClick={areDetailsVisible ? () => setAreDetailsVisible(false) : undefined} description={ !areDetailsVisible && ( - { - setAreDetailsVisible(!areDetailsVisible); - }} - stakeType={stakeType} - /> + + { + setAreDetailsVisible(!areDetailsVisible); + }} + stakeType={stakeType} + /> + {showSolanaTimer && ( + + + + )} + ) } 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..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..eb43e9abcf2 --- /dev/null +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewOutputList/TransactionReviewOutputTimer.tsx @@ -0,0 +1,78 @@ +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 ( + <> + + + + + + + + ); + } + + 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/ExpiredBlockhash.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.tsx new file mode 100644 index 00000000000..18cd2885d25 --- /dev/null +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ExpiredBlockhash.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 ExpiredBlockhashProps = { + symbol: NetworkSymbol; +}; + +export const ExpiredBlockhash = ({ symbol }: ExpiredBlockhashProps) => { + const networkName = getNetwork(symbol).name; + + return ( + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index d3e27e3b12e..b8bc4d7f175 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -8910,7 +8910,7 @@ export default defineMessages({ TR_STAKE_ETH_WILL_BE_BLOCKED: { id: 'TR_STAKE_ETH_WILL_BE_BLOCKED', defaultMessage: - "Your {networkDisplaySymbol} will be blocked during this period and can't be canceled. Learn more", + "Your {networkDisplaySymbol} will be blocked during this period and can't be canceled.", }, TR_STAKE_ACKNOWLEDGE_ENTRY_PERIOD: { id: 'TR_STAKE_ACKNOWLEDGE_ENTRY_PERIOD', @@ -9148,6 +9148,32 @@ 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_SOLANA_TX_SEND_FAILED_TITLE: { + id: 'TR_SOLANA_TX_SEND_FAILED_TITLE', + defaultMessage: 'Send transaction failed', + }, + TR_SOLANA_TX_SEND_FAILED_DESCRIPTION: { + id: 'TR_SOLANA_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_SOLANA_TX_CONFIRMATION_TIMER: { + id: 'TR_SOLANA_TX_CONFIRMATION_TIMER', + defaultMessage: '{value} left', + }, + TR_SOLANA_TX_CONFIRMATION_TIMER_SHORT: { + id: 'TR_SOLANA_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_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/packages/urls/src/urls.ts b/packages/urls/src/urls.ts index 6e496923334..a18bb5186cd 100644 --- a/packages/urls/src/urls.ts +++ b/packages/urls/src/urls.ts @@ -127,6 +127,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/sendFormReducer.ts b/suite-common/wallet-core/src/send/sendFormReducer.ts index 5f86d3fa20f..bf6451df1b0 100644 --- a/suite-common/wallet-core/src/send/sendFormReducer.ts +++ b/suite-common/wallet-core/src/send/sendFormReducer.ts @@ -68,7 +68,10 @@ export const prepareSendFormReducer = createReducerWithExtraDeps(initialState, ( .addCase( sendFormActions.storePrecomposedTransaction, (state, { payload: { precomposedTransaction, formState } }) => { - state.precomposedTx = precomposedTransaction; + state.precomposedTx = { + ...precomposedTransaction, + 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-core/src/send/sendFormThunks.ts b/suite-common/wallet-core/src/send/sendFormThunks.ts index 43a7e3c127e..25fa28675e4 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({ 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 (