use bitcoin::{OutPoint, Txid};
use bitcoin_hashes::Hash as BitcoinHash;
use reqwest::blocking::Client;
use std::thread;
use std::time::{Duration, Instant};
use crate::proofs::extract_merkle_proof_from_block;
use crate::rpc::BitcoinRpc;
use crate::types::BitcoinInclusionProof;
pub const MEMPOOL_SIGNET_BASE: &str = "https://mempool.space/signet/api";
const MAX_RETRIES: u32 = 3;
const INITIAL_BACKOFF: Duration = Duration::from_secs(2);
pub struct MempoolSignetRpc {
client: Client,
base_url: String,
}
impl MempoolSignetRpc {
pub fn new() -> Self {
Self::with_url(MEMPOOL_SIGNET_BASE.to_string())
}
pub fn with_url(base_url: String) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
Self { client, base_url }
}
fn get_with_retry<T: serde::de::DeserializeOwned>(
&self,
url: &str,
) -> Result<T, Box<dyn std::error::Error + Send + Sync>> {
let mut last_err = None;
let mut backoff = INITIAL_BACKOFF;
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
log::warn!(
"Retry {}/{} for {} after {:?} backoff",
attempt,
MAX_RETRIES,
url,
backoff
);
thread::sleep(backoff);
backoff *= 2;
}
match self.client.get(url).send() {
Ok(resp) if resp.status().is_success() => {
return resp.json::<T>().map_err(|e| e.into());
}
Ok(resp) => {
last_err = Some(format!("HTTP {} at {}", resp.status(), url).into());
}
Err(e) => {
last_err = Some(format!("Network error at {}: {}", url, e).into());
}
}
}
Err(last_err.unwrap_or_else(|| "Max retries exceeded".into()))
}
fn get_text_with_retry(
&self,
url: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut last_err = None;
let mut backoff = INITIAL_BACKOFF;
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
thread::sleep(backoff);
backoff *= 2;
}
match self.client.get(url).send() {
Ok(resp) if resp.status().is_success() => {
return resp.text().map_err(|e| e.into());
}
Ok(resp) => {
last_err = Some(format!("HTTP {} at {}", resp.status(), url).into());
}
Err(e) => {
last_err = Some(format!("Network error at {}: {}", url, e).into());
}
}
}
Err(last_err.unwrap_or_else(|| "Max retries exceeded".into()))
}
fn post_text_with_retry(
&self,
url: &str,
body: String,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let mut last_err = None;
let mut backoff = INITIAL_BACKOFF;
for attempt in 0..=MAX_RETRIES {
if attempt > 0 {
thread::sleep(backoff);
backoff *= 2;
}
match self
.client
.post(url)
.header("Content-Type", "text/plain")
.body(body.clone())
.send()
{
Ok(resp) if resp.status().is_success() => {
return resp.text().map_err(|e| e.into());
}
Ok(resp) => {
let status = resp.status();
let error_text = resp.text().unwrap_or_default();
last_err = Some(format!("HTTP {} at {}: {}", status, url, error_text).into());
}
Err(e) => {
last_err = Some(format!("Network error at {}: {}", url, e).into());
}
}
}
Err(last_err.unwrap_or_else(|| "Max retries exceeded".into()))
}
pub fn get_block_info(
&self,
block_hash: &str,
) -> Result<BlockInfo, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/block/{}", self.base_url, block_hash);
self.get_with_retry(&url)
}
pub fn get_tx_status(
&self,
txid: &str,
) -> Result<TxStatus, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/tx/{}/status", self.base_url, txid);
self.get_with_retry(&url)
}
pub fn get_tx(&self, txid: &str) -> Result<TxDetail, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/tx/{}", self.base_url, txid);
self.get_with_retry(&url)
}
pub fn get_tx_hex(
&self,
txid: &str,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/tx/{}/hex", self.base_url, txid);
self.get_text_with_retry(&url)
}
pub fn get_block_txids(
&self,
block_hash: &str,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/block/{}/txids", self.base_url, block_hash);
self.get_with_retry(&url)
}
pub fn wait_for_confirmation(
&self,
txid: [u8; 32],
required_confirmations: u64,
timeout_secs: u64,
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let txid_hex = hex::encode(txid);
let start = Instant::now();
let poll_interval = Duration::from_secs(10);
loop {
if start.elapsed() > Duration::from_secs(timeout_secs) {
return Err("Timeout waiting for confirmation".into());
}
match self.get_tx_status(&txid_hex) {
Ok(status) => {
if status.confirmed {
let tx_height = status.block_height.unwrap_or(0) as u64;
let new_height = self.get_block_count()?;
let confirmations = new_height.saturating_sub(tx_height) + 1;
if confirmations >= required_confirmations {
return Ok(confirmations);
}
log::info!(
"Tx {} has {} confirmations, waiting for {}...",
&txid_hex[..16],
confirmations,
required_confirmations
);
}
}
Err(e) => {
log::debug!("Tx {} not found yet: {}", &txid_hex[..16], e);
}
}
thread::sleep(poll_interval);
}
}
pub fn extract_merkle_proof(
&self,
txid: [u8; 32],
block_hash: [u8; 32],
) -> Result<BitcoinInclusionProof, Box<dyn std::error::Error + Send + Sync>> {
let block_hash_hex = hex::encode(block_hash);
let all_txids_hex = self.get_block_txids(&block_hash_hex)?;
let all_txids: Vec<[u8; 32]> = all_txids_hex
.iter()
.map(|t| {
let decoded = hex::decode(t)?;
let mut arr = [0u8; 32];
arr.copy_from_slice(&decoded);
Ok(arr)
})
.collect::<Result<Vec<_>, Box<dyn std::error::Error + Send + Sync>>>()?;
let block_info = self.get_block_info(&block_hash_hex)?;
let block_height = block_info.height;
extract_merkle_proof_from_block(txid, &all_txids, block_hash, block_height as u64)
.ok_or_else(|| "Failed to extract Merkle proof for txid".into())
}
}
impl Default for MempoolSignetRpc {
fn default() -> Self {
Self::new()
}
}
impl BitcoinRpc for MempoolSignetRpc {
fn get_block_count(&self) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/blocks/tip/height", self.base_url);
self.get_with_retry(&url)
}
fn get_block_hash(
&self,
height: u64,
) -> Result<[u8; 32], Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/block-height/{}", self.base_url, height);
let hash_hex: String = self.get_text_with_retry(&url)?;
let hash_bytes = hex::decode(hash_hex.trim())?;
let mut result = [0u8; 32];
result.copy_from_slice(&hash_bytes);
Ok(result)
}
fn is_utxo_unspent(
&self,
txid: [u8; 32],
vout: u32,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let txid_hex = hex::encode(txid);
let spend_url = format!("{}/tx/{}/outspend/{}", self.base_url, txid_hex, vout);
let spend_status: OutSpendStatus = self.get_with_retry(&spend_url)?;
Ok(!spend_status.spent)
}
fn send_raw_transaction(
&self,
tx_bytes: Vec<u8>,
) -> Result<[u8; 32], Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/tx", self.base_url);
let tx_hex = hex::encode(&tx_bytes);
let txid_hex = self.post_text_with_retry(&url, tx_hex)?;
let txid_bytes = hex::decode(txid_hex.trim())?;
let mut result = [0u8; 32];
result.copy_from_slice(&txid_bytes);
Ok(result)
}
fn get_tx_confirmations(
&self,
txid: [u8; 32],
) -> Result<u64, Box<dyn std::error::Error + Send + Sync>> {
let txid_hex = hex::encode(txid);
match self.get_tx_status(&txid_hex) {
Ok(status) => {
if status.confirmed {
let current_height = self.get_block_count()?;
let tx_height = status.block_height.unwrap_or(0) as u64;
Ok(current_height.saturating_sub(tx_height) + 1)
} else {
Ok(0)
}
}
Err(_) => Ok(0),
}
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct BlockInfo {
pub id: String,
pub height: u32,
pub version: u32,
pub timestamp: u64,
pub tx_count: u32,
pub size: u64,
pub weight: u64,
pub merkle_root: String,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct TxStatus {
pub confirmed: bool,
#[serde(default)]
pub block_height: Option<u32>,
#[serde(default)]
pub block_hash: Option<String>,
#[serde(default)]
pub block_time: Option<u64>,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct TxDetail {
pub txid: String,
pub version: u32,
pub locktime: u64,
pub vin: Vec<TxInput>,
pub vout: Vec<TxOutput>,
pub size: u64,
pub weight: u64,
pub fee: u64,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct TxInput {
pub txid: String,
pub vout: u32,
pub prevout: Option<TxPrevout>,
pub scriptsig: String,
pub is_coinbase: bool,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct TxOutput {
pub scriptpubkey: String,
pub scriptpubkey_asm: String,
pub scriptpubkey_type: String,
pub scriptpubkey_address: String,
pub value: u64,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct TxPrevout {
pub scriptpubkey: String,
pub scriptpubkey_asm: String,
pub scriptpubkey_type: String,
pub scriptpubkey_address: String,
pub value: u64,
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct OutSpendStatus {
pub spent: bool,
#[serde(default)]
pub txid: Option<String>,
#[serde(default)]
pub vin: Option<u32>,
#[serde(default)]
pub status: Option<TxStatus>,
}
pub fn get_address_utxos(
rpc: &MempoolSignetRpc,
address: &bitcoin::Address,
) -> Result<Vec<(OutPoint, u64)>, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/address/{}/utxo", rpc.base_url, address);
let utxos: Vec<AddressUtxo> = rpc.get_with_retry(&url)?;
let result: Vec<(OutPoint, u64)> = utxos
.into_iter()
.map(|u| {
let mut txid_bytes = hex::decode(&u.txid)?;
txid_bytes.reverse();
let txid = Txid::from_slice(&txid_bytes).expect("valid txid");
let outpoint = OutPoint::new(txid, u.vout);
Ok((outpoint, u.value))
})
.collect::<Result<Vec<_>, Box<dyn std::error::Error + Send + Sync>>>()?;
Ok(result)
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct AddressUtxo {
pub txid: String,
pub vout: u32,
pub value: u64,
pub status: TxStatus,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "requires network"]
fn test_get_block_count() {
let rpc = MempoolSignetRpc::new();
let height = rpc.get_block_count().unwrap();
assert!(height > 200_000, "Signet height should be > 200k");
println!("Current Signet height: {}", height);
}
#[test]
#[ignore = "requires network"]
fn test_get_block_hash() {
let rpc = MempoolSignetRpc::new();
let height = rpc.get_block_count().unwrap();
let hash = rpc.get_block_hash(height).unwrap();
assert_ne!(hash, [0u8; 32]);
println!("Block hash at {}: {}", height, hex::encode(hash));
}
}