diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 9061128..1f44366 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -1019,6 +1019,15 @@ async fn run_listen(config: &Config, borrowers: Vec
, execute: bool) -> // bytes. let signer_key = config.bot.signer_key.clone(); + // Operator heartbeat cadence (#333). Default 50 blocks ≈ 150s + // on BSC. The block listener used to log only at DEBUG, so a + // bot running cleanly under default `RUST_LOG=info` produced no + // post-startup output for minutes at a time and operators + // routinely assumed it had hung. `heartbeat_blocks = 0` + // disables the heartbeat entirely (e.g. JSON-log pipelines that + // prefer to derive liveness from the metrics surface). + let heartbeat_blocks = config.bot.heartbeat_blocks; + // The first real (non-backfill) block on the Venus chain seeds // the scanner with the operator-supplied borrower list. // Subsequent scans pull from the scheduler-selected bucket @@ -1045,6 +1054,24 @@ async fn run_listen(config: &Config, borrowers: Vec, execute: bool) -> backfill, "cli drained event" ); + // Operator-visible heartbeat. Keyed off the + // chain block number so the cadence is + // deterministic across restarts (versus an + // internal counter that resets to 0 on every + // boot). Skip backfill heads so a reconnect + // storm does not produce a heartbeat per + // replayed block. + if !backfill + && heartbeat_blocks != 0 + && number % heartbeat_blocks == 0 + { + tracing::info!( + chain = %chain, + block = number, + cadence_blocks = heartbeat_blocks, + "block listener heartbeat" + ); + } if backfill { // Skip backfill — the next real head will // snapshot the final state of the missed diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index f42298e..27e9a30 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -281,6 +281,15 @@ pub struct BotConfig { /// COLD (Healthy) bucket scan cadence. Default every 100 blocks. #[serde(default = "default_cold_scan_blocks")] pub cold_scan_blocks: u64, + /// Cadence (in blocks) for the operator-visible INFO heartbeat + /// emitted while listening (#333). Under the default + /// `RUST_LOG=info` filter the post-startup terminal is otherwise + /// silent for minutes at a time, and operators routinely assume + /// the bot has hung. `50` ≈ 150s on BSC's 3s block time. Set to + /// `0` to disable the heartbeat entirely (the existing DEBUG + /// per-block line still fires when `RUST_LOG=debug`). + #[serde(default = "default_heartbeat_blocks")] + pub heartbeat_blocks: u64, /// Hot-wallet signer key, fed in via `${CHARON_SIGNER_KEY}` env /// substitution in `config/default.toml`. Held in a /// [`SecretString`] so the raw hex never reaches `Debug` output or @@ -327,6 +336,7 @@ impl fmt::Debug for BotConfig { .field("hot_scan_blocks", &self.hot_scan_blocks) .field("warm_scan_blocks", &self.warm_scan_blocks) .field("cold_scan_blocks", &self.cold_scan_blocks) + .field("heartbeat_blocks", &self.heartbeat_blocks) .field( "signer_key", &if self.signer_key.is_some() { @@ -355,6 +365,9 @@ fn default_warm_scan_blocks() -> u64 { fn default_cold_scan_blocks() -> u64 { 100 } +fn default_heartbeat_blocks() -> u64 { + 50 +} /// RPC endpoints for a single chain. **The URLs typically embed API keys; /// `Debug` prints `