diff --git a/Cargo.lock b/Cargo.lock index 4e2a022..9a123ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1128,6 +1128,7 @@ dependencies = [ "anyhow", "charon-core", "clap", + "dotenvy", "tokio", "tracing", "tracing-subscriber", @@ -1425,6 +1426,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index c41b541..899ea0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,10 @@ thiserror = "2" async-trait = "0.1" # CLI -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } + +# .env loader +dotenvy = "0.15" # Internal crates charon-core = { path = "crates/charon-core" } diff --git a/config/default.toml b/config/default.toml index fb151f3..80c9840 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1,7 +1,7 @@ # 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. +# Environment variables below (dollar-sign + braces) are substituted at +# load time — see `.env.example` for the full list of expected vars. [bot] # Drop opportunities below this USD profit threshold, in USD × 1e6. diff --git a/crates/charon-cli/Cargo.toml b/crates/charon-cli/Cargo.toml index 12e2678..76f5543 100644 --- a/crates/charon-cli/Cargo.toml +++ b/crates/charon-cli/Cargo.toml @@ -10,12 +10,13 @@ name = "charon" path = "src/main.rs" [dependencies] -tokio = { workspace = true } -clap = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } +charon-core = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } tracing-subscriber = { workspace = true } -charon-core = { workspace = true } +dotenvy = { workspace = true } [lints] workspace = true diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 8e709dd..e459b5f 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -1,3 +1,119 @@ -fn main() { - println!("charon — not wired up yet"); +//! Charon command-line entrypoint. +//! +//! ```text +//! CHARON_CONFIG=/etc/charon/default.toml charon listen +//! charon --config config/default.toml listen +//! ``` + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use charon_core::Config; +use clap::{Parser, Subcommand}; +use tracing::info; +use tracing_subscriber::EnvFilter; + +/// Charon — multi-chain flash-loan liquidation bot. +#[derive(Parser, Debug)] +#[command(version, about, long_about = None)] +struct Cli { + /// Path to the TOML config file. + /// + /// No default — the operator must supply the path explicitly via + /// `--config` or the `CHARON_CONFIG` environment variable. Avoids the + /// silent cwd-relative `config/default.toml` fallback which breaks inside + /// the Docker deploy image where WORKDIR may differ from the repo root. + #[arg(long, short = 'c', env = "CHARON_CONFIG")] + config: PathBuf, + + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Listen to chain events and track positions. + /// (Scanner wiring arrives in Day 2 — for now this just loads config.) + Listen, +} + +// Explicit multi-thread flavor so the concurrency contract survives any +// future trimming of tokio's `full` feature set. +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<()> { + // Load `.env` if present. Silent no-op if the file isn't there. + let _ = dotenvy::dotenv(); + + // Structured logs go to stderr so `listen` can eventually emit a JSON + // data stream on stdout without interleaving. Verbosity via RUST_LOG. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with_writer(std::io::stderr) + .init(); + + let cli = Cli::parse(); + + info!("charon starting up"); + info!(path = %cli.config.display(), "loading config"); + + let config = Config::load(&cli.config) + .with_context(|| format!("failed to load config from {}", cli.config.display()))?; + + // SECURITY: only counts and non-secret scalars here. + // Never log ws_url, http_url, private keys, wallet addresses, or the + // full Debug of Config / ChainConfig — RPC URLs embed API keys. + info!( + chains = config.chain.len(), + protocols = config.protocol.len(), + flashloan_sources = config.flashloan.len(), + liquidators = config.liquidator.len(), + min_profit_usd_1e6 = config.bot.min_profit_usd_1e6, + "config loaded" + ); + + match cli.command { + Command::Listen => { + run_listen(&config).await?; + } + } + + Ok(()) +} + +/// Long-running listener entry point. Exits cleanly on SIGINT or SIGTERM so +/// the Docker `stop` → SIGTERM → SIGKILL sequence never tears mid-operation. +async fn run_listen(_config: &Config) -> Result<()> { + info!("listen: not wired up yet — scanner arrives in Day 2"); + + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("received SIGINT, shutting down"); + } + _ = wait_sigterm() => { + info!("received SIGTERM, shutting down"); + } + } + + Ok(()) +} + +#[cfg(unix)] +async fn wait_sigterm() { + use tokio::signal::unix::{SignalKind, signal}; + match signal(SignalKind::terminate()) { + Ok(mut s) => { + let _ = s.recv().await; + } + Err(err) => { + tracing::warn!(error = %err, "failed to install SIGTERM handler"); + std::future::pending::<()>().await + } + } +} + +#[cfg(not(unix))] +async fn wait_sigterm() { + std::future::pending::<()>().await }