rustywallet_checker/
bitcoin.rs

1//! Bitcoin balance checker using blockchain.info API
2
3use crate::error::CheckerError;
4use serde::Deserialize;
5
6/// Bitcoin balance result
7#[derive(Debug, Clone)]
8pub struct BitcoinBalance {
9    /// The address checked
10    pub address: String,
11    /// Confirmed balance in satoshis
12    pub balance: u64,
13    /// Unconfirmed balance in satoshis
14    pub unconfirmed: i64,
15    /// Total received in satoshis
16    pub total_received: u64,
17    /// Total sent in satoshis
18    pub total_sent: u64,
19    /// Number of transactions
20    pub tx_count: u64,
21}
22
23#[derive(Debug, Deserialize)]
24struct BlockchainInfoResponse {
25    #[serde(rename = "final_balance")]
26    final_balance: u64,
27    #[serde(rename = "total_received")]
28    total_received: u64,
29    #[serde(rename = "total_sent")]
30    total_sent: u64,
31    #[serde(rename = "n_tx")]
32    n_tx: u64,
33}
34
35#[derive(Debug, Deserialize)]
36#[allow(dead_code)]
37struct BlockstreamResponse {
38    address: String,
39    chain_stats: ChainStats,
40    mempool_stats: MempoolStats,
41}
42
43#[derive(Debug, Deserialize)]
44struct ChainStats {
45    funded_txo_sum: u64,
46    spent_txo_sum: u64,
47    tx_count: u64,
48}
49
50#[derive(Debug, Deserialize)]
51struct MempoolStats {
52    funded_txo_sum: u64,
53    spent_txo_sum: u64,
54}
55
56/// Check Bitcoin address balance using blockchain.info API
57///
58/// # Arguments
59/// * `address` - Bitcoin address (any format: legacy, segwit, taproot)
60///
61/// # Returns
62/// Balance information including confirmed and unconfirmed amounts
63///
64/// # Example
65/// ```no_run
66/// use rustywallet_checker::bitcoin::check_btc_balance;
67///
68/// #[tokio::main]
69/// async fn main() {
70///     let balance = check_btc_balance("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa").await.unwrap();
71///     println!("Balance: {} satoshis", balance.balance);
72/// }
73/// ```
74pub async fn check_btc_balance(address: &str) -> Result<BitcoinBalance, CheckerError> {
75    // Validate address format (basic check)
76    if !is_valid_btc_address(address) {
77        return Err(CheckerError::InvalidAddress(address.to_string()));
78    }
79
80    // Try blockstream.info first (supports all address types)
81    match check_via_blockstream(address).await {
82        Ok(balance) => return Ok(balance),
83        Err(e) => {
84            // Fallback to blockchain.info for legacy addresses
85            if address.starts_with('1') || address.starts_with('3') {
86                match check_via_blockchain_info(address).await {
87                    Ok(balance) => return Ok(balance),
88                    Err(_) => return Err(e),
89                }
90            }
91            // For segwit/taproot, return the blockstream error or 0 balance
92            return Err(e);
93        }
94    }
95}
96
97async fn check_via_blockstream(address: &str) -> Result<BitcoinBalance, CheckerError> {
98    // Try mempool.space first, then blockstream
99    let urls = [
100        format!("https://mempool.space/api/address/{}", address),
101        format!("https://blockstream.info/api/address/{}", address),
102    ];
103
104    let client = reqwest::Client::new();
105    let mut last_error = None;
106
107    for url in urls {
108        let response = match client
109            .get(&url)
110            .header("User-Agent", "rustywallet-checker/0.1")
111            .timeout(std::time::Duration::from_secs(10))
112            .send()
113            .await
114        {
115            Ok(r) => r,
116            Err(e) => {
117                last_error = Some(CheckerError::Network(e));
118                continue;
119            }
120        };
121
122        if response.status() == 429 {
123            last_error = Some(CheckerError::RateLimited);
124            continue;
125        }
126
127        // Address not found = 0 balance (new address)
128        if response.status() == 400 || response.status() == 404 {
129            return Ok(BitcoinBalance {
130                address: address.to_string(),
131                balance: 0,
132                unconfirmed: 0,
133                total_received: 0,
134                total_sent: 0,
135                tx_count: 0,
136            });
137        }
138
139        if !response.status().is_success() {
140            last_error = Some(CheckerError::ApiError(format!(
141                "API returned status {}",
142                response.status()
143            )));
144            continue;
145        }
146
147        let data: BlockstreamResponse = match response.json().await {
148            Ok(d) => d,
149            Err(e) => {
150                last_error = Some(CheckerError::ParseError(e.to_string()));
151                continue;
152            }
153        };
154
155        let confirmed_balance = data.chain_stats.funded_txo_sum - data.chain_stats.spent_txo_sum;
156        let unconfirmed =
157            data.mempool_stats.funded_txo_sum as i64 - data.mempool_stats.spent_txo_sum as i64;
158
159        return Ok(BitcoinBalance {
160            address: address.to_string(),
161            balance: confirmed_balance,
162            unconfirmed,
163            total_received: data.chain_stats.funded_txo_sum,
164            total_sent: data.chain_stats.spent_txo_sum,
165            tx_count: data.chain_stats.tx_count,
166        });
167    }
168
169    Err(last_error.unwrap_or_else(|| CheckerError::ApiError("All providers failed".to_string())))
170}
171
172async fn check_via_blockchain_info(address: &str) -> Result<BitcoinBalance, CheckerError> {
173    let url = format!(
174        "https://blockchain.info/rawaddr/{}?limit=0",
175        address
176    );
177    let client = reqwest::Client::new();
178
179    let response = client
180        .get(&url)
181        .header("User-Agent", "rustywallet-checker/0.1")
182        .send()
183        .await?;
184
185    if response.status() == 429 {
186        return Err(CheckerError::RateLimited);
187    }
188
189    if !response.status().is_success() {
190        return Err(CheckerError::ApiError(format!(
191            "API returned status {}",
192            response.status()
193        )));
194    }
195
196    let data: BlockchainInfoResponse = response
197        .json()
198        .await
199        .map_err(|e| CheckerError::ParseError(e.to_string()))?;
200
201    Ok(BitcoinBalance {
202        address: address.to_string(),
203        balance: data.final_balance,
204        unconfirmed: 0, // blockchain.info doesn't separate unconfirmed in this endpoint
205        total_received: data.total_received,
206        total_sent: data.total_sent,
207        tx_count: data.n_tx,
208    })
209}
210
211/// Basic Bitcoin address validation
212fn is_valid_btc_address(address: &str) -> bool {
213    let len = address.len();
214
215    // Legacy P2PKH (starts with 1)
216    if address.starts_with('1') && (25..=34).contains(&len) {
217        return true;
218    }
219
220    // Legacy P2SH (starts with 3)
221    if address.starts_with('3') && (25..=34).contains(&len) {
222        return true;
223    }
224
225    // SegWit (starts with bc1q)
226    if address.starts_with("bc1q") && (42..=62).contains(&len) {
227        return true;
228    }
229
230    // Taproot (starts with bc1p)
231    if address.starts_with("bc1p") && len == 62 {
232        return true;
233    }
234
235    // Testnet addresses
236    if (address.starts_with('m') || address.starts_with('n') || address.starts_with('2'))
237        && (25..=34).contains(&len)
238    {
239        return true;
240    }
241
242    if address.starts_with("tb1") && len >= 42 {
243        return true;
244    }
245
246    false
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_valid_btc_addresses() {
255        // Legacy
256        assert!(is_valid_btc_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
257        // SegWit
258        assert!(is_valid_btc_address(
259            "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
260        ));
261        // Taproot
262        assert!(is_valid_btc_address(
263            "bc1p5d7rjq7g6rdk2yhzks9smlaqtedr4dekq08ge8ztwac72sfr9rusxg3297"
264        ));
265    }
266
267    #[test]
268    fn test_invalid_btc_addresses() {
269        assert!(!is_valid_btc_address("invalid"));
270        assert!(!is_valid_btc_address("0x1234"));
271        assert!(!is_valid_btc_address(""));
272    }
273}