From 49b82761010ae0f9e0fe1e6fbe6b83e786f55321 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:21:54 -0300 Subject: [PATCH 01/16] chore(cargo): add `Bitcoin Dev Kit Developers` to `authors` field --- Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8438b5ce..02d491e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,10 @@ name = "esplora-client" version = "0.12.1" edition = "2021" -authors = ["Alekos Filini "] +authors = [ + "Alekos Filini ", + "Bitcoin Dev Kit Developers" +] license = "MIT" homepage = "https://github.com/bitcoindevkit/rust-esplora-client" repository = "https://github.com/bitcoindevkit/rust-esplora-client" From 1bb149e2ed83c45550e977226ead48f4418fa978 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:23:12 -0300 Subject: [PATCH 02/16] feat(api): add `BlockInformation` struct --- src/api.rs | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/api.rs b/src/api.rs index 14e3661f..29c9f133 100644 --- a/src/api.rs +++ b/src/api.rs @@ -87,6 +87,59 @@ pub struct BlockTime { pub height: u32, } +/// Information about a [`Block`]. +#[derive(Debug, Clone, Deserialize)] +pub struct BlockInformation { + /// The block's [`BlockHash`]. + pub id: BlockHash, + /// The block's height. + pub height: u32, + /// The block's version. + pub version: block::Version, + /// The block's timestamp. + pub timestamp: u64, + /// The block's transaction count. + pub tx_count: u64, + /// The block's size, in bytes. + pub size: usize, + /// The block's weight. + pub weight: u64, + /// The merkle root of the transactions in the block. + pub merkle_root: hash_types::TxMerkleNode, + /// The [`BlockHash`] of the previous block (`None` for the genesis block). + pub previousblockhash: Option, + /// The block's MTP (Median Time Past). + pub mediantime: u64, + /// The block's nonce value. + pub nonce: u32, + /// The block's `bits` value as a [`CompactTarget`]. + pub bits: CompactTarget, + /// The block's difficulty target value. + pub difficulty: f64, +} + +impl PartialEq for BlockInformation { + fn eq(&self, other: &Self) -> bool { + let Self { difficulty: d1, .. } = self; + let Self { difficulty: d2, .. } = other; + + self.id == other.id + && self.height == other.height + && self.version == other.version + && self.timestamp == other.timestamp + && self.tx_count == other.tx_count + && self.size == other.size + && self.weight == other.weight + && self.merkle_root == other.merkle_root + && self.previousblockhash == other.previousblockhash + && self.mediantime == other.mediantime + && self.nonce == other.nonce + && self.bits == other.bits + && ((d1.is_nan() && d2.is_nan()) || (d1 == d2)) + } +} +impl Eq for BlockInformation {} + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] pub struct BlockSummary { pub id: BlockHash, From c910ab0e352e072e84751c17b18653b2935ea666 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:23:53 -0300 Subject: [PATCH 03/16] feat(api): add `ScriptHashStats` and `ScriptHashTxsSummary` structs --- src/api.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/api.rs b/src/api.rs index 29c9f133..1d58da7a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -176,6 +176,18 @@ pub struct AddressTxsSummary { pub tx_count: u32, } +/// Statistics about a particular [`Script`] hash's confirmed and mempool transactions. +#[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] +pub struct ScriptHashStats { + /// The summary of confirmed transactions for this [`Script`] hash. + pub chain_stats: ScriptHashTxsSummary, + /// The summary of mempool transactions for this [`Script`] hash. + pub mempool_stats: ScriptHashTxsSummary, +} + +/// Contains a summary of the transactions for a particular [`Script`] hash. +pub type ScriptHashTxsSummary = AddressTxsSummary; + /// Information about an UTXO's status: confirmation status, /// confirmation height, confirmation block hash and confirmation block time. #[derive(Debug, Copy, Clone, PartialEq, Eq, Deserialize)] From a5af9f8f9e230d00be06aece703fd2af892ac68d Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:24:21 -0300 Subject: [PATCH 04/16] feat(api): add `MempoolStats` and `MempoolRecentTx` structs --- src/api.rs | 39 +++++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/src/api.rs b/src/api.rs index 1d58da7a..0ce3369a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -2,15 +2,16 @@ //! //! See: +use bitcoin::hash_types; +use serde::Deserialize; + pub use bitcoin::consensus::{deserialize, serialize}; pub use bitcoin::hex::FromHex; -use bitcoin::Weight; pub use bitcoin::{ - transaction, Amount, BlockHash, OutPoint, ScriptBuf, Transaction, TxIn, TxOut, Txid, Witness, + absolute, block, transaction, Amount, BlockHash, CompactTarget, OutPoint, ScriptBuf, + ScriptHash, Transaction, TxIn, TxOut, Txid, Weight, Witness, }; -use serde::Deserialize; - #[derive(Deserialize, Clone, Debug, PartialEq, Eq)] pub struct PrevOut { pub value: u64, @@ -215,6 +216,36 @@ pub struct Utxo { pub value: Amount, } +/// Statistics about the mempool. +#[derive(Clone, Debug, PartialEq, Deserialize)] +pub struct MempoolStats { + /// The number of transactions in the mempool. + pub count: usize, + /// The total size of mempool transactions in virtual bytes. + pub vsize: usize, + /// The total fee paid by mempool transactions, in sats. + pub total_fee: u64, + /// The mempool's fee rate distribution histogram. + /// + /// An array of `(feerate, vsize)` tuples, where each entry's `vsize` is the total vsize + /// of transactions paying more than `feerate` but less than the previous entry's `feerate` + /// (except for the first entry, which has no upper bound). + pub fee_histogram: Vec<(f64, usize)>, +} + +/// A [`Transaction`] that recently entered the mempool. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +pub struct MempoolRecentTx { + /// Transaction ID as a [`Txid`]. + pub txid: Txid, + /// [`Amount`] of fees paid by the transaction, in satoshis. + pub fee: u64, + /// The transaction size, in virtual bytes. + pub vsize: usize, + /// Combined [`Amount`] of the transaction, in satoshis. + pub value: u64, +} + impl Tx { pub fn to_tx(&self) -> Transaction { Transaction { From 35f6a04b1d082fb138b7350bbe8f07399efe8d97 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:34:46 -0300 Subject: [PATCH 05/16] feat(client): add `get_tx_outspends` method --- src/async.rs | 16 ++++++++++------ src/blocking.rs | 15 +++++++++------ src/lib.rs | 31 +++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/async.rs b/src/async.rs index 390eb2f7..fd92b050 100644 --- a/src/async.rs +++ b/src/async.rs @@ -15,22 +15,20 @@ use std::collections::HashMap; use std::marker::PhantomData; use std::str::FromStr; +use bitcoin::block::Header as BlockHeader; use bitcoin::consensus::{deserialize, serialize, Decodable, Encodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; -use bitcoin::Address; -use bitcoin::{ - block::Header as BlockHeader, Block, BlockHash, MerkleBlock, Script, Transaction, Txid, -}; +use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; #[allow(unused_imports)] use log::{debug, error, info, trace}; use reqwest::{header, Client, Response}; -use crate::api::AddressStats; use crate::{ - BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus, Utxo, + AddressStats, BlockInformation, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, + MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, Tx, TxStatus, Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, }; @@ -315,6 +313,12 @@ impl AsyncClient { self.get_opt_response_json(&format!("/tx/{txid}")).await } + /// Get the spend status of a [`Transaction`]'s outputs, given it's [`Txid`]. + pub async fn get_tx_outspends(&self, txid: &Txid) -> Result, Error> { + self.get_response_json(&format!("/tx/{txid}/outspends")) + .await + } + /// Get a [`BlockHeader`] given a particular block hash. pub async fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { self.get_response_hex(&format!("/block/{block_hash}/header")) diff --git a/src/blocking.rs b/src/blocking.rs index b959c858..6e9f2167 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -21,17 +21,15 @@ use log::{debug, error, info, trace}; use minreq::{Proxy, Request, Response}; +use bitcoin::block::Header as BlockHeader; use bitcoin::consensus::{deserialize, serialize, Decodable}; use bitcoin::hashes::{sha256, Hash}; use bitcoin::hex::{DisplayHex, FromHex}; -use bitcoin::Address; -use bitcoin::{ - block::Header as BlockHeader, Block, BlockHash, MerkleBlock, Script, Transaction, Txid, -}; +use bitcoin::{Address, Block, BlockHash, MerkleBlock, Script, Transaction, Txid}; -use crate::api::AddressStats; use crate::{ - BlockStatus, BlockSummary, Builder, Error, MerkleProof, OutputStatus, Tx, TxStatus, Utxo, + AddressStats, BlockInformation, BlockStatus, BlockSummary, Builder, Error, MempoolRecentTx, + MempoolStats, MerkleProof, OutputStatus, ScriptHashStats, Tx, TxStatus, Utxo, BASE_BACKOFF_MILLIS, RETRYABLE_ERROR_CODES, }; @@ -229,6 +227,11 @@ impl BlockingClient { self.get_opt_response_json(&format!("/tx/{txid}")) } + /// Get the spend status of a [`Transaction`]'s outputs, given it's [`Txid`]. + pub fn get_tx_outspends(&self, txid: &Txid) -> Result, Error> { + self.get_response_json(&format!("/tx/{txid}/outspends")) + } + /// Get a [`BlockHeader`] given a particular block hash. pub fn get_header_by_hash(&self, block_hash: &BlockHash) -> Result { self.get_response_hex(&format!("/block/{block_hash}/header")) diff --git a/src/lib.rs b/src/lib.rs index 51bc6869..06b30707 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1065,4 +1065,35 @@ mod test { assert_ne!(address_utxos_async.len(), 0); assert_eq!(address_utxos_blocking, address_utxos_async); } + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_tx_outspends() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND + .client + .new_address_with_type(AddressType::Legacy) + .unwrap(); + + let txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(21000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let outspends_blocking = blocking_client.get_tx_outspends(&txid).unwrap(); + let outspends_async = async_client.get_tx_outspends(&txid).await.unwrap(); + + // Assert that there are 2 outputs: 21K sat and (coinbase - 21K sat). + assert_eq!(outspends_blocking.len(), 2); + assert_eq!(outspends_async.len(), 2); + assert_eq!(outspends_blocking, outspends_async); + + // Assert that both outputs are returned as unspent (spent == false). + assert!(outspends_blocking.iter().all(|output| !output.spent)); + } } From d2c11280813324d99abd3090f4112284870d6cda Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:36:00 -0300 Subject: [PATCH 06/16] feat(client): add `get_scripthash_stats` method --- src/async.rs | 9 +++- src/blocking.rs | 10 +++- src/lib.rs | 136 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/src/async.rs b/src/async.rs index fd92b050..49f361fe 100644 --- a/src/async.rs +++ b/src/async.rs @@ -395,7 +395,14 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get transaction history for the specified address/scripthash, sorted with newest first. + /// Get statistics about a particular [`Script`] hash's confirmed and mempool transactions. + pub async fn get_scripthash_stats(&self, script: &Script) -> Result { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}"); + self.get_response_json(&path).await + } + + /// Get transaction history for the specified address, sorted with newest first. /// /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. /// More can be requested by specifying the last txid seen by the previous query. diff --git a/src/blocking.rs b/src/blocking.rs index 6e9f2167..c2a9ead1 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -329,7 +329,15 @@ impl BlockingClient { self.get_response_json(&path) } - /// Get transaction history for the specified address/scripthash, sorted with newest first. + /// Get statistics about a particular [`Script`] hash's confirmed and mempool transactions. + pub fn get_scripthash_stats(&self, script: &Script) -> Result { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}"); + self.get_response_json(&path) + } + + /// Get transaction history for the specified address, sorted with newest + /// first. /// /// Returns up to 50 mempool transactions plus the first 25 confirmed transactions. /// More can be requested by specifying the last txid seen by the previous query. diff --git a/src/lib.rs b/src/lib.rs index 06b30707..90518acc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1011,6 +1011,142 @@ mod test { assert_eq!(address_stats_async.chain_stats.funded_txo_sum, 1000); } + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_scripthash_stats() { + let (blocking_client, async_client) = setup_clients().await; + + // Create an address of each type. + let address_legacy = BITCOIND + .client + .new_address_with_type(AddressType::Legacy) + .unwrap(); + let address_p2sh_segwit = BITCOIND + .client + .new_address_with_type(AddressType::P2shSegwit) + .unwrap(); + let address_bech32 = BITCOIND + .client + .new_address_with_type(AddressType::Bech32) + .unwrap(); + let address_bech32m = BITCOIND + .client + .new_address_with_type(AddressType::Bech32m) + .unwrap(); + + // Send a transaction to each address. + let _txid = BITCOIND + .client + .send_to_address(&address_legacy, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _txid = BITCOIND + .client + .send_to_address(&address_p2sh_segwit, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _txid = BITCOIND + .client + .send_to_address(&address_bech32, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + let _txid = BITCOIND + .client + .send_to_address(&address_bech32m, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + // Derive each addresses script. + let script_legacy = address_legacy.script_pubkey(); + let script_p2sh_segwit = address_p2sh_segwit.script_pubkey(); + let script_bech32 = address_bech32.script_pubkey(); + let script_bech32m = address_bech32m.script_pubkey(); + + // P2PKH + let scripthash_stats_blocking_legacy = blocking_client + .get_scripthash_stats(&script_legacy) + .unwrap(); + let scripthash_stats_async_legacy = async_client + .get_scripthash_stats(&script_legacy) + .await + .unwrap(); + assert_eq!( + scripthash_stats_blocking_legacy, + scripthash_stats_async_legacy + ); + assert_eq!( + scripthash_stats_blocking_legacy.chain_stats.funded_txo_sum, + 1000 + ); + assert_eq!(scripthash_stats_blocking_legacy.chain_stats.tx_count, 1); + + // P2SH-P2WSH + let scripthash_stats_blocking_p2sh_segwit = blocking_client + .get_scripthash_stats(&script_p2sh_segwit) + .unwrap(); + let scripthash_stats_async_p2sh_segwit = async_client + .get_scripthash_stats(&script_p2sh_segwit) + .await + .unwrap(); + assert_eq!( + scripthash_stats_blocking_p2sh_segwit, + scripthash_stats_async_p2sh_segwit + ); + assert_eq!( + scripthash_stats_blocking_p2sh_segwit + .chain_stats + .funded_txo_sum, + 1000 + ); + assert_eq!( + scripthash_stats_blocking_p2sh_segwit.chain_stats.tx_count, + 1 + ); + + // P2WPKH / P2WSH + let scripthash_stats_blocking_bech32 = blocking_client + .get_scripthash_stats(&script_bech32) + .unwrap(); + let scripthash_stats_async_bech32 = async_client + .get_scripthash_stats(&script_bech32) + .await + .unwrap(); + assert_eq!( + scripthash_stats_blocking_bech32, + scripthash_stats_async_bech32 + ); + assert_eq!( + scripthash_stats_blocking_bech32.chain_stats.funded_txo_sum, + 1000 + ); + assert_eq!(scripthash_stats_blocking_bech32.chain_stats.tx_count, 1); + + // P2TR + let scripthash_stats_blocking_bech32m = blocking_client + .get_scripthash_stats(&script_bech32m) + .unwrap(); + let scripthash_stats_async_bech32m = async_client + .get_scripthash_stats(&script_bech32m) + .await + .unwrap(); + assert_eq!( + scripthash_stats_blocking_bech32m, + scripthash_stats_async_bech32m + ); + assert_eq!( + scripthash_stats_blocking_bech32m.chain_stats.funded_txo_sum, + 1000 + ); + assert_eq!(scripthash_stats_blocking_bech32m.chain_stats.tx_count, 1); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_address_txs() { From b24fe962e684380889ca76865039ca0c5b1ee532 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:36:38 -0300 Subject: [PATCH 07/16] feat(client): add `get_mempool_address_txs` method --- src/async.rs | 9 ++++++++- src/blocking.rs | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/async.rs b/src/async.rs index 49f361fe..8633e1ae 100644 --- a/src/async.rs +++ b/src/async.rs @@ -419,7 +419,14 @@ impl AsyncClient { self.get_response_json(&path).await } - /// Get confirmed transaction history for the specified address/scripthash, + /// Get mempool [`Transaction`]s for the specified [`Address`], sorted with newest first. + pub async fn get_mempool_address_txs(&self, address: &Address) -> Result, Error> { + let path = format!("/address/{address}/txs/mempool"); + + self.get_response_json(&path).await + } + + /// Get transaction history for the specified address/scripthash, /// sorted with newest first. Returns 25 transactions per page. /// More can be requested by specifying the last txid seen by the previous /// query. diff --git a/src/blocking.rs b/src/blocking.rs index c2a9ead1..d7b45950 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -354,7 +354,14 @@ impl BlockingClient { self.get_response_json(&path) } - /// Get confirmed transaction history for the specified address/scripthash, + /// Get mempool [`Transaction`]s for the specified [`Address`], sorted with newest first. + pub fn get_mempool_address_txs(&self, address: &Address) -> Result, Error> { + let path = format!("/address/{address}/txs/mempool"); + + self.get_response_json(&path) + } + + /// Get transaction history for the specified scripthash, /// sorted with newest first. Returns 25 transactions per page. /// More can be requested by specifying the last txid seen by the previous /// query. From ab4fddec77fb26597ab5ecbf1555b022aba0592c Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:42:22 -0300 Subject: [PATCH 08/16] feat(client): add `get_mempool_scripthash_txs` method --- src/async.rs | 8 ++++++++ src/blocking.rs | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/src/async.rs b/src/async.rs index 8633e1ae..9ee2a434 100644 --- a/src/async.rs +++ b/src/async.rs @@ -444,6 +444,14 @@ impl AsyncClient { self.get_response_json(&path).await } + /// Get mempool [`Transaction`] history for the + /// specified [`Script`] hash, sorted with newest first. + pub async fn get_mempool_scripthash_txs(&self, script: &Script) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash:x}/txs/mempool"); + + self.get_response_json(&path).await + } /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub async fn get_fee_estimates(&self) -> Result, Error> { diff --git a/src/blocking.rs b/src/blocking.rs index d7b45950..db6b38bb 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -378,6 +378,14 @@ impl BlockingClient { self.get_response_json(&path) } + /// Get mempool [`Transaction`] history for the + /// specified [`Script`] hash, sorted with newest first. + pub fn get_mempool_scripthash_txs(&self, script: &Script) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash:x}/txs/mempool"); + + self.get_response_json(&path) + } /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// From 68aadc4b16cc94af99231d83899384bc602a7865 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:43:34 -0300 Subject: [PATCH 09/16] feat(client): add `get_mempool_stats` method --- src/async.rs | 6 ++++++ src/blocking.rs | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/src/async.rs b/src/async.rs index 9ee2a434..d55414e3 100644 --- a/src/async.rs +++ b/src/async.rs @@ -452,6 +452,12 @@ impl AsyncClient { self.get_response_json(&path).await } + + /// Get statistics about the mempool. + pub async fn get_mempool_stats(&self) -> Result { + self.get_response_json("/mempool").await + } + /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub async fn get_fee_estimates(&self) -> Result, Error> { diff --git a/src/blocking.rs b/src/blocking.rs index db6b38bb..83133d2c 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -316,6 +316,11 @@ impl BlockingClient { .map(|s| BlockHash::from_str(s.as_str()).map_err(Error::HexToArray))? } + /// Get statistics about the mempool. + pub fn get_mempool_stats(&self) -> Result { + self.get_response_json("/mempool") + } + /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub fn get_fee_estimates(&self) -> Result, Error> { From 1043e01b51d00c260994bf2225fc0242566079c0 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:44:30 -0300 Subject: [PATCH 10/16] feat(client): add `get_mempool_recent_txs` method --- src/async.rs | 5 +++++ src/blocking.rs | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/src/async.rs b/src/async.rs index d55414e3..8d6bb420 100644 --- a/src/async.rs +++ b/src/async.rs @@ -458,6 +458,11 @@ impl AsyncClient { self.get_response_json("/mempool").await } + // Get a list of the last 10 [`Transaction`]s to enter the mempool. + pub async fn get_mempool_recent_txs(&self) -> Result, Error> { + self.get_response_json("/mempool/recent").await + } + /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub async fn get_fee_estimates(&self) -> Result, Error> { diff --git a/src/blocking.rs b/src/blocking.rs index 83133d2c..ddef5e99 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -321,6 +321,11 @@ impl BlockingClient { self.get_response_json("/mempool") } + // Get a list of the last 10 [`Transaction`]s to enter the mempool. + pub fn get_mempool_recent_txs(&self) -> Result, Error> { + self.get_response_json("/mempool/recent") + } + /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub fn get_fee_estimates(&self) -> Result, Error> { From d623d46a4866b2692f4c0f05bcbce4f1c97b3dec Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:45:23 -0300 Subject: [PATCH 11/16] feat(client): add `get_mempool_txids` method --- src/async.rs | 7 +++++++ src/blocking.rs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/async.rs b/src/async.rs index 8d6bb420..d330aa73 100644 --- a/src/async.rs +++ b/src/async.rs @@ -463,6 +463,13 @@ impl AsyncClient { self.get_response_json("/mempool/recent").await } + /// Get the full list of [`Txid`]s in the mempool. + /// + /// The order of the [`Txid`]s is arbitrary. + pub async fn get_mempool_txids(&self) -> Result, Error> { + self.get_response_json("/mempool/txids").await + } + /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub async fn get_fee_estimates(&self) -> Result, Error> { diff --git a/src/blocking.rs b/src/blocking.rs index ddef5e99..22470172 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -326,6 +326,13 @@ impl BlockingClient { self.get_response_json("/mempool/recent") } + /// Get the full list of [`Txid`]s in the mempool. + /// + /// The order of the txids is arbitrary and does not match bitcoind's. + pub fn get_mempool_txids(&self) -> Result, Error> { + self.get_response_json("/mempool/txids") + } + /// Get an map where the key is the confirmation target (in number of /// blocks) and the value is the estimated feerate (in sat/vB). pub fn get_fee_estimates(&self) -> Result, Error> { From 578d51e5493ac40621755974f5c4a4bc9bbdf2b9 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:47:09 -0300 Subject: [PATCH 12/16] feat(client): add `get_block` method --- src/async.rs | 7 +++++++ src/blocking.rs | 8 ++++++++ src/lib.rs | 20 ++++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/src/async.rs b/src/async.rs index d330aa73..f2f3ac97 100644 --- a/src/async.rs +++ b/src/async.rs @@ -476,6 +476,13 @@ impl AsyncClient { self.get_response_json("/fee-estimates").await } + /// Get a summary about a [`Block`], given it's [`BlockHash`]. + pub async fn get_block(&self, blockhash: &BlockHash) -> Result { + let path = format!("/block/{blockhash}"); + + self.get_response_json(&path).await + } + /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// diff --git a/src/blocking.rs b/src/blocking.rs index 22470172..6bb37530 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -403,6 +403,14 @@ impl BlockingClient { self.get_response_json(&path) } + + /// Get a summary about a [`Block`], given it's [`BlockHash`]. + pub fn get_block(&self, blockhash: &BlockHash) -> Result { + let path = format!("/block/{blockhash}"); + + self.get_response_json(&path) + } + /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// diff --git a/src/lib.rs b/src/lib.rs index 90518acc..edb5d6ca 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -267,6 +267,7 @@ mod test { use electrsd::{corepc_node, ElectrsD}; use lazy_static::lazy_static; use std::env; + use std::str::FromStr; use tokio::sync::Mutex; #[cfg(all(feature = "blocking", feature = "async"))] use { @@ -922,6 +923,25 @@ mod test { assert_eq!(scripthash_txs_txids, scripthash_txs_txids_async); } + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block() { + let (blocking_client, async_client) = setup_clients().await; + + // Genesis block `BlockHash` on regtest. + let blockhash_genesis = + BlockHash::from_str("0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206") + .unwrap(); + + let block_info_blocking = blocking_client.get_block(&blockhash_genesis).unwrap(); + let block_info_async = async_client.get_block(&blockhash_genesis).await.unwrap(); + + assert_eq!(block_info_async, block_info_blocking); + assert_eq!(block_info_async.id, blockhash_genesis); + assert_eq!(block_info_async.height, 0); + assert_eq!(block_info_async.previousblockhash, None); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_blocks() { From b4f5bebffac33a205165635481c194be0bc769e4 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:48:30 -0300 Subject: [PATCH 13/16] feat(client): add `get_block_txids` method --- src/async.rs | 7 +++++++ src/blocking.rs | 7 +++++++ src/lib.rs | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/async.rs b/src/async.rs index f2f3ac97..f5910774 100644 --- a/src/async.rs +++ b/src/async.rs @@ -483,6 +483,13 @@ impl AsyncClient { self.get_response_json(&path).await } + /// Get all [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. + pub async fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { + let path = format!("/block/{blockhash}/txids"); + + self.get_response_json(&path).await + } + /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// diff --git a/src/blocking.rs b/src/blocking.rs index 6bb37530..b8159c35 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -411,6 +411,13 @@ impl BlockingClient { self.get_response_json(&path) } + /// Get all [`Txid`]s that belong to a [`Block`] identified by it's [`BlockHash`]. + pub fn get_block_txids(&self, blockhash: &BlockHash) -> Result, Error> { + let path = format!("/block/{blockhash}/txids"); + + self.get_response_json(&path) + } + /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// diff --git a/src/lib.rs b/src/lib.rs index edb5d6ca..539dd3f5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -942,6 +942,45 @@ mod test { assert_eq!(block_info_async.previousblockhash, None); } + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_txids() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND + .client + .new_address_with_type(AddressType::Legacy) + .unwrap(); + + // Create 5 transactions and mine a block. + let txids: Vec<_> = (0..5) + .map(|_| { + BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap() + }) + .collect(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + // Get the block hash at the chain's tip. + let blockhash = blocking_client.get_tip_hash().unwrap(); + + let txids_async = async_client.get_block_txids(&blockhash).await.unwrap(); + let txids_blocking = blocking_client.get_block_txids(&blockhash).unwrap(); + + assert_eq!(txids_async, txids_blocking); + + // Compare expected and received (skipping the coinbase TXID). + for expected_txid in txids.iter() { + assert!(txids_async.contains(expected_txid)); + } + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_blocks() { From 17fcf9a967e824551ddbe6a692c55b385200c601 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:49:13 -0300 Subject: [PATCH 14/16] feat(client): add `get_block_txs` method --- src/async.rs | 18 ++++++++++++++++++ src/blocking.rs | 18 ++++++++++++++++++ src/lib.rs | 15 +++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/async.rs b/src/async.rs index f5910774..66e4a405 100644 --- a/src/async.rs +++ b/src/async.rs @@ -490,6 +490,24 @@ impl AsyncClient { self.get_response_json(&path).await } + /// Get up to 25 [`Transaction`]s from a [`Block`], given it's [`BlockHash`], + /// beginning at `start_index` (starts from 0 if `start_index` is `None`). + /// + /// The `start_index` value MUST be a multiple of 25, + /// else an error will be returned by Esplora. + pub async fn get_block_txs( + &self, + blockhash: &BlockHash, + start_index: Option, + ) -> Result, Error> { + let path = match start_index { + None => format!("/block/{blockhash}/txs"), + Some(start_index) => format!("/block/{blockhash}/txs/{start_index}"), + }; + + self.get_response_json(&path).await + } + /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// diff --git a/src/blocking.rs b/src/blocking.rs index b8159c35..aa01db8d 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -418,6 +418,24 @@ impl BlockingClient { self.get_response_json(&path) } + /// Get up to 25 [`Transaction`]s from a [`Block`], given it's [`BlockHash`], + /// beginning at `start_index` (starts from 0 if `start_index` is `None`). + /// + /// The `start_index` value MUST be a multiple of 25, + /// else an error will be returned by Esplora. + pub fn get_block_txs( + &self, + blockhash: &BlockHash, + start_index: Option, + ) -> Result, Error> { + let path = match start_index { + None => format!("/block/{blockhash}/txs"), + Some(start_index) => format!("/block/{blockhash}/txs/{start_index}"), + }; + + self.get_response_json(&path) + } + /// Gets some recent block summaries starting at the tip or at `height` if /// provided. /// diff --git a/src/lib.rs b/src/lib.rs index 539dd3f5..89542157 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -981,6 +981,21 @@ mod test { } } + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_block_txs() { + let (blocking_client, async_client) = setup_clients().await; + + let _miner = MINER.lock().await; + let blockhash = blocking_client.get_tip_hash().unwrap(); + + let txs_blocking = blocking_client.get_block_txs(&blockhash, None).unwrap(); + let txs_async = async_client.get_block_txs(&blockhash, None).await.unwrap(); + + assert_ne!(txs_blocking.len(), 0); + assert_eq!(txs_blocking.len(), txs_async.len()); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_blocks() { From 0a87280d51f0c41f64d95284e455207cb24a0b5f Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:50:22 -0300 Subject: [PATCH 15/16] feat(client): add `get_scripthash_utxos` method --- src/async.rs | 13 +++++++++++-- src/blocking.rs | 12 +++++++++++- src/lib.rs | 30 ++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/async.rs b/src/async.rs index 66e4a405..3df87bf3 100644 --- a/src/async.rs +++ b/src/async.rs @@ -527,8 +527,17 @@ impl AsyncClient { /// Get all UTXOs locked to an address. pub async fn get_address_utxos(&self, address: &Address) -> Result, Error> { - self.get_response_json(&format!("/address/{address}/utxo")) - .await + let path = format!("/address/{address}/utxo"); + + self.get_response_json(&path).await + } + + /// Get all [`TxOut`]s locked to a [`Script`] hash. + pub async fn get_scripthash_utxos(&self, script: &Script) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}/utxo"); + + self.get_response_json(&path).await } /// Get the underlying base URL. diff --git a/src/blocking.rs b/src/blocking.rs index aa01db8d..e0643c36 100644 --- a/src/blocking.rs +++ b/src/blocking.rs @@ -455,7 +455,17 @@ impl BlockingClient { /// Get all UTXOs locked to an address. pub fn get_address_utxos(&self, address: &Address) -> Result, Error> { - self.get_response_json(&format!("/address/{address}/utxo")) + let path = format!("/address/{address}/utxo"); + + self.get_response_json(&path) + } + + /// Get all [`TxOut`]s locked to a [`Script`] hash. + pub fn get_scripthash_utxos(&self, script: &Script) -> Result, Error> { + let script_hash = sha256::Hash::hash(script.as_bytes()); + let path = format!("/scripthash/{script_hash}/utxo"); + + self.get_response_json(&path) } /// Sends a GET request to the given `url`, retrying failed attempts diff --git a/src/lib.rs b/src/lib.rs index 89542157..6ea24de7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1275,6 +1275,36 @@ mod test { assert_ne!(address_utxos_async.len(), 0); assert_eq!(address_utxos_blocking, address_utxos_async); } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_get_scripthash_utxos() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND + .client + .new_address_with_type(AddressType::Legacy) + .unwrap(); + let script = address.script_pubkey(); + + let _txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(21000)) + .unwrap() + .txid() + .unwrap(); + + let _miner = MINER.lock().await; + generate_blocks_and_wait(1); + + let scripthash_utxos_blocking = blocking_client.get_scripthash_utxos(&script).unwrap(); + let scripthash_utxos_async = async_client.get_scripthash_utxos(&script).await.unwrap(); + + assert_ne!(scripthash_utxos_blocking.len(), 0); + assert_ne!(scripthash_utxos_async.len(), 0); + assert_eq!(scripthash_utxos_blocking, scripthash_utxos_async); + } + #[cfg(all(feature = "blocking", feature = "async"))] #[tokio::test] async fn test_get_tx_outspends() { From ed0b4a8a359f1ece9a754eac695a9e583737c8e3 Mon Sep 17 00:00:00 2001 From: Luis Schwab Date: Mon, 24 Nov 2025 23:50:55 -0300 Subject: [PATCH 16/16] feat(test): add unified test for mempool methods --- src/lib.rs | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 6ea24de7..1cd8fc79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1336,4 +1336,66 @@ mod test { // Assert that both outputs are returned as unspent (spent == false). assert!(outspends_blocking.iter().all(|output| !output.spent)); } + + #[cfg(all(feature = "blocking", feature = "async"))] + #[tokio::test] + async fn test_mempool_methods() { + let (blocking_client, async_client) = setup_clients().await; + + let address = BITCOIND + .client + .new_address_with_type(AddressType::Legacy) + .unwrap(); + + for _ in 0..5 { + let _txid = BITCOIND + .client + .send_to_address(&address, Amount::from_sat(1000)) + .unwrap() + .txid() + .unwrap(); + } + + // Wait for transactions to propagate to electrs' mempool. + tokio::time::sleep(tokio::time::Duration::from_secs(10)).await; + + // Test `get_mempool_stats` + let stats_blocking = blocking_client.get_mempool_stats().unwrap(); + let stats_async = async_client.get_mempool_stats().await.unwrap(); + assert_eq!(stats_blocking, stats_async); + assert!(stats_blocking.count >= 5); + + // Test `get_mempool_recent_txs` + let recent_blocking = blocking_client.get_mempool_recent_txs().unwrap(); + let recent_async = async_client.get_mempool_recent_txs().await.unwrap(); + assert_eq!(recent_blocking, recent_async); + assert!(recent_blocking.len() <= 10); + assert!(!recent_blocking.is_empty()); + + // Test `get_mempool_txids` + let txids_blocking = blocking_client.get_mempool_txids().unwrap(); + let txids_async = async_client.get_mempool_txids().await.unwrap(); + assert_eq!(txids_blocking, txids_async); + assert!(txids_blocking.len() >= 5); + + // Test `get_mempool_scripthash_txs` + let script = address.script_pubkey(); + let scripthash_txs_blocking = blocking_client.get_mempool_scripthash_txs(&script).unwrap(); + let scripthash_txs_async = async_client + .get_mempool_scripthash_txs(&script) + .await + .unwrap(); + assert_eq!(scripthash_txs_blocking, scripthash_txs_async); + assert_eq!(scripthash_txs_blocking.len(), 5); + + // Test `get_mempool_address_txs` + let mempool_address_txs_blocking = + blocking_client.get_mempool_address_txs(&address).unwrap(); + let mempool_address_txs_async = async_client + .get_mempool_address_txs(&address) + .await + .unwrap(); + assert_eq!(mempool_address_txs_blocking, mempool_address_txs_async); + assert_eq!(mempool_address_txs_blocking.len(), 5); + } }