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 7, 2025
1 parent 2e71d93 commit 6d7a742
Show file tree
Hide file tree
Showing 11 changed files with 192 additions and 81 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 { CancelTransactionFailed } from '../UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionFailed';

const isStakeState = (state: SendState | StakeState): state is StakeState => 'data' in state;

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

// Just bool for now, but could be extended into generic error in the future
isRbfConfirmedError?: boolean;
};

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

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

if (!areDetailsVisible && 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 <CancelTransactionFailed />;
}

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 +268,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
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type TransactionReviewSummaryProps = {
tx: GeneralPrecomposedTransactionFinal;
account: Account;
broadcast?: boolean;
onDetailsClick: () => void;
onDetailsClick?: () => void;
stakeType?: StakeType | null;
};

Expand Down Expand Up @@ -107,7 +107,7 @@ export const TransactionReviewSummary = ({
</Note>
)}

{tx.inputs.length > 0 && (
{tx.inputs.length > 0 && onDetailsClick !== undefined && (
// TODO: IconButton doesn't take margin even though it should
<Box margin={{ left: 'auto' }}>
<IconButton
Expand Down
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
2 changes: 2 additions & 0 deletions packages/suite/src/middlewares/wallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -28,4 +29,5 @@ export default [
graphMiddleware,
tradingMiddleware,
coinjoinMiddleware,
replaceByFeeErrorMiddleware,
];
Original file line number Diff line number Diff line change
@@ -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<Dispatch, AppState>) =>
(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;
};
4 changes: 4 additions & 0 deletions suite-common/suite-types/src/modal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ export type UserContextPayload =
type: 'review-transaction';
decision: Deferred<boolean>;
}
| {
type: 'review-transaction-rbf-previous-transaction-mined-error';
decision?: Deferred<boolean>;
}
| {
type: 'import-transaction';
decision: Deferred<{ [key: string]: string }[]>;
Expand Down
Loading

0 comments on commit 6d7a742

Please sign in to comment.