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(
37 "eip155:56".into(),
38 "https://bsc-dataseed.binance.org".into(),
39 );
40 rpc.insert(
41 "eip155:43114".into(),
42 "https://api.avax.network/ext/bc/C/rpc".into(),
43 );
44 rpc.insert(
45 "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".into(),
46 "https://api.mainnet-beta.solana.com".into(),
47 );
48 rpc.insert(
49 "bip122:000000000019d6689c085ae165831e93".into(),
50 "https://mempool.space/api".into(),
51 );
52 rpc.insert(
53 "cosmos:cosmoshub-4".into(),
54 "https://cosmos-rest.publicnode.com".into(),
55 );
56 rpc.insert("tron:mainnet".into(), "https://api.trongrid.io".into());
57 rpc.insert("ton:mainnet".into(), "https://toncenter.com/api/v2".into());
58 rpc.insert(
59 "fil:mainnet".into(),
60 "https://api.node.glif.io/rpc/v1".into(),
61 );
62 rpc.insert(
63 "sui:mainnet".into(),
64 "https://fullnode.mainnet.sui.io:443".into(),
65 );
66 rpc
67 }
68}
69
70impl Default for Config {
71 fn default() -> Self {
72 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
73 Config {
74 vault_path: PathBuf::from(home).join(".ows"),
75 rpc: Self::default_rpc(),
76 plugins: HashMap::new(),
77 backup: None,
78 }
79 }
80}
81
82impl Config {
83 pub fn rpc_url(&self, chain: &str) -> Option<&str> {
85 self.rpc.get(chain).map(|s| s.as_str())
86 }
87
88 pub fn load(path: &std::path::Path) -> Result<Self, crate::error::OwsError> {
90 if !path.exists() {
91 return Ok(Config::default());
92 }
93 let contents =
94 std::fs::read_to_string(path).map_err(|e| crate::error::OwsError::InvalidInput {
95 message: format!("failed to read config: {}", e),
96 })?;
97 serde_json::from_str(&contents).map_err(|e| crate::error::OwsError::InvalidInput {
98 message: format!("failed to parse config: {}", e),
99 })
100 }
101
102 pub fn load_or_default() -> Self {
105 let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
106 let config_path = PathBuf::from(home).join(".ows/config.json");
107 Self::load_or_default_from(&config_path)
108 }
109
110 pub fn load_or_default_from(path: &std::path::Path) -> Self {
112 let mut config = Config::default();
113 if path.exists() {
114 if let Ok(contents) = std::fs::read_to_string(path) {
115 if let Ok(user_config) = serde_json::from_str::<Config>(&contents) {
116 for (k, v) in user_config.rpc {
118 config.rpc.insert(k, v);
119 }
120 config.plugins = user_config.plugins;
121 config.backup = user_config.backup;
122 if user_config.vault_path.as_path() != std::path::Path::new("/tmp/.ows")
123 && user_config.vault_path.to_string_lossy() != ""
124 {
125 config.vault_path = user_config.vault_path;
126 }
127 }
128 }
129 }
130 config
131 }
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137
138 #[test]
139 fn test_default_vault_path() {
140 let config = Config::default();
141 let path_str = config.vault_path.to_string_lossy();
142 assert!(path_str.ends_with(".ows"));
143 }
144
145 #[test]
146 fn test_serde_roundtrip() {
147 let mut rpc = HashMap::new();
148 rpc.insert(
149 "eip155:1".to_string(),
150 "https://eth.rpc.example".to_string(),
151 );
152
153 let config = Config {
154 vault_path: PathBuf::from("/home/test/.ows"),
155 rpc,
156 plugins: HashMap::new(),
157 backup: None,
158 };
159 let json = serde_json::to_string(&config).unwrap();
160 let config2: Config = serde_json::from_str(&json).unwrap();
161 assert_eq!(config.vault_path, config2.vault_path);
162 assert_eq!(config.rpc, config2.rpc);
163 }
164
165 #[test]
166 fn test_rpc_lookup_hit() {
167 let mut config = Config::default();
168 config.rpc.insert(
169 "eip155:1".to_string(),
170 "https://eth.rpc.example".to_string(),
171 );
172 assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.rpc.example"));
173 }
174
175 #[test]
176 fn test_default_rpc_endpoints() {
177 let config = Config::default();
178 assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
179 assert_eq!(
180 config.rpc_url("eip155:137"),
181 Some("https://polygon-rpc.com")
182 );
183 assert_eq!(
184 config.rpc_url("solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"),
185 Some("https://api.mainnet-beta.solana.com")
186 );
187 assert_eq!(
188 config.rpc_url("bip122:000000000019d6689c085ae165831e93"),
189 Some("https://mempool.space/api")
190 );
191 assert_eq!(
192 config.rpc_url("cosmos:cosmoshub-4"),
193 Some("https://cosmos-rest.publicnode.com")
194 );
195 assert_eq!(
196 config.rpc_url("tron:mainnet"),
197 Some("https://api.trongrid.io")
198 );
199 assert_eq!(
200 config.rpc_url("ton:mainnet"),
201 Some("https://toncenter.com/api/v2")
202 );
203 }
204
205 #[test]
206 fn test_rpc_lookup_miss() {
207 let config = Config::default();
208 assert_eq!(config.rpc_url("eip155:999"), None);
209 }
210
211 #[test]
212 fn test_optional_backup() {
213 let config = Config::default();
214 let json = serde_json::to_value(&config).unwrap();
215 assert!(json.get("backup").is_none());
216 }
217
218 #[test]
219 fn test_backup_config_serde() {
220 let config = Config {
221 vault_path: PathBuf::from("/tmp/.ows"),
222 rpc: HashMap::new(),
223 plugins: HashMap::new(),
224 backup: Some(BackupConfig {
225 path: PathBuf::from("/tmp/backup"),
226 auto_backup: Some(true),
227 max_backups: Some(5),
228 }),
229 };
230 let json = serde_json::to_value(&config).unwrap();
231 assert!(json.get("backup").is_some());
232 assert_eq!(json["backup"]["auto_backup"], true);
233 }
234
235 #[test]
236 fn test_load_nonexistent_returns_default() {
237 let config = Config::load(std::path::Path::new("/nonexistent/path/config.json")).unwrap();
238 assert!(config.vault_path.to_string_lossy().ends_with(".ows"));
239 }
240
241 #[test]
242 fn test_load_or_default_nonexistent() {
243 let config = Config::load_or_default_from(std::path::Path::new("/nonexistent/config.json"));
244 assert_eq!(config.rpc.len(), 14);
246 assert_eq!(config.rpc_url("eip155:1"), Some("https://eth.llamarpc.com"));
247 }
248
249 #[test]
250 fn test_load_or_default_merges_overrides() {
251 let dir = tempfile::tempdir().unwrap();
252 let config_path = dir.path().join("config.json");
253 let user_config = serde_json::json!({
254 "vault_path": "/tmp/custom-vault",
255 "rpc": {
256 "eip155:1": "https://custom-eth.rpc",
257 "eip155:11155111": "https://sepolia.rpc"
258 }
259 });
260 std::fs::write(&config_path, serde_json::to_string(&user_config).unwrap()).unwrap();
261
262 let config = Config::load_or_default_from(&config_path);
263 assert_eq!(config.rpc_url("eip155:1"), Some("https://custom-eth.rpc"));
265 assert_eq!(
267 config.rpc_url("eip155:11155111"),
268 Some("https://sepolia.rpc")
269 );
270 assert_eq!(
272 config.rpc_url("eip155:137"),
273 Some("https://polygon-rpc.com")
274 );
275 assert_eq!(config.vault_path, PathBuf::from("/tmp/custom-vault"));
277 }
278}