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 2, 2024
1 parent 75729c0 commit 804e338
Show file tree
Hide file tree
Showing 7 changed files with 149 additions and 25 deletions.
106 changes: 87 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 { BigNumber } from '@trezor/utils';
import { selectAccountTransactionsWithNulls } from '@suite-common/wallet-core';

import { useSelector } from 'src/hooks/suite';

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 @@ -76,23 +87,76 @@ export const useUtxoSelection = ({
composeRequest,
]);

const sortUtxos = (utxos: AccountUtxo[]): AccountUtxo[] => {
if (!utxoSorting) {
return utxos;
}

const sortFromNewestToOldest = (a: AccountUtxo, b: AccountUtxo) => {
let valueA: number;
let valueB: number;

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);
}

return new BigNumber(valueB ?? 0).comparedTo(new BigNumber(valueA ?? 0));
};

const sortFromLargestToSmallest = (a: AccountUtxo, b: AccountUtxo) =>
new BigNumber(b.amount).comparedTo(new BigNumber(a.amount));

// Manipulating the array directly would cause a runtime error, so we create a copy.
const utxosForSorting = utxos.slice();

switch (utxoSorting) {
case 'newestFirst':
return utxosForSorting.sort(sortFromNewestToOldest);
case 'oldestFirst':
return utxosForSorting.sort(sortFromNewestToOldest).reverse();
case 'largestFirst':
return utxosForSorting.sort(sortFromLargestToSmallest);
case 'smallestFirst':
return utxosForSorting.sort(sortFromLargestToSmallest).reverse();
}
};

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)
: 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 +203,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 +270,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 @@ -5716,6 +5716,22 @@ const messages = defineMessagesWithTypeCheck({
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
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 804e338

Please sign in to comment.