From 9c2c7fbf6bf4c492a59236fb97ee1a6ed46fc1b5 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 12:52:30 +0530 Subject: [PATCH 1/2] feat(core): add TOML config loader with ${ENV_VAR} substitution Introduce the Config struct hierarchy covering bot-level knobs, per-chain RPC endpoints, per-protocol addresses, flash-loan sources, and deployed liquidator contracts. Keyed by short names (HashMap) so adding new chains/protocols later is a config change rather than a schema change. Config::load(path): 1. reads the TOML file 2. substitutes \${VAR} placeholders from process env (errors if unset) 3. deserializes into Config via serde Ship config/default.toml with real BSC addresses for Venus Unitroller and Aave V3 Pool (flashLoanSimple source). Liquidator contract address is a placeholder until CharonLiquidator.sol is deployed. .env.example documents the two required env vars (BNB_WS_URL, BNB_HTTP_URL) with public-node defaults for testing. --- .env.example | 8 +++ Cargo.lock | 55 +++++++++++++++- config/default.toml | 36 ++++++++++ crates/charon-core/Cargo.toml | 1 + crates/charon-core/src/config.rs | 109 +++++++++++++++++++++++++++++++ crates/charon-core/src/lib.rs | 2 + 6 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 config/default.toml create mode 100644 crates/charon-core/src/config.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ce19870 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Charon environment variables. +# Copy to `.env` and fill in real values. `.env` is git-ignored. + +# BNB Chain RPC endpoints. +# Public defaults work for testing but are rate-limited; swap in a private +# endpoint (QuickNode / Ankr / Blast / your own node) for production use. +BNB_WS_URL=wss://bsc-rpc.publicnode.com +BNB_HTTP_URL=https://bsc-rpc.publicnode.com diff --git a/Cargo.lock b/Cargo.lock index c9483b3..52f4887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1074,6 +1074,7 @@ dependencies = [ "anyhow", "async-trait", "serde", + "toml", ] [[package]] @@ -2467,7 +2468,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -2984,6 +2985,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3378,6 +3388,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3387,6 +3418,20 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -3394,7 +3439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", - "toml_datetime", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.1", ] @@ -3408,6 +3453,12 @@ dependencies = [ "winnow 1.0.1", ] +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.3" diff --git a/config/default.toml b/config/default.toml new file mode 100644 index 0000000..714d613 --- /dev/null +++ b/config/default.toml @@ -0,0 +1,36 @@ +# Charon — v1 config (Venus on BNB Chain) +# +# Secrets (RPC URLs) are referenced via ${ENV_VAR} and substituted from +# the environment at load time. See `.env.example` for the expected vars. + +[bot] +# Drop opportunities below this USD profit threshold. +min_profit_usd = 5.0 +# Skip liquidations when gas price exceeds this (gwei). +max_gas_gwei = 10 +# Polling cadence for protocols without push events (ms). +scan_interval_ms = 1000 + +# ── Chains ──────────────────────────────────────────────────────────────── +[chain.bnb] +chain_id = 56 +ws_url = "${BNB_WS_URL}" +http_url = "${BNB_HTTP_URL}" + +# ── Lending protocols ───────────────────────────────────────────────────── +[protocol.venus] +chain = "bnb" +# Venus Unitroller (main comptroller on BSC) +comptroller = "0xfd36e2c2a6789db23113685031d7f16329158384" + +# ── Flash-loan sources ──────────────────────────────────────────────────── +[flashloan.aave_v3_bsc] +chain = "bnb" +# Aave V3 Pool on BSC (used for flashLoanSimple — 0.05% fee) +pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb" + +# ── Deployed liquidator contracts ───────────────────────────────────────── +[liquidator.bnb] +chain = "bnb" +# Placeholder — replaced once CharonLiquidator.sol is deployed on BSC. +contract_address = "0x0000000000000000000000000000000000000000" diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index 8eafc41..d9b3f67 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -10,3 +10,4 @@ alloy = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } +toml = { workspace = true } diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs new file mode 100644 index 0000000..c77848f --- /dev/null +++ b/crates/charon-core/src/config.rs @@ -0,0 +1,109 @@ +//! TOML config loader with `${ENV_VAR}` substitution for secrets. +//! +//! Usage: +//! ```no_run +//! use charon_core::config::Config; +//! let cfg = Config::load("config/default.toml").unwrap(); +//! ``` + +use alloy::primitives::Address; +use anyhow::{Context, anyhow}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::Path; + +/// Top-level Charon config loaded from `config/default.toml`. +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub bot: BotConfig, + /// Chains keyed by short name (e.g. `"bnb"`). + pub chain: HashMap, + /// Lending protocols keyed by short name (e.g. `"venus"`). + pub protocol: HashMap, + /// Flash-loan sources keyed by short name (e.g. `"aave_v3_bsc"`). + pub flashloan: HashMap, + /// Deployed liquidator contracts keyed by chain name. + pub liquidator: HashMap, +} + +/// Bot-level knobs — thresholds and intervals. +#[derive(Debug, Clone, Deserialize)] +pub struct BotConfig { + /// Drop opportunities below this USD profit threshold. + pub min_profit_usd: f64, + /// Skip liquidations when gas price exceeds this (gwei). + pub max_gas_gwei: u64, + /// Polling interval for protocols that don't push events. + pub scan_interval_ms: u64, +} + +/// RPC endpoints for a single chain. +#[derive(Debug, Clone, Deserialize)] +pub struct ChainConfig { + pub chain_id: u64, + pub ws_url: String, + pub http_url: String, +} + +/// Address and metadata for a lending protocol on a specific chain. +#[derive(Debug, Clone, Deserialize)] +pub struct ProtocolConfig { + /// Name of the chain this protocol runs on (must match a key in `[chain]`). + pub chain: String, + /// Protocol's main entry point (e.g. Venus Unitroller / Comptroller). + pub comptroller: Address, +} + +/// A flash-loan source available on a given chain. +#[derive(Debug, Clone, Deserialize)] +pub struct FlashLoanConfig { + pub chain: String, + /// Pool / vault address (Aave V3 Pool, Balancer Vault, etc.). + pub pool: Address, +} + +/// Address of the deployed `CharonLiquidator` contract on a chain. +#[derive(Debug, Clone, Deserialize)] +pub struct LiquidatorConfig { + pub chain: String, + pub contract_address: Address, +} + +impl Config { + /// Read a TOML config file, substitute `${ENV_VAR}` placeholders, parse. + /// + /// Returns an error if the file is missing, malformed, or references an + /// environment variable that isn't set. + pub fn load(path: impl AsRef) -> anyhow::Result { + let path = path.as_ref(); + let raw = std::fs::read_to_string(path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + let substituted = substitute_env_vars(&raw) + .with_context(|| format!("env substitution failed for {}", path.display()))?; + let config: Config = toml::from_str(&substituted) + .with_context(|| format!("failed to parse TOML at {}", path.display()))?; + Ok(config) + } +} + +/// Replace every `${NAME}` in `input` with the value of environment variable +/// `NAME`. Returns an error if any referenced variable is unset or if a +/// `${` is not closed by `}`. +fn substitute_env_vars(input: &str) -> anyhow::Result { + let mut output = String::with_capacity(input.len()); + let mut rest = input; + while let Some(start) = rest.find("${") { + output.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let end = after + .find('}') + .ok_or_else(|| anyhow!("unterminated `${{` in config"))?; + let var_name = &after[..end]; + let value = std::env::var(var_name) + .with_context(|| format!("env var `{var_name}` is not set"))?; + output.push_str(&value); + rest = &after[end + 1..]; + } + output.push_str(rest); + Ok(output) +} diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index 04e0db3..a03f44e 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -1,8 +1,10 @@ //! Charon core — shared types, traits, and config. +pub mod config; pub mod traits; pub mod types; +pub use config::Config; pub use traits::LendingProtocol; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, From 3c662d125a0ee88a37e83666d1d5f505db5bbef7 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 20:18:26 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat(core):=20harden=20config=20loader=20?= =?UTF-8?q?=E2=80=94=20integer=20money,=20redacted=20URLs,=20validation,?= =?UTF-8?q?=20ConfigError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Integer money: BotConfig.min_profit_usd -> min_profit_usd_1e6: u64 (USD × 1e6 fixed-point); BotConfig.max_gas_gwei -> max_gas_wei: U256 via decimal-or-hex string deser. Removes f64 precision/NaN risk and lets gas caps express sub-gwei priority fees. - Secret redaction: ChainConfig and Config now have manual Debug impls that print '' for ws_url / http_url and '' so the log sink and panic messages cannot leak API keys embedded in RPC URLs. - Cross-reference validation: Config::load now calls validate() which rejects protocol/flashloan/liquidator entries whose does not exist in [chain.*] and rejects zero addresses. Empty [chain.*] errors. - #[serde(deny_unknown_fields)] on every config struct so typos (e.g. ) error at load instead of silently defaulting the profit floor to zero. - Env-var substitution is now TOML-escape-aware (\, ", \n, \r, \t in values do not break the enclosing string), supports default, and rejects invalid env-var names. - Structured ConfigError (NotFound / Io / UnsetEnvVar / InvalidEnvVarName / UnterminatedInterp / Parse / Parse / Validation) with #[non_exhaustive] so the CLI can map to exit codes and operators see actionable messages. - Config::from_str added for unit tests; config/default.toml updated to the new field names and the zero-address liquidator placeholder is removed (validation now rejects it). Closes #75 #76 #77 #78 #79 #80 #81 --- Cargo.lock | 1 + Cargo.toml | 1 + config/default.toml | 16 +- crates/charon-core/Cargo.toml | 1 + crates/charon-core/src/config.rs | 253 +++++++++++++++++++++++++++---- crates/charon-core/src/lib.rs | 2 +- 6 files changed, 233 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52f4887..ad5315e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1074,6 +1074,7 @@ dependencies = [ "anyhow", "async-trait", "serde", + "thiserror 2.0.18", "toml", ] diff --git a/Cargo.toml b/Cargo.toml index 55f8021..c9fb52b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1" +thiserror = "2" # Async trait objects async-trait = "0.1" diff --git a/config/default.toml b/config/default.toml index 714d613..fb151f3 100644 --- a/config/default.toml +++ b/config/default.toml @@ -4,10 +4,12 @@ # the environment at load time. See `.env.example` for the expected vars. [bot] -# Drop opportunities below this USD profit threshold. -min_profit_usd = 5.0 -# Skip liquidations when gas price exceeds this (gwei). -max_gas_gwei = 10 +# Drop opportunities below this USD profit threshold, in USD × 1e6. +# 5_000_000 = $5.00. Integer fixed-point to avoid f64 precision. +min_profit_usd_1e6 = 5000000 +# Skip liquidations when gas price exceeds this, in wei (decimal string). +# "3000000000" = 3 gwei. Sub-gwei priority fees are representable. +max_gas_wei = "3000000000" # Polling cadence for protocols without push events (ms). scan_interval_ms = 1000 @@ -30,7 +32,5 @@ chain = "bnb" pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb" # ── Deployed liquidator contracts ───────────────────────────────────────── -[liquidator.bnb] -chain = "bnb" -# Placeholder — replaced once CharonLiquidator.sol is deployed on BSC. -contract_address = "0x0000000000000000000000000000000000000000" +# Populated once CharonLiquidator.sol is deployed on BSC mainnet. Do not +# add a zero-address placeholder — config validation rejects it. diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index d9b3f67..20d2841 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -11,3 +11,4 @@ serde = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index c77848f..c92877a 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -1,4 +1,6 @@ -//! TOML config loader with `${ENV_VAR}` substitution for secrets. +//! TOML config loader with `${ENV_VAR}` / `${ENV_VAR:-default}` substitution +//! for secrets, structured error variants, secret redaction in `Debug`, and +//! cross-reference validation. //! //! Usage: //! ```no_run @@ -6,14 +8,44 @@ //! let cfg = Config::load("config/default.toml").unwrap(); //! ``` -use alloy::primitives::Address; -use anyhow::{Context, anyhow}; +use alloy::primitives::{Address, U256}; use serde::Deserialize; use std::collections::HashMap; -use std::path::Path; +use std::fmt; +use std::path::{Path, PathBuf}; + +/// Structured error returned by `Config::load` / `Config::from_str`. +/// +/// Callers match on the variant to choose exit code or remediation. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum ConfigError { + #[error("config file not found: {0}")] + NotFound(PathBuf), + #[error("io error reading {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("env var `{0}` not set")] + UnsetEnvVar(String), + #[error("invalid env var name `{0}` — must match [A-Z_][A-Z0-9_]*")] + InvalidEnvVarName(String), + #[error("unterminated `${{` in config")] + UnterminatedInterp, + #[error("toml parse: {0}")] + Parse(#[from] toml::de::Error), + #[error("validation: {0}")] + Validation(String), +} + +/// Shorthand `Result`. +pub type Result = std::result::Result; /// Top-level Charon config loaded from `config/default.toml`. -#[derive(Debug, Clone, Deserialize)] +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Config { pub bot: BotConfig, /// Chains keyed by short name (e.g. `"bnb"`). @@ -26,27 +58,69 @@ pub struct Config { pub liquidator: HashMap, } +impl fmt::Debug for Config { + // Redact the contents of `chain` — it carries full RPC URLs with API keys. + // Everything else is scalar thresholds and public addresses, safe to print. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Config") + .field("bot", &self.bot) + .field("chain", &ChainCount(self.chain.len())) + .field("protocol", &self.protocol) + .field("flashloan", &self.flashloan) + .field("liquidator", &self.liquidator) + .finish() + } +} + +struct ChainCount(usize); +impl fmt::Debug for ChainCount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "<{} chains redacted>", self.0) + } +} + /// Bot-level knobs — thresholds and intervals. +/// +/// Money values are stored as integers to avoid f64 precision and NaN hazards. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BotConfig { - /// Drop opportunities below this USD profit threshold. - pub min_profit_usd: f64, - /// Skip liquidations when gas price exceeds this (gwei). - pub max_gas_gwei: u64, + /// Minimum profit threshold in USD × 1e6 (six decimals of USD). + /// A value of `5_000_000` means `$5.00`. Fixed-point over f64 so + /// comparisons against oracle-denominated profit are deterministic. + pub min_profit_usd_1e6: u64, + /// Maximum acceptable gas price, in wei (decimal string or integer). + /// Stored as U256 so sub-gwei priority fees are representable and + /// EIP-1559 math stays exact. + #[serde(deserialize_with = "deser_u256_string")] + pub max_gas_wei: U256, /// Polling interval for protocols that don't push events. pub scan_interval_ms: u64, } -/// RPC endpoints for a single chain. -#[derive(Debug, Clone, Deserialize)] +/// RPC endpoints for a single chain. **The URLs typically embed API keys; +/// `Debug` prints `` rather than the URL.** +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ChainConfig { pub chain_id: u64, pub ws_url: String, pub http_url: String, } +impl fmt::Debug for ChainConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChainConfig") + .field("chain_id", &self.chain_id) + .field("ws_url", &"") + .field("http_url", &"") + .finish() + } +} + /// Address and metadata for a lending protocol on a specific chain. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ProtocolConfig { /// Name of the chain this protocol runs on (must match a key in `[chain]`). pub chain: String, @@ -56,6 +130,7 @@ pub struct ProtocolConfig { /// A flash-loan source available on a given chain. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct FlashLoanConfig { pub chain: String, /// Pool / vault address (Aave V3 Pool, Balancer Vault, etc.). @@ -64,32 +139,116 @@ pub struct FlashLoanConfig { /// Address of the deployed `CharonLiquidator` contract on a chain. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct LiquidatorConfig { pub chain: String, pub contract_address: Address, } impl Config { - /// Read a TOML config file, substitute `${ENV_VAR}` placeholders, parse. - /// - /// Returns an error if the file is missing, malformed, or references an - /// environment variable that isn't set. - pub fn load(path: impl AsRef) -> anyhow::Result { - let path = path.as_ref(); - let raw = std::fs::read_to_string(path) - .with_context(|| format!("failed to read config at {}", path.display()))?; - let substituted = substitute_env_vars(&raw) - .with_context(|| format!("env substitution failed for {}", path.display()))?; - let config: Config = toml::from_str(&substituted) - .with_context(|| format!("failed to parse TOML at {}", path.display()))?; + /// Read a TOML config file, substitute `${ENV_VAR}` placeholders, parse + /// and validate. + pub fn load(path: impl AsRef) -> Result { + let path_buf = path.as_ref().to_path_buf(); + let raw = std::fs::read_to_string(&path_buf).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + ConfigError::NotFound(path_buf.clone()) + } else { + ConfigError::Io { + path: path_buf.clone(), + source, + } + } + })?; + Self::from_str(&raw) + } + + /// Parse an already-loaded TOML string (used by tests and embedded configs). + pub fn from_str(raw: &str) -> Result { + let substituted = substitute_env_vars(raw)?; + let config: Config = toml::from_str(&substituted)?; + config.validate()?; Ok(config) } + + /// Cross-reference chain keys, reject sentinel zero addresses. + fn validate(&self) -> Result<()> { + if self.chain.is_empty() { + return Err(ConfigError::Validation("no [chain.*] entries".into())); + } + for (name, p) in &self.protocol { + if !self.chain.contains_key(&p.chain) { + return Err(ConfigError::Validation(format!( + "protocol `{name}` references unknown chain `{}`", + p.chain + ))); + } + if p.comptroller == Address::ZERO { + return Err(ConfigError::Validation(format!( + "protocol `{name}` has zero comptroller address" + ))); + } + } + for (name, f) in &self.flashloan { + if !self.chain.contains_key(&f.chain) { + return Err(ConfigError::Validation(format!( + "flashloan `{name}` references unknown chain `{}`", + f.chain + ))); + } + if f.pool == Address::ZERO { + return Err(ConfigError::Validation(format!( + "flashloan `{name}` has zero pool address" + ))); + } + } + for (name, l) in &self.liquidator { + if !self.chain.contains_key(&l.chain) { + return Err(ConfigError::Validation(format!( + "liquidator `{name}` references unknown chain `{}`", + l.chain + ))); + } + if l.contract_address == Address::ZERO { + return Err(ConfigError::Validation(format!( + "liquidator `{name}` has zero contract address — deploy the contract first" + ))); + } + } + Ok(()) + } +} + +fn deser_u256_string<'de, D>(d: D) -> std::result::Result +where + D: serde::Deserializer<'de>, +{ + use serde::de::Error; + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrInt { + String(String), + Int(u128), + } + match StringOrInt::deserialize(d)? { + StringOrInt::String(s) => { + let trimmed = s.trim(); + if let Some(hex) = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X")) { + U256::from_str_radix(hex, 16).map_err(D::Error::custom) + } else { + U256::from_str_radix(trimmed, 10).map_err(D::Error::custom) + } + } + StringOrInt::Int(n) => Ok(U256::from(n)), + } } -/// Replace every `${NAME}` in `input` with the value of environment variable -/// `NAME`. Returns an error if any referenced variable is unset or if a -/// `${` is not closed by `}`. -fn substitute_env_vars(input: &str) -> anyhow::Result { +/// Replace every `${NAME}` or `${NAME:-default}` in `input` with the value of +/// the environment variable `NAME`. Values are escaped so that TOML-special +/// characters (`"`, `\`, newline) inside env values cannot corrupt the parse. +/// +/// Values are expected to be placed inside double-quoted TOML strings. +fn substitute_env_vars(input: &str) -> Result { let mut output = String::with_capacity(input.len()); let mut rest = input; while let Some(start) = rest.find("${") { @@ -97,13 +256,43 @@ fn substitute_env_vars(input: &str) -> anyhow::Result { let after = &rest[start + 2..]; let end = after .find('}') - .ok_or_else(|| anyhow!("unterminated `${{` in config"))?; - let var_name = &after[..end]; - let value = std::env::var(var_name) - .with_context(|| format!("env var `{var_name}` is not set"))?; - output.push_str(&value); + .ok_or(ConfigError::UnterminatedInterp)?; + let token = &after[..end]; + let (var_name, default) = match token.split_once(":-") { + Some((name, def)) => (name, Some(def)), + None => (token, None), + }; + if !is_valid_env_name(var_name) { + return Err(ConfigError::InvalidEnvVarName(var_name.to_string())); + } + let value = match std::env::var(var_name) { + Ok(v) => v, + Err(_) => match default { + Some(d) => d.to_string(), + None => return Err(ConfigError::UnsetEnvVar(var_name.to_string())), + }, + }; + for c in value.chars() { + match c { + '\\' => output.push_str("\\\\"), + '"' => output.push_str("\\\""), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + _ => output.push(c), + } + } rest = &after[end + 1..]; } output.push_str(rest); Ok(output) } + +fn is_valid_env_name(s: &str) -> bool { + let mut chars = s.chars(); + match chars.next() { + Some(c) if c.is_ascii_uppercase() || c == '_' => {} + _ => return false, + } + chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') +} diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index a03f44e..3e0fb38 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -4,7 +4,7 @@ pub mod config; pub mod traits; pub mod types; -pub use config::Config; +pub use config::{Config, ConfigError}; pub use traits::LendingProtocol; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position,