diff --git a/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts b/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts index acc2940e200..ddd7681dd8e 100644 --- a/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts +++ b/packages/suite/src/hooks/wallet/form/useUtxoSelection.ts @@ -1,9 +1,13 @@ import { useEffect, useMemo } from 'react'; import { UseFormReturn } from 'react-hook-form'; -import { ExcludedUtxos, FormState } from '@suite-common/wallet-types'; +import { ExcludedUtxos, FormState, UtxoSorting } from '@suite-common/wallet-types'; import type { AccountUtxo, PROTO } from '@trezor/connect'; import { getUtxoOutpoint, isSameUtxo } from '@suite-common/wallet-utils'; +import { selectAccountTransactionsWithNulls } from '@suite-common/wallet-core'; + +import { useSelector } from 'src/hooks/suite'; +import { sortUtxos } from 'src/utils/wallet/utxoSortingUtils'; import { useCoinjoinRegisteredUtxos } from './useCoinjoinRegisteredUtxos'; import { @@ -28,19 +32,26 @@ export const useUtxoSelection = ({ setValue, watch, }: UtxoSelectionContextProps): UtxoSelectionContext => { + const accountTransactions = useSelector(state => + selectAccountTransactionsWithNulls(state, account.key), + ); + // register custom form field (without HTMLElement) useEffect(() => { register('isCoinControlEnabled'); register('selectedUtxos'); register('anonymityWarningChecked'); + register('utxoSorting'); }, [register]); const coinjoinRegisteredUtxos = useCoinjoinRegisteredUtxos({ account }); - // has coin control been enabled manually? - const isCoinControlEnabled = watch('isCoinControlEnabled'); - // fee level - const selectedFee = watch('selectedFee'); + const [isCoinControlEnabled, options, selectedFee, utxoSorting] = watch([ + 'isCoinControlEnabled', + 'options', + 'selectedFee', + 'utxoSorting', + ]); // confirmation of spending low-anonymity UTXOs - only relevant for coinjoin account const anonymityWarningChecked = !!watch('anonymityWarningChecked'); // manually selected UTXOs @@ -79,20 +90,29 @@ export const useUtxoSelection = ({ const spendableUtxos: AccountUtxo[] = []; const lowAnonymityUtxos: AccountUtxo[] = []; const dustUtxos: AccountUtxo[] = []; - account?.utxo?.forEach(utxo => { - switch (excludedUtxos[getUtxoOutpoint(utxo)]) { - case 'low-anonymity': - lowAnonymityUtxos.push(utxo); - - return; - case 'dust': - dustUtxos.push(utxo); - - return; - default: - spendableUtxos.push(utxo); - } - }); + + // Skip sorting and categorizing UTXOs if coin control is not enabled. + const utxos = + options?.includes('utxoSelection') && account?.utxo + ? sortUtxos(account?.utxo, utxoSorting, accountTransactions) + : account?.utxo; + + if (utxos?.length) { + utxos?.forEach(utxo => { + switch (excludedUtxos[getUtxoOutpoint(utxo)]) { + case 'low-anonymity': + lowAnonymityUtxos.push(utxo); + + return; + case 'dust': + dustUtxos.push(utxo); + + return; + default: + spendableUtxos.push(utxo); + } + }); + } // category displayed on top and controlled by the check-all checkbox const topCategory = @@ -139,6 +159,8 @@ export const useUtxoSelection = ({ setValue('anonymityWarningChecked', false); } + const selectUtxoSorting = (sorting: UtxoSorting) => setValue('utxoSorting', sorting); + const toggleAnonymityWarning = () => setValue('anonymityWarningChecked', !anonymityWarningChecked); @@ -204,6 +226,8 @@ export const useUtxoSelection = ({ selectedUtxos, spendableUtxos, coinjoinRegisteredUtxos, + utxoSorting, + selectUtxoSorting, toggleAnonymityWarning, toggleCheckAllUtxos, toggleCoinControl, diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 35b058fed43..1a5fb7e4dec 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -5704,6 +5704,22 @@ export default defineMessages({ defaultMessage: 'There are no spendable UTXOs in your account.', description: 'Message showing in Coin control section', }, + TR_LARGEST_FIRST: { + id: 'TR_LARGEST_FIRST', + defaultMessage: 'Largest first', + }, + TR_SMALLEST_FIRST: { + id: 'TR_SMALLEST_FIRST', + defaultMessage: 'Smallest first', + }, + TR_OLDEST_FIRST: { + id: 'TR_OLDEST_FIRST', + defaultMessage: 'Oldest first', + }, + TR_NEWEST_FIRST: { + id: 'TR_NEWEST_FIRST', + defaultMessage: 'Newest first', + }, TR_LOADING_TRANSACTION_DETAILS: { id: 'TR_LOADING_TRANSACTION_DETAILS', defaultMessage: 'Loading transaction details', diff --git a/packages/suite/src/types/wallet/sendForm.ts b/packages/suite/src/types/wallet/sendForm.ts index 1719cef74ed..c65242c6e84 100644 --- a/packages/suite/src/types/wallet/sendForm.ts +++ b/packages/suite/src/types/wallet/sendForm.ts @@ -14,6 +14,7 @@ import { PrecomposedLevels, PrecomposedLevelsCardano, Rate, + UtxoSorting, WalletAccountTransaction, } from '@suite-common/wallet-types'; import { FiatCurrencyCode } from '@suite-common/suite-config'; @@ -50,6 +51,8 @@ export interface UtxoSelectionContext { coinjoinRegisteredUtxos: AccountUtxo[]; isLowAnonymityUtxoSelected: boolean; anonymityWarningChecked: boolean; + utxoSorting?: UtxoSorting; + selectUtxoSorting: (ordering: UtxoSorting) => void; toggleAnonymityWarning: () => void; toggleCheckAllUtxos: () => void; toggleCoinControl: () => void; diff --git a/packages/suite/src/utils/wallet/__tests__/utxoSortingUtils.test.ts b/packages/suite/src/utils/wallet/__tests__/utxoSortingUtils.test.ts new file mode 100644 index 00000000000..ca753e77b59 --- /dev/null +++ b/packages/suite/src/utils/wallet/__tests__/utxoSortingUtils.test.ts @@ -0,0 +1,74 @@ +import { testMocks } from '@suite-common/test-utils'; + +import { sortUtxos } from '../utxoSortingUtils'; + +const UTXOS = [ + testMocks.getUtxo({ amount: '1', blockHeight: undefined, txid: 'txid1', vout: 0 }), + testMocks.getUtxo({ amount: '2', blockHeight: undefined, txid: 'txid2', vout: 1 }), + testMocks.getUtxo({ amount: '2', blockHeight: 1, txid: 'txid2', vout: 0 }), + testMocks.getUtxo({ amount: '2', blockHeight: 2, txid: 'txid3', vout: 0 }), +]; + +const ACCOUNT_TRANSACTIONS = [ + testMocks.getWalletTransaction({ txid: 'txid1', blockTime: undefined }), + testMocks.getWalletTransaction({ txid: 'txid2', blockTime: 1 }), + testMocks.getWalletTransaction({ txid: 'txid3', blockTime: 2 }), +]; + +const findTx = (txid: string) => ACCOUNT_TRANSACTIONS.find(tx => tx.txid === txid); + +describe(sortUtxos.name, () => { + it('sorts UTXOs by newest first', () => { + const sortedUtxos = sortUtxos(UTXOS, 'newestFirst', ACCOUNT_TRANSACTIONS); + expect( + sortedUtxos.map(it => [ + it.blockHeight ?? findTx(it.txid)?.blockTime, + `${it.txid}:${it.vout}`, // for stable sorting + ]), + ).toEqual([ + [2, 'txid3:0'], + [1, 'txid2:1'], + [1, 'txid2:0'], + [undefined, 'txid1:0'], + ]); + }); + + it('sorts UTXOs by oldest first', () => { + const sortedUtxos = sortUtxos(UTXOS, 'oldestFirst', ACCOUNT_TRANSACTIONS); + expect( + sortedUtxos.map(it => [ + it.blockHeight ?? findTx(it.txid)?.blockTime, + `${it.txid}:${it.vout}`, // for stable sorting + ]), + ).toEqual([ + [undefined, 'txid1:0'], + [1, 'txid2:0'], + [1, 'txid2:1'], + [2, 'txid3:0'], + ]); + }); + + it('sorts by size, largest first', () => { + const sortedUtxos = sortUtxos(UTXOS.slice(0, 2), 'largestFirst', ACCOUNT_TRANSACTIONS); + expect(sortedUtxos.map(it => it.amount)).toEqual(['2', '1']); + }); + + it('sorts by size, smallest first', () => { + const sortedUtxos = sortUtxos(UTXOS.slice(0, 2), 'smallestFirst', ACCOUNT_TRANSACTIONS); + expect(sortedUtxos.map(it => it.amount)).toEqual(['1', '2']); + }); + + it('sorts by secondary sorting by `txid` and `vout` in case of same values', () => { + const sortedUtxos = sortUtxos(UTXOS.slice(1, 4), 'smallestFirst', ACCOUNT_TRANSACTIONS); + expect(sortedUtxos.map(it => `${it.txid}:${it.vout}`)).toEqual([ + 'txid2:0', + 'txid2:1', + 'txid3:0', + ]); + }); + + it('returns the original array if utxoSorting is undefined', () => { + const sortedUtxos = sortUtxos(UTXOS, undefined, ACCOUNT_TRANSACTIONS); + expect(sortedUtxos).toEqual(UTXOS); + }); +}); diff --git a/packages/suite/src/utils/wallet/utxoSortingUtils.ts b/packages/suite/src/utils/wallet/utxoSortingUtils.ts new file mode 100644 index 00000000000..465bfcc8b22 --- /dev/null +++ b/packages/suite/src/utils/wallet/utxoSortingUtils.ts @@ -0,0 +1,79 @@ +import { UtxoSorting, WalletAccountTransaction } from '@suite-common/wallet-types'; +import type { AccountUtxo } from '@trezor/connect'; +import { BigNumber } from '@trezor/utils'; + +type UtxoSortingFunction = (a: AccountUtxo, b: AccountUtxo) => number; + +type UtxoSortingFunctionWithContext = (context: { + accountTransactions: WalletAccountTransaction[]; +}) => UtxoSortingFunction; + +const performSecondarySorting: UtxoSortingFunction = (a, b) => { + const secondaryComparison = b.txid.localeCompare(a.txid); + if (secondaryComparison === 0) { + return b.vout - a.vout; + } + + return secondaryComparison; +}; + +const wrapSecondarySorting = + (sortFunction: UtxoSortingFunctionWithContext): UtxoSortingFunctionWithContext => + context => + (a, b) => { + const result = sortFunction(context)(a, b); + + if (result !== 0) { + return result; + } + + return performSecondarySorting(a, b); + }; + +const sortFromLargestToSmallest: UtxoSortingFunctionWithContext = () => (a, b) => + new BigNumber(b.amount).comparedTo(new BigNumber(a.amount)); + +const sortFromNewestToOldest: UtxoSortingFunctionWithContext = + ({ accountTransactions }) => + (a, b) => { + if (a.blockHeight > 0 && b.blockHeight > 0) { + return b.blockHeight - a.blockHeight; + } else { + // Pending transactions do not have blockHeight, so we must use blockTime of the transaction instead. + const getBlockTime = (txid: string) => { + const transaction = accountTransactions.find( + transaction => transaction.txid === txid, + ); + + return transaction?.blockTime ?? 0; + }; + + return getBlockTime(b.txid) - getBlockTime(a.txid); + } + }; + +const utxoSortMap: Record = { + largestFirst: wrapSecondarySorting(sortFromLargestToSmallest), + smallestFirst: + context => + (...params) => + wrapSecondarySorting(sortFromLargestToSmallest)(context)(...params) * -1, + + newestFirst: wrapSecondarySorting(sortFromNewestToOldest), + oldestFirst: + context => + (...params) => + wrapSecondarySorting(sortFromNewestToOldest)(context)(...params) * -1, +}; + +export const sortUtxos = ( + utxos: AccountUtxo[], + utxoSorting: UtxoSorting | undefined, + accountTransactions: WalletAccountTransaction[], +): AccountUtxo[] => { + if (utxoSorting === undefined) { + return utxos; + } + + return [...utxos].sort(utxoSortMap[utxoSorting]({ accountTransactions })); +}; diff --git a/packages/suite/src/views/wallet/send/Options/BitcoinOptions/CoinControl/CoinControl.tsx b/packages/suite/src/views/wallet/send/Options/BitcoinOptions/CoinControl/CoinControl.tsx index 49941ce60d7..fc3512276d3 100644 --- a/packages/suite/src/views/wallet/send/Options/BitcoinOptions/CoinControl/CoinControl.tsx +++ b/packages/suite/src/views/wallet/send/Options/BitcoinOptions/CoinControl/CoinControl.tsx @@ -18,6 +18,7 @@ import { selectCurrentTargetAnonymity } from 'src/reducers/wallet/coinjoinReduce import { selectLabelingDataForSelectedAccount } from 'src/reducers/suite/metadataReducer'; import { filterAndCategorizeUtxos } from 'src/utils/wallet/filterAndCategorizeUtxosUtils'; +import { UtxoSortingSelect } from './UtxoSortingSelect'; import { UtxoSelectionList } from './UtxoSelectionList/UtxoSelectionList'; import { UtxoSearch } from './UtxoSearch'; @@ -26,10 +27,6 @@ const Header = styled.header` padding-bottom: ${spacingsPx.sm}; `; -const SearchWrapper = styled.div` - margin-top: ${spacingsPx.lg}; -`; - const MissingToInput = styled.div<{ $isVisible: boolean }>` /* using visibility rather than display to prevent line height change */ visibility: ${({ $isVisible }) => !$isVisible && 'hidden'}; @@ -204,13 +201,14 @@ export const CoinControl = ({ close }: CoinControlProps) => { {hasEligibleUtxos && ( - + - + + )} {!!spendableUtxosOnPage.length && ( }, + { value: 'oldestFirst', label: }, + { value: 'smallestFirst', label: }, + { value: 'largestFirst', label: }, +]; + +export const UtxoSortingSelect = () => { + const { + utxoSelection: { utxoSorting, selectUtxoSorting }, + } = useSendFormContext(); + + const selectedOption = sortingOptions.find(option => option.value === utxoSorting); + + const handleChange = ({ value }: Option) => selectUtxoSorting(value); + + return ( +