diff --git a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts index 12193db13e2..2fbf1355bd2 100644 --- a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts +++ b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.spec.ts @@ -1,7 +1,8 @@ import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants'; import { createMoney, createNullArrayOfLength, sumNumbers } from '@leather.io/utils'; -import { determineUtxosForSpend } from './local-coin-selection'; +import { filterUneconomicalUtxos, getSizeInfo } from '../utils'; +import { determineUtxosForSpend, determineUtxosForSpendAll } from './local-coin-selection'; const demoUtxos = [ { value: 8200 }, @@ -168,4 +169,64 @@ describe(determineUtxosForSpend.name, () => { .toString() ); }); + + test('that spending all economical spendable utxos does not result in dust utxos', () => { + const feeRate = 3; + const recipients = [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + amount: createMoney(Number(1), 'BTC'), + }, + ]; + const filteredUtxos = filterUneconomicalUtxos({ + utxos: demoUtxos.sort((a, b) => b.value - a.value) as any, + feeRate, + recipients, + }); + const amount = filteredUtxos.reduce((total, utxo) => total + utxo.value, 0) - 2251; + recipients[0].amount = createMoney(Number(amount), 'BTC'); + + const result = determineUtxosForSpend({ + utxos: filteredUtxos as any, + recipients: [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + amount: createMoney(Number(amount), 'BTC'), + }, + ], + feeRate, + }); + expect(result.inputs.length).toEqual(10); + expect(result.outputs.length).toEqual(1); + expect(result.fee).toEqual(2251); + }); + + test('that spending all utxos with sendMax does not result in dust utxos', () => { + const utxos = [{ value: 1000 }, { value: 2000 }, { value: 3000 }]; + const recipients = [ + { + address: 'tb1qt28eagxcl9gvhq2rpj5slg7dwgxae2dn2hk93m', + amount: createMoney(Number(1), 'BTC'), + }, + ]; + const sizeInfo = getSizeInfo({ + inputLength: utxos.length, + isSendMax: true, + recipients, + }); + const feeRate = 3; + const fee = Math.floor(sizeInfo.txVBytes * feeRate); + const amount = utxos.reduce((total, utxo) => total + utxo.value, 0) - fee; + recipients[0].amount = createMoney(Number(amount), 'BTC'); + + const result = determineUtxosForSpendAll({ + utxos: utxos as any, + recipients, + feeRate, + }); + expect(result.inputs.length).toEqual(utxos.length); + expect(result.outputs.length).toEqual(1); + expect(result.fee).toEqual(735); + expect(fee).toEqual(735); + }); }); diff --git a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts index 089ed0c7836..306e0279531 100644 --- a/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts +++ b/src/app/common/transactions/bitcoin/coinselect/local-coin-selection.ts @@ -1,6 +1,7 @@ import BigNumber from 'bignumber.js'; import { validate } from 'bitcoin-address-validation'; +import { BTC_P2WPKH_DUST_AMOUNT } from '@leather.io/constants'; import type { UtxoResponseItem } from '@leather.io/query'; import { sumMoney, sumNumbers } from '@leather.io/utils'; @@ -14,6 +15,11 @@ export class InsufficientFundsError extends Error { } } +interface Output { + value: bigint; + address?: string; +} + export interface DetermineUtxosForSpendArgs { feeRate: number; recipients: TransferRecipient[]; @@ -101,20 +107,24 @@ export function determineUtxosForSpend({ feeRate, recipients, utxos }: Determine new BigNumber(estimateTransactionSize().txVBytes).multipliedBy(feeRate).toNumber() ); - const outputs: { - value: bigint; - address?: string; - }[] = [ + const changeAmount = + BigInt(getUtxoTotal(neededUtxos).toString()) - BigInt(amount.amount.toNumber()) - BigInt(fee); + + const changeUtxos: Output[] = + changeAmount > BTC_P2WPKH_DUST_AMOUNT + ? [ + { + value: changeAmount, + }, + ] + : []; + + const outputs: Output[] = [ ...recipients.map(({ address, amount }) => ({ value: BigInt(amount.amount.toNumber()), address, })), - { - value: - BigInt(getUtxoTotal(neededUtxos).toString()) - - BigInt(amount.amount.toNumber()) - - BigInt(fee), - }, + ...changeUtxos, ]; return { diff --git a/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee-input.tsx b/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee-input.tsx index aa900dd2345..af2b1fd0304 100644 --- a/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee-input.tsx +++ b/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee-input.tsx @@ -4,7 +4,7 @@ import { useField } from 'formik'; import { Stack } from 'leather-styles/jsx'; import { Input } from '@leather.io/ui'; -import { createMoney, satToBtc } from '@leather.io/utils'; +import { satToBtc } from '@leather.io/utils'; import type { TransferRecipient } from '@shared/models/form.model'; @@ -19,7 +19,6 @@ const feeInputLabel = 'sats/vB'; interface Props { onClick?(): void; - amount: number; isSendingMax: boolean; recipients: TransferRecipient[]; hasInsufficientBalanceError: boolean; @@ -30,7 +29,6 @@ interface Props { export function BitcoinCustomFeeInput({ onClick, - amount, isSendingMax, recipients, hasInsufficientBalanceError, @@ -45,7 +43,6 @@ export function BitcoinCustomFeeInput({ }>(null); const getCustomFeeValues = useBitcoinCustomFee({ - amount: createMoney(amount, 'BTC'), isSendingMax, recipients, }); diff --git a/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee.tsx b/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee.tsx index dc92f9ee90f..ff271b81d25 100644 --- a/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee.tsx +++ b/src/app/components/bitcoin-custom-fee/bitcoin-custom-fee.tsx @@ -7,7 +7,6 @@ import * as yup from 'yup'; import type { BtcFeeType } from '@leather.io/models'; import { Button, Link } from '@leather.io/ui'; -import { createMoney } from '@leather.io/utils'; import type { TransferRecipient } from '@shared/models/form.model'; @@ -31,7 +30,6 @@ interface BitcoinCustomFeeProps { } export function BitcoinCustomFee({ - amount, customFeeInitialValue, hasInsufficientBalanceError, isSendingMax, @@ -44,7 +42,6 @@ export function BitcoinCustomFee({ }: BitcoinCustomFeeProps) { const feeInputRef = useRef(null); const getCustomFeeValues = useBitcoinCustomFee({ - amount: createMoney(amount, 'BTC'), isSendingMax, recipients, }); @@ -97,7 +94,6 @@ export function BitcoinCustomFee({ { feeInputRef.current?.focus(); diff --git a/src/app/components/bitcoin-custom-fee/hooks/use-bitcoin-custom-fee.tsx b/src/app/components/bitcoin-custom-fee/hooks/use-bitcoin-custom-fee.tsx index c422ac113fd..cb72151d4b0 100644 --- a/src/app/components/bitcoin-custom-fee/hooks/use-bitcoin-custom-fee.tsx +++ b/src/app/components/bitcoin-custom-fee/hooks/use-bitcoin-custom-fee.tsx @@ -1,28 +1,25 @@ import { useCallback } from 'react'; -import type { Money } from '@leather.io/models'; import { useCryptoCurrencyMarketDataMeanAverage } from '@leather.io/query'; import { baseCurrencyAmountInQuote, createMoney, i18nFormatCurrency } from '@leather.io/utils'; import type { TransferRecipient } from '@shared/models/form.model'; import { + type DetermineUtxosForSpendArgs, determineUtxosForSpend, determineUtxosForSpendAll, } from '@app/common/transactions/bitcoin/coinselect/local-coin-selection'; import { useCurrentNativeSegwitUtxos } from '@app/query/bitcoin/address/utxos-by-address.hooks'; -import { useCurrentBtcCryptoAssetBalanceNativeSegwit } from '@app/query/bitcoin/balance/btc-balance-native-segwit.hooks'; export const MAX_FEE_RATE_MULTIPLIER = 50; interface UseBitcoinCustomFeeArgs { - amount: Money; isSendingMax: boolean; recipients: TransferRecipient[]; } -export function useBitcoinCustomFee({ amount, isSendingMax, recipients }: UseBitcoinCustomFeeArgs) { - const { balance } = useCurrentBtcCryptoAssetBalanceNativeSegwit(); +export function useBitcoinCustomFee({ isSendingMax, recipients }: UseBitcoinCustomFeeArgs) { const { data: utxos = [] } = useCurrentNativeSegwitUtxos(); const btcMarketData = useCryptoCurrencyMarketDataMeanAverage('BTC'); @@ -30,12 +27,7 @@ export function useBitcoinCustomFee({ amount, isSendingMax, recipients }: UseBit (feeRate: number) => { if (!feeRate || !utxos.length) return { fee: 0, fiatFeeValue: '' }; - const satAmount = isSendingMax - ? balance.availableBalance.amount.toNumber() - : amount.amount.toNumber(); - - const determineUtxosArgs = { - amount: satAmount, + const determineUtxosArgs: DetermineUtxosForSpendArgs = { recipients, utxos, feeRate, @@ -51,6 +43,6 @@ export function useBitcoinCustomFee({ amount, isSendingMax, recipients }: UseBit )}`, }; }, - [utxos, isSendingMax, balance.availableBalance.amount, amount.amount, recipients, btcMarketData] + [utxos, isSendingMax, recipients, btcMarketData] ); } diff --git a/src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx b/src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx index 52a92904a6a..8a382bca309 100644 --- a/src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx +++ b/src/app/features/dialogs/increase-fee-dialog/increase-btc-fee-dialog.tsx @@ -93,9 +93,6 @@ export function IncreaseBtcFeeDialog() {