Skip to content

Commit

Permalink
feat: Cancel Transaction
Browse files Browse the repository at this point in the history
  • Loading branch information
peter-sanderson committed Feb 5, 2025
1 parent fc44b51 commit c89c834
Show file tree
Hide file tree
Showing 20 changed files with 989 additions and 25 deletions.
12 changes: 7 additions & 5 deletions packages/suite/src/actions/wallet/send/sendFormThunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,18 +186,20 @@ const applySendFormMetadataLabelsThunk = createThunk(
},
);

type SignAndPushSendFormTransactionThunkParams = {
formState: FormState;
precomposedTransaction: GeneralPrecomposedTransactionFinal;
selectedAccount?: Account;
};

export const signAndPushSendFormTransactionThunk = createThunk(
`${MODULE_PREFIX}/signSendFormTransactionThunk`,
async (
{
formState,
precomposedTransaction,
selectedAccount,
}: {
formState: FormState;
precomposedTransaction: GeneralPrecomposedTransactionFinal;
selectedAccount?: Account;
},
}: SignAndPushSendFormTransactionThunkParams,
{ dispatch, getState },
) => {
const device = selectSelectedDevice(getState());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { SelectedAccountLoaded, WalletAccountTransaction } from '@suite-common/wallet-types';
import { formatNetworkAmount, getFeeUnits } from '@suite-common/wallet-utils';
import { Card, Column, Divider, InfoItem, Row, Text } from '@trezor/components';
import { spacings } from '@trezor/theme';
import { HELP_CENTER_CANCEL_TRANSACTION } from '@trezor/urls';
import { BigNumber } from '@trezor/utils';

import { useCancelTxContext } from '../../../../../../../hooks/wallet/useCancelTxContext';
import { FiatValue } from '../../../../../FiatValue';
import { FormattedCryptoAmount } from '../../../../../FormattedCryptoAmount';
import { Translation } from '../../../../../Translation';
import { TrezorLink } from '../../../../../TrezorLink';

type CancelTransactionProps = {
tx: WalletAccountTransaction;
selectedAccount: SelectedAccountLoaded;
};

export const CancelTransaction = ({ tx, selectedAccount }: CancelTransactionProps) => {
const { account } = selectedAccount;
const { networkType } = account;

const { composedCancelTx } = useCancelTxContext();

if (composedCancelTx === null) {
return;
}

if (composedCancelTx.outputs.length !== 1) {
return null;
}

const output = composedCancelTx.outputs[0];

const feePerByte = new BigNumber(composedCancelTx.feePerByte);
const fee = formatNetworkAmount(composedCancelTx.fee, tx.symbol);

return (
<Card fillType="flat" paddingType="none">
<Row justifyContent="space-between" margin={spacings.md}>
<Text typographyStyle="highlight">
<Translation id="TR_CANCEL_TX_HEADER" />
</Text>

<Text typographyStyle="hint">
<TrezorLink
variant="nostyle"
href={HELP_CENTER_CANCEL_TRANSACTION}
icon="arrowUpRight"
>
<Translation id="TR_LEARN_MORE" />
</TrezorLink>
</Text>
</Row>

<Divider margin={spacings.zero} />

<Column gap={spacings.md} margin={spacings.md}>
<Text typographyStyle="hint">
<Translation id="TR_CANCEL_TX_NOTICE" />
</Text>

<InfoItem
direction="row"
label={
<Row gap={spacings.md}>
<Translation id="TR_CANCEL_TX_FEE" />
<Text variant="tertiary">
{feePerByte.toFormat(2)}&nbsp;
{getFeeUnits(networkType)}
</Text>
</Row>
}
typographyStyle="body"
variant="default"
>
<Column gap={spacings.md} alignItems="flex-end">
<FormattedCryptoAmount
disableHiddenPlaceholder
value={fee ?? undefined}
symbol={tx.symbol}
/>
<Text variant="tertiary" typographyStyle="label">
<FiatValue
disableHiddenPlaceholder
amount={fee ?? '0'}
symbol={tx.symbol}
/>
</Text>
</Column>
</InfoItem>

<Divider
orientation="horizontal"
margin={{ top: spacings.xs, bottom: spacings.xs }}
/>

<InfoItem
direction="row"
label={<Translation id="TR_CANCEL_TX_RETURN_TO_YOUR_WALLET" />}
typographyStyle="body"
variant="default"
>
<Column gap={spacings.md} alignItems="flex-end">
<FormattedCryptoAmount
disableHiddenPlaceholder
value={formatNetworkAmount(output.amount.toString(), tx.symbol)}
symbol={tx.symbol}
/>
<Text variant="tertiary" typographyStyle="label">
<FiatValue
disableHiddenPlaceholder
amount={output.amount.toString()}
symbol={tx.symbol}
/>
</Text>
</Column>
</InfoItem>
</Column>
</Card>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { DEFAULT_PAYMENT } from '@suite-common/wallet-constants';
import { Account, FormState } from '@suite-common/wallet-types';
import { NewModal } from '@trezor/components';

import { Translation } from 'src/components/suite';
import { useDevice, useDispatch } from 'src/hooks/suite';

import { signAndPushSendFormTransactionThunk } from '../../../../../../../actions/wallet/send/sendFormThunks';
import { useCancelTxContext } from '../../../../../../../hooks/wallet/useCancelTxContext';

type CancelTransactionButtonProps = {
account: Account;
};

export const CancelTransactionButton = ({ account }: CancelTransactionButtonProps) => {
const { device, isLocked } = useDevice();

const dispatch = useDispatch();
const { composedCancelTx } = useCancelTxContext();

const handleCancelTx = () => {
if (composedCancelTx === null) {
return;
}

const formState: FormState = {
feeLimit: '', // Eth only
feePerUnit: composedCancelTx.feePerByte,
hasCoinControlBeenOpened: false,
isCoinControlEnabled: false,
options: ['broadcast'],

outputs: composedCancelTx.outputs.map(output => ({
...DEFAULT_PAYMENT,
...output,
amount: output.amount.toString(),
})),

selectedUtxos: [],
};

return dispatch(
signAndPushSendFormTransactionThunk({
formState,
precomposedTransaction: composedCancelTx,
selectedAccount: account,
}),
).unwrap();
};

const isDisabled = isLocked() || !device || !device?.available || composedCancelTx === null;

return (
<NewModal.Button
data-testid="@send/cancel-tx-button"
isDisabled={isDisabled}
onClick={handleCancelTx}
variant="destructive"
>
<Translation id="TR_CANCEL_TX_BUTTON" />
</NewModal.Button>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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 = () => (
<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="TR_CANCEL_TX_FAILED" />
</Text>
<Translation id="TR_CANCEL_TX_FAILED_DESCRIPTION" />

<TrezorLink
typographyStyle="hint"
href={HELP_CENTER_CANCEL_TRANSACTION}
icon="arrowUpRight"
>
<Translation id="TR_LEARN_MORE" />
</TrezorLink>
</Column>
</Card>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, useState } from 'react';

import {
ComposeCancelTransactionPartialAccount,
composeCancelTransactionThunk,
selectTransactionConfirmations,
} from '@suite-common/wallet-core';
import {
Account,
ChainedTransactions,
SelectedAccountLoaded,
WalletAccountTransactionWithRequiredRbfParams,
} from '@suite-common/wallet-types';
import { Banner, Button, Column } from '@trezor/components';
import { PrecomposeResultFinal } from '@trezor/connect';
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 { TxDetailModalBase } from '../TxDetailModalBase';

const isComposeCancelTransactionPartialAccount = (
account: Account,
): account is Account & ComposeCancelTransactionPartialAccount =>
account.addresses !== undefined && account.utxo !== undefined;

type CancelTransactionModalProps = {
tx: WalletAccountTransactionWithRequiredRbfParams;
onCancel: () => void;
onBackClick: () => void;
onShowChained: () => void;
chainedTxs?: ChainedTransactions;
selectedAccount: SelectedAccountLoaded;
};

export const CancelTransactionModal = ({
tx,
onCancel,
onBackClick,
onShowChained,
chainedTxs,
selectedAccount,
}: CancelTransactionModalProps) => {
const [error, setError] = useState<string | null>(null);
const { account } = selectedAccount;

const dispatch = useDispatch();
const [composedCancelTx, setComposedCancelTx] = useState<PrecomposeResultFinal | null>(null);

const confirmations = useSelector(state =>
selectTransactionConfirmations(state, tx.txid, account.key),
);

const isTxConfirmed = confirmations > 0;

useEffect(() => {
if (tx.vsize === undefined) {
return;
}

if (!isComposeCancelTransactionPartialAccount(account)) {
return;
}

dispatch(composeCancelTransactionThunk({ account, tx, chainedTxs }))
.unwrap()
.then(setComposedCancelTx)
.catch(setError);
}, [account, tx, dispatch, chainedTxs]);

return (
<CancelTxContext.Provider value={{ composedCancelTx }}>
<TxDetailModalBase
tx={tx}
onCancel={onCancel}
heading={<Translation id="TR_TRANSACTION_DETAILS" />}
bottomContent={
isTxConfirmed ? (
<Button variant="tertiary" onClick={onCancel}>
<Translation id="TR_CLOSE_WINDOW" />
</Button>
) : (
<>
<CancelTransactionButton account={selectedAccount.account} />
{error !== null ? (
// This shall never happen, error like this always signal big in the code,
// this is here just to make easier to detect and fix
<Banner variant="destructive">
Error: transaction cannot be canceled ({error})
</Banner>
) : null}
</>
)
}
onBackClick={onBackClick}
>
{isTxConfirmed ? (
<CancelTransactionFailed />
) : (
<Column gap={spacings.md}>
<CancelTransaction tx={tx} selectedAccount={selectedAccount} />
<AffectedTransactions showChained={onShowChained} chainedTxs={chainedTxs} />
</Column>
)}
</TxDetailModalBase>
</CancelTxContext.Provider>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type DetailModalProps = {
onCancel: () => void;
tab: TabID | undefined;
onChangeFeeClick: () => void;
onCancelTxClick: () => void;
chainedTxs?: ChainedTransactions;
canReplaceTransaction: boolean;
};
Expand All @@ -23,6 +24,7 @@ export const DetailModal = ({
onCancel,
tab,
onChangeFeeClick,
onCancelTxClick,
chainedTxs,
canReplaceTransaction,
}: DetailModalProps) => {
Expand All @@ -45,6 +47,9 @@ export const DetailModal = ({
<NewModal.Button icon="gauge" variant="tertiary" onClick={onChangeFeeClick}>
<Translation id="TR_BUMP_FEE" />
</NewModal.Button>
<NewModal.Button icon="x" variant="tertiary" onClick={onCancelTxClick}>
<Translation id="TR_CANCEL_TX" />
</NewModal.Button>
</>
) : null
}
Expand Down
Loading

0 comments on commit c89c834

Please sign in to comment.