src/main.rs 12.7 K raw
1
use clap::{Arg, Command};
2
use reqwest::Client;
3
use std::collections::HashMap;
4
use std::error::Error;
5
use serde::{Deserialize,Serialize};
6
use futures::future::join_all;
7
use tokio::task::JoinHandle;
8
use toml;
9
use dirs;
10
use indicatif::{ProgressBar, ProgressStyle};
11
use colored::*;
12
use alloy::providers::{ProviderBuilder};
13
use alloy_ccip_read::CCIPReader;
14
15
#[derive(Serialize)]
16
struct JsonRpcRequest {
17
  jsonrpc: String,
18
  method: String,
19
  params: Vec<String>,
20
  id: u32,
21
}
22
23
#[derive(Deserialize)]
24
struct JsonRpcResponse {
25
  result: String,
26
}
27
28
29
#[derive(Deserialize)]
30
struct Config {
31
  address: Option<String>,
32
  networks: Option<HashMap<String, NetworkConfig>>,
33
}
34
35
#[derive(Deserialize)]
36
struct TokenConfig {
37
  address: String,
38
  decimals: u8,
39
}
40
41
#[derive(Deserialize)]
42
struct NetworkConfig {
43
  name: String,
44
  rpc_url: String,
45
  tokens: Option<HashMap<String, TokenConfig>>,
46
}
47
48
#[derive(Clone)]
49
struct TokenInfo {
50
  symbol: String,
51
  address: String,
52
  decimals: u8,
53
}
54
55
#[derive(Clone)]
56
struct Network {
57
  chain_id: u64,
58
  name: String,
59
  rpc_url: String,
60
  tokens: Vec<TokenInfo>,
61
}
62
63
#[derive(Clone)]
64
struct TokenBalance {
65
  network_name: String,
66
  symbol: String,
67
  balance: f64,
68
}
69
70
enum BalanceResult {
71
  Native(f64, String),
72
  Token(TokenBalance),
73
}
74
75
fn read_config() -> Result<Config, Box<dyn Error>> {
76
  let home_dir = dirs::home_dir().ok_or("Could not find home directory")?;
77
  let config_dir = home_dir.join(".config").join("walletfetch");
78
  let config_path = config_dir.join("config.toml");
79
80
  if !config_path.exists(){
81
82
    std::fs::create_dir_all(&config_dir)?;
83
84
    let default_config = r#"# WalletFetch Configuration
85
# You can set a default address here (optional)
86
# address = "0x..."
87
88
[networks.1]
89
name = "Mainnet"
90
rpc_url = "https://eth.drpc.org"
91
92
[networks.1.tokens]
93
USDC = { address = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", decimals = 6 }
94
95
[networks.42161]
96
name = "Arbitrum"
97
rpc_url = "https://arbitrum.drpc.org"
98
99
[networks.42161.tokens]
100
USDC = { address = "0xaf88d065e77c8cC2239327C5EDb3A432268e5831", decimals = 6 }
101
102
[networks.8453]
103
name = "Base"
104
rpc_url = "https://base.drpc.org"
105
106
[networks.8453.tokens]
107
USDC = { address = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals = 6 }
108
"#;
109
110
    std::fs::write(&config_path, default_config)?;
111
112
    println!("Created default config at: {}", config_path.display());
113
    println!("You can edit this file to customize your networks and tokens.");
114
  }
115
116
  let config_content = std::fs::read_to_string(config_path)?;
117
  let config: Config = toml::from_str(&config_content)?;
118
119
  Ok(config)
120
}
121
122
fn collect_rpc_urls(config: &Config) -> HashMap<u64, Network> {
123
  let mut networks = HashMap::new();
124
125
  if let Some(network_configs) = &config.networks {
126
    for (chain_id_str, network_config) in network_configs {
127
      if let Ok(chain_id) = chain_id_str.parse::<u64>(){
128
        let mut tokens = Vec::new();
129
        if let Some(token_configs) = &network_config.tokens {
130
          for (symbol, token_config) in token_configs {
131
            tokens.push(TokenInfo {
132
              symbol: symbol.clone(),
133
              address: token_config.address.clone(),
134
              decimals: token_config.decimals,
135
            });
136
          }
137
        }
138
139
        networks.insert(chain_id, Network{
140
          chain_id,
141
          name: network_config.name.clone(),
142
          rpc_url: network_config.rpc_url.clone(),
143
          tokens,
144
        });
145
      }
146
    }
147
  }
148
149
  networks
150
}
151
152
fn format_balance_smart(balance: f64, symbol: &str) -> String {
153
    let formatted = if balance >= 1_000_000.0 {
154
        format!("{:.2}M", balance / 1_000_000.0)
155
    } else if balance >= 1_000.0 {
156
        format!("{:.2}K", balance / 1_000.0)
157
    } else if balance >= 1.0 {
158
        format!("{:.2}", balance)
159
    } else if balance > 0.0 {
160
        format!("{:.6}", balance).trim_end_matches('0').trim_end_matches('.').to_string()
161
    } else {
162
        "0".to_string()
163
    };
164
165
    format!("{} {}", formatted, symbol)
166
}
167
168
async fn fetch_balance(
169
  client: &Client,
170
  address: &str,
171
  network: &Network
172
) -> Result<(u64, f64, String), Box<dyn Error + Send + Sync >> {
173
  let request_data = JsonRpcRequest {
174
    jsonrpc: "2.0".to_string(),
175
    method: "eth_getBalance".to_string(),
176
    params: vec![address.to_string(), "latest".to_string()],
177
    id: 1,
178
  };
179
180
  let response = client.post(&network.rpc_url)
181
    .json(&request_data)
182
    .send()
183
    .await?;
184
185
  let status = response.status();
186
187
  if !response.status().is_success() {
188
    let error_text = response.text().await?;
189
    return Err(format!("HTTP error {}: {}", status, error_text).into());
190
  }
191
192
  let response_text = response.text().await?;
193
  let response_body: JsonRpcResponse = serde_json::from_str(&response_text)?;
194
195
  if let Some(hex_str) = response_body.result.strip_prefix("0x"){
196
    if let Ok(balance) = u128::from_str_radix(hex_str, 16){
197
      let eth_balance = balance as f64 / 1_000_000_000_000_000_000.0;
198
      return Ok((network.chain_id, eth_balance, network.name.clone()));
199
    }
200
  }
201
  Err(format!("Failed to parse balance for network {}", network.name).into())
202
}
203
204
async fn fetch_token_balance(
205
  client: &Client,
206
  address: &str,
207
  token: &TokenInfo,
208
  network: &Network,
209
) -> Result<TokenBalance, Box<dyn Error + Send + Sync>> {
210
  let clean_address = address.strip_prefix("0x").unwrap_or(address).to_lowercase();
211
  let data = format!("0x70a08231000000000000000000000000{}", clean_address);
212
213
  let request_data = serde_json::json!({
214
    "jsonrpc": "2.0",
215
    "method": "eth_call",
216
    "params": [
217
      {
218
        "to": token.address,
219
        "data": data
220
      },
221
      "latest"
222
    ],
223
    "id": 1
224
  });
225
226
  let response = client.post(&network.rpc_url)
227
    .json(&request_data)
228
    .send()
229
    .await?;
230
231
  let status = response.status();
232
233
  if !status.is_success(){
234
    let error_text = response.text().await?;
235
    return Err(format!("HTTP error {}: {}", status, error_text).into());
236
  }
237
238
  let response_text = response.text().await?;
239
240
  let response_body: JsonRpcResponse = serde_json::from_str(&response_text)?;
241
242
  if let Some(hex_str) = response_body.result.strip_prefix("0x") {
243
    if let Ok(raw_balance) = u128::from_str_radix(hex_str, 16){
244
      let divisor = 10_u128.pow(token.decimals as u32) as f64;
245
      let balance = raw_balance as f64 / divisor;
246
247
      return Ok(TokenBalance {
248
        network_name: network.name.clone(),
249
        symbol: token.symbol.clone(),
250
        balance,
251
      });
252
    }
253
  }
254
255
  Err(format!("Failed to parse balance for token {} on network {}", token.symbol, network.name).into())
256
}
257
258
fn get_eth_logo() -> &'static str {
259
r#"------------------------------
260
--------------4%--------------
261
-------------44HH-------------
262
------------444HHH------------
263
-----------4444HHHH-----------
264
---------~44444HHHHH~---------
265
--------4444444HHHHHHW--------
266
-------4444HHHHWWWWHHHH-------
267
------KHHHHHHHHWWWWWWWWW------
268
---------HHHHHHWWWWWW---------
269
-------44---HHHWWW---HH-------
270
--------~444?----4HHH~--------
271
----------44444HHHHH----------
272
-----------L444HHHq-----------
273
-------------44HH-------------
274
--------------4H--------------
275
------------------------------"#
276
}
277
278
async fn fetch_all_balances(
279
  address: &str,
280
  networks: HashMap<u64, Network>
281
) -> Result<Vec<BalanceResult>, Box<dyn Error>> {
282
  let client = Client::new();
283
284
  let mut tasks: Vec<JoinHandle<Result<BalanceResult, Box<dyn Error + Send + Sync>>>> = Vec::new();
285
286
  for (_, network) in &networks {
287
    let client_clone = client.clone();
288
    let address_clone = address.to_string();
289
    let network_clone = network.clone();
290
291
    let task = tokio::spawn(async move {
292
      let (_, balance, name) = fetch_balance(&client_clone, &address_clone, &network_clone).await?;
293
      Ok(BalanceResult::Native(balance, name))
294
    });
295
296
    tasks.push(task);
297
298
    for token in &network.tokens {
299
      let client_clone = client.clone();
300
      let address_clone = address.to_string();
301
      let token_clone = token.clone();
302
      let network_clone = network.clone();
303
304
      let task = tokio::spawn(async move {
305
        let token_balance = fetch_token_balance(&client_clone, &address_clone, &token_clone, &network_clone).await?;
306
        Ok(BalanceResult::Token(token_balance))
307
      });
308
309
      tasks.push(task);
310
    }
311
  }
312
313
  let results = join_all(tasks).await;
314
315
  let mut balances = Vec::new();
316
  for result in results {
317
    match result {
318
      Ok(Ok(balance_result)) => {
319
        balances.push(balance_result);
320
      },
321
      Ok(Err(e)) => {
322
        eprintln!("Error fetching balance: {}", e);
323
      },
324
      Err(e) => {
325
        eprintln!("Task error: {}", e);
326
      }
327
    }
328
  }
329
  Ok(balances)
330
}
331
332
async fn resolve_address_or_ens(
333
  input: &str,
334
  networks: &HashMap<u64, Network>
335
) -> Result<String, Box<dyn Error>> {
336
  if input.to_lowercase().ends_with(".eth") {
337
    let mainnet = networks.get(&1);
338
339
    let rpc_url = match mainnet {
340
      Some(network) => &network.rpc_url,
341
      None => return Err("Ethereum mainnet configuration not found for ENS resolution".into())
342
    };
343
344
    let provider = ProviderBuilder::new()
345
      .on_http(rpc_url.parse().unwrap());
346
347
    let reader = CCIPReader::new(provider.boxed());
348
349
    let resolution_result = match reader.resolve_name(input).await {
350
      Ok(result) => result,
351
      Err(e) => return Err(format!("Failed to resolve address for {}: {}", input, e).into())
352
    };
353
354
    let eth_address = format!("{}", resolution_result.addr.value);
355
356
    return Ok(eth_address);
357
  } else {
358
    if !input.starts_with("0x") || input.len() != 42 {
359
      return Err("Invalid Ethereum address format".into());
360
    }
361
362
    Ok(input.to_string())
363
  }
364
}
365
366
#[tokio::main]
367
async fn main() -> Result<(), Box<dyn Error>> {
368
    let matches = Command::new("wallet-fetch")
369
        .version("0.0.1")
370
        .author("Steve Simkins")
371
        .about("Neofetch but for your wallet")
372
        .arg(
373
            Arg::new("address")
374
                .help("Address to fetch info for ")
375
                .index(1)
376
                .required(false)
377
        )
378
        .get_matches();
379
380
    let spinner = ProgressBar::new_spinner();
381
    spinner.set_style(
382
        ProgressStyle::default_spinner()
383
            .tick_chars("⠁⠂⠄⡀⢀⠠⠐⠈ ")
384
            .template("{spinner:.green} {msg}")
385
            .unwrap()
386
    );
387
    spinner.set_message("Fetching balances...");
388
    spinner.enable_steady_tick(std::time::Duration::from_millis(100));
389
390
    let config = read_config()?;
391
392
    let input = match matches.get_one::<String>("address"){
393
      Some(addr) if !addr.is_empty() => addr.to_string(),
394
      _ => match &config.address {
395
        Some(addr) if !addr.is_empty() => addr.clone(),
396
        _ => {
397
          eprintln!("Error: No address provided. Either pass it as an argument or set it in the config file");
398
          return Err("No address provided".into());
399
        }
400
      },
401
    };
402
403
    let networks = collect_rpc_urls(&config);
404
405
    if networks.is_empty(){
406
      eprintln!("RPC URLs are not defined");
407
    }
408
409
    let address = resolve_address_or_ens(&input, &networks).await?;
410
411
412
    let balances = fetch_all_balances(&address, networks).await?;
413
414
    spinner.finish_and_clear();
415
416
    if balances.is_empty(){
417
      println!("No balances found for address {}", address);
418
    } else {
419
      let mut network_balances: HashMap<String, Vec<String>> = HashMap::new();
420
421
      for balance in balances {
422
        match balance {
423
          BalanceResult::Native(eth_balance, network_name) => {
424
            let balance_str = format_balance_smart(eth_balance, "ETH");
425
            network_balances.entry(network_name).or_default().push(balance_str);
426
          },
427
          BalanceResult::Token(token_balance) => {
428
            let balance_str = format_balance_smart(token_balance.balance, &token_balance.symbol);
429
            network_balances.entry(token_balance.network_name).or_default().push(balance_str);
430
          }
431
        }
432
      }
433
434
      let logo = get_eth_logo();
435
      let logo_lines: Vec<&str> = logo.lines().collect();
436
      let logo_height = logo_lines.len();
437
      let logo_width = logo_lines.iter().map(|line| line.len()).max().unwrap_or(0);
438
439
440
      println!();
441
      let address_display = format!("{}", address.bright_green());
442
      println!("{}", format!("Wallet: {}", address_display).bright_cyan());
443
      println!("{}", "=".repeat(50).bright_blue());
444
445
      let mut info_lines = Vec::new();
446
447
      for (network, balances) in network_balances {
448
        if !info_lines.is_empty() {
449
          info_lines.push("".to_string());
450
        }
451
        info_lines.push(format!("{}: ", network.bright_yellow()));
452
        for balance in balances {
453
          info_lines.push(format!("  {} {}", "•".bright_green(), balance.bright_white()));
454
        }
455
      }
456
457
      let display_lines = std::cmp::max(logo_height, info_lines.len());
458
459
      for i in 0..display_lines {
460
        let logo_line = if i < logo_height {
461
          logo_lines[i]
462
        } else {
463
          &" ".repeat(logo_width)
464
        };
465
        let info_line = if i < info_lines.len() { &info_lines[i] } else { "" };
466
467
        println!("{}    {}", logo_line.bright_cyan(), info_line);
468
      }
469
470
      println!();
471
    }
472
473
    Ok(())
474
}