use async_trait::async_trait;
use crate::transaction::{ChainTracker, ChainTrackerError};
pub const DEFAULT_HEADERS_URL: &str = "https://headers.spv.money";
#[derive(Debug, Clone)]
pub struct BlockHeadersServiceConfig {
pub base_url: String,
pub auth_token: Option<String>,
pub timeout_ms: u64,
}
impl Default for BlockHeadersServiceConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_HEADERS_URL.to_string(),
auth_token: None,
timeout_ms: 30_000,
}
}
}
pub struct BlockHeadersServiceTracker {
config: BlockHeadersServiceConfig,
#[cfg(feature = "http")]
client: reqwest::Client,
}
impl BlockHeadersServiceTracker {
pub fn new() -> Self {
Self::with_url(DEFAULT_HEADERS_URL)
}
pub fn with_url(base_url: &str) -> Self {
Self {
config: BlockHeadersServiceConfig {
base_url: base_url.to_string(),
..Default::default()
},
#[cfg(feature = "http")]
client: reqwest::Client::new(),
}
}
pub fn with_config(config: BlockHeadersServiceConfig) -> Self {
Self {
config,
#[cfg(feature = "http")]
client: reqwest::Client::new(),
}
}
pub fn base_url(&self) -> &str {
&self.config.base_url
}
pub fn auth_token(&self) -> Option<&str> {
self.config.auth_token.as_deref()
}
}
impl Default for BlockHeadersServiceTracker {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl ChainTracker for BlockHeadersServiceTracker {
#[cfg(feature = "http")]
async fn is_valid_root_for_height(
&self,
root: &str,
height: u32,
) -> Result<bool, ChainTrackerError> {
use serde::Deserialize;
#[derive(Deserialize)]
struct HeaderResponse {
merkleroot: Option<String>,
#[serde(rename = "merkleRoot")]
merkle_root_alt: Option<String>,
}
let url = format!("{}/api/v1/chain/header/{}", self.config.base_url, height);
let mut request = self.client.get(&url);
if let Some(ref token) = self.config.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.timeout(std::time::Duration::from_millis(self.config.timeout_ms))
.send()
.await
.map_err(|e| ChainTrackerError::NetworkError(e.to_string()))?;
if response.status().as_u16() == 404 {
return Err(ChainTrackerError::BlockNotFound(height));
}
if !response.status().is_success() {
return Err(ChainTrackerError::InvalidResponse(format!(
"HTTP {}",
response.status()
)));
}
let header: HeaderResponse = response
.json()
.await
.map_err(|e| ChainTrackerError::InvalidResponse(e.to_string()))?;
let merkle_root = header
.merkleroot
.or(header.merkle_root_alt)
.ok_or_else(|| {
ChainTrackerError::InvalidResponse("Missing merkleroot in response".to_string())
})?;
Ok(merkle_root.to_lowercase() == root.to_lowercase())
}
#[cfg(not(feature = "http"))]
async fn is_valid_root_for_height(
&self,
_root: &str,
_height: u32,
) -> Result<bool, ChainTrackerError> {
Err(ChainTrackerError::NetworkError(
"HTTP feature not enabled. Add 'http' feature to Cargo.toml".to_string(),
))
}
#[cfg(feature = "http")]
async fn current_height(&self) -> Result<u32, ChainTrackerError> {
use serde::Deserialize;
#[derive(Deserialize)]
struct ChainInfo {
height: Option<u32>,
#[serde(rename = "blockHeight")]
block_height: Option<u32>,
blocks: Option<u32>,
}
let url = format!("{}/api/v1/chain/tip", self.config.base_url);
let mut request = self.client.get(&url);
if let Some(ref token) = self.config.auth_token {
request = request.header("Authorization", format!("Bearer {}", token));
}
let response = request
.timeout(std::time::Duration::from_millis(self.config.timeout_ms))
.send()
.await
.map_err(|e| ChainTrackerError::NetworkError(e.to_string()))?;
if !response.status().is_success() {
return Err(ChainTrackerError::InvalidResponse(format!(
"HTTP {}",
response.status()
)));
}
let info: ChainInfo = response
.json()
.await
.map_err(|e| ChainTrackerError::InvalidResponse(e.to_string()))?;
info.height
.or(info.block_height)
.or(info.blocks)
.ok_or_else(|| {
ChainTrackerError::InvalidResponse("Missing height in response".to_string())
})
}
#[cfg(not(feature = "http"))]
async fn current_height(&self) -> Result<u32, ChainTrackerError> {
Err(ChainTrackerError::NetworkError(
"HTTP feature not enabled. Add 'http' feature to Cargo.toml".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_url() {
let tracker = BlockHeadersServiceTracker::default();
assert_eq!(tracker.base_url(), DEFAULT_HEADERS_URL);
}
#[test]
fn test_custom_url() {
let tracker = BlockHeadersServiceTracker::with_url("https://custom.headers.com");
assert_eq!(tracker.base_url(), "https://custom.headers.com");
}
#[test]
fn test_config_default() {
let config = BlockHeadersServiceConfig::default();
assert_eq!(config.base_url, DEFAULT_HEADERS_URL);
assert!(config.auth_token.is_none());
assert_eq!(config.timeout_ms, 30_000);
}
#[test]
fn test_with_config() {
let config = BlockHeadersServiceConfig {
base_url: "https://test.headers.com".to_string(),
auth_token: Some("test-token".to_string()),
timeout_ms: 60_000,
};
let tracker = BlockHeadersServiceTracker::with_config(config);
assert_eq!(tracker.base_url(), "https://test.headers.com");
assert_eq!(tracker.auth_token(), Some("test-token"));
}
#[test]
fn test_new() {
let tracker = BlockHeadersServiceTracker::new();
assert_eq!(tracker.base_url(), DEFAULT_HEADERS_URL);
assert!(tracker.auth_token().is_none());
}
}