diff --git a/packages/suite/src/actions/wallet/send/sendFormThunks.ts b/packages/suite/src/actions/wallet/send/sendFormThunks.ts
index b7d477a0f64..92ae990fb2d 100644
--- a/packages/suite/src/actions/wallet/send/sendFormThunks.ts
+++ b/packages/suite/src/actions/wallet/send/sendFormThunks.ts
@@ -186,6 +186,12 @@ const applySendFormMetadataLabelsThunk = createThunk(
},
);
+type SignAndPushSendFormTransactionThunkParams = {
+ formState: FormState;
+ precomposedTransaction: GeneralPrecomposedTransactionFinal;
+ selectedAccount?: Account;
+};
+
export const signAndPushSendFormTransactionThunk = createThunk(
`${MODULE_PREFIX}/signSendFormTransactionThunk`,
async (
@@ -193,11 +199,7 @@ export const signAndPushSendFormTransactionThunk = createThunk(
formState,
precomposedTransaction,
selectedAccount,
- }: {
- formState: FormState;
- precomposedTransaction: GeneralPrecomposedTransactionFinal;
- selectedAccount?: Account;
- },
+ }: SignAndPushSendFormTransactionThunkParams,
{ dispatch, getState },
) => {
const device = selectSelectedDevice(getState());
diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx
new file mode 100644
index 00000000000..1ab5ca260df
--- /dev/null
+++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {feePerByte.toFormat(2)}
+ {getFeeUnits(networkType)}
+
+
+ }
+ typographyStyle="body"
+ variant="default"
+ >
+
+
+
+
+
+
+
+
+
+
+ }
+ typographyStyle="body"
+ variant="default"
+ >
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionButton.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionButton.tsx
new file mode 100644
index 00000000000..7b3d6deba5c
--- /dev/null
+++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionButton.tsx
@@ -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 (
+
+
+
+ );
+};
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
new file mode 100644
index 00000000000..7f910d32397
--- /dev/null
+++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionFailed.tsx
@@ -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 = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
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
new file mode 100644
index 00000000000..b040288370f
--- /dev/null
+++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionModal.tsx
@@ -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(null);
+ const { account } = selectedAccount;
+
+ const dispatch = useDispatch();
+ const [composedCancelTx, setComposedCancelTx] = useState(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 (
+
+ }
+ bottomContent={
+ isTxConfirmed ? (
+
+ ) : (
+ <>
+
+ {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
+
+ Error: transaction cannot be canceled ({error})
+
+ ) : null}
+ >
+ )
+ }
+ onBackClick={onBackClick}
+ >
+ {isTxConfirmed ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/Detail/DetailModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/Detail/DetailModal.tsx
index 978edd909bc..4b77a49feb9 100644
--- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/Detail/DetailModal.tsx
+++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/Detail/DetailModal.tsx
@@ -14,6 +14,7 @@ type DetailModalProps = {
onCancel: () => void;
tab: TabID | undefined;
onChangeFeeClick: () => void;
+ onCancelTxClick: () => void;
chainedTxs?: ChainedTransactions;
canReplaceTransaction: boolean;
};
@@ -23,6 +24,7 @@ export const DetailModal = ({
onCancel,
tab,
onChangeFeeClick,
+ onCancelTxClick,
chainedTxs,
canReplaceTransaction,
}: DetailModalProps) => {
@@ -45,6 +47,9 @@ export const DetailModal = ({
+
+
+
>
) : null
}
diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx
index a93d21eae8d..fe97b30beb7 100644
--- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx
+++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/TxDetailModal.tsx
@@ -8,6 +8,7 @@ import { findChainedTransactions, getAccountKey, isPending } from '@suite-common
import { useSelector } from 'src/hooks/suite';
import { Account, WalletAccountTransaction } from 'src/types/wallet';
+import { CancelTransactionModal } from './CancelTransaction/CancelTransactionModal';
import { BumpFeeModal } from './ChangeFee/BumpFeeModal';
import { TabID } from './Detail/AdvancedTxDetails/AdvancedTxDetails';
import { DetailModal } from './Detail/DetailModal';
@@ -18,7 +19,7 @@ const hasRbfParams = (
type TxDetailModalProps = {
tx: WalletAccountTransaction;
- flow: 'detail' | 'bump-fee';
+ flow: 'detail' | 'bump-fee' | 'cancel-transaction';
onCancel: () => void;
};
@@ -57,6 +58,11 @@ export const TxDetailModal = ({ tx, flow, onCancel }: TxDetailModalProps) => {
setTab(undefined);
};
+ const onCancelTxClick = () => {
+ setSection('cancel-transaction');
+ setTab(undefined);
+ };
+
const canReplaceTransaction =
hasRbfParams(tx) &&
networkFeatures?.includes('rbf') &&
@@ -76,12 +82,26 @@ export const TxDetailModal = ({ tx, flow, onCancel }: TxDetailModalProps) => {
);
}
+ if (section === 'cancel-transaction' && canReplaceTransaction) {
+ return (
+
+ );
+ }
+
return (
diff --git a/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx b/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx
index 840fef50207..202f5ed3332 100644
--- a/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx
+++ b/packages/suite/src/components/wallet/TransactionItem/TransactionItem.tsx
@@ -62,7 +62,7 @@ const StyledFeeRow = styled(FeeRow)<{ $noInputsOutputs?: boolean }>`
const DEFAULT_LIMIT = 3;
type OpenModalParams = {
- flow: 'detail' | 'bump-fee';
+ flow: 'detail' | 'bump-fee' | 'cancel-transaction';
};
interface TransactionItemProps {
@@ -155,6 +155,8 @@ export const TransactionItem = memo(
const isExpandable = allOutputs.length - DEFAULT_LIMIT > 0;
const toExpand = allOutputs.length - DEFAULT_LIMIT - limit;
+ const isTxCancellable = transaction.type !== 'self';
+
const openTxDetailsModal = ({ flow }: OpenModalParams) => {
if (isActionDisabled) return; // open explorer
dispatch(
@@ -184,6 +186,17 @@ export const TransactionItem = memo(
);
+ const CancelTransactionButton = ({ isDisabled }: { isDisabled: boolean }) => (
+
+ );
+
const DisabledBumpFeeButtonWithTooltip = () => (
)}
+ {isTxCancellable && (
+
+ )}
)}
diff --git a/packages/suite/src/hooks/wallet/useCancelTxContext.ts b/packages/suite/src/hooks/wallet/useCancelTxContext.ts
new file mode 100644
index 00000000000..1b907e86caf
--- /dev/null
+++ b/packages/suite/src/hooks/wallet/useCancelTxContext.ts
@@ -0,0 +1,19 @@
+import { createContext, useContext } from 'react';
+
+import { PrecomposeResultFinal } from '@trezor/connect';
+
+type CancelTxContextValues = {
+ composedCancelTx: PrecomposeResultFinal | null;
+};
+
+export const CancelTxContext = createContext(null);
+CancelTxContext.displayName = 'CancelTxContext';
+
+// Used across rbf form components
+// Provide combined context of `react-hook-form` with custom values as RbfContextValues
+export const useCancelTxContext = () => {
+ const ctx = useContext(CancelTxContext);
+ if (ctx === null) throw Error('useCancelTxContext used without Context');
+
+ return ctx;
+};
diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts
index 528f37221dc..d18e16edd9f 100644
--- a/packages/suite/src/support/messages.ts
+++ b/packages/suite/src/support/messages.ts
@@ -1876,6 +1876,10 @@ export default defineMessages({
defaultMessage: 'Close',
id: 'TR_CLOSE',
},
+ TR_CLOSE_WINDOW: {
+ defaultMessage: 'Close window',
+ id: 'TR_CLOSE_WINDOW',
+ },
TR_COIN_DISCOVERY_LOADER_DESCRIPTION: {
defaultMessage: 'Checking passphrase wallet for balances & transactions',
id: 'TR_COIN_DISCOVERY_LOADER_DESCRIPTION',
@@ -6399,7 +6403,41 @@ export default defineMessages({
},
TR_BUMP_FEE: {
id: 'TR_BUMP_FEE',
- defaultMessage: 'Bump fee',
+ defaultMessage: 'Speed up',
+ },
+ TR_CANCEL_TX: {
+ id: 'TR_CANCEL_TX',
+ defaultMessage: 'Cancel',
+ },
+ TR_CANCEL_TX_BUTTON: {
+ id: 'TR_CANCEL_TX_BUTTON',
+ defaultMessage: 'Cancel transaction',
+ },
+ TR_CANCEL_TX_HEADER: {
+ id: 'TR_CANCEL_TX_HEADER',
+ defaultMessage: 'Cancel transaction',
+ },
+ TR_CANCEL_TX_NOTICE: {
+ id: 'TR_CANCEL_TX_NOTICE',
+ defaultMessage:
+ 'Once the transaction is canceled successfully, your funds (minus the transaction fee) will be returned to your wallet.',
+ },
+ TR_CANCEL_TX_FEE: {
+ id: 'TR_CANCEL_TX_FEE',
+ defaultMessage: 'Transaction fee',
+ },
+ TR_CANCEL_TX_RETURN_TO_YOUR_WALLET: {
+ id: 'TR_CANCEL_TX_RETURN_TO_YOUR_WALLET',
+ defaultMessage: 'Return to your wallet',
+ },
+ TR_CANCEL_TX_FAILED: {
+ id: 'TR_CANCEL_TX_FAILED',
+ defaultMessage: 'Cancel transaction failed',
+ },
+ TR_CANCEL_TX_FAILED_DESCRIPTION: {
+ id: 'TR_CANCEL_TX_FAILED_DESCRIPTION',
+ defaultMessage:
+ 'The transaction couldn’t be canceled as it has just been confirmed on the Bitcoin network.',
},
TR_BUMP_FEE_SUBTEXT: {
id: 'TR_BUMP_FEE_SUBTEXT',
diff --git a/packages/urls/src/urls.ts b/packages/urls/src/urls.ts
index fcc9d9525b9..a1e43788611 100644
--- a/packages/urls/src/urls.ts
+++ b/packages/urls/src/urls.ts
@@ -117,6 +117,8 @@ export const HELP_CENTER_REPLACE_BY_FEE_ETHEREUM: Url =
'https://trezor.io/learn/a/replace-by-fee-rbf-ethereum';
export const HELP_CENTER_REPLACE_BY_FEE_BITCOIN =
'https://trezor.io/learn/a/replace-by-fee-rbf-bitcoin';
+export const HELP_CENTER_CANCEL_TRANSACTION: Url =
+ 'https://trezor.io/support/a/can-i-cancel-or-reverse-a-transaction';
export const INVITY_URL: Url = 'https://invity.io/';
export const INVITY_SCHEDULE_OF_FEES: Url = 'https://blog.invity.io/schedule-of-fees';
diff --git a/suite-common/suite-types/src/modal.ts b/suite-common/suite-types/src/modal.ts
index 994cc302416..678fb707a74 100644
--- a/suite-common/suite-types/src/modal.ts
+++ b/suite-common/suite-types/src/modal.ts
@@ -51,7 +51,7 @@ export type UserContextPayload =
| {
type: 'transaction-detail';
tx: WalletAccountTransaction;
- flow: 'detail' | 'bump-fee';
+ flow: 'detail' | 'bump-fee' | 'cancel-transaction';
}
| {
type: 'review-transaction';
diff --git a/suite-common/wallet-core/src/index.ts b/suite-common/wallet-core/src/index.ts
index 3af7c0897d0..31e1d0a82df 100644
--- a/suite-common/wallet-core/src/index.ts
+++ b/suite-common/wallet-core/src/index.ts
@@ -1,37 +1,39 @@
export * from './accounts/accountsActions';
export * from './accounts/accountsConstants';
+export * from './accounts/accountsMiddleware';
export * from './accounts/accountsReducer';
export * from './accounts/accountsThunks';
-export * from './accounts/accountsMiddleware';
-export * from './transactions/transactionsActions';
-export * from './transactions/transactionsReducer';
-export * from './transactions/transactionsThunks';
export * from './blockchain/blockchainActions';
+export * from './blockchain/blockchainMiddleware';
export * from './blockchain/blockchainReducer';
export * from './blockchain/blockchainSelectors';
export * from './blockchain/blockchainThunks';
-export * from './blockchain/blockchainMiddleware';
-export * from './fiat-rates/fiatRatesReducer';
-export * from './fiat-rates/fiatRatesSelectors';
-export * from './fiat-rates/fiatRatesThunks';
-export * from './fiat-rates/fiatRatesMiddleware';
-export * from './fiat-rates/fiatRatesTypes';
+export * from './device/deviceActions';
+export * from './device/deviceConstants';
+export * from './device/deviceReducer';
+export * from './device/deviceThunks';
export * from './discovery/discoveryActions';
export * from './discovery/discoveryReducer';
export * from './discovery/discoveryThunks';
export * from './fees/feesReducer';
+export * from './fiat-rates/fiatRatesMiddleware';
+export * from './fiat-rates/fiatRatesReducer';
+export * from './fiat-rates/fiatRatesSelectors';
+export * from './fiat-rates/fiatRatesThunks';
+export * from './fiat-rates/fiatRatesTypes';
+export * from './send/composeCancelTransaction/cancelTransactionTypes';
+export * from './send/composeCancelTransaction/composeCancelTransactionThunk';
export * from './send/sendFormActions';
export * from './send/sendFormReducer';
export * from './send/sendFormThunks';
export * from './send/sendFormTypes';
-export * from './device/deviceActions';
-export * from './device/deviceThunks';
-export * from './device/deviceReducer';
-export * from './device/deviceConstants';
export * from './stake/stakeActions';
+export * from './stake/stakeConstants';
+export * from './stake/stakeMiddleware';
export * from './stake/stakeReducer';
export * from './stake/stakeSelectors';
-export * from './stake/stakeMiddleware';
export * from './stake/stakeThunks';
export * from './stake/stakeTypes';
-export * from './stake/stakeConstants';
+export * from './transactions/transactionsActions';
+export * from './transactions/transactionsReducer';
+export * from './transactions/transactionsThunks';
diff --git a/suite-common/wallet-core/src/send/composeCancelTransaction/calculateNewFee.ts b/suite-common/wallet-core/src/send/composeCancelTransaction/calculateNewFee.ts
new file mode 100644
index 00000000000..ee752c15caa
--- /dev/null
+++ b/suite-common/wallet-core/src/send/composeCancelTransaction/calculateNewFee.ts
@@ -0,0 +1,42 @@
+import { ChainedTransactions } from '@suite-common/wallet-types';
+import { calculateChainedTransactionsFeeForRbf } from '@suite-common/wallet-utils';
+import { BigNumber } from '@trezor/utils';
+
+/**
+ * The current default value for the minRelayTxFee in Bitcoin Core is 1000 satoshi/kB (= 1 sat/B).
+ * A node operator may specify a different value via the startup parameter.
+ *
+ * @see https://github.com/bitcoin/bitcoin/blob/97153a702600430bdaf6af4f6f4eb8593e32819f/src/validation.h#L63
+ * @see https://bitcoin.stackexchange.com/questions/48235/what-is-the-minrelaytxfee
+ */
+const BIP_125_DEFAULT_RELAY_FEE = 1;
+
+type CancelTransactionProps = {
+ newTransactionSize: number;
+ chainedTxs?: ChainedTransactions;
+ originalFee: string;
+ relayFee?: number;
+};
+
+export const calculateNewFee = ({
+ newTransactionSize,
+ chainedTxs,
+ originalFee,
+ relayFee = BIP_125_DEFAULT_RELAY_FEE,
+}: CancelTransactionProps) => {
+ /**
+ * Rules:
+ ° 3. The replacement transaction pays an absolute fee of at least the sum paid by the original transactions.
+ * 4. The replacement transaction must also pay for its own bandwidth at or above the rate set by the node's minimum relay fee setting.
+ *
+ * @see https://github.com/bitcoin/bips/blob/master/bip-0125.mediawiki#implementation-details
+ */
+ const newFeeRate = new BigNumber(originalFee) // BIP-125 rule 3 (paying for original transaction)
+ .plus(newTransactionSize * relayFee) // BIP-125 rule 4 (paying the relay fee)
+ .div(newTransactionSize);
+
+ const chainedTransactionFees =
+ chainedTxs && calculateChainedTransactionsFeeForRbf({ chainedTxs });
+
+ return { newFeeRate, chainedTransactionFees };
+};
diff --git a/suite-common/wallet-core/src/send/composeCancelTransaction/cancelTransactionTypes.ts b/suite-common/wallet-core/src/send/composeCancelTransaction/cancelTransactionTypes.ts
new file mode 100644
index 00000000000..81b8a53f0cc
--- /dev/null
+++ b/suite-common/wallet-core/src/send/composeCancelTransaction/cancelTransactionTypes.ts
@@ -0,0 +1,7 @@
+import { Account } from '@suite-common/wallet-types';
+import TrezorConnect from '@trezor/connect';
+
+export type ConnectComposeTxCallParams = Parameters[0];
+
+export type ComposeCancelTransactionPartialAccount = Pick &
+ ConnectComposeTxCallParams['account'];
diff --git a/suite-common/wallet-core/src/send/composeCancelTransaction/composeCancelTransactionThunk.ts b/suite-common/wallet-core/src/send/composeCancelTransaction/composeCancelTransactionThunk.ts
new file mode 100644
index 00000000000..ec87667961e
--- /dev/null
+++ b/suite-common/wallet-core/src/send/composeCancelTransaction/composeCancelTransactionThunk.ts
@@ -0,0 +1,139 @@
+import { isRejected } from '@reduxjs/toolkit';
+
+import { createThunk } from '@suite-common/redux-utils';
+import { ChainedTransactions, WalletAccountTransaction } from '@suite-common/wallet-types';
+import { getMyInputsFromTransaction } from '@suite-common/wallet-utils';
+import TrezorConnect, { DEFAULT_SORTING_STRATEGY, PrecomposeResultFinal } from '@trezor/connect';
+
+import { SEND_MODULE_PREFIX } from '../sendFormConstants';
+import { calculateNewFee } from './calculateNewFee';
+import {
+ ComposeCancelTransactionPartialAccount,
+ ConnectComposeTxCallParams,
+} from './cancelTransactionTypes';
+import { resolveCancelAddress } from './resolveCancelAddress';
+
+type ConnectComposeParams = {
+ account: ComposeCancelTransactionPartialAccount;
+ tx: Pick;
+ newFeeRate: string;
+ baseFee?: number;
+};
+
+const composeCancelTransaction = async ({
+ account,
+ tx,
+ newFeeRate,
+ baseFee,
+}: ConnectComposeParams) => {
+ // override Account data, similar to RBF
+ const cancelAccount: ConnectComposeTxCallParams['account'] = {
+ ...account,
+ utxo: getMyInputsFromTransaction({ tx, account }),
+
+ // make sure that the exact same change output will be picked by @trezor/connect > hd-wallet during the tx compose process
+ addresses: account.addresses,
+ };
+
+ const cancelAddress = resolveCancelAddress({ account, tx });
+
+ const params: ConnectComposeTxCallParams = {
+ feeLevels: [{ feePerUnit: newFeeRate }],
+ account: cancelAccount,
+ outputs: [
+ {
+ type: 'send-max',
+ address: cancelAddress,
+ },
+ ],
+ sortingStrategy: DEFAULT_SORTING_STRATEGY,
+ coin: account.symbol,
+ baseFee,
+ };
+
+ return await TrezorConnect.composeTransaction(params);
+};
+
+type CalculateNewTransactionSizeParams = {
+ tx: Pick;
+ account: ComposeCancelTransactionPartialAccount;
+};
+
+const calculateNewTransactionSize = createThunk<
+ number,
+ CalculateNewTransactionSizeParams,
+ { rejectValue: string }
+>(
+ `${SEND_MODULE_PREFIX}/calculateNewTransactionSize`,
+ async ({ account, tx }, { rejectWithValue }) => {
+ const tempCancelTxResult = await composeCancelTransaction({
+ account,
+ tx,
+ newFeeRate: '1', // We don't care about the fee, we just need to compose transaction to get its size
+ });
+
+ if (!tempCancelTxResult.success) {
+ return rejectWithValue(`Unexpected compose error: ${tempCancelTxResult.payload.error}`);
+ }
+
+ const tempCancelTx = tempCancelTxResult.payload[0];
+
+ if (tempCancelTx.type !== 'final') {
+ return rejectWithValue('Unexpected compose tempCancelTxResult (non-final)');
+ }
+
+ return tempCancelTx.bytes;
+ },
+);
+
+export type ComposeCancelTransactionThunkParams = {
+ tx: Pick;
+ account: ComposeCancelTransactionPartialAccount;
+ chainedTxs?: ChainedTransactions;
+};
+
+export const composeCancelTransactionThunk = createThunk<
+ PrecomposeResultFinal,
+ ComposeCancelTransactionThunkParams,
+ { rejectValue: string }
+>(
+ `${SEND_MODULE_PREFIX}/composeCancelTransactionThunk`,
+ async ({ tx, account, chainedTxs }, { rejectWithValue, dispatch }) => {
+ if (tx.vsize === undefined) {
+ return rejectWithValue('Transaction vsize is not loaded');
+ }
+
+ const response = await dispatch(calculateNewTransactionSize({ account, tx }));
+
+ if (isRejected(response)) {
+ return rejectWithValue(response.error.message ?? 'unknown');
+ }
+
+ const newTransactionSize = response.payload;
+
+ const { newFeeRate, chainedTransactionFees } = calculateNewFee({
+ originalFee: tx.fee,
+ newTransactionSize,
+ chainedTxs,
+ });
+
+ const sizeCalculationResponse = await composeCancelTransaction({
+ account,
+ tx,
+ newFeeRate: newFeeRate.toString(),
+ baseFee: chainedTransactionFees, // BIP-125 rule 3 (paying for chained transactions)
+ });
+
+ if (!sizeCalculationResponse.success) {
+ return rejectWithValue('Unexpected compose result (error)');
+ }
+
+ const composedTx = sizeCalculationResponse.payload[0];
+
+ if (composedTx.type !== 'final') {
+ return rejectWithValue('Unexpected compose result (non-final)');
+ }
+
+ return composedTx;
+ },
+);
diff --git a/suite-common/wallet-core/src/send/composeCancelTransaction/resolveCancelAddress.ts b/suite-common/wallet-core/src/send/composeCancelTransaction/resolveCancelAddress.ts
new file mode 100644
index 00000000000..d236b96b2e3
--- /dev/null
+++ b/suite-common/wallet-core/src/send/composeCancelTransaction/resolveCancelAddress.ts
@@ -0,0 +1,26 @@
+import { WalletAccountTransaction } from '@suite-common/wallet-types';
+
+import { ComposeCancelTransactionPartialAccount } from './cancelTransactionTypes';
+
+type ResolveCancelAddress = {
+ account: ComposeCancelTransactionPartialAccount;
+ tx: Pick;
+};
+
+export const resolveCancelAddress = ({ account, tx }: ResolveCancelAddress): string => {
+ const firstChangeAddress = tx.details.vout.find(vout => vout.isAccountOwned);
+
+ if (
+ firstChangeAddress !== undefined &&
+ firstChangeAddress.addresses !== undefined &&
+ firstChangeAddress.addresses.length > 0
+ ) {
+ return firstChangeAddress.addresses[0];
+ }
+
+ if (account.addresses.unused.length < 1) {
+ throw new Error('No unused addresses, should not happen!');
+ }
+
+ return account.addresses.unused[0].address;
+};
diff --git a/suite-common/wallet-core/tests/send/composeCancelTransaction/calculateNewFee.test.ts b/suite-common/wallet-core/tests/send/composeCancelTransaction/calculateNewFee.test.ts
new file mode 100644
index 00000000000..5f1bcf9f8db
--- /dev/null
+++ b/suite-common/wallet-core/tests/send/composeCancelTransaction/calculateNewFee.test.ts
@@ -0,0 +1,31 @@
+import { chainedTxsFixture } from './chainedTransactions.fixture';
+import { calculateNewFee } from '../../../src/send/composeCancelTransaction/calculateNewFee';
+
+describe(calculateNewFee.name, () => {
+ it('accounts default RELAY_FEE for the new transaction size (110B) which is less then original TX (141B) ', () => {
+ const result = calculateNewFee({
+ chainedTxs: chainedTxsFixture,
+ newTransactionSize: 110,
+ originalFee: '1410',
+ });
+
+ expect(result.chainedTransactionFees).toBe(1410); // This is just sum of fees in `chainedTxsFixture`
+
+ // (originalFee + newTransactionSize * RELAY_FEE) / newTransactionSize = (1410 + 110 * 1) / 110 = 13.81818181818181818182
+ expect(result.newFeeRate.toString()).toBe('13.81818181818181818182');
+ });
+
+ it('accounts RELAY_FEE for the new transaction size (110B) which is less then original TX (141B) ', () => {
+ const result = calculateNewFee({
+ chainedTxs: chainedTxsFixture,
+ newTransactionSize: 110,
+ originalFee: '1410',
+ relayFee: 2,
+ });
+
+ expect(result.chainedTransactionFees).toBe(1410); // This is just sum of fees in `chainedTxsFixture`
+
+ // (originalFee + newTransactionSize * RELAY_FEE) / newTransactionSize = (1410 + 110 * 2) / 110 = 14.81818181818181818182
+ expect(result.newFeeRate.toString()).toBe('14.81818181818181818182');
+ });
+});
diff --git a/suite-common/wallet-core/tests/send/composeCancelTransaction/chainedTransactions.fixture.ts b/suite-common/wallet-core/tests/send/composeCancelTransaction/chainedTransactions.fixture.ts
new file mode 100644
index 00000000000..77ddf85b11a
--- /dev/null
+++ b/suite-common/wallet-core/tests/send/composeCancelTransaction/chainedTransactions.fixture.ts
@@ -0,0 +1,99 @@
+import { ChainedTransactions } from '@suite-common/wallet-types';
+
+export const chainedTxsFixture: ChainedTransactions = {
+ own: [
+ {
+ descriptor:
+ 'vpub5Z9LPnVj4bx9zqAjLJvRgnaUrcwXaW1H48VYtizkQeP2vLDxqWTNKqeYujfqquxuUEXdAfwtdVuCKYscvz4EXH9cADxKFHyvdapGXQnhvWf',
+ deviceState: 'mk9cuzrhHk5qy4K4u5tu3aiwD6DXo13zuy@8806280C47785970FA58D555:1',
+ symbol: 'regtest',
+ type: 'sent',
+ txid: 'f915b58d18616519eff1da0662c9629dcbddd0165c87227f9a8c4d7b3fa95d83',
+ hex: '0200000000010190e45827b595d02923212be77de384a064db08b676d2b1008826cefd310eff050100000000fdffffff02fc63e0d601000000160014ad77e39bb0914ce76de2d9b6922588b0d55aea3400ab904100000000160014936c86635e6ced12c5e83494ceff3d6c1152b7ab02483045022100a1f12a13f043f66e939a2688ce75f634278583b65e1df44ab124d1fe3796d690022064c889927b655c2e600042665f8ddc2f12167c50066792a09237bd2be153d1e0012103fee374cce30b5016a6ed48e841e0a835fd5fe8f25181dd406dac27328c5f471600000000',
+ blockTime: 1738322817,
+ blockHeight: -1,
+ amount: '1100000000',
+ fee: '1410',
+ vsize: 141,
+ feeRate: '10',
+ targets: [
+ {
+ n: 1,
+ addresses: ['bcrt1qjdkgvc67dnk3930gxj2valeadsg49datkyz7tu'],
+ isAddress: true,
+ amount: '1100000000',
+ },
+ ],
+ tokens: [],
+ internalTransfers: [],
+ rbf: true,
+ details: {
+ vin: [
+ {
+ txid: '05ff0e31fdce268800b1d276b608db64a084e37de72b212329d095b52758e490',
+ vout: 1,
+ sequence: 4294967293,
+ n: 0,
+ addresses: ['bcrt1qte33uyyfzrdrm9nqk0uwlq9dqr6ezu2gurhree'],
+ isAddress: true,
+ isOwn: true,
+ value: '8999998590',
+ isAccountOwned: true,
+ },
+ ],
+ vout: [
+ {
+ value: '7899997180',
+ n: 0,
+ hex: '0014ad77e39bb0914ce76de2d9b6922588b0d55aea34',
+ addresses: ['bcrt1q44m78xasj9xwwm0zmxmfyfvgkr244635x6u5gt'],
+ isAddress: true,
+ isOwn: true,
+ isAccountOwned: true,
+ },
+ {
+ value: '1100000000',
+ n: 1,
+ hex: '0014936c86635e6ced12c5e83494ceff3d6c1152b7ab',
+ addresses: ['bcrt1qjdkgvc67dnk3930gxj2valeadsg49datkyz7tu'],
+ isAddress: true,
+ },
+ ],
+ size: 223,
+ totalInput: '8999998590',
+ totalOutput: '8999997180',
+ },
+ rbfParams: {
+ txid: 'f915b58d18616519eff1da0662c9629dcbddd0165c87227f9a8c4d7b3fa95d83',
+ utxo: [
+ {
+ amount: '8999998590',
+ txid: '05ff0e31fdce268800b1d276b608db64a084e37de72b212329d095b52758e490',
+ vout: 1,
+ address: 'bcrt1qte33uyyfzrdrm9nqk0uwlq9dqr6ezu2gurhree',
+ path: "m/84'/1'/0'/1/0",
+ blockHeight: 0,
+ confirmations: 0,
+ },
+ ],
+ outputs: [
+ {
+ type: 'change',
+ address: 'bcrt1q44m78xasj9xwwm0zmxmfyfvgkr244635x6u5gt',
+ amount: '7899997180',
+ formattedAmount: '78.9999718',
+ },
+ {
+ type: 'payment',
+ address: 'bcrt1qjdkgvc67dnk3930gxj2valeadsg49datkyz7tu',
+ amount: '1100000000',
+ formattedAmount: '11',
+ },
+ ],
+ feeRate: '10',
+ baseFee: 1410,
+ },
+ },
+ ],
+ others: [],
+};
diff --git a/suite-common/wallet-core/tests/send/composeCancelTransaction/composeCancelTransactionThunk.test.ts b/suite-common/wallet-core/tests/send/composeCancelTransaction/composeCancelTransactionThunk.test.ts
new file mode 100644
index 00000000000..64069537524
--- /dev/null
+++ b/suite-common/wallet-core/tests/send/composeCancelTransaction/composeCancelTransactionThunk.test.ts
@@ -0,0 +1,189 @@
+import { configureMockStore } from '@suite-common/test-utils';
+import { WalletAccountTransaction } from '@suite-common/wallet-types';
+import TrezorConnect, { PrecomposeResultFinal } from '@trezor/connect';
+
+import { chainedTxsFixture } from './chainedTransactions.fixture';
+import {
+ ComposeCancelTransactionThunkParams,
+ composeCancelTransactionThunk,
+} from '../../../src/send/composeCancelTransaction/composeCancelTransactionThunk';
+
+const initStore = () => configureMockStore({});
+
+const FIRST_ACCOUNT_CHANGE_ADDRESS = 'bcrt1qte33uyyfzrdrm9nqk0uwlq9dqr6ezu2gurhree';
+
+const account: ComposeCancelTransactionThunkParams['account'] = {
+ path: "m/84'/1'/0'",
+ symbol: 'regtest',
+ utxo: [],
+ addresses: {
+ change: [
+ {
+ address: FIRST_ACCOUNT_CHANGE_ADDRESS,
+ path: "m/84'/1'/0'/1/0",
+ transfers: 2,
+ balance: '',
+ sent: '',
+ received: '',
+ },
+ ],
+ used: [],
+ unused: [
+ {
+ address: 'bcrt1qaqma3u205mykw7uhrav5tugn8ylu9f55uk8leg',
+ path: "m/84'/1'/0'/0/1",
+ transfers: 0,
+ balance: '',
+ sent: '',
+ received: '',
+ },
+ ],
+ },
+};
+const ORIGINAL_CHANGE_ADDRESS = 'bcrt1qte33uyyfzrdrm9nqk0uwlq9dqr6ezu2gurhree';
+
+const transactionWithChange: Pick = {
+ fee: '1410',
+ vsize: 141,
+ details: {
+ vin: [
+ {
+ value: '10000000000',
+ txid: 'c6a6069c1e19ebf6a0fc0db781208181d9352b10a1be8d5e3210670025551bfb',
+ n: 0,
+ addresses: ['bcrt1qreeergcmsw604zgd7hsreq6872swxnh3485fs5'],
+ isAddress: true,
+ },
+ ],
+ vout: [
+ {
+ value: '1000000000', // Spend 10BTC
+ n: 0,
+ addresses: ['bcrt1qjdkgvc67dnk3930gxj2valeadsg49datkyz7tu'],
+ isAddress: true,
+ },
+ {
+ value: '8999998590', // Change address
+ n: 1,
+ addresses: [ORIGINAL_CHANGE_ADDRESS],
+ isAddress: true,
+ isAccountOwned: true,
+ },
+ ],
+ size: 222,
+ totalInput: '10000000000',
+ totalOutput: '9999998590',
+ },
+};
+
+const transactionWithNoChange: Pick = {
+ fee: '1100',
+ vsize: 110,
+ details: {
+ vin: [
+ {
+ value: '8999998590',
+ n: 0,
+ addresses: ['bcrt1qte33uyyfzrdrm9nqk0uwlq9dqr6ezu2gurhree'],
+ isAddress: true,
+ },
+ ],
+ vout: [
+ {
+ value: '8999997490',
+ n: 0,
+ addresses: ['bcrt1qjdkgvc67dnk3930gxj2valeadsg49datkyz7tu'],
+ isAddress: true,
+ },
+ ],
+ size: 192,
+ totalInput: '8999998590',
+ totalOutput: '8999997490',
+ },
+};
+
+const createComposeTsResult = (extra?: Partial): PrecomposeResultFinal => ({
+ bytes: 110,
+ fee: '110',
+ feePerByte: '1',
+ inputs: [],
+ outputs: [],
+ outputsPermutation: [],
+ totalSpent: '',
+ type: 'final',
+ ...extra,
+});
+
+const createComposeTransactionMock = () =>
+ jest
+ .spyOn(TrezorConnect, 'composeTransaction')
+
+ // First `composeTransaction` call is just to get size of the transaction
+ .mockImplementation(() =>
+ Promise.resolve({ success: true, payload: [createComposeTsResult()] }),
+ )
+
+ // Second `composeTransaction` call calculates the fee
+ .mockImplementation(() =>
+ Promise.resolve({
+ success: true,
+ // 1520 + 1410 = 2930, responsibility of `composeTransaction` so not tested
+ payload: [createComposeTsResult({ fee: '2930' })],
+ }),
+ );
+
+describe(composeCancelTransactionThunk.name, () => {
+ it('calculates correctly the cancel fee when there is a chain transaction and cancel transaction is less bytes then the original', async () => {
+ const store = initStore();
+ const composeTransactionMock = createComposeTransactionMock();
+
+ await store
+ .dispatch(
+ composeCancelTransactionThunk({
+ tx: transactionWithChange,
+ account,
+ chainedTxs: chainedTxsFixture,
+ }),
+ )
+ .unwrap();
+
+ // First call
+ const first = composeTransactionMock.mock.calls[0][0]; // First call, first argument
+ expect(first.feeLevels[0].feePerUnit).toBe('1');
+ expect(first.baseFee).toBe(undefined);
+
+ // Second call
+ const second = composeTransactionMock.mock.calls[1][0]; // Second call, first argument
+
+ // This is the most important assertion. This is the fee, that satisfies the condition set by BIP-125
+ // with the new size of the transaction of 110 bytes.
+ expect(second.feeLevels[0].feePerUnit).toBe('13.81818181818181818182'); // = (1410 + 110 * 1) / 110
+ expect(second.baseFee).toBe(1410); // This is the sum of fees for chained transactions
+ expect(second.outputs).toStrictEqual([
+ {
+ address: ORIGINAL_CHANGE_ADDRESS,
+ type: 'send-max',
+ },
+ ]);
+ });
+
+ it('uses first change address if tx has no change output (no chained transactions)', async () => {
+ const store = initStore();
+
+ const composeTransactionMock = createComposeTransactionMock();
+
+ await store
+ .dispatch(composeCancelTransactionThunk({ tx: transactionWithNoChange, account }))
+ .unwrap();
+
+ // Second call
+ const second = composeTransactionMock.mock.calls[1][0]; // Second call, first argument
+
+ expect(second.outputs).toStrictEqual([
+ {
+ address: FIRST_ACCOUNT_CHANGE_ADDRESS,
+ type: 'send-max',
+ },
+ ]);
+ });
+});