From ae5e059c48893e2b3db9d436742b543bbf14ff1d Mon Sep 17 00:00:00 2001 From: obchain Date: Sat, 2 May 2026 22:40:51 +0530 Subject: [PATCH 1/2] feat(cli): cross-check liquidator contract bytecode at startup (closes #399) A misconfigured fork (skipped dev-0 nonce reset, wrong signer, mismatched contract_address) silently produced "every simulation reverts" with no operator-facing signal. New verify_liquidator_deployed helper calls eth_getCode on the configured liquidator address at startup; empty bytecode aborts with a deploy-context-aware remediation hint (fork: re-run forge create + check dev-0 nonce reset; mainnet: verify the configured address against the deploy receipt rather than redeploying). Hooked into the venus pipeline assembly right before AaveFlashLoan connect, only when both [flashloan.aave_v3_bsc] and [liquidator.] are configured. Scan-only chains skip by construction. API: provider.get_code_at(addr).block_id(BlockId::latest()).await. The explicit latest pin is kept despite alloy's default to keep intent visible at the call site. Four httpmock tests: - empty_bytecode_on_fork_aborts_with_forge_hint - empty_bytecode_on_mainnet_uses_config_hint_not_redeploy (locks in the deploy-context-aware message wording) - non_empty_bytecode_passes - rpc_error_aborts dev-deps: httpmock, reqwest, url (already in workspace). Follow-up tracked separately: wire the same check into run_replay (#395) once that lands. --- Cargo.lock | 3 + crates/charon-cli/Cargo.toml | 5 + crates/charon-cli/src/main.rs | 206 +++++++++++++++++++++++++++++++--- 3 files changed, 198 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2e8ed8..78f9264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1377,11 +1377,14 @@ dependencies = [ "clap", "dotenvy", "futures-util", + "httpmock", "metrics", + "reqwest", "secrecy", "tokio", "tracing", "tracing-subscriber", + "url", ] [[package]] diff --git a/crates/charon-cli/Cargo.toml b/crates/charon-cli/Cargo.toml index 8b5fca9..452905b 100644 --- a/crates/charon-cli/Cargo.toml +++ b/crates/charon-cli/Cargo.toml @@ -28,5 +28,10 @@ async-trait = { workspace = true } futures-util = { workspace = true } metrics = { workspace = true } +[dev-dependencies] +httpmock = "0.7" +reqwest = { workspace = true } +url = { workspace = true } + [lints] workspace = true diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 9870a7e..5e79189 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -482,22 +482,6 @@ fn parse_borrower_file(path: &std::path::Path) -> Vec
{ out } -/// Issue #398: pick the URL the `--execute` submitter connects to so -/// that the startup gate at the top of `run_listen` and the harness -/// build cannot disagree. -/// -/// Precedence: -/// 1. `private_rpc_url` when set — preferred on every profile. -/// 2. `chain.http_url` when `allow_public_mempool = true` (dev / -/// mainnet over public RPC; the gate already accepted the -/// config) **or** the operator is on the fork profile (anvil -/// loopback, issue #396). -/// 3. Bail with a message that lists every recognised remediation. -/// -/// The `allow_loopback` flag passed to `Submitter::connect` is the -/// caller's responsibility — it stays tied to the fork profile only, -/// because public-mempool fallback over a non-loopback URL must still -/// require https/wss (no plaintext on any non-fork path). fn resolve_execute_submit_url( private_rpc_url: Option<&SecretString>, http_url: &str, @@ -518,6 +502,49 @@ fn resolve_execute_submit_url( ) } +/// Issue #399: cross-check at startup that the address configured +/// under `[liquidator.].contract_address` actually has bytecode +/// on the connected chain. +async fn verify_liquidator_deployed( + provider: &P, + liquidator: Address, + chain_name: &str, + profile_tag: Option<&str>, +) -> Result<()> +where + P: Provider, + T: alloy::transports::Transport + Clone, +{ + let code: Bytes = provider + .get_code_at(liquidator) + .block_id(alloy::eips::BlockId::latest()) + .await + .with_context(|| { + format!("eth_getCode failed for liquidator {liquidator} on chain '{chain_name}'") + })?; + if code.is_empty() { + let hint = if profile_tag == Some("fork") { + "re-run `forge create CharonLiquidator` against the fork with the configured \ + signer (and confirm the dev-0 nonce was reset), or update \ + [liquidator.].contract_address to match the actual CREATE address" + } else { + "verify [liquidator.].contract_address matches the deploy receipt for \ + this chain — do not redeploy on mainnet without confirming the configured \ + address is wrong" + }; + bail!( + "liquidator contract not deployed at {liquidator} on chain '{chain_name}': {hint}" + ); + } + info!( + chain = %chain_name, + liquidator = %liquidator, + bytecode_bytes = code.len(), + "liquidator bytecode check passed" + ); + Ok(()) +} + /// Long-running listener entry point. Spawns one `BlockListener` per /// configured chain, drains the shared `ChainEvent` channel, and exits /// cleanly on SIGINT or SIGTERM so the Docker `stop` → SIGTERM → @@ -1046,6 +1073,24 @@ async fn run_listen( .context("aave v3: pool address mismatch — refusing to start")?; } } + // Issue #399: refuse to start when the configured + // liquidator address has empty bytecode on the + // connected chain. Otherwise every simulation + // returns a generic Reverted (no code at address) + // and the operator has no signal that the deploy + // and the config disagree — the typical fork-mode + // foot-gun where dev-0 nonce reset was skipped or + // a different signer deployed at a different + // CREATE address. + verify_liquidator_deployed( + provider.as_ref(), + liq_cfg.contract_address, + chain_name.as_str(), + config.bot.profile_tag.as_deref(), + ) + .await + .context("liquidator bytecode check failed — refusing to start")?; + let aave = Arc::new( AaveFlashLoan::connect( provider.clone(), @@ -3285,3 +3330,132 @@ mod resolve_execute_submit_url_tests { ); } } + +#[cfg(test)] +#[allow(clippy::items_after_test_module)] +mod verify_liquidator_deployed_tests { + use super::*; + use alloy::primitives::address; + use alloy::providers::{ProviderBuilder, RootProvider}; + use alloy::rpc::client::ClientBuilder; + use alloy::transports::BoxTransport; + use alloy::transports::http::Http; + use httpmock::prelude::*; + + fn boxed_http_provider(url: &str) -> RootProvider { + let parsed: url::Url = url.parse().expect("valid http url"); + let http = Http::with_client(reqwest::Client::new(), parsed); + let rpc_client = ClientBuilder::default().transport(http, true); + ProviderBuilder::new().on_client(rpc_client.boxed()) + } + + /// Empty bytecode (`"0x"`) on a fork profile ⇒ bail with a + /// remediation message that names the address and chain and + /// directs the operator at `forge create`. Guards against the + /// typical fork foot-gun where dev-0 nonce reset was skipped and + /// the deploy landed at a different CREATE address than + /// `config/fork.toml` has baked in. + #[tokio::test] + async fn empty_bytecode_on_fork_aborts_with_forge_hint() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/").body_contains(r#""eth_getCode""#); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":0,"result":"0x"}"#); + }) + .await; + + let provider = boxed_http_provider(&server.url("/")); + let liq = address!("5FbDB2315678afecb367f032d93F642f64180aa3"); + let err = verify_liquidator_deployed(&provider, liq, "bnb", Some("fork")) + .await + .expect_err("empty bytecode must bail"); + let msg = format!("{err:#}"); + assert!(msg.contains("not deployed"), "expected reason in: {msg}"); + assert!(msg.contains(&liq.to_string()), "expected address in: {msg}"); + assert!(msg.contains("bnb"), "expected chain in: {msg}"); + assert!( + msg.contains("forge create"), + "fork hint must mention forge create, got: {msg}" + ); + } + + /// Empty bytecode on a non-fork profile (mainnet/testnet) ⇒ bail + /// with a remediation message that does NOT recommend redeploying. + /// On mainnet a missing contract is almost always a config typo; + /// telling the operator to "re-run forge create" against mainnet + /// would be actively wrong. + #[tokio::test] + async fn empty_bytecode_on_mainnet_uses_config_hint_not_redeploy() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/").body_contains(r#""eth_getCode""#); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":0,"result":"0x"}"#); + }) + .await; + + let provider = boxed_http_provider(&server.url("/")); + let liq = address!("5FbDB2315678afecb367f032d93F642f64180aa3"); + let err = verify_liquidator_deployed(&provider, liq, "bnb", None) + .await + .expect_err("empty bytecode must bail"); + let msg = format!("{err:#}"); + assert!(msg.contains("not deployed"), "expected reason in: {msg}"); + assert!( + !msg.contains("forge create"), + "mainnet hint must not point at forge create, got: {msg}" + ); + assert!( + msg.contains("verify") && msg.contains("deploy receipt"), + "mainnet hint must point at the deploy receipt, got: {msg}" + ); + } + + /// Non-empty bytecode at the configured address ⇒ Ok. + #[tokio::test] + async fn non_empty_bytecode_passes() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/").body_contains(r#""eth_getCode""#); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"jsonrpc":"2.0","id":0,"result":"0x6080604052"}"#); + }) + .await; + + let provider = boxed_http_provider(&server.url("/")); + let liq = address!("5FbDB2315678afecb367f032d93F642f64180aa3"); + verify_liquidator_deployed(&provider, liq, "bnb", Some("fork")) + .await + .expect("non-empty bytecode must pass"); + } + + /// Transport / RPC failure ⇒ surface with the same remediation + /// hook as a misconfigured deploy address. The startup gate must + /// not silently pass on an upstream blip — better to refuse to + /// start and let the operator retry than to run the bot blind. + #[tokio::test] + async fn rpc_error_aborts() { + let server = MockServer::start_async().await; + let _m = server + .mock_async(|when, then| { + when.method(POST).path("/"); + then.status(500).body("upstream error"); + }) + .await; + + let provider = boxed_http_provider(&server.url("/")); + let liq = address!("5FbDB2315678afecb367f032d93F642f64180aa3"); + let err = verify_liquidator_deployed(&provider, liq, "bnb", None) + .await + .expect_err("rpc error must bail"); + let msg = format!("{err:#}"); + assert!(msg.contains("eth_getCode"), "expected RPC label in: {msg}"); + } +} From ea35e3beda7449aef8b5555838f5144b1699ef48 Mon Sep 17 00:00:00 2001 From: obchain Date: Sun, 3 May 2026 01:42:03 +0530 Subject: [PATCH 2/2] style: cargo fmt --- crates/charon-cli/src/main.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 5e79189..b8d2e9e 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -532,9 +532,7 @@ where this chain — do not redeploy on mainnet without confirming the configured \ address is wrong" }; - bail!( - "liquidator contract not deployed at {liquidator} on chain '{chain_name}': {hint}" - ); + bail!("liquidator contract not deployed at {liquidator} on chain '{chain_name}': {hint}"); } info!( chain = %chain_name, @@ -3360,7 +3358,9 @@ mod verify_liquidator_deployed_tests { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { - when.method(POST).path("/").body_contains(r#""eth_getCode""#); + when.method(POST) + .path("/") + .body_contains(r#""eth_getCode""#); then.status(200) .header("content-type", "application/json") .body(r#"{"jsonrpc":"2.0","id":0,"result":"0x"}"#); @@ -3392,7 +3392,9 @@ mod verify_liquidator_deployed_tests { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { - when.method(POST).path("/").body_contains(r#""eth_getCode""#); + when.method(POST) + .path("/") + .body_contains(r#""eth_getCode""#); then.status(200) .header("content-type", "application/json") .body(r#"{"jsonrpc":"2.0","id":0,"result":"0x"}"#); @@ -3422,7 +3424,9 @@ mod verify_liquidator_deployed_tests { let server = MockServer::start_async().await; let _m = server .mock_async(|when, then| { - when.method(POST).path("/").body_contains(r#""eth_getCode""#); + when.method(POST) + .path("/") + .body_contains(r#""eth_getCode""#); then.status(200) .header("content-type", "application/json") .body(r#"{"jsonrpc":"2.0","id":0,"result":"0x6080604052"}"#);