use std::env::var;
use bitcoin::{
bip32::Xpriv,
block::Header,
consensus::{self, encode::serialize_hex},
Address, Block, BlockHash, Network, Transaction, Txid,
};
use corepc_types::model;
use corepc_types::v29::{
CreateWallet, GetAddressInfo, GetBlockHeader, GetBlockVerboseOne, GetBlockVerboseZero,
GetBlockchainInfo, GetMempoolInfo, GetNewAddress, GetRawMempool, GetRawMempoolVerbose,
GetRawTransaction, GetRawTransactionVerbose, GetTransaction, GetTxOut, ImportDescriptors,
ListDescriptors, ListTransactions, ListUnspent, PsbtBumpFee, SignRawTransactionWithWallet,
SubmitPackage, TestMempoolAccept, WalletCreateFundedPsbt, WalletProcessPsbt,
};
use serde_json::value::{RawValue, Value};
use tracing::*;
use crate::{
client::Client,
error::ClientError,
to_value,
traits::{Broadcaster, Reader, Signer, Wallet},
types::{
CreateRawTransactionArguments, CreateRawTransactionInput, CreateRawTransactionOutput,
CreateWalletArguments, ImportDescriptorInput, ListUnspentQueryOptions,
PreviousTransactionOutput, PsbtBumpFeeOptions, SighashType, WalletCreateFundedPsbtOptions,
},
ClientResult,
};
const MIN_FEE_RATE_BTC_VKB: f64 = 0.00001;
impl Reader for Client {
async fn estimate_smart_fee(&self, conf_target: u16) -> ClientResult<u64> {
let result = self
.call::<Box<RawValue>>("estimatesmartfee", &[to_value(conf_target)?])
.await?
.to_string();
let result_map: Value = result.parse::<Value>()?;
let btc_vkb = result_map
.get("feerate")
.and_then(|v| v.as_f64())
.unwrap_or(MIN_FEE_RATE_BTC_VKB);
if btc_vkb <= 0.0 {
return Err(ClientError::Other(
"Invalid fee rate: must be positive".to_string(),
));
}
let sat_vb = (btc_vkb * 100_000_000.0 / 1_000.0) as u64;
Ok(sat_vb)
}
async fn get_block_header(&self, hash: &BlockHash) -> ClientResult<Header> {
let get_block_header = self
.call::<GetBlockHeader>(
"getblockheader",
&[to_value(hash.to_string())?, to_value(false)?],
)
.await?;
let header = get_block_header
.block_header()
.map_err(|err| ClientError::Other(format!("header decode: {err}")))?;
Ok(header)
}
async fn get_block(&self, hash: &BlockHash) -> ClientResult<Block> {
let get_block = self
.call::<GetBlockVerboseZero>("getblock", &[to_value(hash.to_string())?, to_value(0)?])
.await?;
let block = get_block
.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))?
.0;
Ok(block)
}
async fn get_block_height(&self, hash: &BlockHash) -> ClientResult<u64> {
let block_verobose = self
.call::<GetBlockVerboseOne>("getblock", &[to_value(hash.to_string())?])
.await?;
let block_height = block_verobose.height as u64;
Ok(block_height)
}
async fn get_block_header_at(&self, height: u64) -> ClientResult<Header> {
let hash = self.get_block_hash(height).await?;
self.get_block_header(&hash).await
}
async fn get_block_at(&self, height: u64) -> ClientResult<Block> {
let hash = self.get_block_hash(height).await?;
self.get_block(&hash).await
}
async fn get_block_count(&self) -> ClientResult<u64> {
self.call::<u64>("getblockcount", &[]).await
}
async fn get_block_hash(&self, height: u64) -> ClientResult<BlockHash> {
self.call::<BlockHash>("getblockhash", &[to_value(height)?])
.await
}
async fn get_blockchain_info(&self) -> ClientResult<model::GetBlockchainInfo> {
let res = self
.call::<GetBlockchainInfo>("getblockchaininfo", &[])
.await?;
res.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_current_timestamp(&self) -> ClientResult<u32> {
let best_block_hash = self.call::<BlockHash>("getbestblockhash", &[]).await?;
let block = self.get_block(&best_block_hash).await?;
Ok(block.header.time)
}
async fn get_raw_mempool(&self) -> ClientResult<model::GetRawMempool> {
let resp = self.call::<GetRawMempool>("getrawmempool", &[]).await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_raw_mempool_verbose(&self) -> ClientResult<model::GetRawMempoolVerbose> {
let resp = self
.call::<GetRawMempoolVerbose>("getrawmempool", &[to_value(true)?])
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_mempool_info(&self) -> ClientResult<model::GetMempoolInfo> {
let resp = self.call::<GetMempoolInfo>("getmempoolinfo", &[]).await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_raw_transaction_verbosity_zero(
&self,
txid: &Txid,
) -> ClientResult<model::GetRawTransaction> {
let resp = self
.call::<GetRawTransaction>(
"getrawtransaction",
&[to_value(txid.to_string())?, to_value(0)?],
)
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_raw_transaction_verbosity_one(
&self,
txid: &Txid,
) -> ClientResult<model::GetRawTransactionVerbose> {
let resp = self
.call::<GetRawTransactionVerbose>(
"getrawtransaction",
&[to_value(txid.to_string())?, to_value(1)?],
)
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_tx_out(
&self,
txid: &Txid,
vout: u32,
include_mempool: bool,
) -> ClientResult<model::GetTxOut> {
let resp = self
.call::<GetTxOut>(
"gettxout",
&[
to_value(txid.to_string())?,
to_value(vout)?,
to_value(include_mempool)?,
],
)
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn network(&self) -> ClientResult<Network> {
let chain = self
.call::<GetBlockchainInfo>("getblockchaininfo", &[])
.await?
.chain;
Network::from_core_arg(&chain).map_err(|e| ClientError::Parse(e.to_string()))
}
}
impl Broadcaster for Client {
async fn send_raw_transaction(&self, tx: &Transaction) -> ClientResult<Txid> {
let txstr = serialize_hex(tx);
trace!(txstr = %txstr, "Sending raw transaction");
match self
.call::<Txid>("sendrawtransaction", &[to_value(txstr)?])
.await
{
Ok(txid) => {
trace!(?txid, "Transaction sent");
Ok(txid)
}
Err(err @ ClientError::Server(_, _)) if err.is_rpc_verify_already_in_utxo_set() => {
Ok(tx.compute_txid())
}
Err(err @ ClientError::Server(_, _)) => Err(err),
Err(e) => Err(ClientError::Other(e.to_string())),
}
}
async fn test_mempool_accept(
&self,
tx: &Transaction,
) -> ClientResult<model::TestMempoolAccept> {
let txstr = serialize_hex(tx);
trace!(%txstr, "Testing mempool accept");
let resp = self
.call::<TestMempoolAccept>("testmempoolaccept", &[to_value([txstr])?])
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn submit_package(&self, txs: &[Transaction]) -> ClientResult<model::SubmitPackage> {
let txstrs: Vec<String> = txs.iter().map(serialize_hex).collect();
let resp = self
.call::<SubmitPackage>("submitpackage", &[to_value(txstrs)?])
.await?;
trace!(?resp, "Got submit package response");
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
}
impl Wallet for Client {
async fn get_new_address(&self) -> ClientResult<Address> {
let address_unchecked = self
.call::<GetNewAddress>("getnewaddress", &[])
.await?
.0
.parse::<Address<_>>()
.map_err(|e| ClientError::Parse(e.to_string()))?
.assume_checked();
Ok(address_unchecked)
}
async fn get_transaction(&self, txid: &Txid) -> ClientResult<model::GetTransaction> {
let resp = self
.call::<GetTransaction>("gettransaction", &[to_value(txid.to_string())?])
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn list_transactions(
&self,
count: Option<usize>,
) -> ClientResult<model::ListTransactions> {
let resp = self
.call::<ListTransactions>("listtransactions", &[to_value(count)?])
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn list_wallets(&self) -> ClientResult<Vec<String>> {
self.call::<Vec<String>>("listwallets", &[]).await
}
async fn create_raw_transaction(
&self,
raw_tx: CreateRawTransactionArguments,
) -> ClientResult<Transaction> {
let raw_tx = self
.call::<String>(
"createrawtransaction",
&[to_value(raw_tx.inputs)?, to_value(raw_tx.outputs)?],
)
.await?;
trace!(%raw_tx, "Created raw transaction");
consensus::encode::deserialize_hex(&raw_tx)
.map_err(|e| ClientError::Other(format!("Failed to deserialize raw transaction: {e}")))
}
async fn wallet_create_funded_psbt(
&self,
inputs: &[CreateRawTransactionInput],
outputs: &[CreateRawTransactionOutput],
locktime: Option<u32>,
options: Option<WalletCreateFundedPsbtOptions>,
bip32_derivs: Option<bool>,
) -> ClientResult<model::WalletCreateFundedPsbt> {
let resp = self
.call::<WalletCreateFundedPsbt>(
"walletcreatefundedpsbt",
&[
to_value(inputs)?,
to_value(outputs)?,
to_value(locktime.unwrap_or(0))?,
to_value(options.unwrap_or_default())?,
to_value(bip32_derivs)?,
],
)
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_address_info(&self, address: &Address) -> ClientResult<model::GetAddressInfo> {
trace!(address = %address, "Getting address info");
let resp = self
.call::<GetAddressInfo>("getaddressinfo", &[to_value(address.to_string())?])
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn list_unspent(
&self,
min_conf: Option<u32>,
max_conf: Option<u32>,
addresses: Option<&[Address]>,
include_unsafe: Option<bool>,
query_options: Option<ListUnspentQueryOptions>,
) -> ClientResult<model::ListUnspent> {
let addr_strings: Vec<String> = addresses
.map(|addrs| addrs.iter().map(|a| a.to_string()).collect())
.unwrap_or_default();
let mut params = vec![
to_value(min_conf.unwrap_or(1))?,
to_value(max_conf.unwrap_or(9_999_999))?,
to_value(addr_strings)?,
to_value(include_unsafe.unwrap_or(true))?,
];
if let Some(query_options) = query_options {
params.push(to_value(query_options)?);
}
let resp = self.call::<ListUnspent>("listunspent", ¶ms).await?;
trace!(?resp, "Got UTXOs");
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
}
impl Signer for Client {
async fn sign_raw_transaction_with_wallet(
&self,
tx: &Transaction,
prev_outputs: Option<Vec<PreviousTransactionOutput>>,
) -> ClientResult<model::SignRawTransactionWithWallet> {
let tx_hex = serialize_hex(tx);
trace!(tx_hex = %tx_hex, "Signing transaction");
trace!(?prev_outputs, "Signing transaction with previous outputs");
let resp = self
.call::<SignRawTransactionWithWallet>(
"signrawtransactionwithwallet",
&[to_value(tx_hex)?, to_value(prev_outputs)?],
)
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn get_xpriv(&self) -> ClientResult<Option<Xpriv>> {
if var("BITCOIN_XPRIV_RETRIEVABLE").is_err() {
return Ok(None);
}
let descriptors = self
.call::<ListDescriptors>("listdescriptors", &[to_value(true)?]) .await?
.descriptors;
if descriptors.is_empty() {
return Err(ClientError::Other("No descriptors found".to_string()));
}
let descriptor = descriptors
.iter()
.find(|d| d.descriptor.contains("tr("))
.map(|d| d.descriptor.clone())
.ok_or(ClientError::Xpriv)?;
let xpriv_str = descriptor
.split("tr(")
.nth(1)
.ok_or(ClientError::Xpriv)?
.split("/")
.next()
.ok_or(ClientError::Xpriv)?;
let xpriv = xpriv_str.parse::<Xpriv>().map_err(|_| ClientError::Xpriv)?;
Ok(Some(xpriv))
}
async fn import_descriptors(
&self,
descriptors: Vec<ImportDescriptorInput>,
wallet_name: String,
) -> ClientResult<ImportDescriptors> {
let wallet_args = CreateWalletArguments {
name: wallet_name,
load_on_startup: Some(true),
};
let _wallet_create = self
.call::<CreateWallet>("createwallet", &[to_value(wallet_args.clone())?])
.await;
let _wallet_load = self
.call::<CreateWallet>("loadwallet", &[to_value(wallet_args)?])
.await;
let result = self
.call::<ImportDescriptors>("importdescriptors", &[to_value(descriptors)?])
.await?;
Ok(result)
}
async fn wallet_process_psbt(
&self,
psbt: &str,
sign: Option<bool>,
sighashtype: Option<SighashType>,
bip32_derivs: Option<bool>,
) -> ClientResult<model::WalletProcessPsbt> {
let mut params = vec![to_value(psbt)?, to_value(sign.unwrap_or(true))?];
if let Some(sighashtype) = sighashtype {
params.push(to_value(sighashtype)?);
}
if let Some(bip32_derivs) = bip32_derivs {
params.push(to_value(bip32_derivs)?);
}
let resp = self
.call::<WalletProcessPsbt>("walletprocesspsbt", ¶ms)
.await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
async fn psbt_bump_fee(
&self,
txid: &Txid,
options: Option<PsbtBumpFeeOptions>,
) -> ClientResult<model::PsbtBumpFee> {
let mut params = vec![to_value(txid.to_string())?];
if let Some(options) = options {
params.push(to_value(options)?);
}
let resp = self.call::<PsbtBumpFee>("psbtbumpfee", ¶ms).await?;
resp.into_model()
.map_err(|e| ClientError::Parse(e.to_string()))
}
}
#[cfg(test)]
mod test {
use std::sync::Once;
use bitcoin::{hashes::Hash, transaction, Amount, FeeRate, NetworkKind};
use corepc_types::v29::ImportDescriptorsResult;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use super::*;
use crate::{
test_utils::corepc_node_helpers::{get_bitcoind_and_client, mine_blocks},
types::{CreateRawTransactionInput, CreateRawTransactionOutput},
Auth,
};
const COINBASE_AMOUNT: Amount = Amount::from_sat(50 * 100_000_000);
fn init_tracing() {
static INIT: Once = Once::new();
INIT.call_once(|| {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.try_init()
.ok();
});
}
#[tokio::test()]
async fn client_works() {
init_tracing();
let (bitcoind, client) = get_bitcoind_and_client();
let got = client.network().await.unwrap();
let expected = Network::Regtest;
assert_eq!(expected, got);
let get_blockchain_info = client.get_blockchain_info().await.unwrap();
assert_eq!(get_blockchain_info.blocks, 0);
let _ = client
.get_current_timestamp()
.await
.expect("must be able to get current timestamp");
let blocks = mine_blocks(&bitcoind, 101, None).unwrap();
let expected = blocks.last().unwrap();
let got = client.get_block(expected).await.unwrap().block_hash();
assert_eq!(*expected, got);
let target_height = blocks.len() as u64;
let expected = blocks.last().unwrap();
let got = client
.get_block_at(target_height)
.await
.unwrap()
.block_hash();
assert_eq!(*expected, got);
let expected = blocks.len() as u64;
let got = client.get_block_count().await.unwrap();
assert_eq!(expected, got);
let target_height = blocks.len() as u64;
let expected = blocks.last().unwrap();
let got = client.get_block_hash(target_height).await.unwrap();
assert_eq!(*expected, got);
let target_height = blocks.len() as u64;
let expected = blocks.last().unwrap();
let got = client.get_block_header_at(target_height).await.unwrap();
assert_eq!(*expected, got.block_hash());
let address = client.get_new_address().await.unwrap();
let txid = client
.call::<String>(
"sendtoaddress",
&[to_value(address.to_string()).unwrap(), to_value(1).unwrap()],
)
.await
.unwrap()
.parse::<Txid>()
.unwrap();
let tx = client.get_transaction(&txid).await.unwrap().tx;
let got = client.send_raw_transaction(&tx).await.unwrap();
let expected = txid; assert_eq!(expected, got);
let got = client
.get_raw_transaction_verbosity_zero(&txid)
.await
.unwrap()
.0
.compute_txid();
assert_eq!(expected, got);
let got = client
.get_raw_transaction_verbosity_one(&txid)
.await
.unwrap()
.transaction
.compute_txid();
assert_eq!(expected, got);
let got = client.get_raw_mempool().await.unwrap();
let expected = vec![txid];
assert_eq!(expected, got.0);
let got = client.get_raw_mempool_verbose().await.unwrap();
assert_eq!(got.0.len(), 1);
assert_eq!(got.0.get(&txid).unwrap().height, 101);
let got = client.get_mempool_info().await.unwrap();
assert!(got.loaded.unwrap_or(false));
assert_eq!(got.size, 1);
assert_eq!(got.unbroadcast_count, Some(1));
let got = client.estimate_smart_fee(1).await.unwrap();
let expected = 1; assert_eq!(expected, got);
let got = client
.sign_raw_transaction_with_wallet(&tx, None)
.await
.unwrap();
assert!(got.complete);
assert!(got.errors.is_empty());
let txids = client
.test_mempool_accept(&tx)
.await
.expect("must be able to test mempool accept");
let got = txids
.results
.first()
.expect("there must be at least one txid");
assert_eq!(
got.txid,
tx.compute_txid(),
"txids must match in the mempool"
);
let got = client.send_raw_transaction(&tx).await.unwrap();
assert!(got.as_byte_array().len() == 32);
let got = client.list_transactions(None).await.unwrap();
assert_eq!(got.0.len(), 10);
mine_blocks(&bitcoind, 1, None).unwrap();
let got = client
.list_unspent(None, None, None, None, None)
.await
.unwrap();
assert_eq!(got.0.len(), 3);
let got = client.get_xpriv().await.unwrap().unwrap().network;
let expected = NetworkKind::Test;
assert_eq!(expected, got);
let descriptor_string = "tr([e61b318f/20000'/20']tprv8ZgxMBicQKsPd4arFr7sKjSnKFDVMR2JHw9Y8L9nXN4kiok4u28LpHijEudH3mMYoL4pM5UL9Bgdz2M4Cy8EzfErmU9m86ZTw6hCzvFeTg7/101/*)#2plamwqs".to_owned();
let timestamp = "now".to_owned();
let list_descriptors = vec![ImportDescriptorInput {
desc: descriptor_string,
active: Some(true),
timestamp,
}];
let got = client
.import_descriptors(list_descriptors, "strata".to_owned())
.await
.unwrap()
.0;
let expected = vec![ImportDescriptorsResult {
success: true,
warnings: Some(vec![
"Range not given, using default keypool range".to_string()
]),
error: None,
}];
assert_eq!(expected, got);
let psbt_address = client.get_new_address().await.unwrap();
let psbt_outputs = vec![CreateRawTransactionOutput::AddressAmount {
address: psbt_address.to_string(),
amount: 1.0,
}];
let funded_psbt = client
.wallet_create_funded_psbt(&[], &psbt_outputs, None, None, None)
.await
.unwrap();
assert!(!funded_psbt.psbt.inputs.is_empty());
assert!(funded_psbt.fee.to_sat() > 0);
let processed_psbt = client
.wallet_process_psbt(&funded_psbt.psbt.to_string(), None, None, None)
.await
.unwrap();
assert!(!processed_psbt.psbt.inputs.is_empty());
assert!(processed_psbt.complete);
let finalized_psbt = client
.wallet_process_psbt(&funded_psbt.psbt.to_string(), Some(true), None, None)
.await
.unwrap();
assert!(finalized_psbt.complete);
assert!(finalized_psbt.hex.is_some());
let signed_tx = finalized_psbt.hex.as_ref().unwrap();
let signed_txid = signed_tx.compute_txid();
let got = client
.test_mempool_accept(signed_tx)
.await
.unwrap()
.results
.first()
.unwrap()
.txid;
assert_eq!(signed_txid, got);
let info_address = client.get_new_address().await.unwrap();
let address_info = client.get_address_info(&info_address).await.unwrap();
assert_eq!(address_info.address, info_address.as_unchecked().clone());
assert!(address_info.is_mine);
assert!(address_info.solvable.unwrap_or(false));
let unspent_address = client.get_new_address().await.unwrap();
let unspent_txid = client
.call::<String>(
"sendtoaddress",
&[
to_value(unspent_address.to_string()).unwrap(),
to_value(1.0).unwrap(),
],
)
.await
.unwrap();
mine_blocks(&bitcoind, 1, None).unwrap();
let utxos = client
.list_unspent(Some(1), Some(9_999_999), None, Some(true), None)
.await
.unwrap();
assert!(!utxos.0.is_empty());
let utxos_filtered = client
.list_unspent(
Some(1),
Some(9_999_999),
Some(std::slice::from_ref(&unspent_address)),
Some(true),
None,
)
.await
.unwrap();
assert!(!utxos_filtered.0.is_empty());
let found_utxo = utxos_filtered.0.iter().any(|utxo| {
utxo.txid.to_string() == unspent_txid
&& utxo.address.clone().assume_checked().to_string() == unspent_address.to_string()
});
assert!(found_utxo);
let query_options = ListUnspentQueryOptions {
minimum_amount: Some(Amount::from_btc(0.5).unwrap()),
maximum_amount: Some(Amount::from_btc(2.0).unwrap()),
maximum_count: Some(10),
};
let utxos_with_query = client
.list_unspent(
Some(1),
Some(9_999_999),
None,
Some(true),
Some(query_options),
)
.await
.unwrap();
assert!(!utxos_with_query.0.is_empty());
for utxo in &utxos_with_query.0 {
let amount_btc = utxo.amount.to_btc();
assert!((0.5..=2.0).contains(&amount_btc));
}
let tx = finalized_psbt.hex.unwrap();
assert!(!tx.input.is_empty());
assert!(!tx.output.is_empty());
}
#[tokio::test()]
async fn get_tx_out() {
init_tracing();
let (bitcoind, client) = get_bitcoind_and_client();
let got = client.network().await.unwrap();
let expected = Network::Regtest;
assert_eq!(expected, got);
let address = bitcoind.client.new_address().unwrap();
let blocks = mine_blocks(&bitcoind, 101, Some(address)).unwrap();
let last_block = client.get_block(blocks.first().unwrap()).await.unwrap();
let coinbase_tx = last_block.coinbase().unwrap();
let got = client
.get_tx_out(&coinbase_tx.compute_txid(), 0, true)
.await
.unwrap();
assert_eq!(got.tx_out.value, COINBASE_AMOUNT);
let new_address = bitcoind.client.new_address().unwrap();
let send_amount = Amount::from_sat(COINBASE_AMOUNT.to_sat() - 2_000); let _send_tx = bitcoind
.client
.send_to_address(&new_address, send_amount)
.unwrap()
.txid()
.unwrap();
let result = client
.get_tx_out(&coinbase_tx.compute_txid(), 0, true)
.await;
trace!(?result, "gettxout result");
assert!(result.is_err());
}
#[tokio::test()]
async fn submit_package() {
init_tracing();
let (bitcoind, client) = get_bitcoind_and_client();
let got = client.network().await.unwrap();
let expected = Network::Regtest;
assert_eq!(expected, got);
let blocks = mine_blocks(&bitcoind, 101, None).unwrap();
let last_block = client.get_block(blocks.first().unwrap()).await.unwrap();
let coinbase_tx = last_block.coinbase().unwrap();
let destination = client.get_new_address().await.unwrap();
let change_address = client.get_new_address().await.unwrap();
let amount = Amount::from_btc(1.0).unwrap();
let fees = Amount::from_btc(0.0001).unwrap();
let change_amount = COINBASE_AMOUNT - amount - fees;
let amount_minus_fees = Amount::from_sat(amount.to_sat() - 2_000);
let send_back_address = client.get_new_address().await.unwrap();
let parent_raw_tx = CreateRawTransactionArguments {
inputs: vec![CreateRawTransactionInput {
txid: coinbase_tx.compute_txid().to_string(),
vout: 0,
}],
outputs: vec![
CreateRawTransactionOutput::AddressAmount {
address: destination.to_string(),
amount: amount.to_btc(),
},
CreateRawTransactionOutput::AddressAmount {
address: change_address.to_string(),
amount: change_amount.to_btc(),
},
],
};
let parent = client.create_raw_transaction(parent_raw_tx).await.unwrap();
let signed_parent = client
.sign_raw_transaction_with_wallet(&parent, None)
.await
.unwrap()
.tx;
let parent_submitted = client.send_raw_transaction(&signed_parent).await.unwrap();
let child_raw_tx = CreateRawTransactionArguments {
inputs: vec![CreateRawTransactionInput {
txid: parent_submitted.to_string(),
vout: 0,
}],
outputs: vec![
CreateRawTransactionOutput::AddressAmount {
address: send_back_address.to_string(),
amount: amount_minus_fees.to_btc(),
},
],
};
let child = client.create_raw_transaction(child_raw_tx).await.unwrap();
let signed_child = client
.sign_raw_transaction_with_wallet(&child, None)
.await
.unwrap()
.tx;
let result = client
.submit_package(&[signed_parent, signed_child])
.await
.unwrap();
assert_eq!(result.tx_results.len(), 2);
assert_eq!(result.package_msg, "success");
}
#[tokio::test]
async fn submit_package_1p1c() {
init_tracing();
let (bitcoind, client) = get_bitcoind_and_client();
let server_version = bitcoind.client.server_version().unwrap();
assert!(server_version > 28);
let destination = client.get_new_address().await.unwrap();
let blocks = mine_blocks(&bitcoind, 101, None).unwrap();
let last_block = client.get_block(blocks.first().unwrap()).await.unwrap();
let coinbase_tx = last_block.coinbase().unwrap();
let parent_raw_tx = CreateRawTransactionArguments {
inputs: vec![CreateRawTransactionInput {
txid: coinbase_tx.compute_txid().to_string(),
vout: 0,
}],
outputs: vec![CreateRawTransactionOutput::AddressAmount {
address: destination.to_string(),
amount: COINBASE_AMOUNT.to_btc(),
}],
};
let mut parent = client.create_raw_transaction(parent_raw_tx).await.unwrap();
parent.version = transaction::Version(3);
assert_eq!(parent.version, transaction::Version(3));
trace!(?parent, "parent:");
let signed_parent = client
.sign_raw_transaction_with_wallet(&parent, None)
.await
.unwrap()
.tx;
assert_eq!(signed_parent.version, transaction::Version(3));
let parent_broadcasted = client.send_raw_transaction(&signed_parent).await;
assert!(parent_broadcasted.is_err());
let amount_minus_fees = Amount::from_sat(COINBASE_AMOUNT.to_sat() - 43_000);
let child_raw_tx = CreateRawTransactionArguments {
inputs: vec![CreateRawTransactionInput {
txid: signed_parent.compute_txid().to_string(),
vout: 0,
}],
outputs: vec![CreateRawTransactionOutput::AddressAmount {
address: destination.to_string(),
amount: amount_minus_fees.to_btc(),
}],
};
let mut child = client.create_raw_transaction(child_raw_tx).await.unwrap();
child.version = transaction::Version(3);
assert_eq!(child.version, transaction::Version(3));
trace!(?child, "child:");
let prev_outputs = vec![PreviousTransactionOutput {
txid: parent.compute_txid(),
vout: 0,
script_pubkey: parent.output[0].script_pubkey.to_hex_string(),
redeem_script: None,
witness_script: None,
amount: Some(COINBASE_AMOUNT.to_btc()),
}];
let signed_child = client
.sign_raw_transaction_with_wallet(&child, Some(prev_outputs))
.await
.unwrap()
.tx;
assert_eq!(signed_child.version, transaction::Version(3));
let child_broadcasted = client.send_raw_transaction(&signed_child).await;
assert!(child_broadcasted.is_err());
let result = client
.submit_package(&[signed_parent, signed_child])
.await
.unwrap();
assert_eq!(result.tx_results.len(), 2);
assert_eq!(result.package_msg, "success");
}
#[tokio::test]
async fn test_invalid_credentials_return_401_error() {
init_tracing();
let (bitcoind, _) = get_bitcoind_and_client();
let url = bitcoind.rpc_url();
let auth = Auth::UserPass("wrong_user".to_string(), "wrong_password".to_string());
let invalid_client = Client::new(url, auth, None, None, None).unwrap();
let result = invalid_client.get_blockchain_info().await;
assert!(result.is_err());
let error = result.unwrap_err();
match error {
ClientError::Status(status_code, message) => {
assert_eq!(status_code, 401);
assert!(message.contains("Unauthorized"));
}
_ => panic!("Expected Status(401, _) error, but got: {error:?}"),
}
}
#[tokio::test]
async fn test_send_raw_transaction_exposes_rpc_error_code_on_http_500() {
init_tracing();
let (_bitcoind, client) = get_bitcoind_and_client();
let result = client
.call::<String>("sendrawtransaction", &[to_value("deadbeef").unwrap()])
.await;
match result {
Err(ClientError::Server(code, message)) => {
assert_eq!(code, -22);
assert!(
message.to_lowercase().contains("decode"),
"expected decode-related RPC error message, got: {message}"
);
}
other => panic!("Expected Server(-22, _), got: {other:?}"),
}
}
#[tokio::test]
async fn test_get_raw_transaction_exposes_rpc_error_code_on_http_500() {
init_tracing();
let (_bitcoind, client) = get_bitcoind_and_client();
let missing_txid = Txid::from_slice(&[0u8; 32]).expect("must be a valid txid");
let error = client
.get_raw_transaction_verbosity_zero(&missing_txid)
.await
.expect_err("missing txid must fail");
assert!(
!matches!(error, ClientError::Status(..) | ClientError::Parse(..)),
"expected parsed RPC error, got transport/parsing error: {error:?}"
);
assert!(
error.is_tx_not_found(),
"expected tx-not-found classification, got: {error:?}"
);
}
#[tokio::test]
async fn psbt_bump_fee() {
init_tracing();
let (bitcoind, client) = get_bitcoind_and_client();
mine_blocks(&bitcoind, 101, None).unwrap();
let destination = client.get_new_address().await.unwrap();
let amount = Amount::from_btc(0.001).unwrap();
let txid = bitcoind
.client
.send_to_address_rbf(&destination, amount)
.unwrap()
.txid()
.unwrap();
let mempool = client.get_raw_mempool().await.unwrap();
assert!(
mempool.0.contains(&txid),
"Transaction should be in mempool for RBF"
);
let signed_tx = client
.psbt_bump_fee(&txid, None)
.await
.unwrap()
.psbt
.extract_tx()
.unwrap();
let signed_txid = signed_tx.compute_txid();
let got = client
.test_mempool_accept(&signed_tx)
.await
.unwrap()
.results
.first()
.unwrap()
.txid;
assert_eq!(
got, signed_txid,
"Bumped transaction should be accepted in mempool"
);
let options = PsbtBumpFeeOptions {
fee_rate: Some(FeeRate::from_sat_per_kwu(20)), ..Default::default()
};
trace!(?options, "Calling psbt_bump_fee");
let signed_tx = client
.psbt_bump_fee(&txid, Some(options))
.await
.unwrap()
.psbt
.extract_tx()
.unwrap();
let signed_txid = signed_tx.compute_txid();
let got = client
.test_mempool_accept(&signed_tx)
.await
.unwrap()
.results
.first()
.unwrap()
.txid;
assert_eq!(
got, signed_txid,
"Bumped transaction should be accepted in mempool"
);
}
#[cfg(feature = "raw_rpc")]
#[tokio::test]
async fn call_raw() {
init_tracing();
let (bitcoind, client) = get_bitcoind_and_client();
mine_blocks(&bitcoind, 5, None).unwrap();
let expected = client.get_block_count().await.unwrap();
let got: u64 = client.call_raw("getblockcount", &[]).await.unwrap();
assert_eq!(expected, got);
let height = 0;
let expected_hash = client.get_block_hash(height).await.unwrap();
let got_hash: BlockHash = client
.call_raw("getblockhash", &[to_value(height).unwrap()])
.await
.unwrap();
assert_eq!(expected_hash, got_hash);
}
#[test]
fn test_network_chain_response() {
let test_cases = vec![
("main", Network::Bitcoin),
("test", Network::Testnet),
("testnet4", Network::Testnet4),
("signet", Network::Signet),
("regtest", Network::Regtest),
];
for (bitcoind_chain_str, expected_network) in test_cases {
let result = Network::from_core_arg(bitcoind_chain_str);
assert!(result.is_ok(), "failed for chain: {}", bitcoind_chain_str);
assert_eq!(result.unwrap(), expected_network);
}
}
}