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 f3442a9..4e2a022 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1142,6 +1142,7 @@ dependencies = [ "async-trait", "serde", "thiserror 2.0.18", + "toml", ] [[package]] @@ -2616,7 +2617,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]] @@ -3144,6 +3145,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" @@ -3574,6 +3584,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" @@ -3583,6 +3614,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" @@ -3590,7 +3635,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.2", ] @@ -3604,6 +3649,12 @@ dependencies = [ "winnow 1.0.2", ] +[[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..fb151f3 --- /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, 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 + +# ── 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 ───────────────────────────────────────── +# 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 75fafd7..3bce3fe 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -13,4 +13,5 @@ alloy = { workspace = true } 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 new file mode 100644 index 0000000..c92877a --- /dev/null +++ b/crates/charon-core/src/config.rs @@ -0,0 +1,298 @@ +//! 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 +//! use charon_core::config::Config; +//! let cfg = Config::load("config/default.toml").unwrap(); +//! ``` + +use alloy::primitives::{Address, U256}; +use serde::Deserialize; +use std::collections::HashMap; +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(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +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, +} + +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 { + /// 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. **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, + /// 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)] +#[serde(deny_unknown_fields)] +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)] +#[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 + /// 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}` 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("${") { + output.push_str(&rest[..start]); + let after = &rest[start + 2..]; + let end = after + .find('}') + .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 a7dd0a0..42661eb 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, ConfigError}; pub use traits::{LendingProtocol, LendingProtocolError, Result as LendingResult}; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position,