rustywallet_checker/
bitcoin.rs1use crate::error::CheckerError;
4use serde::Deserialize;
5
6#[derive(Debug, Clone)]
8pub struct BitcoinBalance {
9 pub address: String,
11 pub balance: u64,
13 pub unconfirmed: i64,
15 pub total_received: u64,
17 pub total_sent: u64,
19 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
56pub async fn check_btc_balance(address: &str) -> Result<BitcoinBalance, CheckerError> {
75 if !is_valid_btc_address(address) {
77 return Err(CheckerError::InvalidAddress(address.to_string()));
78 }
79
80 match check_via_blockstream(address).await {
82 Ok(balance) => return Ok(balance),
83 Err(e) => {
84 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 return Err(e);
93 }
94 }
95}
96
97async fn check_via_blockstream(address: &str) -> Result<BitcoinBalance, CheckerError> {
98 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 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, total_received: data.total_received,
206 total_sent: data.total_sent,
207 tx_count: data.n_tx,
208 })
209}
210
211fn is_valid_btc_address(address: &str) -> bool {
213 let len = address.len();
214
215 if address.starts_with('1') && (25..=34).contains(&len) {
217 return true;
218 }
219
220 if address.starts_with('3') && (25..=34).contains(&len) {
222 return true;
223 }
224
225 if address.starts_with("bc1q") && (42..=62).contains(&len) {
227 return true;
228 }
229
230 if address.starts_with("bc1p") && len == 62 {
232 return true;
233 }
234
235 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 assert!(is_valid_btc_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"));
257 assert!(is_valid_btc_address(
259 "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"
260 ));
261 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}