Skip to main content

kya_validator/
blockchain.rs

1// Blockchain Solvency Verification
2// Multi-provider support for checking on-chain balances and transactions
3
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::sync::Arc;
7use tokio::sync::RwLock;
8
9/// Blockchain provider types
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ProviderType {
12    #[serde(rename = "alchemy")]
13    Alchemy,
14    #[serde(rename = "infura")]
15    Infura,
16    #[serde(rename = "ankr")]
17    Ankr,
18    #[serde(rename = "quicknode")]
19    QuickNode,
20}
21
22/// Blockchain network
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24pub enum Network {
25    #[serde(rename = "ethereum")]
26    Ethereum,
27    #[serde(rename = "polygon")]
28    Polygon,
29    #[serde(rename = "arbitrum")]
30    Arbitrum,
31    #[serde(rename = "optimism")]
32    Optimism,
33    #[serde(rename = "bsc")]
34    Bsc,
35}
36
37impl Network {
38    /// Get chain ID for the network
39    pub fn chain_id(&self) -> u64 {
40        match self {
41            Network::Ethereum => 1,
42            Network::Polygon => 137,
43            Network::Arbitrum => 42161,
44            Network::Optimism => 10,
45            Network::Bsc => 56,
46        }
47    }
48}
49
50/// Blockchain provider configuration
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct BlockchainProvider {
53    pub provider_type: ProviderType,
54    pub api_key: Option<String>,
55    pub rpc_url: Option<String>,
56}
57
58impl BlockchainProvider {
59    /// Create new provider with API key
60    pub fn new(provider_type: ProviderType, api_key: Option<String>) -> Self {
61        Self {
62            provider_type,
63            api_key,
64            rpc_url: None,
65        }
66    }
67
68    /// Create provider with custom RPC URL
69    pub fn with_rpc_url(provider_type: ProviderType, rpc_url: String) -> Self {
70        Self {
71            provider_type,
72            api_key: None,
73            rpc_url: Some(rpc_url),
74        }
75    }
76
77    /// Get RPC endpoint URL for the provider
78    pub fn rpc_url(&self, network: Network) -> String {
79        if let Some(custom_url) = &self.rpc_url {
80            return custom_url.clone();
81        }
82
83        match (self.provider_type, network) {
84            (ProviderType::Alchemy, Network::Ethereum) => {
85                if let Some(key) = &self.api_key {
86                    format!("https://eth-mainnet.g.alchemy.com/v2/{}", key)
87                } else {
88                    "https://eth-mainnet.g.alchemy.com/v2/demo".to_string()
89                }
90            }
91            (ProviderType::Infura, Network::Ethereum) => {
92                if let Some(key) = &self.api_key {
93                    format!("https://mainnet.infura.io/v3/{}", key)
94                } else {
95                    "https://mainnet.infura.io/v3/demo".to_string()
96                }
97            }
98            (ProviderType::Ankr, Network::Ethereum) => "https://rpc.ankr.com/eth".to_string(),
99            (ProviderType::QuickNode, Network::Ethereum) => {
100                if let Some(key) = &self.api_key {
101                    format!("https://{}.quiknode.pro/{}", key, "eth-mainnet")
102                } else {
103                    "https://demo.quiknode.pro/eth-mainnet".to_string()
104                }
105            }
106            (ProviderType::Alchemy, Network::Polygon) => {
107                if let Some(key) = &self.api_key {
108                    format!("https://polygon-mainnet.g.alchemy.com/v2/{}", key)
109                } else {
110                    "https://polygon-mainnet.g.alchemy.com/v2/demo".to_string()
111                }
112            }
113            _ => format!("https://rpc.{}.com", network.chain_id()),
114        }
115    }
116}
117
118/// Solvency check configuration
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SolvencyCheck {
121    pub address: String,
122    pub network: Network,
123    pub min_balance: String, // in wei
124    pub provider: BlockchainProvider,
125    pub check_transactions: bool,
126    pub transaction_window_days: Option<u64>,
127}
128
129/// Result of solvency check
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct SolvencyReport {
132    pub address: String,
133    pub network: Network,
134    pub balance: String,
135    pub balance_ether: f64,
136    pub meets_minimum: bool,
137    pub minimum_balance: String,
138    pub provider: ProviderType,
139    pub transaction_count: Option<u64>,
140    pub last_activity: Option<u64>, // timestamp
141    pub errors: Vec<String>,
142    pub cached: bool,
143}
144
145/// JSON-RPC request structure
146#[derive(Debug, Serialize)]
147struct JsonRpcRequest<'a> {
148    jsonrpc: &'a str,
149    method: &'a str,
150    params: &'a [serde_json::Value],
151    id: u64,
152}
153
154/// JSON-RPC response for eth_getBalance
155#[derive(Debug, Deserialize)]
156struct BalanceResponse {
157    result: String,
158}
159
160/// JSON-RPC response for eth_getTransactionCount
161#[derive(Debug, Deserialize)]
162struct TransactionCountResponse {
163    result: String,
164}
165
166/// Balance cache entry
167#[derive(Debug, Clone)]
168struct CacheEntry {
169    balance: String,
170    balance_ether: f64,
171    transaction_count: Option<u64>,
172    timestamp: u64,
173}
174
175/// Balance cache with TTL
176#[derive(Debug)]
177struct BalanceCache {
178    entries: Arc<RwLock<HashMap<String, CacheEntry>>>,
179    ttl_secs: u64,
180}
181
182impl BalanceCache {
183    fn new(ttl_secs: u64) -> Self {
184        Self {
185            entries: Arc::new(RwLock::new(HashMap::new())),
186            ttl_secs,
187        }
188    }
189
190    async fn get(&self, key: &str) -> Option<CacheEntry> {
191        let entries: tokio::sync::RwLockReadGuard<'_, HashMap<String, CacheEntry>> =
192            self.entries.read().await;
193        let entry = entries.get(key)?;
194
195        // Check TTL
196        let now = std::time::SystemTime::now()
197            .duration_since(std::time::UNIX_EPOCH)
198            .unwrap()
199            .as_secs();
200
201        if now - entry.timestamp > self.ttl_secs {
202            return None;
203        }
204
205        Some(entry.clone())
206    }
207
208    async fn set(&self, key: String, entry: CacheEntry) {
209        let mut entries: tokio::sync::RwLockWriteGuard<'_, HashMap<String, CacheEntry>> =
210            self.entries.write().await;
211        let now = std::time::SystemTime::now()
212            .duration_since(std::time::UNIX_EPOCH)
213            .unwrap()
214            .as_secs();
215
216        let entry = CacheEntry {
217            timestamp: now,
218            ..entry
219        };
220        entries.insert(key, entry);
221    }
222}
223
224/// Verify solvency (check on-chain balance)
225pub async fn verify_solvency(check: &SolvencyCheck) -> SolvencyReport {
226    let mut errors = Vec::new();
227    let provider_url = check.provider.rpc_url(check.network);
228    let cache_key = format!("{}:{}", check.address, check.network.chain_id());
229
230    // Use default cache (5 minutes TTL)
231    let cache = BalanceCache::new(300);
232
233    // Check cache first
234    if let Some(cached) = cache.get(&cache_key).await {
235        let meets_minimum = compare_balance(&cached.balance, &check.min_balance);
236
237        return SolvencyReport {
238            address: check.address.clone(),
239            network: check.network,
240            balance: cached.balance.clone(),
241            balance_ether: cached.balance_ether,
242            meets_minimum,
243            minimum_balance: check.min_balance.clone(),
244            provider: check.provider.provider_type,
245            transaction_count: cached.transaction_count,
246            last_activity: None,
247            errors,
248            cached: true,
249        };
250    }
251
252    // Query balance from provider
253    let balance_result = query_balance(&provider_url, &check.address).await;
254
255    let (balance, balance_ether) = match balance_result {
256        Ok(b) => b,
257        Err(e) => {
258            errors.push(format!("Failed to query balance: {}", e));
259            return SolvencyReport {
260                address: check.address.clone(),
261                network: check.network,
262                balance: "0".to_string(),
263                balance_ether: 0.0,
264                meets_minimum: false,
265                minimum_balance: check.min_balance.clone(),
266                provider: check.provider.provider_type,
267                transaction_count: None,
268                last_activity: None,
269                errors,
270                cached: false,
271            };
272        }
273    };
274
275    // Query transaction count if requested
276    let transaction_count = if check.check_transactions {
277        Some(
278            query_transaction_count(&provider_url, &check.address)
279                .await
280                .unwrap_or(0),
281        )
282    } else {
283        None
284    };
285
286    let meets_minimum = compare_balance(&balance, &check.min_balance);
287
288    // Cache the result
289    cache
290        .set(
291            cache_key,
292            CacheEntry {
293                balance: balance.clone(),
294                balance_ether,
295                transaction_count,
296                timestamp: 0, // will be set in set()
297            },
298        )
299        .await;
300
301    SolvencyReport {
302        address: check.address.clone(),
303        network: check.network,
304        balance,
305        balance_ether,
306        meets_minimum,
307        minimum_balance: check.min_balance.clone(),
308        provider: check.provider.provider_type,
309        transaction_count,
310        last_activity: None,
311        errors,
312        cached: false,
313    }
314}
315
316/// Query balance from JSON-RPC endpoint
317async fn query_balance(rpc_url: &str, address: &str) -> Result<(String, f64), String> {
318    let client: reqwest::Client = reqwest::Client::new();
319    let request = JsonRpcRequest {
320        jsonrpc: "2.0",
321        method: "eth_getBalance",
322        params: &[serde_json::json!(address), serde_json::json!("latest")],
323        id: 1,
324    };
325
326    let response: reqwest::Response = client
327        .post(rpc_url)
328        .json(&request)
329        .send()
330        .await
331        .map_err(|e| format!("HTTP request failed: {}", e))?;
332
333    if !response.status().is_success() {
334        return Err(format!("HTTP error: {}", response.status()));
335    }
336
337    let body: BalanceResponse = response
338        .json()
339        .await
340        .map_err(|e| format!("Failed to parse response: {}", e))?;
341
342    // Parse hex balance to wei
343    let balance_wei = u128::from_str_radix(&body.result[2..], 16)
344        .map_err(|e| format!("Failed to parse balance: {}", e))?;
345
346    // Convert to ether
347    let balance_ether = balance_wei as f64 / 1e18;
348
349    Ok((body.result, balance_ether))
350}
351
352/// Query transaction count from JSON-RPC endpoint
353async fn query_transaction_count(rpc_url: &str, address: &str) -> Result<u64, String> {
354    let client: reqwest::Client = reqwest::Client::new();
355    let request = JsonRpcRequest {
356        jsonrpc: "2.0",
357        method: "eth_getTransactionCount",
358        params: &[serde_json::json!(address), serde_json::json!("latest")],
359        id: 2,
360    };
361
362    let response: reqwest::Response = client
363        .post(rpc_url)
364        .json(&request)
365        .send()
366        .await
367        .map_err(|e| format!("HTTP request failed: {}", e))?;
368
369    if !response.status().is_success() {
370        return Err(format!("HTTP error: {}", response.status()));
371    }
372
373    let body: TransactionCountResponse = response
374        .json()
375        .await
376        .map_err(|e| format!("Failed to parse response: {}", e))?;
377
378    let count = u64::from_str_radix(&body.result[2..], 16)
379        .map_err(|e| format!("Failed to parse count: {}", e))?;
380
381    Ok(count)
382}
383
384/// Compare two balance strings (hex wei values)
385fn compare_balance(balance: &str, minimum: &str) -> bool {
386    let balance_wei = u128::from_str_radix(&balance[2..], 16).unwrap_or(0);
387    let min_wei = u128::from_str_radix(&minimum[2..], 16).unwrap_or(0);
388
389    balance_wei >= min_wei
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[tokio::test]
397    async fn test_provider_urls() {
398        let provider = BlockchainProvider::new(ProviderType::Alchemy, Some("test_key".to_string()));
399        assert_eq!(
400            provider.rpc_url(Network::Ethereum),
401            "https://eth-mainnet.g.alchemy.com/v2/test_key"
402        );
403    }
404
405    #[test]
406    fn test_network_chain_ids() {
407        assert_eq!(Network::Ethereum.chain_id(), 1);
408        assert_eq!(Network::Polygon.chain_id(), 137);
409        assert_eq!(Network::Arbitrum.chain_id(), 42161);
410    }
411
412    #[test]
413    fn test_compare_balance() {
414        // 1 ETH in hex = 0xde0b6b3a7640000
415        let balance = "0xde0b6b3a7640000";
416        let minimum = "0xde0b6b3a7640000";
417        assert!(compare_balance(balance, minimum));
418
419        let lower = "0x0de0b6b3a764000";
420        assert!(!compare_balance(lower, minimum));
421    }
422
423    #[tokio::test]
424    async fn test_solvency_report_structure() {
425        let check = SolvencyCheck {
426            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f0bE".to_string(),
427            network: Network::Ethereum,
428            min_balance: "0x0".to_string(),
429            provider: BlockchainProvider::new(ProviderType::Alchemy, None),
430            check_transactions: false,
431            transaction_window_days: None,
432        };
433
434        // This will fail due to network, but tests structure
435        let report = verify_solvency(&check).await;
436
437        assert_eq!(report.address, check.address);
438        assert_eq!(report.network, check.network);
439        assert_eq!(report.provider, check.provider.provider_type);
440    }
441}