From c89c8342c8d2d1263699162e4a65d4adc9860fc0 Mon Sep 17 00:00:00 2001 From: Peter Sanderson Date: Thu, 23 Jan 2025 15:08:18 +0100 Subject: [PATCH] feat: Cancel Transaction --- .../src/actions/wallet/send/sendFormThunks.ts | 12 +- .../CancelTransaction/CancelTransaction.tsx | 122 +++++++++++ .../CancelTransactionButton.tsx | 63 ++++++ .../CancelTransactionFailed.tsx | 29 +++ .../CancelTransactionModal.tsx | 113 +++++++++++ .../TxDetailModal/Detail/DetailModal.tsx | 5 + .../TxDetailModal/TxDetailModal.tsx | 22 +- .../TransactionItem/TransactionItem.tsx | 18 +- .../src/hooks/wallet/useCancelTxContext.ts | 19 ++ packages/suite/src/support/messages.ts | 40 +++- packages/urls/src/urls.ts | 2 + suite-common/suite-types/src/modal.ts | 2 +- suite-common/wallet-core/src/index.ts | 34 ++-- .../calculateNewFee.ts | 42 ++++ .../cancelTransactionTypes.ts | 7 + .../composeCancelTransactionThunk.ts | 139 +++++++++++++ .../resolveCancelAddress.ts | 26 +++ .../calculateNewFee.test.ts | 31 +++ .../chainedTransactions.fixture.ts | 99 +++++++++ .../composeCancelTransactionThunk.test.ts | 189 ++++++++++++++++++ 20 files changed, 989 insertions(+), 25 deletions(-) create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionButton.tsx create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionFailed.tsx create mode 100644 packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransactionModal.tsx create mode 100644 packages/suite/src/hooks/wallet/useCancelTxContext.ts create mode 100644 suite-common/wallet-core/src/send/composeCancelTransaction/calculateNewFee.ts create mode 100644 suite-common/wallet-core/src/send/composeCancelTransaction/cancelTransactionTypes.ts create mode 100644 suite-common/wallet-core/src/send/composeCancelTransaction/composeCancelTransactionThunk.ts create mode 100644 suite-common/wallet-core/src/send/composeCancelTransaction/resolveCancelAddress.ts create mode 100644 suite-common/wallet-core/tests/send/composeCancelTransaction/calculateNewFee.test.ts create mode 100644 suite-common/wallet-core/tests/send/composeCancelTransaction/chainedTransactions.fixture.ts create mode 100644 suite-common/wallet-core/tests/send/composeCancelTransaction/composeCancelTransactionThunk.test.ts 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', + }, + ]); + }); +});