From ff9ff5d6813642c7fe862a92c2925c9991e1d709 Mon Sep 17 00:00:00 2001 From: Polybius93 Date: Wed, 1 Jan 2025 16:25:38 +0100 Subject: [PATCH] feat: add dust output removal function, add tests --- src/constants/dlc-handler.constants.ts | 2 ++ src/functions/bitcoin/bitcoin-functions.ts | 12 ++++++++ src/functions/bitcoin/psbt-functions.ts | 16 ++++++---- tests/mocks/bitcoin.test.constants.ts | 7 +++++ tests/unit/bitcoin-functions.test.ts | 22 +++++++++++++ tests/unit/psbt-functions.test.ts | 36 +++++++++++++++++++++- 6 files changed, 88 insertions(+), 7 deletions(-) diff --git a/src/constants/dlc-handler.constants.ts b/src/constants/dlc-handler.constants.ts index 7502a28..4a279dd 100644 --- a/src/constants/dlc-handler.constants.ts +++ b/src/constants/dlc-handler.constants.ts @@ -5,3 +5,5 @@ export const DLCHandlers = { LEDGER: 'ledger', DFNS: 'dfns', } as const; + +export const DUST_LIMIT = 546n; diff --git a/src/functions/bitcoin/bitcoin-functions.ts b/src/functions/bitcoin/bitcoin-functions.ts index 6453ff7..087561e 100644 --- a/src/functions/bitcoin/bitcoin-functions.ts +++ b/src/functions/bitcoin/bitcoin-functions.ts @@ -17,6 +17,7 @@ import { bitcoin, regtest, testnet } from 'bitcoinjs-lib/src/networks.js'; import { Decimal } from 'decimal.js'; import * as ellipticCurveCryptography from 'tiny-secp256k1'; +import { DUST_LIMIT } from '../../constants/dlc-handler.constants.js'; import { BitcoinInputSigningConfig, BitcoinTransaction, @@ -46,6 +47,17 @@ export function getFeeAmount(bitcoinAmount: number, feeBasisPoints: number): num return new Decimal(bitcoinAmount).times(feePercentage).trunc().toNumber(); } +export function removeDustOutputs( + outputs: { address: string; amount: bigint }[], + dustLimit = DUST_LIMIT +): void { + for (let i = outputs.length - 1; i >= 0; i--) { + if (outputs[i].amount < dustLimit) { + outputs.splice(i, 1); + } + } +} + /** * Derives the Public Key at the Unhardened Path (0/0) from a given Extended Public Key. * @param extendedPublicKey - The base58-encoded Extended Public Key. diff --git a/src/functions/bitcoin/psbt-functions.ts b/src/functions/bitcoin/psbt-functions.ts index 5406c86..6f32796 100644 --- a/src/functions/bitcoin/psbt-functions.ts +++ b/src/functions/bitcoin/psbt-functions.ts @@ -4,6 +4,7 @@ import { P2Ret, P2TROut } from '@scure/btc-signer/payment'; import { Network, Psbt } from 'bitcoinjs-lib'; import { PartialSignature } from 'ledger-bitcoin/build/main/lib/appClient.js'; +import { DUST_LIMIT } from '../../constants/dlc-handler.constants.js'; import { BitcoinInputSigningConfig, PaymentTypes } from '../../models/bitcoin-models.js'; import { reverseBytes } from '../../utilities/index.js'; import { @@ -11,11 +12,10 @@ import { getFeeAmount, getFeeRecipientAddress, getUTXOs, + removeDustOutputs, } from '../bitcoin/bitcoin-functions.js'; import { fetchBitcoinTransaction } from './bitcoin-request-functions.js'; -const DUST_LIMIT = 546n; - /** * Creates a Funding Transaction to deposit Bitcoin into an empty Vault. * Uses the UTXOs of the User to create the Funding Transaction. @@ -61,7 +61,9 @@ export async function createFundingTransaction( const psbtOutputs = [ { address: multisigAddress, amount: depositAmount }, { address: feeAddress, amount: BigInt(feeAmount) }, - ].filter(output => output.amount >= DUST_LIMIT); + ]; + + removeDustOutputs(psbtOutputs); const selected = selectUTXO(userUTXOs, psbtOutputs, 'default', { changeAddress: depositAddress, @@ -206,7 +208,9 @@ export async function createDepositTransaction( address: multisigAddress, amount: BigInt(depositAmount) + BigInt(vaultTransactionOutputValue), }, - ].filter(output => output.amount >= DUST_LIMIT); + ]; + + removeDustOutputs(depositOutputs); const depositSelected = selectUTXO(depositInputs, depositOutputs, 'all', { changeAddress: depositAddress, @@ -326,9 +330,9 @@ export async function createWithdrawTransaction( }); } - const filteredOutputs = outputs.filter(output => output.amount >= DUST_LIMIT); + removeDustOutputs(outputs); - const selected = selectUTXO(inputs, filteredOutputs, 'default', { + const selected = selectUTXO(inputs, outputs, 'default', { changeAddress: withdrawAddress, feePerByte: feeRate, bip69: false, diff --git a/tests/mocks/bitcoin.test.constants.ts b/tests/mocks/bitcoin.test.constants.ts index 69b4410..ca08a1d 100644 --- a/tests/mocks/bitcoin.test.constants.ts +++ b/tests/mocks/bitcoin.test.constants.ts @@ -46,3 +46,10 @@ export const TEST_FEE_RECIPIENT_PUBLIC_KEY_1 = export const TEST_FEE_DEPOSIT_BASIS_POINTS_1 = 100n; export const TEST_FEE_RATE_1 = 6n; + +export const TEST_OUTPUTS = [ + { address: 'addr1', amount: 1000n }, + { address: 'addr2', amount: 545n }, + { address: 'addr3', amount: 546n }, + { address: 'addr4', amount: 100n }, +]; diff --git a/tests/unit/bitcoin-functions.test.ts b/tests/unit/bitcoin-functions.test.ts index 606ec28..12c078f 100644 --- a/tests/unit/bitcoin-functions.test.ts +++ b/tests/unit/bitcoin-functions.test.ts @@ -12,6 +12,7 @@ import { getInputIndicesByScript, getScriptMatchingOutputFromTransaction, getUnspendableKeyCommittedToUUID, + removeDustOutputs, } from '../../src/functions/bitcoin/bitcoin-functions'; import { TEST_TESTNET_ATTESTOR_EXTENDED_GROUP_PUBLIC_KEY_1, @@ -31,6 +32,7 @@ import { TEST_ALICE_NATIVE_SEGWIT_PUBLIC_KEY_2, TEST_ALICE_TAPROOT_PUBLIC_KEY_1, TEST_ALICE_TAPROOT_PUBLIC_KEY_2, + TEST_OUTPUTS, TEST_TAPROOT_MULTISIG_PAYMENT_SCRIPT_1, TEST_TAPROOT_UNHARDENED_DERIVED_PUBLIC_KEY_1, TEST_UNHARDENED_DERIVED_UNSPENDABLE_KEY_COMMITED_TO_UUID_1, @@ -68,6 +70,26 @@ describe('Bitcoin Functions', () => { }); }); + describe('removeDustOutputs', () => { + it('removes single dust output', () => { + const outputs = [TEST_OUTPUTS[0], TEST_OUTPUTS[1]]; + removeDustOutputs(outputs); + expect(outputs).toEqual([TEST_OUTPUTS[0]]); + }); + + it('removes multiple dust outputs', () => { + const outputs = [...TEST_OUTPUTS]; + removeDustOutputs(outputs); + expect(outputs).toEqual([TEST_OUTPUTS[0], TEST_OUTPUTS[2]]); + }); + + it('keeps all outputs if none are dust', () => { + const outputs = [TEST_OUTPUTS[0], TEST_OUTPUTS[2]]; + removeDustOutputs(outputs); + expect(outputs).toEqual(outputs); + }); + }); + describe('getFeeRecipientAddress', () => { describe('mainnet', () => { const network = bitcoin; diff --git a/tests/unit/psbt-functions.test.ts b/tests/unit/psbt-functions.test.ts index 9c0246d..169440d 100644 --- a/tests/unit/psbt-functions.test.ts +++ b/tests/unit/psbt-functions.test.ts @@ -46,7 +46,7 @@ describe('PSBT Functions', () => { jest.clearAllMocks(); }); describe('createFundingTransaction', () => { - it('should successfully create a valid funding transaction', async () => { + it('should successfully create a valid funding transaction and exclude change output under dust limit', async () => { jest .spyOn(bitcoinFunctions, 'getUTXOs') .mockImplementationOnce(async () => TEST_BITCOIN_REGTEST_NATIVE_SEGWIT_UTXOS_1); @@ -83,5 +83,39 @@ describe('PSBT Functions', () => { ) ).not.toThrow(); }); + it('should successfully create a valid funding transaction and exclude fee output under dust limit', async () => { + jest + .spyOn(bitcoinFunctions, 'getUTXOs') + .mockImplementationOnce(async () => TEST_BITCOIN_REGTEST_NATIVE_SEGWIT_UTXOS_1); + + const depositTransaction = await createFundingTransaction( + TEST_REGTEST_BITCOIN_BLOCKCHAIN_API, + regtest, + 9900n, + multisigPayment, + depositPayment, + TEST_FEE_RATE_1, + TEST_FEE_RECIPIENT_PUBLIC_KEY_1, + TEST_FEE_DEPOSIT_BASIS_POINTS_1 + ); + + expect(depositTransaction).toBeDefined(); + expect(depositTransaction.inputsLength).toBe(1); + expect(depositTransaction.outputsLength).toBe(2); + expect(depositTransaction.getOutput(0).amount?.toString()).toBe('9900'); + expect(depositTransaction.getOutput(0).script).toStrictEqual(multisigPayment.script); + expect(depositTransaction.getOutput(1).amount?.toString()).toBe('99989182'); + expect(depositTransaction.getOutput(1).script).toStrictEqual(depositPayment.script); + expect(() => + depositTransaction.sign( + deriveUnhardenedKeyPairFromRootPrivateKey( + TEST_BITCOIN_REGTEST_NATIVE_SEGWIT_XPRIV_1, + regtest, + 'p2wpkh', + 0 + ).privateKey! + ) + ).not.toThrow(); + }); }); });