diff --git a/packages/extension/src/providers/bitcoin/libs/utils.ts b/packages/extension/src/providers/bitcoin/libs/utils.ts index 6da7331ca..52fdd139f 100644 --- a/packages/extension/src/providers/bitcoin/libs/utils.ts +++ b/packages/extension/src/providers/bitcoin/libs/utils.ts @@ -1,7 +1,7 @@ import { BitcoinNetworkInfo, HaskoinUnspentType } from '../types'; import { address as BTCAddress } from 'bitcoinjs-lib'; import { GasPriceTypes } from '@/providers/common/types'; -import { fromBase } from '@enkryptcom/utils'; +import { fromBase, toBase } from '@enkryptcom/utils'; import BigNumber from 'bignumber.js'; import { BitcoinNetwork } from '../types/bitcoin-network'; import { BTCTxInfo } from '../ui/types'; @@ -15,6 +15,16 @@ const isAddress = (address: string, network: BitcoinNetworkInfo): boolean => { } }; +/** + * The network's dust limit in base units. + * + * Nodes will not relay a transaction that carries an output below this, so a + * change output worth less than the limit cannot be added to a transaction. It + * has to be left behind as fee instead. + */ +const getDustThreshold = (network: BitcoinNetwork): number => + Number(toBase(network.dust.toString(), network.decimals)); + const getTxInfo = ( utxos: HaskoinUnspentType[], ordinalUTXO?: HaskoinUnspentType, @@ -101,4 +111,4 @@ const getGasCostValues = async ( }; return gasCostValues; }; -export { isAddress, getGasCostValues, getTxInfo }; +export { isAddress, getGasCostValues, getTxInfo, getDustThreshold }; diff --git a/packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts b/packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts new file mode 100644 index 000000000..c412ad167 --- /dev/null +++ b/packages/extension/src/providers/bitcoin/tests/bitcoin.dust.test.ts @@ -0,0 +1,20 @@ +// @vitest-environment node +// +// The bitcoin provider tests run in the node environment: bitcoinjs-lib checks +// Buffer values against Uint8Array, and under jsdom the two come from different +// realms. +import { describe, it, expect } from 'vitest'; +import bitcoinNetworks from '../networks'; +import { getDustThreshold } from '../libs/utils'; + +describe('Should report the dust limit in base units', () => { + it('should convert each network limit from whole coins', () => { + // 0.00000546 BTC + expect(getDustThreshold(bitcoinNetworks.bitcoin)).to.be.eq(546); + expect(getDustThreshold(bitcoinNetworks.bitcoinTest)).to.be.eq(546); + // 0.0001 LTC, an order of magnitude above the bitcoin limit + expect(getDustThreshold(bitcoinNetworks.litecoin)).to.be.eq(10000); + // 0.01 DOGE + expect(getDustThreshold(bitcoinNetworks.dogecoin)).to.be.eq(1000000); + }); +}); diff --git a/packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue b/packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue index 8937fe6fc..d05c3f3a7 100644 --- a/packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue +++ b/packages/extension/src/providers/bitcoin/ui/send-transaction/index.vue @@ -186,7 +186,11 @@ import Browser from 'webextension-polyfill'; import { ProviderName } from '@/types/provider'; import PublicKeyRing from '@/libs/keyring/public-keyring'; -import { getGasCostValues, isAddress } from '../../libs/utils'; +import { + getDustThreshold, + getGasCostValues, + isAddress, +} from '../../libs/utils'; import BitcoinAPI from '@/providers/bitcoin/libs/api'; import { calculateSizeBasedOnType } from '../libs/tx-size'; import { HaskoinUnspentType } from '../../types'; @@ -517,7 +521,9 @@ const sendAction = async () => { }); } const remainder = UTXOBalance.value.sub(toAmount).sub(currentFee); - if (remainder.gtn(0)) { + // Change worth less than the dust limit cannot be paid out: the node would + // reject the whole transaction as non standard. Leave it as fee instead. + if (remainder.gten(getDustThreshold(props.network as BitcoinNetwork))) { txInfo.outputs.push({ address: props.network.displayAddress(addressFrom.value), value: remainder.toNumber(), diff --git a/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts b/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts index 58848cd00..f28ea2708 100644 --- a/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts +++ b/packages/extension/src/ui/action/views/swap/libs/swap-txs.ts @@ -18,7 +18,10 @@ import { ApiPromise } from '@polkadot/api'; import { TransactionType } from '../types'; import { BitcoinNetwork } from '@/providers/bitcoin/types/bitcoin-network'; import BitcoinAPI from '@/providers/bitcoin/libs/api'; -import { getTxInfo as getBTCTxInfo } from '@/providers/bitcoin/libs/utils'; +import { + getTxInfo as getBTCTxInfo, + getDustThreshold, +} from '@/providers/bitcoin/libs/utils'; import { toBN } from 'web3-utils'; import { BTCTxInfo } from '@/providers/bitcoin/ui/types'; @@ -58,7 +61,9 @@ export const getBitcoinNativeTransaction = async ( address: tx.to, value: toAmount.toNumber(), }); - if (remainder > 0) { + // Change worth less than the dust limit cannot be paid out: the node would + // reject the whole transaction as non standard. Leave it as fee instead. + if (remainder >= getDustThreshold(network)) { txInfo.outputs.push({ address: network.displayAddress(tx.from), value: remainder,