Skip to main content

gm_lib/
network.rs

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>, // TODO this can rather be an array
22    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                // TODO handle this error when alchemy API key not present
51                &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            // TODO remove this panic and allow user to gracefully handle this situation like providing
63            // their own RPC URL or ALCHEMY_API_KEY
64            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    // TODO This function should be on Network
95    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    // TODO This function should be on Network
103    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                // merge entries
137                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        // load networks from disk and override defaults
174        // TODO too many .clone() used here, improve it
175        let store = NetworkStore::load()?;
176        for network in &store.networks {
177            insert(network.clone());
178        }
179
180        // Sort by chain ID and keep testnets at the bottom
181        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}