Skip to content

feat: add psbtbumpfee support #632

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions crates/utils/src/bitcoin_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use bitcoin::{
};
use bitcoincore_rpc::{
bitcoin::{BlockHash, Transaction},
json,
json::{EstimateMode, TestMempoolAcceptResult},
jsonrpc::{error::RpcError, Error as JsonRpcError},
Auth, Client, Error as BitcoinError, RpcApi,
Expand Down Expand Up @@ -181,6 +182,20 @@ pub struct BumpFeeResult {
pub errors: Vec<String>,
}

#[derive(Deserialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct PsbtBumpFeeResult {
/// The base64-encoded unsigned PSBT of the new transaction. Only returned when wallet private
/// keys are disabled.
pub psbt: Option<String>,
/// 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<String>,
}

impl From<RpcError> for BitcoinRpcError {
fn from(err: RpcError) -> Self {
match num::FromPrimitive::from_i32(err.code) {
Expand Down Expand Up @@ -216,6 +231,8 @@ pub enum Error {
InvalidRecipient,
#[error("Hex decoding error: {0}")]
HexDecodeError(#[from] hex::FromHexError),
#[error("Finalized PSBT did not return a raw transaction hex")]
MissingRawTxHex,
}

#[derive(Clone)]
Expand Down Expand Up @@ -454,6 +471,54 @@ impl BitcoinClient {
Err(err) => Err(err.into()), // Handle the case where the RPC call fails
}
}

pub fn psbt_bump_fee(
&self,
txid: &Txid,
options: Option<&BumpFeeOptions>,
) -> Result<PsbtBumpFeeResult, Error> {
// Serialize options if provided
let opts = match options {
Some(options) => Some(options.to_serializable(self.rpc.version()?)),
None => None,
};

// Prepare arguments
let args = vec![serde_json::to_value(txid)?, serde_json::to_value(opts)?];

// Call the "psbtbumpfee" RPC method
let result = self.rpc.call("psbtbumpfee", &args);

// Handle the result
match result {
Ok(result_value) => {
let result: Result<PsbtBumpFeeResult, _> = serde_json::from_value(result_value);
match result {
Ok(bump_fee_result) => Ok(bump_fee_result),
Err(err) => {
println!("Failed to deserialize into PsbtBumpFeeResult");
Err(err.into())
}
}
}
Err(err) => Err(err.into()),
}
}

pub fn sign_and_finalize_psbt(
&self,
psbt: &str,
sign: Option<bool>,
sighash_type: Option<json::SigHashType>,
bip32derivs: Option<bool>,
) -> Result<Transaction, Error> {
let wallet_process_psbt =
self.rpc.wallet_process_psbt(psbt, sign, sighash_type, bip32derivs)?;
let finalized = self.rpc.finalize_psbt(&wallet_process_psbt.psbt, Some(true))?;
let tx_bytes = finalized.hex.ok_or(Error::MissingRawTxHex)?;
let tx: Transaction = consensus::deserialize(&tx_bytes)?;
Ok(tx)
}
}

fn merklize(left: Sha256dHash, right: Sha256dHash) -> Sha256dHash {
Expand Down
76 changes: 76 additions & 0 deletions crates/utils/src/bitcoin_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,82 @@ mod tests {
);
}

#[tokio::test]
async fn test_psbt_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"))?);

let to_addr = bitcoin_client.rpc.get_new_address(None, None).unwrap().assume_checked();

// Set the amount to send
let amount = Amount::from_sat(100_000); // 0.001 BTC (adjust as necessary)

// Send the transaction (low fee expected)
let txid = bitcoin_client
.rpc
.send_to_address(&to_addr, amount, None, None, None, Some(true), None, None)
.unwrap();

// Step 3: Psbt Bump the fee for the low-fee transaction by calling bump_fee
let psbt_bump_fee = bitcoin_client
.psbt_bump_fee(
&txid,
Some(&BumpFeeOptions {
conf_target: None,
fee_rate: None,
replaceable: Some(true), // Allow the transaction to be replaceable
estimate_mode: None,
}),
)
.unwrap();

// the previous tx fee should be less than the newly created tx fee
assert!(psbt_bump_fee.origfee < psbt_bump_fee.fee);

// Sign and finalize the PSBT
let tx = bitcoin_client.sign_and_finalize_psbt(
&psbt_bump_fee.psbt.unwrap(),
None,
None,
None,
)?;

// broadcast the bumped fee transaction
bitcoin_client.validate_and_send_raw_transaction(&tx).unwrap();

// 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
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());

// Get the psbt bumped fee transaction's
let tx_info = bitcoin_client.rpc.get_transaction(&tx.compute_txid(), None).unwrap();

// Assert that the psbt bumped fee transaction has confirmations
assert!(tx_info.info.confirmations.is_positive());

Ok(())
}

#[tokio::test]
async fn test_bump_fee() -> Result<()> {
// Step 1: Create and initialize BitcoinCore instance for test
Expand Down
Loading