1use std::{collections::HashMap, fmt::Display};
2
3use alloy::{primitives::Address, providers::ProviderBuilder};
4use serde::{Deserialize, Serialize};
5
6use crate::{
7 disk::{Config, DiskInterface, FileFormat},
8 utils::Provider,
9};
10
11#[derive(Serialize, Deserialize, Clone, Debug, Default)]
12pub struct Network {
13 pub name: String,
14 pub name_alchemy: Option<String>,
15 #[serde(default)]
16 pub name_aliases: Vec<String>,
17 pub chain_id: u32,
18 pub symbol: Option<String>,
19 pub native_decimals: Option<u8>,
20 pub price_ticker: Option<String>,
21 pub rpc_url: Option<String>, pub rpc_alchemy: Option<String>,
23 pub rpc_infura: Option<String>,
24 pub explorer_url: Option<String>,
25 pub is_testnet: bool,
26 pub tokens: Vec<Token>,
27}
28
29#[derive(Serialize, Deserialize, Clone, Debug, Default)]
30pub struct Token {
31 pub name: String,
32 pub symbol: String,
33 pub decimals: u8,
34 pub contract_address: Address,
35}
36
37impl Display for Network {
38 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39 write!(f, "{} (chain_id: {})", self.name, self.chain_id)
40 }
41}
42
43impl Network {
44 pub fn get_rpc(&self) -> crate::Result<String> {
45 if let Some(rpc_url) = &self.rpc_url {
46 Ok(rpc_url.clone())
47 } else if let Some(rpc_alchemy) = &self.rpc_alchemy {
48 Ok(rpc_alchemy.replace(
49 "{}",
50 &Config::alchemy_api_key()?,
52 ))
53 } else if let Some(name_alchemy) = &self.name_alchemy {
54 Ok(format!(
55 "https://{}.g.alchemy.com/v2/{}",
56 name_alchemy,
57 Config::alchemy_api_key()?
58 ))
59 } else if let Some(rpc_infura) = &self.rpc_infura {
60 Ok(rpc_infura.clone())
61 } else {
62 Err(crate::Error::InternalError(format!(
65 "No RPC URL found for network {} - chain_id {}",
66 self.name, self.chain_id
67 )))
68 }
69 }
70
71 pub fn get_tx_url(&self, tx_hash: &str) -> Option<String> {
72 self.explorer_url
73 .as_ref()
74 .map(|explorer_url| explorer_url.replace("{}", tx_hash))
75 }
76
77 pub fn get_provider(&self) -> crate::Result<Provider> {
78 let rpc_url = self.get_rpc()?.parse()?;
79 Ok(ProviderBuilder::new().connect_http(rpc_url))
80 }
81}
82
83#[derive(Serialize, Deserialize, Debug, Default)]
84pub struct NetworkStore {
85 pub networks: Vec<Network>,
86}
87
88impl DiskInterface for NetworkStore {
89 const FILE_NAME: &'static str = "networks";
90 const FORMAT: FileFormat = FileFormat::YAML;
91}
92
93impl NetworkStore {
94 pub fn from_name(network_name: &str) -> crate::Result<Network> {
96 let network_store = NetworkStore::load()?;
97 network_store
98 .get_by_name(network_name)
99 .ok_or(crate::Error::NetworkNotFound(network_name.to_string()))
100 }
101
102 pub fn from_chain_id(chain_id: u32) -> crate::Result<Network> {
104 let network_store = NetworkStore::load()?;
105 network_store
106 .get_by_chain_id(chain_id)
107 .ok_or(crate::Error::NetworkNotFound(format!(
108 "Chain ID {chain_id}",
109 )))
110 }
111
112 pub fn load_networks(testnet_mode: bool) -> crate::Result<Vec<Network>> {
113 Ok(NetworkStore::load()?
114 .networks
115 .into_iter()
116 .filter(|n| n.is_testnet == testnet_mode)
117 .collect())
118 }
119
120 pub fn sort_config() -> crate::Result<Self> {
121 let mut networks = HashMap::<u32, Network>::new();
122
123 let merge_tokens = |a: Vec<Token>, b: Vec<Token>| {
124 let mut tokens = HashMap::<Address, Token>::new();
125 for token in a.into_iter().chain(b) {
126 tokens.insert(token.contract_address, token);
127 }
128 let mut tokens = tokens.values().cloned().collect::<Vec<Token>>();
129 tokens.sort_by(|a, b| a.contract_address.cmp(&b.contract_address));
130 tokens
131 };
132
133 let mut insert = |entry: Network| {
134 let existing = networks.remove(&entry.chain_id);
135 let entry = if let Some(existing) = existing {
136 let mut name_aliases = vec![];
138 for n in entry
139 .name_aliases
140 .iter()
141 .chain(existing.name_aliases.iter())
142 {
143 if !name_aliases.contains(n) {
144 name_aliases.push(n.clone());
145 }
146 }
147 Network {
148 name: entry.name,
149 name_alchemy: entry.name_alchemy.or(existing.name_alchemy),
150 name_aliases,
151 chain_id: entry.chain_id,
152 symbol: entry.symbol.or(existing.symbol),
153 native_decimals: entry.native_decimals.or(existing.native_decimals),
154 price_ticker: entry.price_ticker.or(existing.price_ticker),
155 rpc_url: entry.rpc_url.or(existing.rpc_url),
156 rpc_alchemy: entry.rpc_alchemy.or(existing.rpc_alchemy),
157 rpc_infura: entry.rpc_infura.or(existing.rpc_infura),
158 explorer_url: entry.explorer_url.or(existing.explorer_url),
159 is_testnet: entry.is_testnet,
160 tokens: merge_tokens(entry.tokens, existing.tokens),
161 }
162 } else {
163 entry
164 };
165
166 networks.insert(entry.chain_id, entry);
167 };
168
169 for network in default_networks() {
170 insert(network);
171 }
172
173 let store = NetworkStore::load()?;
176 for network in &store.networks {
177 insert(network.clone());
178 }
179
180 let mut networks: Vec<Network> = networks.values().cloned().collect();
182 networks.sort_by(|a, b| {
183 a.chain_id
184 .cmp(&b.chain_id)
185 .then(a.is_testnet.cmp(&b.is_testnet))
186 });
187
188 let store = NetworkStore {
189 networks: networks.clone(),
190 };
191
192 store.save()?;
193 Ok(store)
194 }
195
196 pub fn get_by_name(&self, network_name: &str) -> Option<Network> {
197 self.networks
198 .iter()
199 .find(|n| {
200 n.name == network_name
201 || n.name_alchemy
202 .as_ref()
203 .map(|name| name == network_name)
204 .unwrap_or(false)
205 || n.name_aliases.contains(&network_name.to_string())
206 })
207 .cloned()
208 }
209
210 pub fn get_by_chain_id(&self, chain_id: u32) -> Option<Network> {
211 self.networks
212 .iter()
213 .find(|n| n.chain_id == chain_id)
214 .cloned()
215 }
216
217 pub fn get_alchemy_network_names(&self, testnet_mode: bool) -> Vec<String> {
218 self.networks
219 .iter()
220 .filter_map(|n| {
221 (n.is_testnet == testnet_mode)
222 .then_some(n.name_alchemy.clone())
223 .flatten()
224 })
225 .collect()
226 }
227
228 pub fn get_iter(&self, testnet_mode: bool) -> impl Iterator<Item = &Network> {
229 self.networks
230 .iter()
231 .filter(move |n| n.is_testnet == testnet_mode)
232 }
233
234 pub fn register_token(
235 &mut self,
236 network_name: &str,
237 token_address: Address,
238 token_symbol: Option<&str>,
239 token_name: &str,
240 token_decimals: u8,
241 ) {
242 let network = self
243 .networks
244 .iter_mut()
245 .find(|n| {
246 n.name == network_name
247 || n.name_alchemy
248 .as_ref()
249 .map(|name| name == network_name)
250 .unwrap_or(false)
251 })
252 .expect("network not found");
253
254 let result = network
255 .tokens
256 .iter()
257 .find(|token| token.contract_address == token_address);
258
259 if result.is_none() {
260 network.tokens.push(Token {
261 name: token_name.to_string(),
262 symbol: token_symbol.unwrap_or("UNKNOWN").to_string(),
263 decimals: token_decimals,
264 contract_address: token_address,
265 });
266 }
267 }
268
269 pub fn has_token(&self, token_address: &Address) -> bool {
270 self.networks.iter().any(|network| {
271 network
272 .tokens
273 .iter()
274 .any(|token| token.contract_address == *token_address)
275 })
276 }
277}
278
279impl TryFrom<String> for Network {
280 type Error = crate::Error;
281
282 fn try_from(value: String) -> crate::Result<Self> {
283 let networks = NetworkStore::load()?;
284 networks
285 .get_by_name(&value)
286 .ok_or(crate::Error::NetworkNotFound(value))
287 }
288}
289
290fn default_networks() -> Vec<Network> {
291 vec![
292 Network {
293 name: "Mainnet".to_string(),
294 name_alchemy: Some("eth-mainnet".to_string()),
295 name_aliases: vec![],
296 chain_id: 1,
297 symbol: Some("ETH".to_string()),
298 native_decimals: Some(18),
299 price_ticker: Some("ETH".to_string()),
300 rpc_url: None,
301 rpc_alchemy: Some(("https://eth-mainnet.g.alchemy.com/v2/{}").to_string()),
302 rpc_infura: None,
303 explorer_url: None,
304 is_testnet: false,
305 tokens: vec![
306 Token {
307 name: "Wrapped Ether".to_string(),
308 symbol: "WETH".to_string(),
309 decimals: 18,
310 contract_address: "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
311 .parse()
312 .unwrap(),
313 },
314 Token {
315 name: "MakerDAO's DAI".to_string(),
316 symbol: "DAI".to_string(),
317 decimals: 18,
318 contract_address: "0x6b175474e89094c44da98b954eedeac495271d0f"
319 .parse()
320 .unwrap(),
321 },
322 Token {
323 name: "Coinbase USD Coin".to_string(),
324 symbol: "USDC".to_string(),
325 decimals: 6,
326 contract_address: "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
327 .parse()
328 .unwrap(),
329 },
330 Token {
331 name: "Tether USD".to_string(),
332 symbol: "USDT".to_string(),
333 decimals: 6,
334 contract_address: "0xdAC17F958D2ee523a2206206994597C13D831ec7"
335 .parse()
336 .unwrap(),
337 },
338 ],
339 },
340 Network {
341 name: "Arbitrum".to_string(),
342 name_alchemy: Some("arb-mainnet".to_string()),
343 name_aliases: vec![],
344 chain_id: 42161,
345 symbol: Some("ArbETH".to_string()),
346 native_decimals: Some(18),
347 price_ticker: Some("ETH".to_string()),
348 rpc_url: None,
349 rpc_alchemy: Some(("https://arb-mainnet.g.alchemy.com/v2/{}").to_string()),
350 rpc_infura: None,
351 explorer_url: Some("https://arbiscan.io/tx/{}".to_string()),
352 is_testnet: false,
353 tokens: vec![
354 Token {
355 name: "Wrapped Ether".to_string(),
356 symbol: "WETH".to_string(),
357 decimals: 18,
358 contract_address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
359 .parse()
360 .unwrap(),
361 },
362 Token {
363 name: "MakerDAO's DAI".to_string(),
364 symbol: "DAI".to_string(),
365 decimals: 18,
366 contract_address: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1"
367 .parse()
368 .unwrap(),
369 },
370 Token {
371 name: "Coinbase USD Coin".to_string(),
372 symbol: "USDC".to_string(),
373 decimals: 6,
374 contract_address: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831"
375 .parse()
376 .unwrap(),
377 },
378 Token {
379 name: "Coinbase USD Coin Bridged".to_string(),
380 symbol: "USDC(Bridged)".to_string(),
381 decimals: 6,
382 contract_address: "0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8"
383 .parse()
384 .unwrap(),
385 },
386 Token {
387 name: "Tether USD".to_string(),
388 symbol: "USDT".to_string(),
389 decimals: 6,
390 contract_address: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9"
391 .parse()
392 .unwrap(),
393 },
394 ],
395 },
396 Network {
397 name: "Optimism".to_string(),
398 name_alchemy: Some("opt-mainnet".to_string()),
399 name_aliases: vec![],
400 chain_id: 10,
401 symbol: Some("OpETH".to_string()),
402 native_decimals: Some(18),
403 price_ticker: Some("ETH".to_string()),
404 rpc_url: None,
405 rpc_alchemy: Some(("https://opt-mainnet.g.alchemy.com/v2/{}").to_string()),
406 rpc_infura: None,
407 explorer_url: None,
408 is_testnet: false,
409 tokens: vec![],
410 },
411 Network {
412 name: "Base".to_string(),
413 name_alchemy: Some("base-mainnet".to_string()),
414 name_aliases: vec![],
415 chain_id: 8453,
416 symbol: Some("BaseETH".to_string()),
417 native_decimals: Some(18),
418 price_ticker: Some("ETH".to_string()),
419 rpc_url: None,
420 rpc_alchemy: Some(("https://base-mainnet.g.alchemy.com/v2/{}").to_string()),
421 rpc_infura: None,
422 explorer_url: None,
423 is_testnet: false,
424 tokens: vec![
425 Token {
426 name: "Wrapped Ether".to_string(),
427 symbol: "WETH".to_string(),
428 decimals: 18,
429 contract_address: "0x82aF49447D8a07e3bd95BD0d56f35241523fBab1"
430 .parse()
431 .unwrap(),
432 },
433 Token {
434 name: "Coinbase USD Coin".to_string(),
435 symbol: "USDC".to_string(),
436 decimals: 6,
437 contract_address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
438 .parse()
439 .unwrap(),
440 },
441 ],
442 },
443 Network {
444 name: "Polygon".to_string(),
445 name_alchemy: Some("polygon-mainnet".to_string()),
446 name_aliases: vec!["matic-mainnet".to_string()],
447 chain_id: 137,
448 symbol: Some("PolygonETH".to_string()),
449 native_decimals: Some(18),
450 price_ticker: Some("ETH".to_string()),
451 rpc_url: None,
452 rpc_alchemy: Some(("https://polygon-mainnet.g.alchemy.com/v2/{}").to_string()),
453 rpc_infura: None,
454 explorer_url: None,
455 is_testnet: false,
456 tokens: vec![],
457 },
458 Network {
459 name: "Sepolia".to_string(),
460 name_alchemy: Some("eth-sepolia".to_string()),
461 name_aliases: vec![],
462 chain_id: 11155111,
463 symbol: Some("sepoliaETH".to_string()),
464 native_decimals: Some(18),
465 price_ticker: None,
466 rpc_url: None,
467 rpc_alchemy: Some(("https://eth-sepolia.g.alchemy.com/v2/{}").to_string()),
468 rpc_infura: None,
469 explorer_url: Some("https://sepolia.etherscan.io/tx/{}".to_string()),
470 is_testnet: true,
471 tokens: vec![],
472 },
473 ]
474}