From 668f637b889d51cf134713a913c10a0863c7960f Mon Sep 17 00:00:00 2001 From: nakul1010 Date: Fri, 18 Apr 2025 18:32:02 +0530 Subject: [PATCH 1/5] feat: Add fee rate handling and bump fee functionality --- crates/utils/src/bitcoin_client.rs | 117 ++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/crates/utils/src/bitcoin_client.rs b/crates/utils/src/bitcoin_client.rs index 68b649c8..161e149f 100644 --- a/crates/utils/src/bitcoin_client.rs +++ b/crates/utils/src/bitcoin_client.rs @@ -9,12 +9,13 @@ use bitcoin::{ }; use bitcoincore_rpc::{ bitcoin::{BlockHash, Transaction}, - json::TestMempoolAcceptResult, + json::{EstimateMode, TestMempoolAcceptResult}, jsonrpc::{error::RpcError, Error as JsonRpcError}, Auth, Client, Error as BitcoinError, RpcApi, }; use num_derive::FromPrimitive; -use serde_json::error::Category as SerdeJsonCategory; +use serde::{Deserialize, Serialize}; +use serde_json::{error::Category as SerdeJsonCategory, json}; use std::{sync::Arc, time::Duration}; use tokio::time::{error::Elapsed, sleep, timeout}; use tracing::*; @@ -85,6 +86,99 @@ pub enum BitcoinRpcError { RpcUnknownError = 0, } +/// A representation of a fee rate. Bitcoin Core uses different units in different +/// versions. To avoid burdening the user with using the correct unit, this struct +/// provides an umambiguous way to represent the fee rate, and the lib will perform +/// the necessary conversions. +#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)] +pub struct FeeRate(Amount); + +impl FeeRate { + /// Construct FeeRate from the amount per vbyte + pub fn per_vbyte(amount_per_vbyte: Amount) -> Self { + // internal representation is amount per vbyte + Self(amount_per_vbyte) + } + + /// Construct FeeRate from the amount per kilo-vbyte + pub fn per_kvbyte(amount_per_kvbyte: Amount) -> Self { + // internal representation is amount per vbyte, so divide by 1000 + Self::per_vbyte(amount_per_kvbyte / 1000) + } + + pub fn to_sat_per_vbyte(&self) -> f64 { + // multiply by the number of decimals to get sat + self.0.to_sat() as f64 // TODO: Changed this + } + + pub fn to_btc_per_kvbyte(&self) -> f64 { + // divide by 10^8 to get btc/vbyte, then multiply by 10^3 to get btc/kbyte + self.0.to_sat() as f64 / 100_000.0 + } +} + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct BumpFeeOptions { + /// Confirmation target in blocks. + pub conf_target: Option, + /// Specify a fee rate instead of relying on the built-in fee estimator. + pub fee_rate: Option, + /// Whether this transaction could be replaced due to BIP125 (replace-by-fee) + pub replaceable: Option, + /// The fee estimate mode + pub estimate_mode: Option, +} + +#[derive(Serialize, Clone, PartialEq, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct SerializableBumpFeeOptions { + #[serde(rename = "conf_target", skip_serializing_if = "Option::is_none")] + /// Confirmation target in blocks. + pub conf_target: Option, + /// Specify a fee rate instead of relying on the built-in fee estimator. + #[serde(rename = "fee_rate")] + pub fee_rate: Option, + /// Whether this transaction could be replaced due to BIP125 (replace-by-fee) + #[serde(skip_serializing_if = "Option::is_none")] + pub replaceable: Option, + /// The fee estimate mode + #[serde(rename = "estimate_mode", skip_serializing_if = "Option::is_none")] + pub estimate_mode: Option, +} + +impl BumpFeeOptions { + pub fn to_serializable(&self, version: usize) -> SerializableBumpFeeOptions { + let fee_rate = self.fee_rate.map(|x| { + if version < 210000 { + x.to_btc_per_kvbyte() + } else { + x.to_sat_per_vbyte() + } + }); + + SerializableBumpFeeOptions { + fee_rate, + conf_target: self.conf_target, + replaceable: self.replaceable, + estimate_mode: self.estimate_mode, + } + } +} + +#[derive(Deserialize, Clone, PartialEq, Eq, Debug)] +#[serde(rename_all = "camelCase")] +pub struct BumpFeeResult { + /// The base64-encoded unsigned PSBT of the new transaction. Only returned when wallet private + /// keys are disabled. + pub psbt: Option, + /// The id of the new transaction. Only returned when wallet private keys are enabled. + pub txid: Option, + pub origfee: Amount, + pub fee: Amount, + /// Errors encountered during processing. + pub errors: Vec, +} + impl From for BitcoinRpcError { fn from(err: RpcError) -> Self { match num::FromPrimitive::from_i32(err.code) { @@ -323,6 +417,25 @@ impl BitcoinClient { let transaction = signed_tx.transaction()?; Ok(transaction) } + + fn bump_fee( + &self, + txid: &Txid, + options: Option<&BumpFeeOptions>, + ) -> Result { + let opts = match options { + Some(options) => Some(options.to_serializable(self.rpc.version()?)), + None => None, + }; + + let params = match opts { + Some(o) => json!([txid, o]), + None => json!([txid]), + }; + + let result = self.rpc.call("bumpfee", &[params])?; + Ok(serde_json::from_value(result)?) + } } fn merklize(left: Sha256dHash, right: Sha256dHash) -> Sha256dHash { From aa8167799d42e968b454e05124cea4e92ce3dfeb Mon Sep 17 00:00:00 2001 From: nakul1010 Date: Mon, 21 Apr 2025 12:14:13 +0530 Subject: [PATCH 2/5] fix: bump fee and add test case --- crates/utils/src/bitcoin_client.rs | 38 +++++++++++----- crates/utils/src/bitcoin_core.rs | 71 ++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 11 deletions(-) diff --git a/crates/utils/src/bitcoin_client.rs b/crates/utils/src/bitcoin_client.rs index 161e149f..d9247280 100644 --- a/crates/utils/src/bitcoin_client.rs +++ b/crates/utils/src/bitcoin_client.rs @@ -15,7 +15,7 @@ use bitcoincore_rpc::{ }; use num_derive::FromPrimitive; use serde::{Deserialize, Serialize}; -use serde_json::{error::Category as SerdeJsonCategory, json}; +use serde_json::error::Category as SerdeJsonCategory; use std::{sync::Arc, time::Duration}; use tokio::time::{error::Elapsed, sleep, timeout}; use tracing::*; @@ -165,7 +165,7 @@ impl BumpFeeOptions { } } -#[derive(Deserialize, Clone, PartialEq, Eq, Debug)] +#[derive(Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct BumpFeeResult { /// The base64-encoded unsigned PSBT of the new transaction. Only returned when wallet private @@ -173,8 +173,8 @@ pub struct BumpFeeResult { pub psbt: Option, /// The id of the new transaction. Only returned when wallet private keys are enabled. pub txid: Option, - pub origfee: Amount, - pub fee: Amount, + pub origfee: f64, + pub fee: f64, /// Errors encountered during processing. pub errors: Vec, } @@ -418,23 +418,39 @@ impl BitcoinClient { Ok(transaction) } - fn bump_fee( + pub fn bump_fee( &self, txid: &Txid, options: Option<&BumpFeeOptions>, ) -> Result { + // Serialize options if provided let opts = match options { Some(options) => Some(options.to_serializable(self.rpc.version()?)), None => None, }; - let params = match opts { - Some(o) => json!([txid, o]), - None => json!([txid]), - }; + // Prepare arguments + let args = vec![serde_json::to_value(txid)?, serde_json::to_value(opts)?]; + + // Call the "bumpfee" RPC method with borrowed args + let result = self.rpc.call("bumpfee", &args); - let result = self.rpc.call("bumpfee", &[params])?; - Ok(serde_json::from_value(result)?) + // Handle the result + match result { + Ok(result_value) => { + // Try to deserialize the result into the expected BumpFeeResult + let result: Result = serde_json::from_value(result_value); + + match result { + Ok(bump_fee_result) => Ok(bump_fee_result), + Err(err) => { + println!("Failed to deserialize into BumpFeeResult"); + Err(err.into()) + } + } + } + Err(err) => Err(err.into()), // Handle the case where the RPC call fails + } } } diff --git a/crates/utils/src/bitcoin_core.rs b/crates/utils/src/bitcoin_core.rs index a26817a9..08e052b9 100644 --- a/crates/utils/src/bitcoin_core.rs +++ b/crates/utils/src/bitcoin_core.rs @@ -215,6 +215,8 @@ impl BitcoinCore { #[cfg(test)] mod tests { use super::*; + use crate::{BitcoinClient, BumpFeeOptions}; + use bitcoin::Amount; #[test] fn can_launch_bitcoin() { @@ -231,4 +233,73 @@ mod tests { 5000000000 ); } + + // test async function with bitcoin client + #[tokio::test] + async fn test_bump_fee() -> Result<()> { + // Step 1: Create and initialize BitcoinCore instance for test + let bitcoin = BitcoinCore::new().spawn(); + + // Fund Alice's wallet + bitcoin.fund_wallet("Alice").expect("Should fund Alice"); + + // Fund Bob's wallet + bitcoin.fund_wallet("Bob").expect("Should fund Alice"); + + // Check that Bob's balance is 5000000000 satoshis (i.e., 5 BTC) + assert_eq!( + bitcoin.client(Some("Bob")).unwrap().get_balance(None, None).unwrap().to_sat(), + 5000000000 + ); + + // Initialize BitcoinClient for Alice (make sure Alice's wallet is used) + let bitcoin_client = BitcoinClient::from(bitcoin.client(Some("Alice"))?); + + // Step 2: Send to yourself with very low fee (simulate low fee transaction) + let to_addr = bitcoin_client.rpc.get_new_address(None, None).unwrap().assume_checked(); + + // Set the amount to send (you can adjust the amount as needed) + let amount = Amount::from_sat(100_000); // 0.001 BTC (adjust as necessary) + + // Send the transaction to yourself (low fee expected) + let txid = bitcoin_client + .rpc + .send_to_address(&to_addr, amount, None, None, None, Some(true), None, None) + .unwrap(); + + // Step 3: Bump the fee for the low-fee transaction by calling bump_fee + let bump_fee = bitcoin_client + .bump_fee( + &txid, + Some(&BumpFeeOptions { + conf_target: None, + fee_rate: None, + replaceable: Some(true), // Allow the transaction to be replaceable + estimate_mode: None, + }), + ) + .unwrap(); + + // Assert there are no errors when bumping the fee + assert!(bump_fee.errors.is_empty()); + + // Step 4: Generate 100 blocks to confirm the transaction and bump fee transaction + bitcoin_client.rpc.generate_to_address(100, &to_addr).unwrap(); + + // Check the original transaction's status + let tx_info = bitcoin_client.rpc.get_transaction(&txid, None).unwrap(); + + // Assert that the original transaction has negative confirmations + assert!(tx_info.info.confirmations.is_negative()); + + // Check the bumped fee transaction's status (should now be confirmed) + let tx_info = bitcoin_client.rpc.get_transaction(&bump_fee.txid.unwrap(), None).unwrap(); + + // Assert that the bumped fee transaction has confirmations (i.e., it has been confirmed in + // the blockchain) + assert!(tx_info.info.confirmations.is_positive()); + + // Test successfully passes if all assertions hold true + Ok(()) + } } From 1449c791d29d627a90046a2ee81a2aea14c689f5 Mon Sep 17 00:00:00 2001 From: nakul1010 Date: Mon, 21 Apr 2025 12:15:08 +0530 Subject: [PATCH 3/5] chore: remove comment --- crates/utils/src/bitcoin_core.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/utils/src/bitcoin_core.rs b/crates/utils/src/bitcoin_core.rs index 08e052b9..38eab2a4 100644 --- a/crates/utils/src/bitcoin_core.rs +++ b/crates/utils/src/bitcoin_core.rs @@ -234,7 +234,6 @@ mod tests { ); } - // test async function with bitcoin client #[tokio::test] async fn test_bump_fee() -> Result<()> { // Step 1: Create and initialize BitcoinCore instance for test From be486980761c2321bb70b9ebae1ea74bd985b18d Mon Sep 17 00:00:00 2001 From: nakul1010 Date: Mon, 21 Apr 2025 12:17:34 +0530 Subject: [PATCH 4/5] chore: remove comment --- crates/utils/src/bitcoin_core.rs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/utils/src/bitcoin_core.rs b/crates/utils/src/bitcoin_core.rs index 38eab2a4..23450ce2 100644 --- a/crates/utils/src/bitcoin_core.rs +++ b/crates/utils/src/bitcoin_core.rs @@ -282,23 +282,21 @@ mod tests { // Assert there are no errors when bumping the fee assert!(bump_fee.errors.is_empty()); - // Step 4: Generate 100 blocks to confirm the transaction and bump fee transaction + // Step 4: Generate 100 blocks to confirm the bump fee transaction bitcoin_client.rpc.generate_to_address(100, &to_addr).unwrap(); - // Check the original transaction's status + // Check the original transaction let tx_info = bitcoin_client.rpc.get_transaction(&txid, None).unwrap(); // Assert that the original transaction has negative confirmations assert!(tx_info.info.confirmations.is_negative()); - // Check the bumped fee transaction's status (should now be confirmed) + // Get the bumped fee transaction's let tx_info = bitcoin_client.rpc.get_transaction(&bump_fee.txid.unwrap(), None).unwrap(); - // Assert that the bumped fee transaction has confirmations (i.e., it has been confirmed in - // the blockchain) + // Assert that the bumped fee transaction has confirmations assert!(tx_info.info.confirmations.is_positive()); - // Test successfully passes if all assertions hold true Ok(()) } } From b941c3d0d8b11f17e9fd508531e0827e9b9fa308 Mon Sep 17 00:00:00 2001 From: nakul1010 Date: Mon, 21 Apr 2025 12:48:43 +0530 Subject: [PATCH 5/5] chore: add code comment --- crates/utils/src/bitcoin_client.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/utils/src/bitcoin_client.rs b/crates/utils/src/bitcoin_client.rs index d9247280..92be62df 100644 --- a/crates/utils/src/bitcoin_client.rs +++ b/crates/utils/src/bitcoin_client.rs @@ -173,7 +173,9 @@ pub struct BumpFeeResult { pub psbt: Option, /// The id of the new transaction. Only returned when wallet private keys are enabled. pub txid: Option, + /// The fee of the original transaction (before bumping), denominated in BTC. pub origfee: f64, + /// The fee of the newly created bumped transaction, denominated in BTC. pub fee: f64, /// Errors encountered during processing. pub errors: Vec,