Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions crates/charon-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,15 @@ async fn run_listen(config: &Config, borrowers: Vec<Address>, 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
Expand All @@ -1045,6 +1054,24 @@ async fn run_listen(config: &Config, borrowers: Vec<Address>, 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
Expand Down
41 changes: 41 additions & 0 deletions crates/charon-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 `<redacted>` rather than the URL.**
Expand Down Expand Up @@ -1053,6 +1066,7 @@ mod private_rpc_tests {
hot_scan_blocks: 1,
warm_scan_blocks: 10,
cold_scan_blocks: 100,
heartbeat_blocks: 50,
signer_key: None,
profile_tag: None,
},
Expand Down Expand Up @@ -1307,6 +1321,7 @@ mod fork_profile_tests {
hot_scan_blocks: 1,
warm_scan_blocks: 10,
cold_scan_blocks: 100,
heartbeat_blocks: 50,
signer_key: None,
profile_tag: Some("fork".into()),
},
Expand Down Expand Up @@ -1455,6 +1470,32 @@ mod fork_profile_tests {
let w: Wrapper = toml::from_str("").expect("parse empty");
assert!(w.chainlink_max_age_secs.is_empty());
}

#[test]
fn heartbeat_blocks_defaults_to_50_when_unset() {
// BotConfig requires several non-default fields; populate
// only what serde demands and let `heartbeat_blocks` flow
// through `default_heartbeat_blocks()`.
let toml_src = r#"
min_profit_usd_1e6 = 5000000
max_gas_wei = "5000000000"
scan_interval_ms = 1000
"#;
let bot: BotConfig = toml::from_str(toml_src).expect("parse");
assert_eq!(bot.heartbeat_blocks, 50);
}

#[test]
fn heartbeat_blocks_round_trips_explicit_override() {
let toml_src = r#"
min_profit_usd_1e6 = 5000000
max_gas_wei = "5000000000"
scan_interval_ms = 1000
heartbeat_blocks = 0
"#;
let bot: BotConfig = toml::from_str(toml_src).expect("parse");
assert_eq!(bot.heartbeat_blocks, 0, "explicit 0 disables heartbeat");
}
}

#[cfg(test)]
Expand Down
Loading