Skip to content

Commit

Permalink
feat(suite, suite-common): add utxo sorting
Browse files Browse the repository at this point in the history
  • Loading branch information
komret committed Dec 6, 2024
1 parent f246835 commit 587e8d1
Show file tree
Hide file tree
Showing 9 changed files with 232 additions and 25 deletions.
62 changes: 43 additions & 19 deletions packages/suite/src/hooks/wallet/form/useUtxoSelection.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -139,6 +159,8 @@ export const useUtxoSelection = ({
setValue('anonymityWarningChecked', false);
}

const selectUtxoSorting = (sorting: UtxoSorting) => setValue('utxoSorting', sorting);

const toggleAnonymityWarning = () =>
setValue('anonymityWarningChecked', !anonymityWarningChecked);

Expand Down Expand Up @@ -204,6 +226,8 @@ export const useUtxoSelection = ({
selectedUtxos,
spendableUtxos,
coinjoinRegisteredUtxos,
utxoSorting,
selectUtxoSorting,
toggleAnonymityWarning,
toggleCheckAllUtxos,
toggleCoinControl,
Expand Down
16 changes: 16 additions & 0 deletions packages/suite/src/support/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5712,6 +5712,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',
Expand Down
3 changes: 3 additions & 0 deletions packages/suite/src/types/wallet/sendForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PrecomposedLevels,
PrecomposedLevelsCardano,
Rate,
UtxoSorting,
WalletAccountTransaction,
} from '@suite-common/wallet-types';
import { FiatCurrencyCode } from '@suite-common/suite-config';
Expand Down Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions packages/suite/src/utils/wallet/__tests__/utxoSortingUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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: '1', 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 }),
];

describe('sortUtxos', () => {
it('should sort UTXOs by newest first', () => {
const sortedUtxos = sortUtxos(UTXOS, 'newestFirst', ACCOUNT_TRANSACTIONS);
expect(sortedUtxos).toEqual([UTXOS[3], UTXOS[1], UTXOS[2], UTXOS[0]]);
});

it('should sort UTXOs by oldest first', () => {
const sortedUtxos = sortUtxos(UTXOS, 'oldestFirst', ACCOUNT_TRANSACTIONS);
expect(sortedUtxos).toEqual([UTXOS[0], UTXOS[2], UTXOS[1], UTXOS[3]]);
});

it('should sort UTXOs by largest first', () => {
const sortedUtxos = sortUtxos(UTXOS, 'largestFirst', ACCOUNT_TRANSACTIONS);
expect(sortedUtxos).toEqual([UTXOS[3], UTXOS[2], UTXOS[1], UTXOS[0]]);
});

it('should sort UTXOs by smallest first', () => {
const sortedUtxos = sortUtxos(UTXOS, 'smallestFirst', ACCOUNT_TRANSACTIONS);
expect(sortedUtxos).toEqual([UTXOS[0], UTXOS[1], UTXOS[2], UTXOS[3]]);
});

it('should return the original array if utxoSorting is undefined', () => {
const sortedUtxos = sortUtxos(UTXOS, undefined, ACCOUNT_TRANSACTIONS);
expect(sortedUtxos).toEqual(UTXOS);
});
});
84 changes: 84 additions & 0 deletions packages/suite/src/utils/wallet/utxoSortingUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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 sortFromLargestToSmallest: UtxoSortingFunctionWithContext = () => (a, b) => {
const comparisonResult = new BigNumber(b.amount).comparedTo(new BigNumber(a.amount));

if (comparisonResult === 0) {
return performSecondarySorting(a, b);
}

return comparisonResult;
};

const sortFromNewestToOldest: UtxoSortingFunctionWithContext =
({ accountTransactions }) =>
(a, b) => {
let valueA;
let valueB;
if (a.blockHeight > 0 && b.blockHeight > 0) {
valueA = a.blockHeight;
valueB = b.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;
};
valueA = getBlockTime(a.txid);
valueB = getBlockTime(b.txid);
}

const comparisonResult = valueB - valueA;

if (comparisonResult === 0) {
return performSecondarySorting(a, b);
}

return comparisonResult;
};

const utxoSortMap: Record<UtxoSorting, UtxoSortingFunctionWithContext> = {
largestFirst: sortFromLargestToSmallest,
smallestFirst:
context =>
(...params) =>
sortFromLargestToSmallest(context)(...params) * -1,

newestFirst: sortFromNewestToOldest,
oldestFirst:
context =>
(...params) =>
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 }));
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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'};
Expand Down Expand Up @@ -204,13 +201,14 @@ export const CoinControl = ({ close }: CoinControlProps) => {
</Row>
</Header>
{hasEligibleUtxos && (
<SearchWrapper>
<Row gap={spacings.sm} margin={{ top: spacings.lg }}>
<UtxoSearch
searchQuery={searchQuery}
setSearch={setSearchQuery}
setSelectedPage={setSelectedPage}
/>
</SearchWrapper>
<UtxoSortingSelect />
</Row>
)}
{!!spendableUtxosOnPage.length && (
<UtxoSelectionList
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ReactNode } from 'react';

import { UtxoSorting } from '@suite-common/wallet-types';
import { Option, Select } from '@trezor/components';

import { Translation } from 'src/components/suite';
import { useSendFormContext } from 'src/hooks/wallet';

const sortingOptions: { value: UtxoSorting; label: ReactNode }[] = [
{ value: 'newestFirst', label: <Translation id="TR_NEWEST_FIRST" /> },
{ value: 'oldestFirst', label: <Translation id="TR_OLDEST_FIRST" /> },
{ value: 'smallestFirst', label: <Translation id="TR_SMALLEST_FIRST" /> },
{ value: 'largestFirst', label: <Translation id="TR_LARGEST_FIRST" /> },
];

export const UtxoSortingSelect = () => {
const {
utxoSelection: { utxoSorting, selectUtxoSorting },
} = useSendFormContext();

const selectedOption = sortingOptions.find(option => option.value === utxoSorting);

const handleChange = ({ value }: Option) => selectUtxoSorting(value);

return (
<Select
options={sortingOptions}
value={selectedOption}
onChange={handleChange}
size="small"
width={240}
data-testid="@coin-control/utxo-sorting-select"
/>
);
};
1 change: 1 addition & 0 deletions suite-common/wallet-constants/src/sendForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const DEFAULT_VALUES = {
outputs: [],
isCoinControlEnabled: false,
hasCoinControlBeenOpened: false,
utxoSorting: 'newestFirst',
} as const;

// Time-to-live (TTL) in cardano represents a slot, or deadline by which a transaction must be submitted.
Expand Down
3 changes: 3 additions & 0 deletions suite-common/wallet-types/src/sendForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type FormOptions =
| 'ethereumNonce' // TODO
| 'rippleDestinationTag';

export type UtxoSorting = 'newestFirst' | 'oldestFirst' | 'smallestFirst' | 'largestFirst';

export interface FormState {
outputs: Output[]; // output arrays, each element is corresponding with single Output item
setMaxOutputId?: number;
Expand All @@ -31,4 +33,5 @@ export interface FormState {
hasCoinControlBeenOpened: boolean;
anonymityWarningChecked?: boolean;
selectedUtxos: AccountUtxo[];
utxoSorting?: UtxoSorting;
}

0 comments on commit 587e8d1

Please sign in to comment.