From 40e00a7fbe0a3517d31907f1016327357e241b3b Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 12:56:09 +0530 Subject: [PATCH 1/2] feat(cli): add clap CLI with listen subcommand and config loading Wire up the first runnable binary. `charon` now accepts: --config / -c (defaults to config/default.toml) listen (subcommand; real scanner arrives in Day 2) On startup: 1. dotenvy loads `.env` if present (silent no-op otherwise) 2. tracing_subscriber configures structured logging (RUST_LOG=debug to dial up verbosity) 3. Cli::parse() validates args via clap derive 4. Config::load() reads + substitutes + parses TOML; errors bubble up with context about which file failed 5. Successful load logs a one-line summary (chain / protocol / flashloan counts, min profit) Also fix a parser/comment collision in config/default.toml: a doc comment had a literal \${ENV_VAR} placeholder that the substituter was picking up as a real env reference. Comment now describes the syntax in prose. cargo run -- --config config/default.toml listen now works end-to-end after \`cp .env.example .env\`. --- Cargo.lock | 239 ++++++++++++++++++++++++++++++++++ Cargo.toml | 3 + config/default.toml | 4 +- crates/charon-cli/Cargo.toml | 9 ++ crates/charon-cli/src/main.rs | 71 +++++++++- 5 files changed, 322 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 52f4887..a9c1953 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -655,6 +664,56 @@ dependencies = [ "tracing", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -1065,6 +1124,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "charon-cli" version = "0.1.0" +dependencies = [ + "anyhow", + "charon-core", + "clap", + "dotenvy", + "tokio", + "tracing", + "tracing-subscriber", +] [[package]] name = "charon-core" @@ -1081,6 +1149,52 @@ dependencies = [ name = "charon-scanner" version = "0.1.0" +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "const-hex" version = "1.18.1" @@ -1311,6 +1425,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" @@ -1987,6 +2107,12 @@ dependencies = [ "serde", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -2070,6 +2196,12 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2135,6 +2267,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2169,6 +2310,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2248,6 +2398,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "openssl" version = "0.10.78" @@ -2638,6 +2794,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3048,12 +3215,31 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "signature" version = "2.2.0" @@ -3111,6 +3297,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "strum" version = "0.26.3" @@ -3273,6 +3465,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "threadpool" version = "1.8.1" @@ -3310,7 +3511,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -3533,6 +3736,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -3639,6 +3872,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 55f8021..3c91168 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,9 @@ async-trait = "0.1" # CLI clap = { version = "4", features = ["derive"] } +# .env loader +dotenvy = "0.15" + # Internal crates charon-core = { path = "crates/charon-core" } charon-scanner = { path = "crates/charon-scanner" } diff --git a/config/default.toml b/config/default.toml index 714d613..c7e27fc 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. diff --git a/crates/charon-cli/Cargo.toml b/crates/charon-cli/Cargo.toml index 97525dd..1c594a6 100644 --- a/crates/charon-cli/Cargo.toml +++ b/crates/charon-cli/Cargo.toml @@ -8,3 +8,12 @@ description = "Charon command-line entrypoint" [[bin]] name = "charon" path = "src/main.rs" + +[dependencies] +charon-core = { workspace = true } +clap = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +dotenvy = { workspace = true } diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 8e709dd..ecc7c69 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -1,3 +1,70 @@ -fn main() { - println!("charon — not wired up yet"); +//! Charon command-line entrypoint. +//! +//! ```text +//! 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. + #[arg(long, short = 'c', default_value = "config/default.toml")] + 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, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Load `.env` if present. Silent no-op if the file isn't there. + let _ = dotenvy::dotenv(); + + // Structured logging. Override verbosity with RUST_LOG=debug etc. + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .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()))?; + + info!( + chains = config.chain.len(), + protocols = config.protocol.len(), + flashloan_sources = config.flashloan.len(), + liquidators = config.liquidator.len(), + min_profit_usd = config.bot.min_profit_usd, + "config loaded" + ); + + match cli.command { + Command::Listen => { + info!("listen: not wired up yet — scanner arrives in Day 2"); + } + } + + Ok(()) } From a2354880c434d682b3ff0530a8b97cc98c0e39e8 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 20:21:02 +0530 Subject: [PATCH 2/2] feat(cli): stderr logs, required --config, SIGTERM/SIGINT shutdown, explicit multi-thread flavor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tracing_subscriber writes to stderr so 'listen' can emit JSON on stdout later without interleaving log output into the data stream. - --config has no default; must come from --config flag or CHARON_CONFIG env var. Removes the cwd-relative 'config/default.toml' fallback that silently resolves wrong inside the Docker WORKDIR. - clap workspace feature set adds 'env' so the env = "CHARON_CONFIG" attr compiles. - Annotate the startup info! block with a SECURITY comment enumerating the fields that must never appear there (ws_url, http_url, private keys, wallet addresses, full Debug of Config / ChainConfig). - Long-running listen now runs inside tokio::select! with SIGINT (ctrl_c) and SIGTERM branches so docker stop → SIGTERM exits cleanly before the 10 s SIGKILL. wait_sigterm is cfg-gated for non-Unix targets. - #[tokio::main(flavor = "multi_thread")] makes the concurrency contract explicit and immune to future tokio feature-flag trimming. Closes #82 #83 #84 #85 #86 --- Cargo.toml | 2 +- crates/charon-cli/src/main.rs | 57 ++++++++++++++++++++++++++++++++--- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3c91168..7d9c9f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,7 +35,7 @@ anyhow = "1" async-trait = "0.1" # CLI -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } # .env loader dotenvy = "0.15" diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index ecc7c69..2119d36 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -1,6 +1,7 @@ //! Charon command-line entrypoint. //! //! ```text +//! CHARON_CONFIG=/etc/charon/default.toml charon listen //! charon --config config/default.toml listen //! ``` @@ -17,7 +18,12 @@ use tracing_subscriber::EnvFilter; #[command(version, about, long_about = None)] struct Cli { /// Path to the TOML config file. - #[arg(long, short = 'c', default_value = "config/default.toml")] + /// + /// 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)] @@ -31,16 +37,20 @@ enum Command { Listen, } -#[tokio::main] +// 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 logging. Override verbosity with RUST_LOG=debug etc. + // 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(); @@ -51,6 +61,9 @@ async fn main() -> Result<()> { 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(), @@ -62,9 +75,45 @@ async fn main() -> Result<()> { match cli.command { Command::Listen => { - info!("listen: not wired up yet — scanner arrives in Day 2"); + 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 +}