Skip to content

Commit 804e338

Browse files
committed
feat(suite, suite-common): add utxo sorting
1 parent 75729c0 commit 804e338

File tree

7 files changed

+149
-25
lines changed

7 files changed

+149
-25
lines changed

packages/suite/src/hooks/wallet/form/useUtxoSelection.ts

Lines changed: 87 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import { useEffect, useMemo } from 'react';
22
import { UseFormReturn } from 'react-hook-form';
33

4-
import { ExcludedUtxos, FormState } from '@suite-common/wallet-types';
4+
import { ExcludedUtxos, FormState, UtxoSorting } from '@suite-common/wallet-types';
55
import type { AccountUtxo, PROTO } from '@trezor/connect';
66
import { getUtxoOutpoint, isSameUtxo } from '@suite-common/wallet-utils';
7+
import { BigNumber } from '@trezor/utils';
8+
import { selectAccountTransactionsWithNulls } from '@suite-common/wallet-core';
9+
10+
import { useSelector } from 'src/hooks/suite';
711

812
import { useCoinjoinRegisteredUtxos } from './useCoinjoinRegisteredUtxos';
913
import {
@@ -28,19 +32,26 @@ export const useUtxoSelection = ({
2832
setValue,
2933
watch,
3034
}: UtxoSelectionContextProps): UtxoSelectionContext => {
35+
const accountTransactions = useSelector(state =>
36+
selectAccountTransactionsWithNulls(state, account.key),
37+
);
38+
3139
// register custom form field (without HTMLElement)
3240
useEffect(() => {
3341
register('isCoinControlEnabled');
3442
register('selectedUtxos');
3543
register('anonymityWarningChecked');
44+
register('utxoSorting');
3645
}, [register]);
3746

3847
const coinjoinRegisteredUtxos = useCoinjoinRegisteredUtxos({ account });
3948

40-
// has coin control been enabled manually?
41-
const isCoinControlEnabled = watch('isCoinControlEnabled');
42-
// fee level
43-
const selectedFee = watch('selectedFee');
49+
const [isCoinControlEnabled, options, selectedFee, utxoSorting] = watch([
50+
'isCoinControlEnabled',
51+
'options',
52+
'selectedFee',
53+
'utxoSorting',
54+
]);
4455
// confirmation of spending low-anonymity UTXOs - only relevant for coinjoin account
4556
const anonymityWarningChecked = !!watch('anonymityWarningChecked');
4657
// manually selected UTXOs
@@ -76,23 +87,76 @@ export const useUtxoSelection = ({
7687
composeRequest,
7788
]);
7889

90+
const sortUtxos = (utxos: AccountUtxo[]): AccountUtxo[] => {
91+
if (!utxoSorting) {
92+
return utxos;
93+
}
94+
95+
const sortFromNewestToOldest = (a: AccountUtxo, b: AccountUtxo) => {
96+
let valueA: number;
97+
let valueB: number;
98+
99+
if (a.blockHeight > 0 && b.blockHeight > 0) {
100+
valueA = a.blockHeight;
101+
valueB = b.blockHeight;
102+
} else {
103+
// Pending transactions do not have blockHeight, so we must use blockTime of the transaction instead.
104+
const getBlockTime = (txid: string) => {
105+
const transaction = accountTransactions.find(
106+
transaction => transaction.txid === txid,
107+
);
108+
109+
return transaction?.blockTime ?? 0;
110+
};
111+
valueA = getBlockTime(a.txid);
112+
valueB = getBlockTime(b.txid);
113+
}
114+
115+
return new BigNumber(valueB ?? 0).comparedTo(new BigNumber(valueA ?? 0));
116+
};
117+
118+
const sortFromLargestToSmallest = (a: AccountUtxo, b: AccountUtxo) =>
119+
new BigNumber(b.amount).comparedTo(new BigNumber(a.amount));
120+
121+
// Manipulating the array directly would cause a runtime error, so we create a copy.
122+
const utxosForSorting = utxos.slice();
123+
124+
switch (utxoSorting) {
125+
case 'newestFirst':
126+
return utxosForSorting.sort(sortFromNewestToOldest);
127+
case 'oldestFirst':
128+
return utxosForSorting.sort(sortFromNewestToOldest).reverse();
129+
case 'largestFirst':
130+
return utxosForSorting.sort(sortFromLargestToSmallest);
131+
case 'smallestFirst':
132+
return utxosForSorting.sort(sortFromLargestToSmallest).reverse();
133+
}
134+
};
135+
79136
const spendableUtxos: AccountUtxo[] = [];
80137
const lowAnonymityUtxos: AccountUtxo[] = [];
81138
const dustUtxos: AccountUtxo[] = [];
82-
account?.utxo?.forEach(utxo => {
83-
switch (excludedUtxos[getUtxoOutpoint(utxo)]) {
84-
case 'low-anonymity':
85-
lowAnonymityUtxos.push(utxo);
86-
87-
return;
88-
case 'dust':
89-
dustUtxos.push(utxo);
90-
91-
return;
92-
default:
93-
spendableUtxos.push(utxo);
94-
}
95-
});
139+
// Skip sorting and categorizing UTXOs if coin control is not enabled.
140+
const utxos =
141+
options?.includes('utxoSelection') && account?.utxo
142+
? sortUtxos(account?.utxo)
143+
: account?.utxo;
144+
if (utxos?.length) {
145+
utxos?.forEach(utxo => {
146+
switch (excludedUtxos[getUtxoOutpoint(utxo)]) {
147+
case 'low-anonymity':
148+
lowAnonymityUtxos.push(utxo);
149+
150+
return;
151+
case 'dust':
152+
dustUtxos.push(utxo);
153+
154+
return;
155+
default:
156+
spendableUtxos.push(utxo);
157+
}
158+
});
159+
}
96160

97161
// category displayed on top and controlled by the check-all checkbox
98162
const topCategory =
@@ -139,6 +203,8 @@ export const useUtxoSelection = ({
139203
setValue('anonymityWarningChecked', false);
140204
}
141205

206+
const selectUtxoSorting = (sorting: UtxoSorting) => setValue('utxoSorting', sorting);
207+
142208
const toggleAnonymityWarning = () =>
143209
setValue('anonymityWarningChecked', !anonymityWarningChecked);
144210

@@ -204,6 +270,8 @@ export const useUtxoSelection = ({
204270
selectedUtxos,
205271
spendableUtxos,
206272
coinjoinRegisteredUtxos,
273+
utxoSorting,
274+
selectUtxoSorting,
207275
toggleAnonymityWarning,
208276
toggleCheckAllUtxos,
209277
toggleCoinControl,

packages/suite/src/support/messages.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5716,6 +5716,22 @@ const messages = defineMessagesWithTypeCheck({
57165716
defaultMessage: 'There are no spendable UTXOs in your account.',
57175717
description: 'Message showing in Coin control section',
57185718
},
5719+
TR_LARGEST_FIRST: {
5720+
id: 'TR_LARGEST_FIRST',
5721+
defaultMessage: 'Largest first',
5722+
},
5723+
TR_SMALLEST_FIRST: {
5724+
id: 'TR_SMALLEST_FIRST',
5725+
defaultMessage: 'Smallest first',
5726+
},
5727+
TR_OLDEST_FIRST: {
5728+
id: 'TR_OLDEST_FIRST',
5729+
defaultMessage: 'Oldest first',
5730+
},
5731+
TR_NEWEST_FIRST: {
5732+
id: 'TR_NEWEST_FIRST',
5733+
defaultMessage: 'Newest first',
5734+
},
57195735
TR_LOADING_TRANSACTION_DETAILS: {
57205736
id: 'TR_LOADING_TRANSACTION_DETAILS',
57215737
defaultMessage: 'Loading transaction details',

packages/suite/src/types/wallet/sendForm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
PrecomposedLevels,
1515
PrecomposedLevelsCardano,
1616
Rate,
17+
UtxoSorting,
1718
WalletAccountTransaction,
1819
} from '@suite-common/wallet-types';
1920
import { FiatCurrencyCode } from '@suite-common/suite-config';
@@ -50,6 +51,8 @@ export interface UtxoSelectionContext {
5051
coinjoinRegisteredUtxos: AccountUtxo[];
5152
isLowAnonymityUtxoSelected: boolean;
5253
anonymityWarningChecked: boolean;
54+
utxoSorting?: UtxoSorting;
55+
selectUtxoSorting: (ordering: UtxoSorting) => void;
5356
toggleAnonymityWarning: () => void;
5457
toggleCheckAllUtxos: () => void;
5558
toggleCoinControl: () => void;

packages/suite/src/views/wallet/send/Options/BitcoinOptions/CoinControl/CoinControl.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { selectCurrentTargetAnonymity } from 'src/reducers/wallet/coinjoinReduce
1818
import { selectLabelingDataForSelectedAccount } from 'src/reducers/suite/metadataReducer';
1919
import { filterAndCategorizeUtxos } from 'src/utils/wallet/filterAndCategorizeUtxosUtils';
2020

21+
import { UtxoSortingSelect } from './UtxoSortingSelect';
2122
import { UtxoSelectionList } from './UtxoSelectionList/UtxoSelectionList';
2223
import { UtxoSearch } from './UtxoSearch';
2324

@@ -26,10 +27,6 @@ const Header = styled.header`
2627
padding-bottom: ${spacingsPx.sm};
2728
`;
2829

29-
const SearchWrapper = styled.div`
30-
margin-top: ${spacingsPx.lg};
31-
`;
32-
3330
const MissingToInput = styled.div<{ $isVisible: boolean }>`
3431
/* using visibility rather than display to prevent line height change */
3532
visibility: ${({ $isVisible }) => !$isVisible && 'hidden'};
@@ -204,13 +201,14 @@ export const CoinControl = ({ close }: CoinControlProps) => {
204201
</Row>
205202
</Header>
206203
{hasEligibleUtxos && (
207-
<SearchWrapper>
204+
<Row gap={spacings.sm} margin={{ top: spacings.lg }}>
208205
<UtxoSearch
209206
searchQuery={searchQuery}
210207
setSearch={setSearchQuery}
211208
setSelectedPage={setSelectedPage}
212209
/>
213-
</SearchWrapper>
210+
<UtxoSortingSelect />
211+
</Row>
214212
)}
215213
{!!spendableUtxosOnPage.length && (
216214
<UtxoSelectionList
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { ReactNode } from 'react';
2+
3+
import { UtxoSorting } from '@suite-common/wallet-types';
4+
import { Option, Select } from '@trezor/components';
5+
6+
import { Translation } from 'src/components/suite';
7+
import { useSendFormContext } from 'src/hooks/wallet';
8+
9+
const sortingOptions: { value: UtxoSorting; label: ReactNode }[] = [
10+
{ value: 'newestFirst', label: <Translation id="TR_NEWEST_FIRST" /> },
11+
{ value: 'oldestFirst', label: <Translation id="TR_OLDEST_FIRST" /> },
12+
{ value: 'smallestFirst', label: <Translation id="TR_SMALLEST_FIRST" /> },
13+
{ value: 'largestFirst', label: <Translation id="TR_LARGEST_FIRST" /> },
14+
];
15+
16+
export const UtxoSortingSelect = () => {
17+
const {
18+
utxoSelection: { utxoSorting, selectUtxoSorting },
19+
} = useSendFormContext();
20+
21+
const selectedOption = sortingOptions.find(option => option.value === utxoSorting);
22+
23+
const handleChange = ({ value }: Option) => selectUtxoSorting(value);
24+
25+
return (
26+
<Select
27+
options={sortingOptions}
28+
value={selectedOption}
29+
onChange={handleChange}
30+
size="small"
31+
width={240}
32+
data-testid="@coin-control/utxo-sorting-select"
33+
/>
34+
);
35+
};

suite-common/wallet-constants/src/sendForm.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export const DEFAULT_VALUES = {
5151
outputs: [],
5252
isCoinControlEnabled: false,
5353
hasCoinControlBeenOpened: false,
54+
utxoSorting: 'newestFirst',
5455
} as const;
5556

5657
// Time-to-live (TTL) in cardano represents a slot, or deadline by which a transaction must be submitted.

suite-common/wallet-types/src/sendForm.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ export type FormOptions =
1010
| 'ethereumNonce' // TODO
1111
| 'rippleDestinationTag';
1212

13+
export type UtxoSorting = 'newestFirst' | 'oldestFirst' | 'smallestFirst' | 'largestFirst';
14+
1315
export interface FormState {
1416
outputs: Output[]; // output arrays, each element is corresponding with single Output item
1517
setMaxOutputId?: number;
@@ -31,4 +33,5 @@ export interface FormState {
3133
hasCoinControlBeenOpened: boolean;
3234
anonymityWarningChecked?: boolean;
3335
selectedUtxos: AccountUtxo[];
36+
utxoSorting?: UtxoSorting;
3437
}

0 commit comments

Comments
 (0)