Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add error state for RBF when previous transaction gets mined befor user sends the modal #16878

Merged
merged 1 commit into from
Feb 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is ugly hack, that unfortunately have to employ. The similar mechanism is used in wallet-switcher.

This is currently the only way, how to send some date from place where cancel on device happen to the place where result of cancelled operation is awaited

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 && (
peter-sanderson marked this conversation as resolved.
Show resolved Hide resolved
<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
Loading