From 223a4cfde24c86d88ca747678ec49ea1b973c417 Mon Sep 17 00:00:00 2001 From: Jamie Ford Date: Thu, 21 Nov 2024 13:15:44 +1100 Subject: [PATCH 1/5] fix: bouncer lock utxos after selection --- bouncer/shared/send_btc.ts | 38 +++++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/bouncer/shared/send_btc.ts b/bouncer/shared/send_btc.ts index f79f53e090..95aad18f5f 100644 --- a/bouncer/shared/send_btc.ts +++ b/bouncer/shared/send_btc.ts @@ -12,25 +12,33 @@ export const btcClient = new Client({ }); export async function selectInputs(amount: number) { - // List unspent UTXOs - const utxos = await btcClient.listUnspent(); + return btcClientMutex.runExclusive(async () => { + // List unspent UTXOs + const utxos = await btcClient.listUnspent(); - // Find a UTXO with enough funds - const utxo = utxos.find((u) => u.amount >= amount); - if (!utxo) throw new Error('Insufficient funds'); - // TODO: be able to select more than one UTXO + // Find a UTXO with enough funds + const utxo = utxos.find((u) => u.amount >= amount); + if (!utxo) throw new Error('Insufficient funds'); + // TODO: be able to select more than one UTXO - const change = utxo.amount - amount; + // Lock the selected UTXO to prevent it from being used in another transaction + await btcClient.lockUnspent(false, [{ txid: utxo.txid, vout: utxo.vout }]); - // Prepare the transaction inputs and outputs - const inputs = [ - { - txid: utxo.txid, - vout: utxo.vout, - }, - ]; + const change = utxo.amount - amount; - return { inputs, change }; + // Prepare the transaction inputs + const inputs = [ + { + txid: utxo.txid, + vout: utxo.vout, + }, + ]; + + return { + inputs, + change, + }; + }); } export async function waitForBtcTransaction(txid: string, confirmations = 1) { From 59cf85ab5504c24d953097ed9c270499a79ea1cc Mon Sep 17 00:00:00 2001 From: Jamie Ford Date: Mon, 25 Nov 2024 13:47:53 +1100 Subject: [PATCH 2/5] refactor: use mutex instead of locking --- bouncer/shared/send_btc.ts | 67 ++++++++++++++++++++++++--------- bouncer/tests/btc_vault_swap.ts | 35 ++++------------- 2 files changed, 56 insertions(+), 46 deletions(-) diff --git a/bouncer/shared/send_btc.ts b/bouncer/shared/send_btc.ts index 95aad18f5f..57da971383 100644 --- a/bouncer/shared/send_btc.ts +++ b/bouncer/shared/send_btc.ts @@ -12,32 +12,63 @@ export const btcClient = new Client({ }); export async function selectInputs(amount: number) { - return btcClientMutex.runExclusive(async () => { - // List unspent UTXOs - const utxos = await btcClient.listUnspent(); + // List unspent UTXOs + const utxos = await btcClient.listUnspent(); + + // Find a UTXO with enough funds + const utxo = utxos.find((u) => u.amount >= amount); + if (!utxo) throw new Error('Insufficient funds'); + // TODO: be able to select more than one UTXO + + const change = utxo.amount - amount; - // Find a UTXO with enough funds - const utxo = utxos.find((u) => u.amount >= amount); - if (!utxo) throw new Error('Insufficient funds'); - // TODO: be able to select more than one UTXO + // Prepare the transaction inputs + const inputs = [ + { + txid: utxo.txid, + vout: utxo.vout, + }, + ]; - // Lock the selected UTXO to prevent it from being used in another transaction - await btcClient.lockUnspent(false, [{ txid: utxo.txid, vout: utxo.vout }]); + return { + inputs, + change, + }; +} + +export async function sendVaultTransaction( + nulldataUtxo: string, + amountBtc: number, + depositAddress: string, + refundAddress: string, +) { + return btcClientMutex.runExclusive(async () => { + const feeBtc = 0.00001; + const { inputs, change } = await selectInputs(Number(amountBtc) + feeBtc); - const change = utxo.amount - amount; + // The `createRawTransaction` function will add the op codes, so we have to remove them here. + const nullDataWithoutOpCodes = nulldataUtxo.replace('0x', '').substring(4); - // Prepare the transaction inputs - const inputs = [ + const outputs = [ { - txid: utxo.txid, - vout: utxo.vout, + [depositAddress]: amountBtc, + }, + { + data: nullDataWithoutOpCodes, + }, + { + [refundAddress]: change, }, ]; - return { - inputs, - change, - }; + const rawTx = await btcClient.createRawTransaction(inputs, outputs, 0, false); + const signedTx = await btcClient.signRawTransactionWithWallet(rawTx); + const txid = await btcClient.sendRawTransaction(signedTx.hex); + + if (!txid) { + throw new Error('Broadcast failed'); + } + return txid as string; }); } diff --git a/bouncer/tests/btc_vault_swap.ts b/bouncer/tests/btc_vault_swap.ts index d3d72dfd5a..b93ceb18da 100644 --- a/bouncer/tests/btc_vault_swap.ts +++ b/bouncer/tests/btc_vault_swap.ts @@ -1,11 +1,10 @@ import assert from 'assert'; import { ExecutableTest } from '../shared/executable_test'; -import { BTC_ENDPOINT, selectInputs, waitForBtcTransaction, btcClient } from '../shared/send_btc'; +import { BTC_ENDPOINT, waitForBtcTransaction, sendVaultTransaction } from '../shared/send_btc'; import { amountToFineAmount, Asset, assetDecimals, - btcClientMutex, createStateChainKeypair, newAddress, observeBalanceIncrease, @@ -51,9 +50,6 @@ async function buildAndSendBtcVaultSwap( affiliates.push({ account: affiliateAddress, bps: commissionBps }); } - const feeBtc = 0.00001; - const { inputs, change } = await selectInputs(Number(depositAmountBtc) + feeBtc); - const vaultSwapDetails = (await chainflip.rpc( `cf_get_vault_swap_details`, broker.address, @@ -71,32 +67,15 @@ async function buildAndSendBtcVaultSwap( testBtcVaultSwap.debugLog('nulldata_utxo:', vaultSwapDetails.nulldata_utxo); testBtcVaultSwap.debugLog('deposit_address:', vaultSwapDetails.deposit_address); - // The `createRawTransaction` function will add the op codes, so we have to remove them here. - const nullDataWithoutOpCodes = vaultSwapDetails.nulldata_utxo.replace('0x', '').substring(4); - - const outputs = [ - { - [vaultSwapDetails.deposit_address]: depositAmountBtc, - }, - { - data: nullDataWithoutOpCodes, - }, - { - [refundAddress]: change, - }, - ]; - - const rawTx = await btcClient.createRawTransaction(inputs, outputs, 0, false); - const signedTx = await btcClient.signRawTransactionWithWallet(rawTx); - const txid = await btcClientMutex.runExclusive(async () => - btcClient.sendRawTransaction(signedTx.hex), + const txid = await sendVaultTransaction( + vaultSwapDetails.nulldata_utxo, + depositAmountBtc, + vaultSwapDetails.deposit_address, + refundAddress, ); - if (!txid) { - throw new Error('Broadcast failed'); - } testBtcVaultSwap.log('Broadcast successful, txid:', txid); - await waitForBtcTransaction(txid as string); + await waitForBtcTransaction(txid); testBtcVaultSwap.debugLog('Transaction confirmed'); } From b12c7b10c574c9be57ccc30ed86ab7ef5170fd3b Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 25 Nov 2024 13:36:57 +0100 Subject: [PATCH 3/5] feat: use bitcoin node's input selection. --- bouncer/commands/create_raw_btc_tx.ts | 21 ++++---- bouncer/shared/send_btc.ts | 73 ++++++++++----------------- bouncer/tests/lp_api_test.ts | 2 +- 3 files changed, 37 insertions(+), 59 deletions(-) diff --git a/bouncer/commands/create_raw_btc_tx.ts b/bouncer/commands/create_raw_btc_tx.ts index b4377fe920..58c3790e38 100755 --- a/bouncer/commands/create_raw_btc_tx.ts +++ b/bouncer/commands/create_raw_btc_tx.ts @@ -2,26 +2,23 @@ // Constructs a very simple Raw BTC transaction. Can be used for manual testing a raw broadcast for example. // Usage: ./commands/create_raw_btc_tx.ts -import { BTC_ENDPOINT, btcClient, selectInputs } from '../shared/send_btc'; +import { BTC_ENDPOINT, btcClient } from '../shared/send_btc'; console.log(`Btc endpoint is set to '${BTC_ENDPOINT}'`); const createRawTransaction = async (toAddress: string, amountInBtc: number | string) => { try { - // Inputs and outputs - const feeInBtc = 0.00001; - const { inputs, change } = await selectInputs(Number(amountInBtc) + feeInBtc); - const changeAddress = await btcClient.getNewAddress(); - const outputs = { - [toAddress]: amountInBtc, - [changeAddress]: change, - }; - // Create the raw transaction - const rawTx = await btcClient.createRawTransaction(inputs, outputs); + const rawTx = await btcClient.createRawTransaction([], { + [toAddress]: amountInBtc, + }); + const fundedTx = (await btcClient.fundRawTransaction(rawTx, { + changeAddress: await btcClient.getNewAddress(), + feeRate: 0.00001, + })) as { hex: string }; // Sign the raw transaction - const signedTx = await btcClient.signRawTransactionWithWallet(rawTx); + const signedTx = await btcClient.signRawTransactionWithWallet(fundedTx); // Here's your raw signed transaction console.log('Raw signed transaction:', signedTx.hex); diff --git a/bouncer/shared/send_btc.ts b/bouncer/shared/send_btc.ts index 57da971383..d799eee0e6 100644 --- a/bouncer/shared/send_btc.ts +++ b/bouncer/shared/send_btc.ts @@ -11,29 +11,26 @@ export const btcClient = new Client({ wallet: 'whale', }); -export async function selectInputs(amount: number) { - // List unspent UTXOs - const utxos = await btcClient.listUnspent(); - - // Find a UTXO with enough funds - const utxo = utxos.find((u) => u.amount >= amount); - if (!utxo) throw new Error('Insufficient funds'); - // TODO: be able to select more than one UTXO - - const change = utxo.amount - amount; - - // Prepare the transaction inputs - const inputs = [ - { - txid: utxo.txid, - vout: utxo.vout, - }, - ]; +export async function fundAndSendTransaction( + outputs: object[], + changeAddress: string, + feeRate?: number, +): Promise { + return btcClientMutex.runExclusive(async () => { + const rawTx = await btcClient.createRawTransaction([], outputs); + const fundedTx = (await btcClient.fundRawTransaction(rawTx, { + changeAddress, + feeRate: feeRate ?? 0.00001, + })) as { hex: string }; + const signedTx = await btcClient.signRawTransactionWithWallet(fundedTx.hex); + const txId = (await btcClient.sendRawTransaction(signedTx.hex)) as string | undefined; + + if (!txId) { + throw new Error('Broadcast failed'); + } - return { - inputs, - change, - }; + return txId; + }); } export async function sendVaultTransaction( @@ -41,35 +38,19 @@ export async function sendVaultTransaction( amountBtc: number, depositAddress: string, refundAddress: string, -) { - return btcClientMutex.runExclusive(async () => { - const feeBtc = 0.00001; - const { inputs, change } = await selectInputs(Number(amountBtc) + feeBtc); - - // The `createRawTransaction` function will add the op codes, so we have to remove them here. - const nullDataWithoutOpCodes = nulldataUtxo.replace('0x', '').substring(4); - - const outputs = [ +): Promise { + return fundAndSendTransaction( + [ { [depositAddress]: amountBtc, }, { - data: nullDataWithoutOpCodes, + // The `createRawTransaction` function will add the op codes, so we have to remove them here. + data: nulldataUtxo.replace('0x', '').substring(4), }, - { - [refundAddress]: change, - }, - ]; - - const rawTx = await btcClient.createRawTransaction(inputs, outputs, 0, false); - const signedTx = await btcClient.signRawTransactionWithWallet(rawTx); - const txid = await btcClient.sendRawTransaction(signedTx.hex); - - if (!txid) { - throw new Error('Broadcast failed'); - } - return txid as string; - }); + ], + refundAddress, + ); } export async function waitForBtcTransaction(txid: string, confirmations = 1) { diff --git a/bouncer/tests/lp_api_test.ts b/bouncer/tests/lp_api_test.ts index d0cfce5409..ae62abf77d 100644 --- a/bouncer/tests/lp_api_test.ts +++ b/bouncer/tests/lp_api_test.ts @@ -21,7 +21,7 @@ import { getChainflipApi, observeEvent } from '../shared/utils/substrate'; import { ExecutableTest } from '../shared/executable_test'; /* eslint-disable @typescript-eslint/no-use-before-define */ -export const testLpApi = new ExecutableTest('LP-API', main, 200); +export const testLpApi = new ExecutableTest('LP-API', main, 1000); type RpcAsset = { asset: string; From df77ae3e3f505e6ec42eafd3df6ac53d853a8b9a Mon Sep 17 00:00:00 2001 From: Jamie Ford Date: Tue, 26 Nov 2024 11:00:05 +1100 Subject: [PATCH 4/5] chore: set correct timeouts for LP-API bouncer test --- bouncer/tests/lp_api_test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/bouncer/tests/lp_api_test.ts b/bouncer/tests/lp_api_test.ts index ae62abf77d..dd54f77935 100644 --- a/bouncer/tests/lp_api_test.ts +++ b/bouncer/tests/lp_api_test.ts @@ -21,7 +21,7 @@ import { getChainflipApi, observeEvent } from '../shared/utils/substrate'; import { ExecutableTest } from '../shared/executable_test'; /* eslint-disable @typescript-eslint/no-use-before-define */ -export const testLpApi = new ExecutableTest('LP-API', main, 1000); +export const testLpApi = new ExecutableTest('LP-API', main, 200); type RpcAsset = { asset: string; @@ -397,27 +397,27 @@ async function main() { const registerLiquidityRefundAddress = new ExecutableTest( 'LP-API::Register-Liquidity-Refund-Address', testRegisterLiquidityRefundAddress, - 500, + 30, ); const LiquidityDeposit = new ExecutableTest( 'LP-API::Liquidity-Deposit', testLiquidityDeposit, - 500, + 60, ); - const WithdrawAsset = new ExecutableTest('LP-API::Withdraw-Asset', testWithdrawAsset, 500); + const WithdrawAsset = new ExecutableTest('LP-API::Withdraw-Asset', testWithdrawAsset, 60); const RegisterWithExistingLpAccount = new ExecutableTest( 'LP-API::testRegisterWithExistingLpAccount', testRegisterWithExistingLpAccount, - 500, + 15, ); - const RangeOrder = new ExecutableTest('LP-API::Range-Order', testRangeOrder, 500); - const LimitOrder = new ExecutableTest('LP-API::Limit-Order', testLimitOrder, 500); + const RangeOrder = new ExecutableTest('LP-API::Range-Order', testRangeOrder, 60); + const LimitOrder = new ExecutableTest('LP-API::Limit-Order', testLimitOrder, 120); const GetOpenSwapChannels = new ExecutableTest( 'LP-API::Get-Open-Swap-Channels', testGetOpenSwapChannels, - 500, + 15, ); - const TransferAsset = new ExecutableTest('LP-API::TransferAsset', testTransferAsset, 500); + const TransferAsset = new ExecutableTest('LP-API::TransferAsset', testTransferAsset, 30); // Provide the amount of liquidity needed for the tests await provideLiquidityAndTestAssetBalances(); From bdf6514dd1eaf9f0e9331d6fcbe8048a0cfa2cb3 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 26 Nov 2024 14:10:11 +0100 Subject: [PATCH 5/5] fix: additional btc tx options: - lockUnspents: true to ensure we don't try to double-spend - changePosition: 2 on vault swaps to ensure a valid transaction --- bouncer/commands/create_raw_btc_tx.ts | 1 + bouncer/shared/send_btc.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/bouncer/commands/create_raw_btc_tx.ts b/bouncer/commands/create_raw_btc_tx.ts index 58c3790e38..4960598f3e 100755 --- a/bouncer/commands/create_raw_btc_tx.ts +++ b/bouncer/commands/create_raw_btc_tx.ts @@ -15,6 +15,7 @@ const createRawTransaction = async (toAddress: string, amountInBtc: number | str const fundedTx = (await btcClient.fundRawTransaction(rawTx, { changeAddress: await btcClient.getNewAddress(), feeRate: 0.00001, + lockUnspents: true, })) as { hex: string }; // Sign the raw transaction diff --git a/bouncer/shared/send_btc.ts b/bouncer/shared/send_btc.ts index d799eee0e6..e4c1442946 100644 --- a/bouncer/shared/send_btc.ts +++ b/bouncer/shared/send_btc.ts @@ -21,6 +21,8 @@ export async function fundAndSendTransaction( const fundedTx = (await btcClient.fundRawTransaction(rawTx, { changeAddress, feeRate: feeRate ?? 0.00001, + lockUnspents: true, + changePosition: 2, })) as { hex: string }; const signedTx = await btcClient.signRawTransactionWithWallet(fundedTx.hex); const txId = (await btcClient.sendRawTransaction(signedTx.hex)) as string | undefined;