Skip to content

Commit

Permalink
feat: add error state for RBF when previous transaction gets mined be…
Browse files Browse the repository at this point in the history
…for user sends the modal
  • Loading branch information
peter-sanderson committed Feb 10, 2025
1 parent bc47eef commit 2db04c3
Show file tree
Hide file tree
Showing 14 changed files with 257 additions and 114 deletions.
20 changes: 20 additions & 0 deletions packages/suite/src/actions/wallet/send/replaceByFeeErrorThunk.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
);
},
);
12 changes: 9 additions & 3 deletions packages/suite/src/actions/wallet/send/sendFormThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions packages/suite/src/actions/wallet/send/sendThunksConsts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const MODULE_PREFIX = '@send';
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@ import { TransactionReviewModalContent } from './TransactionReviewModalContent';
// contexts are distinguished by `type` prop
type TransactionReviewModalProps =
| Extract<UserContextPayload, { type: 'review-transaction' }>
| { 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();
Expand All @@ -31,6 +35,7 @@ export const TransactionReviewModal = ({ decision }: TransactionReviewModalProps
decision={decision}
txInfoState={txInfoState}
cancelSignTx={handleCancelSignTx}
isRbfConfirmedError={type === 'review-transaction-rbf-previous-transaction-mined-error'}
/>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand All @@ -42,12 +44,14 @@ type TransactionReviewModalContentProps = {
decision: Deferred<boolean, string | number | undefined> | undefined;
txInfoState: SendState | StakeState;
cancelSignTx: () => void;
isRbfConfirmedError?: boolean;
};

export const TransactionReviewModalContent = ({
decision,
txInfoState,
cancelSignTx,
isRbfConfirmedError,
}: TransactionReviewModalContentProps) => {
const dispatch = useDispatch();
const account = useSelector(selectAccountIncludingChosenInTrading);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -164,17 +171,89 @@ export const TransactionReviewModalContent = ({
reportTransactionCreatedEvent('downloaded');
};

const BottomContent = () => {
if (isRbfConfirmedError) {
return (
<NewModal.Button variant="tertiary" onClick={onCancel}>
<Translation id="TR_CLOSE" />
</NewModal.Button>
);
}

if (areDetailsVisible) {
return null;
}

if (isBroadcastEnabled) {
return (
<NewModal.Button
data-testid="@modal/send"
isDisabled={!serializedTx}
isLoading={isSending}
onClick={handleSend}
>
<Translation id={actionLabel} />
</NewModal.Button>
);
}

return (
<>
<NewModal.Button
isDisabled={!serializedTx}
onClick={handleCopy}
data-testid="@send/copy-raw-transaction"
>
<Translation id="COPY_TRANSACTION_TO_CLIPBOARD" />
</NewModal.Button>
<NewModal.Button
variant="tertiary"
isDisabled={!serializedTx}
onClick={handleDownload}
>
<Translation id="DOWNLOAD_TRANSACTION" />
</NewModal.Button>
</>
);
};

const Content = () => {
if (areDetailsVisible) {
return <TransactionReviewDetails tx={precomposedTx} txHash={serializedTx?.tx} />;
}

if (isRbfConfirmedError) {
return <ReplaceByFeeFailedOriginalTxConfirmed type="replace-by-fee" />;
}

return (
<TransactionReviewOutputList
account={account}
precomposedTx={precomposedTx}
signedTx={serializedTx}
outputs={outputs}
buttonRequestsCount={buttonRequestsCount}
isRbfAction={isRbfAction}
isTradingAction={isTradingAction}
isSending={isSending}
stakeType={stakeType || undefined}
/>
);
};

return (
<NewModal.Backdrop>
<ConfirmOnDevice
title={<Translation id="TR_CONFIRM_ON_TREZOR" />}
steps={outputs.length + 1}
activeStep={serializedTx ? outputs.length + 2 : buttonRequestsCount}
deviceModelInternal={deviceModelInternal}
deviceUnitColor={device?.features?.unit_color}
successText={<Translation id="TR_CONFIRMED_TX" />}
onCancel={onCancel}
/>
{!isRbfConfirmedError && (
<ConfirmOnDevice
title={<Translation id="TR_CONFIRM_ON_TREZOR" />}
steps={outputs.length + 1}
activeStep={serializedTx ? outputs.length + 2 : buttonRequestsCount}
deviceModelInternal={deviceModelInternal}
deviceUnitColor={device?.features?.unit_color}
successText={<Translation id="TR_CONFIRMED_TX" />}
onCancel={onCancel}
/>
)}
<NewModal.ModalBase
heading={<Translation id={areDetailsVisible ? 'TR_DETAIL' : actionLabel} />}
onBackClick={areDetailsVisible ? () => setAreDetailsVisible(false) : undefined}
Expand All @@ -191,53 +270,10 @@ export const TransactionReviewModalContent = ({
/>
)
}
bottomContent={
!areDetailsVisible &&
(isBroadcastEnabled ? (
<NewModal.Button
data-testid="@modal/send"
isDisabled={!serializedTx}
isLoading={isSending}
onClick={handleSend}
>
<Translation id={actionLabel} />
</NewModal.Button>
) : (
<>
<NewModal.Button
isDisabled={!serializedTx}
onClick={handleCopy}
data-testid="@send/copy-raw-transaction"
>
<Translation id="COPY_TRANSACTION_TO_CLIPBOARD" />
</NewModal.Button>
<NewModal.Button
variant="tertiary"
isDisabled={!serializedTx}
onClick={handleDownload}
>
<Translation id="DOWNLOAD_TRANSACTION" />
</NewModal.Button>
</>
))
}
bottomContent={<BottomContent />}
size="small"
>
{areDetailsVisible ? (
<TransactionReviewDetails tx={precomposedTx} txHash={serializedTx?.tx} />
) : (
<TransactionReviewOutputList
account={account}
precomposedTx={precomposedTx}
signedTx={serializedTx}
outputs={outputs}
buttonRequestsCount={buttonRequestsCount}
isRbfAction={isRbfAction}
isTradingAction={isTradingAction}
isSending={isSending}
stakeType={stakeType || undefined}
/>
)}
<Content />
</NewModal.ModalBase>
</NewModal.Backdrop>
);
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -100,7 +100,7 @@ export const CancelTransactionModal = ({
onBackClick={onBackClick}
>
{isTxConfirmed ? (
<CancelTransactionFailed />
<ReplaceByFeeFailedOriginalTxConfirmed type="cancel-transaction" />
) : (
<Column gap={spacings.md}>
<CancelTransaction tx={tx} selectedAccount={selectedAccount} />
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ReplaceByFeeFailedOriginalTxConfirmedProps['type'], TranslationKey> = {
'replace-by-fee': 'TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED',
'cancel-transaction': 'TR_CANCEL_TX_FAILED_ALREADY_MINED',
};

const descriptionMap: Record<ReplaceByFeeFailedOriginalTxConfirmedProps['type'], TranslationKey> = {
'replace-by-fee': 'TR_REPLACE_BY_FEE_FAILED_ALREADY_MINED_DESCRIPTION',
'cancel-transaction': 'TR_CANCEL_TX_FAILED_ALREADY_MINED_DESCRIPTION',
};

const helpLink: Record<ReplaceByFeeFailedOriginalTxConfirmedProps['type'], Url> = {
'replace-by-fee': HELP_CENTER_REPLACE_BY_FEE_BITCOIN,
'cancel-transaction': HELP_CENTER_CANCEL_TRANSACTION,
};

export const ReplaceByFeeFailedOriginalTxConfirmed = ({
type,
}: ReplaceByFeeFailedOriginalTxConfirmedProps) => (
<Card fillType="flat">
<Column gap={spacings.xs}>
<Box margin={{ bottom: spacings.md }}>
<IconCircle name="warning" size={110} variant="destructive" />
</Box>

<Text typographyStyle="titleSmall">
<Translation id={titleMap[type]} />
</Text>
<Translation id={descriptionMap[type]} />

<TrezorLink typographyStyle="hint" href={helpLink[type]} icon="arrowUpRight">
<Translation id="TR_LEARN_MORE" />
</TrezorLink>
</Column>
</Card>
);
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ export const UserContextModal = ({
);
case 'review-transaction':
return <TransactionReviewModal {...payload} />;
case 'review-transaction-rbf-previous-transaction-mined-error':
return <TransactionReviewModal {...payload} />;
case 'cardano-withdraw-modal':
return <CardanoWithdrawModal onCancel={onCancel} />;
case 'trading-buy-terms': {
Expand Down
Loading

0 comments on commit 2db04c3

Please sign in to comment.