diff --git a/packages/suite/src/actions/wallet/send/replaceByFeeErrorThunk.ts b/packages/suite/src/actions/wallet/send/replaceByFeeErrorThunk.ts new file mode 100644 index 00000000000..9740c7eedfe --- /dev/null +++ b/packages/suite/src/actions/wallet/send/replaceByFeeErrorThunk.ts @@ -0,0 +1,20 @@ +import { createThunk } from '@suite-common/redux-utils'; +import TrezorConnect from '@trezor/connect'; + +import { MODULE_PREFIX } from './sendThunksConsts'; +import { openModal } from '../../suite/modalActions'; + +export const RBF_ERROR_ALREADY_MINED = 'replace-by-fee-error-transaction-already-mined'; + +export const replaceByFeeErrorThunk = createThunk( + `${MODULE_PREFIX}/replaceByFeeErrorThunk`, + (_, { dispatch }) => { + TrezorConnect.cancel(RBF_ERROR_ALREADY_MINED); + + dispatch( + openModal({ + type: 'review-transaction-rbf-previous-transaction-mined-error', + }), + ); + }, +); diff --git a/packages/suite/src/actions/wallet/send/sendFormThunks.ts b/packages/suite/src/actions/wallet/send/sendFormThunks.ts index 92ae990fb2d..ac9a95e553c 100644 --- a/packages/suite/src/actions/wallet/send/sendFormThunks.ts +++ b/packages/suite/src/actions/wallet/send/sendFormThunks.ts @@ -33,8 +33,8 @@ import { import { RbfLabelsToBeUpdated } from 'src/types/wallet/sendForm'; import { findLabelsToBeMovedOrDeleted, moveLabelsForRbfAction } from '../moveLabelsForRbfActions'; - -export const MODULE_PREFIX = '@send'; +import { RBF_ERROR_ALREADY_MINED } from './replaceByFeeErrorThunk'; +import { MODULE_PREFIX } from './sendThunksConsts'; export const saveSendFormDraftThunk = createThunk( `${MODULE_PREFIX}/saveSendFormDraftThunk`, @@ -227,7 +227,13 @@ export const signAndPushSendFormTransactionThunk = createThunk( ); if (isRejected(signResponse)) { - // close modal manually since UI.CLOSE_UI.WINDOW was blocked + // Do not close the modal, as we need that modal to display the error state. + if (signResponse.payload?.message === RBF_ERROR_ALREADY_MINED) { + return; + } + + // Close the modal manually since UI.CLOSE_UI.WINDOW was + // blocked by `modalActions.preserve` above. dispatch(modalActions.onCancel()); return; diff --git a/packages/suite/src/actions/wallet/send/sendThunksConsts.ts b/packages/suite/src/actions/wallet/send/sendThunksConsts.ts new file mode 100644 index 00000000000..102032a780f --- /dev/null +++ b/packages/suite/src/actions/wallet/send/sendThunksConsts.ts @@ -0,0 +1 @@ +export const MODULE_PREFIX = '@send'; 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 6a9e3d43370..3e9441aecd2 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModal.tsx @@ -10,9 +10,13 @@ import { TransactionReviewModalContent } from './TransactionReviewModalContent'; // contexts are distinguished by `type` prop type TransactionReviewModalProps = | Extract - | { type: 'sign-transaction'; decision?: undefined }; + | { type: 'sign-transaction'; decision?: undefined } + | Extract< + UserContextPayload, + { type: 'review-transaction-rbf-previous-transaction-mined-error' } + >; -export const TransactionReviewModal = ({ decision }: TransactionReviewModalProps) => { +export const TransactionReviewModal = ({ type, decision }: TransactionReviewModalProps) => { const send = useSelector(state => state.wallet.send); const stake = useSelector(selectStake); const dispatch = useDispatch(); @@ -31,6 +35,7 @@ export const TransactionReviewModal = ({ decision }: TransactionReviewModalProps decision={decision} txInfoState={txInfoState} cancelSignTx={handleCancelSignTx} + isRbfConfirmedError={type === 'review-transaction-rbf-previous-transaction-mined-error'} /> ); }; 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 4a9838cbdf6..cc83a639d36 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewModalContent.tsx @@ -22,6 +22,7 @@ import { ConfirmOnDevice } from '@trezor/product-components'; import { EventType, analytics } from '@trezor/suite-analytics'; 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 { selectIsActionAbortable } from 'src/reducers/suite/suiteReducer'; @@ -32,6 +33,7 @@ import { TransactionReviewDetails } from './TransactionReviewDetails'; import { TransactionReviewOutputList } from './TransactionReviewOutputList/TransactionReviewOutputList'; import { TransactionReviewSummary } from './TransactionReviewSummary'; import { ConfirmActionModal } from '../DeviceContextModal/ConfirmActionModal'; +import { ReplaceByFeeFailedOriginalTxConfirmed } from '../UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed'; const isStakeState = (state: SendState | StakeState): state is StakeState => 'data' in state; @@ -42,12 +44,14 @@ type TransactionReviewModalContentProps = { decision: Deferred | undefined; txInfoState: SendState | StakeState; cancelSignTx: () => void; + isRbfConfirmedError?: boolean; }; export const TransactionReviewModalContent = ({ decision, txInfoState, cancelSignTx, + isRbfConfirmedError, }: TransactionReviewModalContentProps) => { const dispatch = useDispatch(); const account = useSelector(selectAccountIncludingChosenInTrading); @@ -97,13 +101,16 @@ export const TransactionReviewModalContent = ({ ? precomposedForm.stakeType : getTxStakeNameByDataHex(outputs[0]?.value); - const onCancel = - isActionAbortable || serializedTx - ? () => { - cancelSignTx(); - decision?.resolve(false); - } - : undefined; + const onCancel = () => { + if (isRbfConfirmedError) { + dispatch(modalActions.onCancel()); + } + + if (isActionAbortable || serializedTx) { + cancelSignTx(); + decision?.resolve(false); + } + }; const actionLabel = getTransactionReviewModalActionText({ stakeType, @@ -164,17 +171,89 @@ export const TransactionReviewModalContent = ({ reportTransactionCreatedEvent('downloaded'); }; + const BottomContent = () => { + if (isRbfConfirmedError) { + return ( + + + + ); + } + + if (areDetailsVisible) { + return null; + } + + if (isBroadcastEnabled) { + return ( + + + + ); + } + + return ( + <> + + + + + + + + ); + }; + + const Content = () => { + if (areDetailsVisible) { + return ; + } + + if (isRbfConfirmedError) { + return ; + } + + return ( + + ); + }; + return ( - } - steps={outputs.length + 1} - activeStep={serializedTx ? outputs.length + 2 : buttonRequestsCount} - deviceModelInternal={deviceModelInternal} - deviceUnitColor={device?.features?.unit_color} - successText={} - onCancel={onCancel} - /> + {!isRbfConfirmedError && ( + } + steps={outputs.length + 1} + activeStep={serializedTx ? outputs.length + 2 : buttonRequestsCount} + deviceModelInternal={deviceModelInternal} + deviceUnitColor={device?.features?.unit_color} + successText={} + onCancel={onCancel} + /> + )} } onBackClick={areDetailsVisible ? () => setAreDetailsVisible(false) : undefined} @@ -191,53 +270,10 @@ export const TransactionReviewModalContent = ({ /> ) } - bottomContent={ - !areDetailsVisible && - (isBroadcastEnabled ? ( - - - - ) : ( - <> - - - - - - - - )) - } + bottomContent={} size="small" > - {areDetailsVisible ? ( - - ) : ( - - )} + ); diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionFailed.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionFailed.tsx deleted file mode 100644 index 7f910d32397..00000000000 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionFailed.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Box, Card, Column, IconCircle, Text } from '@trezor/components'; -import { spacings } from '@trezor/theme'; -import { HELP_CENTER_CANCEL_TRANSACTION } from '@trezor/urls'; - -import { Translation } from '../../../../../Translation'; -import { TrezorLink } from '../../../../../TrezorLink'; - -export const CancelTransactionFailed = () => ( - - - - - - - - - - - - - - - - -); diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionModal.tsx index b040288370f..d16ff6c1e3a 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionModal.tsx @@ -17,11 +17,11 @@ import { spacings } from '@trezor/theme'; import { CancelTransaction } from './CancelTransaction'; import { CancelTransactionButton } from './CancelTransactionButton'; -import { CancelTransactionFailed } from './CancelTransactionFailed'; import { useDispatch, useSelector } from '../../../../../../../hooks/suite'; import { CancelTxContext } from '../../../../../../../hooks/wallet/useCancelTxContext'; import { Translation } from '../../../../../Translation'; import { AffectedTransactions } from '../AffectedTransactions/AffectedTransactions'; +import { ReplaceByFeeFailedOriginalTxConfirmed } from '../ReplaceByFeeFailedOriginalTxConfirmed'; import { TxDetailModalBase } from '../TxDetailModalBase'; const isComposeCancelTransactionPartialAccount = ( @@ -100,7 +100,7 @@ export const CancelTransactionModal = ({ onBackClick={onBackClick} > {isTxConfirmed ? ( - + ) : ( diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed.tsx new file mode 100644 index 00000000000..e6759d6fd64 --- /dev/null +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ReplaceByFeeFailedOriginalTxConfirmed.tsx @@ -0,0 +1,50 @@ +import { Box, Card, Column, IconCircle, Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { + HELP_CENTER_CANCEL_TRANSACTION, + HELP_CENTER_REPLACE_BY_FEE_BITCOIN, + Url, +} from '@trezor/urls'; + +import { Translation, TranslationKey } from '../../../../Translation'; +import { TrezorLink } from '../../../../TrezorLink'; + +type ReplaceByFeeFailedOriginalTxConfirmedProps = { + type: 'replace-by-fee' | 'cancel-transaction'; +}; + +const titleMap: Record = { + 'replace-by-fee': 'TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED', + 'cancel-transaction': 'TR_CANCEL_TX_FAILED_ALREADY_MINED', +}; + +const descriptionMap: Record = { + 'replace-by-fee': 'TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED_DESCRIPTION', + 'cancel-transaction': 'TR_CANCEL_TX_FAILED_ALREADY_MINED_DESCRIPTION', +}; + +const helpLink: Record = { + 'replace-by-fee': HELP_CENTER_REPLACE_BY_FEE_BITCOIN, + 'cancel-transaction': HELP_CENTER_CANCEL_TRANSACTION, +}; + +export const ReplaceByFeeFailedOriginalTxConfirmed = ({ + type, +}: ReplaceByFeeFailedOriginalTxConfirmedProps) => ( + + + + + + + + + + + + + + + + +); diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx index f19585aee47..ebea3fd21a1 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/UserContextModal.tsx @@ -106,6 +106,8 @@ export const UserContextModal = ({ ); case 'review-transaction': return ; + case 'review-transaction-rbf-previous-transaction-mined-error': + return ; case 'cardano-withdraw-modal': return ; case 'trading-buy-terms': { diff --git a/packages/suite/src/middlewares/wallet/index.ts b/packages/suite/src/middlewares/wallet/index.ts index 6ae75b51689..afe11968ea8 100644 --- a/packages/suite/src/middlewares/wallet/index.ts +++ b/packages/suite/src/middlewares/wallet/index.ts @@ -15,6 +15,7 @@ import walletMiddleware from './walletMiddleware'; import graphMiddleware from './graphMiddleware'; import { tradingMiddleware } from './tradingMiddleware'; import { coinjoinMiddleware } from './coinjoinMiddleware'; +import { replaceByFeeErrorMiddleware } from './replaceByFeeErrorMiddleware'; export default [ prepareBlockchainMiddleware(extraDependencies), @@ -28,4 +29,5 @@ export default [ graphMiddleware, tradingMiddleware, coinjoinMiddleware, + replaceByFeeErrorMiddleware, ]; diff --git a/packages/suite/src/middlewares/wallet/replaceByFeeErrorMiddleware.ts b/packages/suite/src/middlewares/wallet/replaceByFeeErrorMiddleware.ts new file mode 100644 index 00000000000..487d4ada18c --- /dev/null +++ b/packages/suite/src/middlewares/wallet/replaceByFeeErrorMiddleware.ts @@ -0,0 +1,33 @@ +import { MiddlewareAPI } from 'redux'; + +import { transactionsActions } from '@suite-common/wallet-core/'; +import { isRbfTransaction } from '@suite-common/wallet-utils'; + +import { Action, AppState, Dispatch } from 'src/types/suite'; + +import { replaceByFeeErrorThunk } from '../../actions/wallet/send/replaceByFeeErrorThunk'; + +export const replaceByFeeErrorMiddleware = + (api: MiddlewareAPI) => + (next: Dispatch) => + (action: Action): Action => { + next(action); + + if (transactionsActions.addTransaction.match(action)) { + const { transactions } = action.payload; + + const precomposedTx = api.getState().wallet.send?.precomposedTx; + + if (precomposedTx !== undefined && isRbfTransaction(precomposedTx)) { + const addedTransaction = transactions.find( + tx => tx.txid === precomposedTx.prevTxid, + ); + + if (addedTransaction !== undefined && addedTransaction.blockHeight !== undefined) { + api.dispatch(replaceByFeeErrorThunk()); + } + } + } + + return action; + }; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index cd398732cfb..80d11e31840 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -6442,15 +6442,24 @@ export default defineMessages({ id: 'TR_CANCEL_TX_RETURN_TO_YOUR_WALLET', defaultMessage: 'Return to your wallet', }, - TR_CANCEL_TX_FAILED: { - id: 'TR_CANCEL_TX_FAILED', + TR_CANCEL_TX_FAILED_ALREADY_MINED: { + id: 'TR_CANCEL_TX_FAILED_ALREADY_MINED', defaultMessage: 'Cancel transaction failed', }, - TR_CANCEL_TX_FAILED_DESCRIPTION: { - id: 'TR_CANCEL_TX_FAILED_DESCRIPTION', + TR_CANCEL_TX_FAILED_ALREADY_MINED_DESCRIPTION: { + id: 'TR_CANCEL_TX_FAILED_ALREADY_MINED_DESCRIPTION', defaultMessage: 'The transaction couldn’t be canceled as it has just been confirmed on the Bitcoin network.', }, + TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED: { + id: 'TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED', + defaultMessage: 'Replace transaction failed', + }, + TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED_DESCRIPTION: { + id: 'TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED_DESCRIPTION', + defaultMessage: + 'The transaction couldn’t be replaced as it has just been confirmed on the Bitcoin network.', + }, TR_BUMP_FEE_SUBTEXT: { id: 'TR_BUMP_FEE_SUBTEXT', defaultMessage: 'Speed up this transaction confirmation by paying a higher fee.', diff --git a/suite-common/suite-types/src/modal.ts b/suite-common/suite-types/src/modal.ts index 678fb707a74..71f4bda33ef 100644 --- a/suite-common/suite-types/src/modal.ts +++ b/suite-common/suite-types/src/modal.ts @@ -57,6 +57,10 @@ export type UserContextPayload = type: 'review-transaction'; decision: Deferred; } + | { + type: 'review-transaction-rbf-previous-transaction-mined-error'; + decision?: Deferred; + } | { type: 'import-transaction'; decision: Deferred<{ [key: string]: string }[]>; diff --git a/suite-common/wallet-core/src/transactions/transactionsActions.ts b/suite-common/wallet-core/src/transactions/transactionsActions.ts index fef9ed17997..6822ab66a27 100644 --- a/suite-common/wallet-core/src/transactions/transactionsActions.ts +++ b/suite-common/wallet-core/src/transactions/transactionsActions.ts @@ -21,6 +21,22 @@ const removeTransaction = createAction( (payload: { account: Account; txs: { txid: string }[] }) => ({ payload }), ); +type AddTransactionActionProps = { + transactions: (AccountTransaction & Partial)[]; + account: Account; + page?: number; + perPage?: number; +}; + +type AddTransactionActionResult = { + payload: { + transactions: WalletAccountTransaction[]; + account: Account; + page?: number; + perPage?: number; + }; +}; + const addTransaction = createAction( `${TRANSACTIONS_MODULE_PREFIX}/addTransaction`, ({ @@ -28,19 +44,7 @@ const addTransaction = createAction( account, page, perPage, - }: { - transactions: (AccountTransaction & Partial)[]; - account: Account; - page?: number; - perPage?: number; - }): { - payload: { - transactions: WalletAccountTransaction[]; - account: Account; - page?: number; - perPage?: number; - }; - } => ({ + }: AddTransactionActionProps): AddTransactionActionResult => ({ payload: { transactions: transactions.map(t => enhanceTransaction(t, account)), account,