oxidized_builder/
config.rs

1use crate::common::constants;
2use crate::common::error::AppError;
3use alloy::primitives::Address;
4use config::{Config, Environment, File};
5use serde::{Deserialize, Deserializer};
6use std::collections::HashMap;
7use std::str::FromStr;
8
9#[derive(Debug, Deserialize, Clone)]
10pub struct GlobalSettings {
11    // General
12    #[serde(default = "default_debug")]
13    pub debug: bool,
14    #[serde(
15        default = "default_chain",
16        deserialize_with = "deserialize_chain_list"
17    )]
18    pub chains: Vec<u64>,
19
20    // Identity
21    pub wallet_key: String,
22    pub wallet_address: Address,
23    pub profit_receiver_address: Option<Address>,
24
25    // Transaction
26    #[serde(default = "default_max_gas")]
27    pub max_gas_price_gwei: u64,
28    #[serde(default = "default_sim_backend")]
29    pub simulation_backend: String, // "revm", "anvil", etc.
30
31    // MEV
32    #[serde(default = "default_true")]
33    pub flashloan_enabled: bool,
34    #[serde(default = "default_true")]
35    pub sandwich_attacks_enabled: bool,
36
37    // Dynamic Maps (Chain ID -> URL)
38    // In Rust config, we often flatten these or use specific prefixes
39    // but for simplicity, we map specific env vars manually if needed,
40    // or use a HashMap if the config file structure supports it.
41    pub rpc_urls: Option<HashMap<String, String>>,
42    pub ws_urls: Option<HashMap<String, String>>,
43    pub chainlink_feeds: Option<HashMap<String, String>>, // Symbol -> aggregator address
44    pub flashbots_relay_url: Option<String>,
45    pub bundle_signer_key: Option<String>,
46    #[serde(default = "default_metrics_port")]
47    pub metrics_port: u16,
48    #[serde(default = "default_true")]
49    pub strategy_enabled: bool,
50    #[serde(default = "default_slippage_bps")]
51    pub slippage_bps: u64,
52    pub gas_caps_gwei: Option<HashMap<String, u64>>,
53    #[serde(default = "default_mev_share_url")]
54    pub mev_share_stream_url: String,
55    #[serde(default = "default_mev_share_history_limit")]
56    pub mev_share_history_limit: u32,
57    #[serde(default = "default_true")]
58    pub mev_share_enabled: bool,
59
60    // Per-chain maps
61    pub router_allowlist_by_chain: Option<HashMap<String, HashMap<String, String>>>,
62    pub chainlink_feeds_by_chain: Option<HashMap<String, HashMap<String, String>>>,
63}
64
65// Defaults
66fn default_debug() -> bool {
67    false
68}
69fn default_chain() -> Vec<u64> {
70    vec![1]
71}
72fn default_max_gas() -> u64 {
73    200
74}
75fn default_true() -> bool {
76    true
77}
78fn default_metrics_port() -> u16 {
79    9000
80}
81fn default_slippage_bps() -> u64 {
82    50
83}
84fn default_sim_backend() -> String {
85    "revm".to_string()
86}
87fn default_mev_share_url() -> String {
88    "https://mev-share.flashbots.net".to_string()
89}
90fn default_mev_share_history_limit() -> u32 {
91    200
92}
93
94fn deserialize_chain_list<'de, D>(deserializer: D) -> Result<Vec<u64>, D::Error>
95where
96    D: Deserializer<'de>,
97{
98    use serde::de::{Error, SeqAccess, Visitor};
99    use std::fmt;
100
101    struct ChainVisitor;
102
103    impl<'de> Visitor<'de> for ChainVisitor {
104        type Value = Vec<u64>;
105
106        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
107            formatter.write_str("a sequence of chain ids or a string with comma-separated ids")
108        }
109
110        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
111        where
112            E: Error,
113        {
114            parse_chain_list(v).map_err(E::custom)
115        }
116
117        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
118        where
119            A: SeqAccess<'de>,
120        {
121            let mut out = Vec::new();
122            while let Some(elem) = seq.next_element::<u64>()? {
123                out.push(elem);
124            }
125            Ok(out)
126        }
127    }
128
129    deserializer.deserialize_any(ChainVisitor)
130}
131
132impl GlobalSettings {
133    pub fn load_with_path(path: Option<&str>) -> Result<Self, AppError> {
134        // Load .env file if it exists
135        dotenvy::dotenv().ok();
136
137        let mut builder = Config::builder();
138
139        if let Some(path) = path {
140            builder = builder.add_source(File::with_name(path).required(true));
141        } else {
142            builder = builder.add_source(File::with_name("config").required(false));
143        }
144
145        builder = builder
146            // 3. Environment variables (e.g. WALLET_KEY overrides everything)
147            .add_source(Environment::default());
148
149        let mut settings: GlobalSettings = builder.build()?.try_deserialize()?;
150
151        // Allow CHAINS env to be comma/space separated string (e.g. "1,137")
152        if let Ok(chains_str) = std::env::var("CHAINS") {
153            settings.chains = parse_chain_list(&chains_str)?;
154        }
155
156        // Basic Validation
157        if settings.wallet_key.is_empty() {
158            return Err(AppError::Config("WALLET_KEY is missing".to_string()));
159        }
160
161        Ok(settings)
162    }
163
164    pub fn load() -> Result<Self, AppError> {
165        Self::load_with_path(None)
166    }
167
168    /// Helper to get RPC URL for a specific chain
169    pub fn get_rpc_url(&self, chain_id: u64) -> Result<String, AppError> {
170        // Try looking for explicit map
171        if let Some(urls) = &self.rpc_urls {
172            if let Some(url) = urls.get(&chain_id.to_string()) {
173                return Ok(url.clone());
174            }
175        }
176
177        // Fallback to env var convention: RPC_URL_1, RPC_URL_137
178        let env_key = format!("RPC_URL_{}", chain_id);
179        std::env::var(&env_key)
180            .map_err(|_| AppError::Config(format!("No RPC URL found for chain {}", chain_id)))
181    }
182
183    /// Helper to get WS URL for a specific chain
184    pub fn get_ws_url(&self, chain_id: u64) -> Result<String, AppError> {
185        if let Some(urls) = &self.ws_urls {
186            if let Some(url) = urls.get(&chain_id.to_string()) {
187                return Ok(url.clone());
188            }
189        }
190
191        let candidates = [
192            format!("WS_URL_{}", chain_id),
193            format!("WEBSOCKET_URL_{}", chain_id),
194        ];
195
196        for key in candidates {
197            if let Ok(v) = std::env::var(&key) {
198                return Ok(v);
199            }
200        }
201
202        Err(AppError::Config(format!(
203            "No WS URL found for chain {}",
204            chain_id
205        )))
206    }
207
208    pub fn get_chainlink_feed(&self, symbol: &str) -> Option<String> {
209        self.chainlink_feeds
210            .as_ref()
211            .and_then(|m| m.get(&symbol.to_uppercase()).cloned())
212    }
213
214    pub fn flashbots_relay_url(&self) -> String {
215        self.flashbots_relay_url
216            .clone()
217            .or_else(|| std::env::var("FLASHBOTS_RELAY_URL").ok())
218            .unwrap_or_else(|| "https://relay.flashbots.net".to_string())
219    }
220
221    pub fn bundle_signer_key(&self) -> String {
222        self.bundle_signer_key
223            .clone()
224            .or_else(|| std::env::var("BUNDLE_SIGNER_KEY").ok())
225            .unwrap_or_else(|| self.wallet_key.clone())
226    }
227
228    pub fn gas_cap_for_chain(&self, chain_id: u64) -> Option<u64> {
229        self.gas_caps_gwei
230            .as_ref()
231            .and_then(|m| m.get(&chain_id.to_string()).cloned())
232    }
233
234    pub fn routers_for_chain(
235        &self,
236        chain_id: u64,
237    ) -> Result<HashMap<String, Address>, AppError> {
238        if let Some(map) = self
239            .router_allowlist_by_chain
240            .as_ref()
241            .and_then(|m| m.get(&chain_id.to_string()))
242        {
243            return parse_address_map(map, "router_allowlist_by_chain");
244        }
245
246        Ok(constants::default_routers_for_chain(chain_id))
247    }
248
249    pub fn chainlink_feeds_for_chain(
250        &self,
251        chain_id: u64,
252    ) -> Result<HashMap<String, Address>, AppError> {
253        if let Some(map) = self
254            .chainlink_feeds_by_chain
255            .as_ref()
256            .and_then(|m| m.get(&chain_id.to_string()))
257        {
258            return parse_address_map(map, "chainlink_feeds_by_chain");
259        }
260
261        if let Some(map) = &self.chainlink_feeds {
262            return parse_address_map(map, "chainlink_feeds");
263        }
264
265        Ok(constants::default_chainlink_feeds(chain_id))
266    }
267}
268
269fn parse_chain_list(raw: &str) -> Result<Vec<u64>, AppError> {
270    let cleaned = raw.trim_matches(|c| c == '`' || c == '"' || c == '\'');
271    let mut out = Vec::new();
272    for part in cleaned.split(|c: char| c == ',' || c.is_whitespace()) {
273        let p = part.trim();
274        if p.is_empty() {
275            continue;
276        }
277        let id: u64 = p
278            .parse()
279            .map_err(|_| AppError::Config(format!("Invalid chain id '{}'", p)))?;
280        out.push(id);
281    }
282    if out.is_empty() {
283        return Err(AppError::Config("CHAINS env is empty".into()));
284    }
285    Ok(out)
286}
287
288fn parse_address_map(
289    raw: &HashMap<String, String>,
290    field: &str,
291) -> Result<HashMap<String, Address>, AppError> {
292    raw.iter()
293        .map(|(k, v)| {
294            Address::from_str(v)
295                .map(|addr| (k.to_uppercase(), addr))
296                .map_err(|_| AppError::InvalidAddress(format!("{field}:{k} -> {v}")))
297        })
298        .collect()
299}