1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::PathBuf;
4
5#[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#[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 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 pub fn rpc_url(&self, chain: &str) -> Option<&str> {
103 self.rpc.get(chain).map(|s| s.as_str())
104 }
105
106 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 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 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 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 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 assert_eq!(config.rpc_url("eip155:1"), Some("https://custom-eth.rpc"));
297 assert_eq!(
299 config.rpc_url("eip155:11155111"),
300 Some("https://sepolia.rpc")
301 );
302 assert_eq!(
304 config.rpc_url("eip155:137"),
305 Some("https://polygon-rpc.com")
306 );
307 assert_eq!(config.vault_path, PathBuf::from("/tmp/custom-vault"));
309 }
310}