1use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::sync::Arc;
7use tokio::sync::RwLock;
8
9#[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#[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 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#[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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SolvencyCheck {
121 pub address: String,
122 pub network: Network,
123 pub min_balance: String, pub provider: BlockchainProvider,
125 pub check_transactions: bool,
126 pub transaction_window_days: Option<u64>,
127}
128
129#[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>, pub errors: Vec<String>,
142 pub cached: bool,
143}
144
145#[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#[derive(Debug, Deserialize)]
156struct BalanceResponse {
157 result: String,
158}
159
160#[derive(Debug, Deserialize)]
162struct TransactionCountResponse {
163 result: String,
164}
165
166#[derive(Debug, Clone)]
168struct CacheEntry {
169 balance: String,
170 balance_ether: f64,
171 transaction_count: Option<u64>,
172 timestamp: u64,
173}
174
175#[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 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
224pub 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 let cache = BalanceCache::new(300);
232
233 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 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 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
290 .set(
291 cache_key,
292 CacheEntry {
293 balance: balance.clone(),
294 balance_ether,
295 transaction_count,
296 timestamp: 0, },
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
316async 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 let balance_wei = u128::from_str_radix(&body.result[2..], 16)
344 .map_err(|e| format!("Failed to parse balance: {}", e))?;
345
346 let balance_ether = balance_wei as f64 / 1e18;
348
349 Ok((body.result, balance_ether))
350}
351
352async 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
384fn 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 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 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}