Skip to main content

ows_core/
config.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5/// Backup configuration.
6#[derive(Debug, Clone, Serialize, Deserialize)]
7pub struct BackupConfig {
8    pub path: PathBuf,
9    #[serde(skip_serializing_if = "Option::is_none")]
10    pub auto_backup: Option<bool>,
11    #[serde(skip_serializing_if = "Option::is_none")]
12    pub max_backups: Option<u32>,
13}
14
15/// Application configuration.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Config {
18    pub vault_path: PathBuf,
19    #[serde(default)]
20    pub rpc: HashMap<String, String>,
21    #[serde(default)]
22    pub plugins: HashMap<String, serde_json::Value>,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub backup: Option<BackupConfig>,
25}
26
27impl Config {
28    /// Returns the built-in default RPC endpoints for well-known chains.
29    pub fn default_rpc() -> HashMap<String, String> {
30        let mut rpc = HashMap::new();
31        rpc.insert("eip155:1".into(), "https://eth.llamarpc.com".into());
32        rpc.insert("eip155:137".into(), "https://polygon-rpc.com".into());
33        rpc.insert("eip155:42161".into(), "https://arb1.arbitrum.io/rpc".into());
34        rpc.insert("eip155:10".into(), "https://mainnet.optimism.io".into());
35        rpc.insert("eip155:8453".into(), "https://mainnet.base.org".into());
36        rpc.insert("eip155:9745".into(), "https://rpc.plasma.to".into());
37        rpc.insert(
38            "eip155:56".into(),
39            "https://bsc-dataseed.binance.org".into(),
40        );
41        rpc.insert(
42            "eip155:43114".into(),
43            "https://api.avax.network/ext/bc/C/rpc".into(),
44        );
45        rpc.insert(
46            "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into(),
47            "https://api.mainnet-beta.solana.com".into(),
48        );
49        rpc.insert(
50            "bip122:000000000019d6689c085ae165831e93".into(),
51            "https://mempool.space/api".into(),
52        );
53        rpc.insert(
54            "cosmos:cosmoshub-4".into(),
55            "https://cosmos-rest.publicnode.com".into(),
56        );
57        rpc.insert("tron:mainnet".into(), "https://api.trongrid.io".into());
58        rpc.insert("ton:mainnet".into(), "https://toncenter.com/api/v2".into());
59        rpc.insert(
60            "fil:mainnet".into(),
61            "https://api.node.glif.io/rpc/v1".into(),
62        );
63        rpc.insert(
64            "sui:mainnet".into(),
65            "https://fullnode.mainnet.sui.io:443".into(),
66        );
67        rpc.insert("xrpl:mainnet".into(), "https://s1.ripple.com:51234".into());
68        rpc.insert(
69            "xrpl:testnet".into(),
70            "https://s.altnet.rippletest.net:51234".into(),
71        );
72        rpc.insert(
73            "xrpl:devnet".into(),
74            "https://s.devnet.rippletest.net:51234".into(),
75        );
76        rpc.insert("nano:mainnet".into(), "https://rpc.nano.to".into());
77        rpc.insert("near:mainnet".into(), "https://rpc.mainnet.near.org".into());
78        rpc.insert("near:testnet".into(), "https://rpc.testnet.near.org".into());
79        rpc.insert("eip155:4217".into(), "https://rpc.tempo.xyz".into());
80        rpc.insert(
81            "eip155:999".into(),
82            "https://rpc.hyperliquid.xyz/evm".into(),
83        );
84        rpc
85    }
86}
87
88impl Default for Config {
89    fn default() -> Self {
90        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
91        Config {
92            vault_path: PathBuf::from(home).join(".ows"),
93            rpc: Self::default_rpc(),
94            plugins: HashMap::new(),
95            backup: None,
96        }
97    }
98}
99
100impl Config {
101    /// Look up an RPC URL by chain identifier.
102    pub fn rpc_url(&self, chain: &str) -> Option<&str> {
103        self.rpc.get(chain).map(|s| s.as_str())
104    }
105
106    /// Load config from a file path, or return defaults if file doesn't exist.
107    pub fn load(path: &std::path::Path) -> Result<Self, crate::error::OwsError> {
108        if !path.exists() {
109            return Ok(Config::default());
110        }
111        let contents =
112            std::fs::read_to_string(path).map_err(|e| crate::error::OwsError::InvalidInput {
113                message: format!("failed to read config: {}", e),
114            })?;
115        serde_json::from_str(&contents).map_err(|e| crate::error::OwsError::InvalidInput {
116            message: format!("failed to parse config: {}", e),
117        })
118    }
119
120    /// Load `~/.ows/config.json`, merging user overrides on top of defaults.
121    /// If the file doesn't exist, returns the built-in defaults.
122    pub fn load_or_default() -> Self {
123        let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
124        let config_path = PathBuf::from(home).join(".ows/config.json");
125        Self::load_or_default_from(&config_path)
126    }
127
128    /// Load config from a specific path, merging user overrides on top of defaults.
129    pub fn load_or_default_from(path: &std::path::Path) -> Self {
130        let mut config = Config::default();
131        if path.exists() {
132            if let Ok(contents) = std::fs::read_to_string(path) {
133                if let Ok(user_config) = serde_json::from_str::<Config>(&contents) {
134                    // User overrides take priority
135                    for (k, v) in user_config.rpc {
136                        config.rpc.insert(k, v);
137                    }
138                    config.plugins = user_config.plugins;
139                    config.backup = user_config.backup;
140                    if user_config.vault_path.as_path() != std::path::Path::new("/tmp/.ows")
141                        && user_config.vault_path.to_string_lossy() != ""
142                    {
143                        config.vault_path = user_config.vault_path;
144                    }
145                }
146            }
147        }
148        config
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn test_default_vault_path() {
158        let config = Config::default();
159        let path_str = config.vault_path.to_string_lossy();
160        assert!(path_str.ends_with(".ows"));
161    }
162
163    #[test]
164    fn test_serde_roundtrip() {
165        let mut rpc = HashMap::new();
166        rpc.insert(
167            "eip155:1".to_string(),
168            "https://eth.rpc.example".to_string(),
169        );
170
171        let config = Config {
172            vault_path: PathBuf::from("/home/test/.ows"),
173            rpc,
174            plugins: HashMap::new(),
175            backup: None,
176        };
177        let json = serde_json::to_string(&config).unwrap();
178        let config2: Config = serde_json::from_str(&json).unwrap();
179        assert_eq!(config.vault_path, config2.vault_path);
180        assert_eq!(config.rpc, config2.rpc);
181    }
182
183    #[test]
184    fn test_rpc_lookup_hit() {
185        let mut config = Config::default();
186        config.rpc.insert(
187            "eip155:1".to_string(),
188            "https://eth.rpc.example".to_string(),
189        );
190        assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.rpc.example"));
191    }
192
193    #[test]
194    fn test_default_rpc_endpoints() {
195        let config = Config::default();
196        assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
197        assert_eq!(
198            config.rpc_url("eip155:137"),
199            Some("https://polygon-rpc.com")
200        );
201        assert_eq!(config.rpc_url("eip155:9745"), Some("https://rpc.plasma.to"));
202        assert_eq!(
203            config.rpc_url("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"),
204            Some("https://api.mainnet-beta.solana.com")
205        );
206        assert_eq!(
207            config.rpc_url("bip122:000000000019d6689c085ae165831e93"),
208            Some("https://mempool.space/api")
209        );
210        assert_eq!(
211            config.rpc_url("cosmos:cosmoshub-4"),
212            Some("https://cosmos-rest.publicnode.com")
213        );
214        assert_eq!(
215            config.rpc_url("tron:mainnet"),
216            Some("https://api.trongrid.io")
217        );
218        assert_eq!(
219            config.rpc_url("ton:mainnet"),
220            Some("https://toncenter.com/api/v2")
221        );
222        assert_eq!(config.rpc_url("eip155:4217"), Some("https://rpc.tempo.xyz"));
223        assert_eq!(
224            config.rpc_url("eip155:999"),
225            Some("https://rpc.hyperliquid.xyz/evm")
226        );
227    }
228
229    #[test]
230    fn test_rpc_lookup_miss() {
231        let config = Config::default();
232        assert_eq!(config.rpc_url("eip155:99999"), None);
233    }
234
235    #[test]
236    fn test_optional_backup() {
237        let config = Config::default();
238        let json = serde_json::to_value(&config).unwrap();
239        assert!(json.get("backup").is_none());
240    }
241
242    #[test]
243    fn test_backup_config_serde() {
244        let config = Config {
245            vault_path: PathBuf::from("/tmp/.ows"),
246            rpc: HashMap::new(),
247            plugins: HashMap::new(),
248            backup: Some(BackupConfig {
249                path: PathBuf::from("/tmp/backup"),
250                auto_backup: Some(true),
251                max_backups: Some(5),
252            }),
253        };
254        let json = serde_json::to_value(&config).unwrap();
255        assert!(json.get("backup").is_some());
256        assert_eq!(json["backup"]["auto_backup"], true);
257    }
258
259    #[test]
260    fn test_load_nonexistent_returns_default() {
261        let config = Config::load(std::path::Path::new("/nonexistent/path/config.json")).unwrap();
262        assert!(config.vault_path.to_string_lossy().ends_with(".ows"));
263    }
264
265    #[test]
266    fn test_load_or_default_nonexistent() {
267        let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json"));
268        // Should have all default RPCs
269        assert_eq!(config.rpc.len(), 23);
270        assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
271        assert_eq!(
272            config.rpc_url("near:mainnet"),
273            Some("https://rpc.mainnet.near.org")
274        );
275        assert_eq!(
276            config.rpc_url("near:testnet"),
277            Some("https://rpc.testnet.near.org")
278        );
279    }
280
281    #[test]
282    fn test_load_or_default_merges_overrides() {
283        let dir = tempfile::tempdir().unwrap();
284        let config_path = dir.path().join("config.json");
285        let user_config = serde_json::json!({
286            "vault_path": "/tmp/custom-vault",
287            "rpc": {
288                "eip155:1": "https://custom-eth.rpc",
289                "eip155:11155111": "https://sepolia.rpc"
290            }
291        });
292        std::fs::write(&config_path, serde_json::to_string(&user_config).unwrap()).unwrap();
293
294        let config = Config::load_or_default_from(&config_path);
295        // User override replaces default
296        assert_eq!(config.rpc_url("eip155:1"), Some("https://custom-eth.rpc"));
297        // User-added chain
298        assert_eq!(
299            config.rpc_url("eip155:11155111"),
300            Some("https://sepolia.rpc")
301        );
302        // Defaults preserved
303        assert_eq!(
304            config.rpc_url("eip155:137"),
305            Some("https://polygon-rpc.com")
306        );
307        // Custom vault path
308        assert_eq!(config.vault_path, PathBuf::from("/tmp/custom-vault"));
309    }
310}