use crate::Chain;
use serde::Deserialize;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BalanceError {
#[error("HTTP request failed: {0}")]
Request(#[from] reqwest::Error),
#[error("Invalid address: {0}")]
InvalidAddress(String),
#[error("API error: {0}")]
Api(String),
#[error("Unsupported chain: {0}")]
UnsupportedChain(String),
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Balance {
pub confirmed: u128,
pub unconfirmed: i128,
}
impl Balance {
#[allow(clippy::cast_possible_wrap)]
pub fn total(&self) -> i128 {
self.confirmed as i128 + self.unconfirmed
}
pub fn confirmed_btc(&self) -> f64 {
self.confirmed as f64 / 100_000_000.0
}
pub fn total_btc(&self) -> f64 {
self.total() as f64 / 100_000_000.0
}
pub fn confirmed_ltc(&self) -> f64 {
self.confirmed as f64 / 100_000_000.0
}
pub fn total_ltc(&self) -> f64 {
self.total() as f64 / 100_000_000.0
}
pub fn confirmed_eth(&self) -> f64 {
self.confirmed as f64 / 1e18
}
pub fn total_eth(&self) -> f64 {
self.total() as f64 / 1e18
}
pub fn confirmed_dcr(&self) -> f64 {
self.confirmed as f64 / 100_000_000.0
}
pub fn total_dcr(&self) -> f64 {
self.total() as f64 / 100_000_000.0
}
pub fn confirmed_ar(&self) -> f64 {
self.confirmed as f64 / 1_000_000_000_000.0
}
pub fn total_ar(&self) -> f64 {
self.total() as f64 / 1_000_000_000_000.0
}
}
#[derive(Deserialize)]
struct MempoolAddressResponse {
chain_stats: MempoolStats,
mempool_stats: MempoolStats,
}
#[derive(Deserialize)]
struct MempoolStats {
funded_txo_sum: u64,
spent_txo_sum: u64,
}
#[derive(Deserialize)]
struct EtherscanResponse {
status: String,
message: String,
result: String,
}
#[derive(Deserialize)]
struct DcrdataAddressTotalsResponse {
dcr_unspent: f64,
}
fn is_invalid_address_status(status: Option<reqwest::StatusCode>) -> bool {
matches!(
status,
Some(reqwest::StatusCode::BAD_REQUEST | reqwest::StatusCode::UNPROCESSABLE_ENTITY)
)
}
async fn fetch_mempool_compatible(address: &str, base_url: &str) -> Result<Balance, BalanceError> {
let url = format!("{}/api/address/{}", base_url, address);
let response: MempoolAddressResponse = reqwest::get(&url)
.await?
.error_for_status()
.map_err(|e| {
if is_invalid_address_status(e.status()) {
BalanceError::InvalidAddress(address.to_string())
} else {
BalanceError::Request(e)
}
})?
.json()
.await?;
let confirmed = u128::from(response.chain_stats.funded_txo_sum)
- u128::from(response.chain_stats.spent_txo_sum);
let unconfirmed = i128::from(response.mempool_stats.funded_txo_sum)
- i128::from(response.mempool_stats.spent_txo_sum);
Ok(Balance {
confirmed,
unconfirmed,
})
}
async fn fetch_btc(address: &str) -> Result<Balance, BalanceError> {
fetch_mempool_compatible(address, "https://mempool.space").await
}
async fn fetch_eth(address: &str) -> Result<Balance, BalanceError> {
dotenvy::dotenv().ok();
let api_key = std::env::var("ETHERSCAN_API_KEY")
.map_err(|_| BalanceError::Api("ETHERSCAN_API_KEY environment variable not set".into()))?;
let url = format!(
"https://api.etherscan.io/v2/api?chainid=1&module=account&action=balance&address={}&apikey={}",
address, api_key
);
let response: EtherscanResponse = reqwest::get(&url)
.await?
.error_for_status()
.map_err(BalanceError::Request)?
.json()
.await?;
if response.status != "1" {
return Err(BalanceError::Api(format!(
"Etherscan API error: {}",
response.message
)));
}
let wei: u128 = response
.result
.parse()
.map_err(|_| BalanceError::Api("Failed to parse balance".into()))?;
Ok(Balance {
confirmed: wei,
unconfirmed: 0,
})
}
async fn fetch_ltc(address: &str) -> Result<Balance, BalanceError> {
fetch_mempool_compatible(address, "https://litecoinspace.org").await
}
async fn fetch_dcr(address: &str) -> Result<Balance, BalanceError> {
let url = format!("https://dcrdata.decred.org/api/address/{}/totals", address);
let response: DcrdataAddressTotalsResponse = reqwest::get(&url)
.await?
.error_for_status()
.map_err(|e| {
if is_invalid_address_status(e.status()) {
BalanceError::InvalidAddress(address.to_string())
} else {
BalanceError::Request(e)
}
})?
.json()
.await?;
let atoms = (response.dcr_unspent * 100_000_000.0).round();
if atoms < 0.0 {
return Err(BalanceError::Api(
"Unexpected negative balance from dcrdata".into(),
));
}
let confirmed = atoms as u128;
Ok(Balance {
confirmed,
unconfirmed: 0,
})
}
async fn fetch_ar(address: &str) -> Result<Balance, BalanceError> {
let url = format!("https://arweave.net/wallet/{}/balance", address);
let text = reqwest::get(&url)
.await?
.error_for_status()
.map_err(|e| {
if is_invalid_address_status(e.status()) {
BalanceError::InvalidAddress(address.to_string())
} else {
BalanceError::Request(e)
}
})?
.text()
.await?;
let winston: u128 = text
.trim()
.parse()
.map_err(|_| BalanceError::Api(format!("Failed to parse Arweave balance: {}", text)))?;
Ok(Balance {
confirmed: winston,
unconfirmed: 0,
})
}
pub async fn fetch(address: &str, chain: Chain) -> Result<Balance, BalanceError> {
match chain {
Chain::Bitcoin => fetch_btc(address).await,
Chain::Ethereum => fetch_eth(address).await,
Chain::Litecoin => fetch_ltc(address).await,
Chain::Decred => fetch_dcr(address).await,
Chain::Arweave => fetch_ar(address).await,
Chain::Monero => Err(BalanceError::UnsupportedChain(chain.name().to_string())),
}
}
pub async fn fetch_many(addresses: &[(&str, Chain)]) -> Vec<Result<Balance, BalanceError>> {
let futures: Vec<_> = addresses
.iter()
.map(|(addr, chain)| fetch(addr, *chain))
.collect();
futures::future::join_all(futures).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_balance_btc_conversion() {
let balance = Balance {
confirmed: 100_000_000,
unconfirmed: 50_000_000,
};
assert_eq!(balance.confirmed_btc(), 1.0);
assert_eq!(balance.total_btc(), 1.5);
assert_eq!(balance.total(), 150_000_000);
}
#[test]
fn test_balance_negative_unconfirmed() {
let balance = Balance {
confirmed: 100_000_000,
unconfirmed: -30_000_000,
};
assert_eq!(balance.total(), 70_000_000);
assert_eq!(balance.total_btc(), 0.7);
}
#[test]
fn test_balance_zero() {
let balance = Balance::default();
assert_eq!(balance.confirmed, 0);
assert_eq!(balance.unconfirmed, 0);
assert_eq!(balance.confirmed_btc(), 0.0);
assert_eq!(balance.total_btc(), 0.0);
assert_eq!(balance.total(), 0);
}
#[test]
fn test_balance_max_btc_supply() {
let balance = Balance {
confirmed: 2_100_000_000_000_000,
unconfirmed: 0,
};
assert_eq!(balance.confirmed_btc(), 21_000_000.0);
assert_eq!(balance.total_btc(), 21_000_000.0);
}
#[test]
fn test_balance_negative_total_from_large_pending_outgoing() {
let balance = Balance {
confirmed: 100_000_000,
unconfirmed: -150_000_000,
};
assert_eq!(balance.total(), -50_000_000);
assert_eq!(balance.total_btc(), -0.5);
}
#[test]
fn test_balance_eth_conversion() {
let balance = Balance {
confirmed: 1_000_000_000_000_000_000,
unconfirmed: 0,
};
assert_eq!(balance.confirmed_eth(), 1.0);
assert_eq!(balance.total_eth(), 1.0);
}
#[tokio::test]
#[ignore]
async fn test_fetch_btc_satoshi_genesis_address_has_funds() {
let result = fetch("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", Chain::Bitcoin).await;
assert!(result.is_ok());
let balance = result.unwrap();
assert!(balance.confirmed > 0);
}
#[tokio::test]
#[ignore]
async fn test_fetch_btc_invalid_address_returns_error() {
let result = fetch("invalid_address_xyz", Chain::Bitcoin).await;
assert!(result.is_err());
}
#[tokio::test]
#[ignore]
async fn test_fetch_btc_valid_empty_address() {
let result = fetch("1111111111111111111114oLvT2", Chain::Bitcoin).await;
assert!(result.is_ok());
}
#[tokio::test]
#[ignore]
async fn test_fetch_many_btc_known_addresses() {
let genesis = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa";
let satoshi_dice = "1dice8EMZmqKvrGE4Qc9bUFf9PX3xaYDp";
let results =
fetch_many(&[(genesis, Chain::Bitcoin), (satoshi_dice, Chain::Bitcoin)]).await;
assert_eq!(results.len(), 2);
assert!(results[0].is_ok());
assert!(results[1].is_ok());
}
#[tokio::test]
#[ignore]
async fn test_fetch_eth_vitalik_address() {
let result = fetch(
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
Chain::Ethereum,
)
.await;
assert!(result.is_ok());
}
#[test]
fn test_balance_ltc_conversion() {
let balance = Balance {
confirmed: 100_000_000,
unconfirmed: 0,
};
assert_eq!(balance.confirmed_ltc(), 1.0);
assert_eq!(balance.total_ltc(), 1.0);
}
#[tokio::test]
#[ignore]
async fn test_fetch_ltc_known_address() {
let result = fetch("LVuDpNCSSj6pQ7t9Pv6d6sUkLKoqDEVUnJ", Chain::Litecoin).await;
assert!(result.is_ok());
}
#[tokio::test]
#[ignore]
async fn test_fetch_dcr_known_address() {
let result = fetch("DsRaAja82UvgnqYaBHYFuyCKURFX2rCyEJ8", Chain::Decred).await;
assert!(result.is_ok());
}
#[tokio::test]
#[ignore]
async fn test_fetch_dcr_invalid_address_returns_error() {
let result = fetch("invalid_address_xyz", Chain::Decred).await;
assert!(matches!(result, Err(BalanceError::InvalidAddress(_))));
}
#[test]
fn test_balance_ar_conversion() {
let balance = Balance {
confirmed: 1_000_000_000_000,
unconfirmed: 0,
};
assert_eq!(balance.confirmed_ar(), 1.0);
assert_eq!(balance.total_ar(), 1.0);
}
#[tokio::test]
#[ignore]
async fn test_fetch_ar_known_address() {
let result = fetch(
"PbdTDYikdddWfNFlDt2aZokALXKe1mJVSC9TALUBNv8",
Chain::Arweave,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
#[ignore]
async fn test_fetch_unsupported_chain() {
let result = fetch("some_address", Chain::Monero).await;
assert!(matches!(result, Err(BalanceError::UnsupportedChain(_))));
}
}