diff --git a/.env.example b/.env.example index 4ab0aab..278b6b4 100644 --- a/.env.example +++ b/.env.example @@ -35,3 +35,17 @@ CHARON_BSC_PRIVATE_RPC_AUTH= # compatibility. CHARON_BNB_TESTNET_WS_URL=wss://bsc-testnet-rpc.publicnode.com CHARON_BNB_TESTNET_HTTP_URL=https://bsc-testnet-rpc.publicnode.com + +# Local anvil-fork port — consumed by both `scripts/anvil_fork.sh` +# and `config/fork.toml`. Defaults to 8545 (standard anvil). Override +# when running parallel forks on one host: +# CHARON_ANVIL_PORT=8546 ./scripts/anvil_fork.sh +# CHARON_ANVIL_PORT=8546 charon --config config/fork.toml listen +# The config file falls back to 8545 via `${CHARON_ANVIL_PORT:-8545}` +# so setting this variable is optional. +#CHARON_ANVIL_PORT=8545 + +# Block number to pin the anvil fork at — consumed by +# `scripts/anvil_fork.sh`. Leave unset to fork the latest block. +# Useful when reproducing a specific liquidation scenario. +#FORK_BLOCK=41000000 diff --git a/README.md b/README.md index aafddfe..f2170cb 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,38 @@ Environment variables expected by the default config: | `BNB_WS_URL` | BNB Chain WebSocket RPC endpoint | | `BNB_HTTP_URL` | BNB Chain HTTPS RPC endpoint (for multicall) | +### Run profiles + +Three TOML profiles ship in [`config/`](config/). Pick one with `--config`. + +| Profile | File | When to use | +|---|---|---| +| Mainnet | `config/default.toml` | Production runs against BSC mainnet (real capital). | +| Testnet | `config/testnet.toml` | Venus on BSC testnet (Chapel, chainId 97) — no Aave V3 on Chapel, runs read-only. | +| Local anvil fork | `config/fork.toml` | Full end-to-end against a local anvil fork of BSC mainnet. Zero capital risk. | + +#### Local anvil fork (full end-to-end, no capital) + +Fork BSC mainnet locally. Real Venus state, real Aave V3, real PancakeSwap — liquidate real positions against a private chain. + +Terminal A — boot the fork: + +```sh +./scripts/anvil_fork.sh # forks latest block via dRPC, falls back to PublicNode +FORK_BLOCK=41000000 ./scripts/anvil_fork.sh # pin a specific block +CHARON_ANVIL_PORT=8546 ./scripts/anvil_fork.sh # run on a non-default port +``` + +Terminal B — run Charon against it: + +```sh +cargo run -- --config config/fork.toml listen +``` + +The fork profile carries `profile_tag = "fork"`; `Config::validate` rejects it at startup if any chain's `ws_url` / `http_url` resolves to a non-loopback host. This keeps the intentionally lowered profit gate from ever pointing at mainnet by accident. + +The fork profile omits `[liquidator.bnb]` by default — after `forge create` against the local anvil, add a `[liquidator.bnb]` section pointing at the deployed address to exercise the full liquidation path. Until then the CLI runs in read-only mode (scanner + metrics only). + --- ## Project structure diff --git a/config/fork.toml b/config/fork.toml new file mode 100644 index 0000000..d542a5f --- /dev/null +++ b/config/fork.toml @@ -0,0 +1,102 @@ +# Charon — local anvil fork profile (BSC mainnet state, local host). +# +# Pair with `scripts/anvil_fork.sh`. The fork process exposes HTTP+WS +# on 127.0.0.1:${CHARON_ANVIL_PORT:-8545} and mirrors every mainnet +# address the default profile uses, so addresses here match +# `config/default.toml` exactly. +# +# Why a separate file instead of reusing `default.toml`: +# - RPC URLs point at localhost, not production endpoints. +# - `private_rpc_url` is omitted — no private relay for a local +# fork; the local anvil IS the submission surface. The +# `profile_tag = "fork"` bypasses the PrivateRpcRequired gate. +# - `min_profit_usd_1e6` is lowered so synthetic / partial positions +# the operator stages manually aren't filtered out during demos. +# +# Running a secondary fork on a different port: +# CHARON_ANVIL_PORT=8546 ./scripts/anvil_fork.sh +# CHARON_ANVIL_PORT=8546 charon --config config/fork.toml listen +# Both the script and this profile read `CHARON_ANVIL_PORT` (falling +# back to 8545) so a dev can spin up parallel forks without editing +# the TOML. + +[bot] +# Lower gate for demo staging — real ops should use default.toml. +# $0.01 in USD × 1e6 fixed-point (post feat/19 integer schema). +min_profit_usd_1e6 = 10000 +# 20 gwei, expressed in wei (decimal string). Matches the lowered +# demo-gate intent: cheap anvil transactions shouldn't get filtered. +max_gas_wei = "20000000000" +scan_interval_ms = 1000 +# liquidatable/near_liq thresholds use serde defaults from BotConfig +# (HF 1.0000 / 1.0500). Scanner bucket cadences also defaulted. +# No signer key by default — the fork is read-only until the operator +# exports CHARON_SIGNER_KEY and deploys CharonLiquidator via `forge create`. +signer_key = "${CHARON_SIGNER_KEY:-}" +# Safety gate: `Config::validate` rejects this profile at startup if any +# chain points at a non-loopback RPC. Keeps a lowered profit gate from +# ever pointing at mainnet by accident (#254). +profile_tag = "fork" + +# ── Chains ──────────────────────────────────────────────────────────────── +[chain.bnb] +# BSC mainnet chain id preserved by `anvil --chain-id 56`. Signed +# transactions remain valid across operator/fork boundaries as long as +# this matches what the script passes. +chain_id = 56 +ws_url = "ws://127.0.0.1:${CHARON_ANVIL_PORT:-8545}" +http_url = "http://127.0.0.1:${CHARON_ANVIL_PORT:-8545}" +priority_fee_gwei = 1 +# No private relay on a local fork — the fork profile's loopback gate +# (see `profile_tag = "fork"` above) bypasses the PrivateRpcRequired +# check that the mainnet profile enforces. + +# ── Lending protocols ───────────────────────────────────────────────────── +[protocol.venus] +chain = "bnb" +comptroller = "0xfd36e2c2a6789db23113685031d7f16329158384" + +# ── Flash-loan sources ──────────────────────────────────────────────────── +# Aave V3 IS on BSC mainnet, so the fork inherits a real pool. +[flashloan.aave_v3_bsc] +chain = "bnb" +pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb" +# Aave V3 PoolDataProvider on BSC — matches default.toml. Resolves +# aTokens and reserve configuration bitmaps for the adapter. +data_provider = "0x41393e5e337606dc3821075Af65AeE84D7688CBD" + +# ── Deployed liquidator contracts ───────────────────────────────────────── +# Liquidator section intentionally omitted — no CharonLiquidator is +# deployed on the fork by default. A placeholder address(0) here would +# let the scanner produce opportunities that then fail inside TxBuilder +# with an opaque encoding error the moment the executor tries to build +# calldata (#252). Omission flips the CLI onto the read-only arm in +# `run_listen` (scanner + metrics only), matching the pattern that +# `config/testnet.toml` uses for Chapel. +# +# After running `forge create` against the local anvil, add a +# `[liquidator.bnb]` section pointing at the deployed address to +# exercise the full route: +# +# [liquidator.bnb] +# chain = "bnb" +# contract_address = "0x" + +# ── Prometheus metrics exporter ─────────────────────────────────────────── +# Loopback-only bind: the fork profile is a local-only demo surface, +# the exporter has no auth, and operators routinely run the fork from a +# laptop on public Wi-Fi. `0.0.0.0` would silently publish /metrics +# (scanner state, wallet balances, gas oracle reads) to the LAN — +# loopback is the only responsible default (#249, cross-ref #213). +[metrics] +enabled = true +bind = "127.0.0.1:9091" + +# ── Chainlink price feeds (per chain, per asset symbol) ─────────────────── +# Mainnet feed addresses resolve against the fork. +[chainlink.bnb] +BNB = "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE" +BTCB = "0x264990fbd0A4796A3E3d8E37022BdAf1A5a4C1f0" +ETH = "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e" +USDT = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320" +USDC = "0x51597f405303C4377E36123cBc172b13269EA163" diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index e18e72a..d5c3da3 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -51,6 +51,26 @@ pub enum ConfigError { /// Chain key (matches a `[chain.]` section). chain: String, }, + /// A `profile_tag = "fork"` profile has a chain whose `ws_url` / + /// `http_url` does not resolve to a loopback host. The fork + /// profile ships an intentionally lowered profit gate tuned for + /// local demo staging; pointing it at a non-loopback endpoint + /// (mainnet, testnet, or a remote RPC) would fire liquidations + /// against real state at that gate. Refused at load time — + /// operator must either flip back to `config/default.toml` or + /// point the fork profile at the local anvil it was built for. + #[error( + "profile_tag=\"fork\" is a local-only profile and must point every chain's ws_url/http_url at loopback; got chain.{chain}.{field}={url}. Refusing to start with a lowered profit gate against non-loopback RPC." + )] + ForkProfileNonLoopbackRpc { + /// Chain key (matches a `[chain.]` section). + chain: String, + /// Which URL field failed the loopback check — `ws_url` or `http_url`. + field: &'static str, + /// The offending URL (post env-substitution) so the operator + /// sees exactly what to fix in the TOML. + url: String, + }, } /// Shorthand `Result`. @@ -260,6 +280,23 @@ pub struct BotConfig { /// non-empty value via the env var, never a literal in the file. #[serde(default, deserialize_with = "deser_optional_secret")] pub signer_key: Option, + /// Optional profile marker used by [`Config::validate`] to enforce + /// profile-specific invariants at startup. Known tags: + /// + /// - `Some("fork")` — marks `config/fork.toml`, a local-only + /// profile that must target loopback RPCs because its profit + /// gate is intentionally lowered for demo staging. Any chain + /// with a non-loopback `ws_url` / `http_url` is rejected at + /// load time ([`ConfigError::ForkProfileNonLoopbackRpc`]). + /// - `None` / `Some("mainnet")` / `Some("testnet")` — production + /// profiles; no additional relaxations or invariants. + /// + /// Unknown tags parse without error (forward-compat) but carry no + /// semantics until a new match arm is wired into `validate()`. The + /// tag is not a secret — operators see it in `Debug` output so a + /// misconfigured profile is obvious at a glance. + #[serde(default)] + pub profile_tag: Option, } impl fmt::Debug for BotConfig { @@ -281,6 +318,7 @@ impl fmt::Debug for BotConfig { "" }, ) + .field("profile_tag", &self.profile_tag) .finish() } } @@ -454,17 +492,43 @@ impl Config { if self.chain.is_empty() { return Err(ConfigError::Validation("no [chain.*] entries".into())); } + let is_fork = self.is_fork_profile(); + // Fork-profile loopback gate runs BEFORE the mainnet-oriented + // private-mempool gate: pointing a lowered-profit fork profile + // at a remote RPC is a bigger footgun than a missing private + // RPC, and the error copy is more actionable. + if is_fork { + for (name, c) in &self.chain { + for (field, url) in [ + ("ws_url", c.ws_url.as_str()), + ("http_url", c.http_url.as_str()), + ] { + if !is_loopback_url(url) { + return Err(ConfigError::ForkProfileNonLoopbackRpc { + chain: name.clone(), + field, + url: url.to_string(), + }); + } + } + } + } // Private-mempool gate: every configured chain must either carry // a `private_rpc_url` or explicitly opt in to the public mempool // via `allow_public_mempool = true`. Applying the check per // chain (rather than only per deployed liquidator) means a // misconfigured chain can never fall back to public broadcast - // later in the pipeline. - for (name, c) in &self.chain { - if c.private_rpc_url.is_none() && !c.allow_public_mempool { - return Err(ConfigError::PrivateRpcRequired { - chain: name.clone(), - }); + // later in the pipeline. The fork profile bypasses this gate + // entirely: the loopback invariant above already confines + // submission to the local anvil, and there is no public + // mempool on a local fork to front-run into. + if !is_fork { + for (name, c) in &self.chain { + if c.private_rpc_url.is_none() && !c.allow_public_mempool { + return Err(ConfigError::PrivateRpcRequired { + chain: name.clone(), + }); + } } } if self.bot.near_liq_threshold_bps <= self.bot.liquidatable_threshold_bps { @@ -527,6 +591,65 @@ impl Config { self.metrics.validate()?; Ok(()) } + + /// Return `true` when this config represents the local anvil fork + /// profile (`config/fork.toml`). The check is intentionally narrow + /// — only the literal `"fork"` tag flips the relaxations in + /// [`Config::validate`]. Production profiles (`None`, + /// `Some("mainnet")`, `Some("testnet")`) never trigger the fork + /// branches. + fn is_fork_profile(&self) -> bool { + matches!(self.bot.profile_tag.as_deref(), Some("fork")) + } +} + +/// Return `true` iff `url`'s host component is a loopback address. +/// +/// Accepts `127.0.0.0/8`, `::1`, and the `localhost` hostname (case +/// insensitive). Works against `http://`, `https://`, `ws://`, and +/// `wss://` URLs; any scheme that uses the `scheme://host[:port]/…` +/// shape is handled. +/// +/// This is a string-level check, not a DNS resolve — the fork profile +/// only needs to reject obviously-non-local URLs at config-load time. +/// DNS-based `localhost` aliases that resolve off-loopback are +/// sufficiently rare that we accept them here and rely on the operator +/// not to shoot themselves in the foot with a pathological /etc/hosts. +fn is_loopback_url(url: &str) -> bool { + let Some(after_scheme) = url.split_once("://").map(|(_, rest)| rest) else { + return false; + }; + // Strip off userinfo if present ("user:pass@host"). + let after_userinfo = after_scheme.rsplit_once('@').map_or(after_scheme, |(_, h)| h); + // Host ends at the first '/', '?', '#', or end-of-string. + let host_and_port = after_userinfo + .find(['/', '?', '#']) + .map_or(after_userinfo, |i| &after_userinfo[..i]); + + // IPv6 literal: "[::1]:8545" → pull out "::1". + let host = if let Some(rest) = host_and_port.strip_prefix('[') { + match rest.find(']') { + Some(end) => &rest[..end], + None => return false, + } + } else { + // IPv4 / hostname: split off ":port" by rfind so hostnames with + // colons (none expected here) aren't mis-parsed. + host_and_port + .rsplit_once(':') + .map_or(host_and_port, |(h, _)| h) + }; + + if host.eq_ignore_ascii_case("localhost") { + return true; + } + if let Ok(v4) = host.parse::() { + return v4.is_loopback(); + } + if let Ok(v6) = host.parse::() { + return v6.is_loopback(); + } + false } fn deser_u256_string<'de, D>(d: D) -> std::result::Result @@ -736,6 +859,7 @@ mod private_rpc_tests { warm_scan_blocks: 10, cold_scan_blocks: 100, signer_key: None, + profile_tag: None, }, chain: chains, protocol: HashMap::new(), @@ -817,3 +941,160 @@ mod private_rpc_tests { assert_eq!(url.expose_secret(), "https://priv.example/rpc"); } } + +#[cfg(test)] +mod fork_profile_tests { + //! Tests for the `profile_tag = "fork"` branch of + //! [`Config::validate`]. Exercise both directions: + //! - loopback URLs pass and bypass the private-RPC gate (so + //! `fork.toml` can omit `allow_public_mempool` and + //! `private_rpc_url`); + //! - non-loopback URLs fail with + //! [`ConfigError::ForkProfileNonLoopbackRpc`] naming the + //! offending chain + field. + //! + //! A parallel `url` helper block covers [`is_loopback_url`] across + //! every common RPC-URL shape the loader sees in practice. + + use super::*; + + fn fork_chain(ws: &str, http: &str) -> ChainConfig { + ChainConfig { + chain_id: 56, + ws_url: ws.into(), + http_url: http.into(), + priority_fee_gwei: 1, + private_rpc_url: None, + private_rpc_auth: None, + allow_public_mempool: false, + } + } + + fn fork_cfg(chain: ChainConfig) -> Config { + let mut chains = HashMap::new(); + chains.insert("bnb".to_string(), chain); + Config { + bot: BotConfig { + min_profit_usd_1e6: 10_000, // $0.01 — lowered for fork + max_gas_wei: U256::from(20_000_000_000u64), + scan_interval_ms: 1000, + liquidatable_threshold_bps: 10_000, + near_liq_threshold_bps: 10_500, + hot_scan_blocks: 1, + warm_scan_blocks: 10, + cold_scan_blocks: 100, + signer_key: None, + profile_tag: Some("fork".into()), + }, + chain: chains, + protocol: HashMap::new(), + flashloan: HashMap::new(), + liquidator: HashMap::new(), + chainlink: HashMap::new(), + metrics: MetricsConfig::default(), + } + } + + #[test] + fn fork_profile_allows_loopback_without_private_rpc() { + // Mirrors the shape shipped in `config/fork.toml`: loopback + // URLs, no `private_rpc_url`, no `allow_public_mempool` opt-in. + // The fork-profile branch in validate() must bypass the + // PrivateRpcRequired gate entirely. + let cfg = fork_cfg(fork_chain("ws://127.0.0.1:8545", "http://127.0.0.1:8545")); + cfg.validate() + .expect("fork profile + loopback URLs must validate"); + } + + #[test] + fn fork_profile_rejects_non_loopback_ws() { + let cfg = fork_cfg(fork_chain( + "wss://bsc-rpc.publicnode.com", + "http://127.0.0.1:8545", + )); + let err = cfg + .validate() + .expect_err("fork profile with public ws_url must fail"); + match err { + ConfigError::ForkProfileNonLoopbackRpc { chain, field, url } => { + assert_eq!(chain, "bnb"); + assert_eq!(field, "ws_url"); + assert_eq!(url, "wss://bsc-rpc.publicnode.com"); + } + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn fork_profile_rejects_non_loopback_http() { + let cfg = fork_cfg(fork_chain( + "ws://127.0.0.1:8545", + "https://bsc.drpc.org", + )); + let err = cfg + .validate() + .expect_err("fork profile with public http_url must fail"); + match err { + ConfigError::ForkProfileNonLoopbackRpc { chain, field, .. } => { + assert_eq!(chain, "bnb"); + assert_eq!(field, "http_url"); + } + other => panic!("unexpected error variant: {other:?}"), + } + } + + #[test] + fn non_fork_profile_skips_loopback_gate() { + // A mainnet/testnet profile with a non-loopback RPC must NOT + // trip the fork loopback gate — that gate only applies when + // `profile_tag == Some("fork")`. This test locks in that the + // fork branch is strictly additive and never alters the + // behaviour of production profiles. + let mut chain = fork_chain("wss://remote.example", "https://remote.example"); + chain.private_rpc_url = Some(SecretString::from("https://priv.example".to_string())); + let mut cfg = fork_cfg(chain); + cfg.bot.profile_tag = None; + cfg.validate() + .expect("non-fork profile with remote RPC + private_rpc must validate"); + } + + #[test] + fn unknown_profile_tag_does_not_relax_any_gate() { + // Forward-compat: a profile tag the code doesn't recognise + // (e.g. a typo like "froke") must fall through to mainnet- + // invariant behaviour. Pair a missing private_rpc_url with + // no allow_public_mempool and the PrivateRpcRequired gate + // must fire — same as an untagged mainnet profile. + let mut cfg = fork_cfg(fork_chain( + "wss://remote.example", + "https://remote.example", + )); + cfg.bot.profile_tag = Some("froke".into()); + let err = cfg + .validate() + .expect_err("unknown tag must not bypass mainnet gates"); + assert!( + matches!(err, ConfigError::PrivateRpcRequired { .. }), + "expected PrivateRpcRequired, got {err:?}" + ); + } + + #[test] + fn loopback_url_matches_common_forms() { + assert!(is_loopback_url("http://127.0.0.1:8545")); + assert!(is_loopback_url("ws://127.0.0.1")); + assert!(is_loopback_url("http://localhost:9091")); + assert!(is_loopback_url("https://LocalHost/")); + assert!(is_loopback_url("ws://[::1]:8545")); + assert!(is_loopback_url("http://127.255.255.254")); + } + + #[test] + fn loopback_url_rejects_public_hosts() { + assert!(!is_loopback_url("wss://bsc-rpc.publicnode.com")); + assert!(!is_loopback_url("https://bsc.drpc.org")); + assert!(!is_loopback_url("http://10.0.0.1:8545")); + assert!(!is_loopback_url("http://192.168.1.1")); + assert!(!is_loopback_url("not-a-url")); + } +} diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs index dccb59a..fbcd33b 100644 --- a/crates/charon-core/tests/config_profiles.rs +++ b/crates/charon-core/tests/config_profiles.rs @@ -84,6 +84,14 @@ fn default_profile_parses() { assert_eq!(cfg.chain["bnb"].chain_id, 56); assert!(cfg.flashloan.contains_key("aave_v3_bsc")); assert!(cfg.metrics.enabled); + // Default profile is untagged — fork-branch relaxations must not + // trigger. Pair this with `fork_profile_parses_*` below so a + // future edit that accidentally tags default.toml as "fork" fails + // at least one test. + assert!( + cfg.bot.profile_tag.is_none(), + "default profile must NOT carry profile_tag — that marker is reserved for fork.toml" + ); } #[test] @@ -111,4 +119,90 @@ fn testnet_profile_parses_and_omits_flashloan() { cfg.chain["bnb"].allow_public_mempool, "testnet profile must opt in to public mempool — no private RPC on Chapel" ); + assert!( + cfg.bot.profile_tag.is_none(), + "testnet profile must NOT carry profile_tag=\"fork\" — that's reserved for fork.toml" + ); +} + +#[test] +fn fork_profile_parses_and_targets_localhost() { + // `config/fork.toml` env substitution: only `CHARON_ANVIL_PORT` + // is referenced and it carries its own `:-8545` default, so no + // stubs are strictly required. We still pass an empty pairs list + // so the fixture helper panics loudly if a future edit adds a + // new `${VAR}` without a default — forcing the test to be + // updated alongside the TOML. + let fork_path = workspace_root().join("config/fork.toml"); + let raw = load_with_stubbed_env(&fork_path, &[]); + let cfg = Config::from_str(&raw).expect("fork.toml should parse and validate"); + + assert_eq!(cfg.chain["bnb"].chain_id, 56); + assert!( + cfg.chain["bnb"].ws_url.starts_with("ws://127.0.0.1"), + "fork profile must point ws_url at the local anvil instance, got {}", + cfg.chain["bnb"].ws_url + ); + assert!( + cfg.chain["bnb"].http_url.starts_with("http://127.0.0.1"), + "fork profile must point http_url at the local anvil instance, got {}", + cfg.chain["bnb"].http_url + ); + assert!( + cfg.flashloan.contains_key("aave_v3_bsc"), + "fork profile keeps Aave V3 — mainnet state inherited by the fork" + ); + + // profile_tag guards the loopback-only invariant at startup (#254). + assert_eq!( + cfg.bot.profile_tag.as_deref(), + Some("fork"), + "fork profile must carry profile_tag=\"fork\" so Config::validate can lock down non-loopback RPCs" + ); + + // The `/metrics` exporter is authless — binding to 0.0.0.0 on a + // local demo laptop silently leaks scanner/wallet/gas state to + // LAN peers. Lock loopback-only for the fork profile explicitly. + assert!( + cfg.metrics.bind.ip().is_loopback(), + "fork profile metrics.bind must be a loopback address, got {}", + cfg.metrics.bind + ); + + // Lowered profit gate is the entire point of the fork profile — + // if a future edit accidentally raises it to match default.toml + // (or higher), demos silently stop firing on small staged + // positions and this assert catches it. Compare in 1e6 USD + // fixed-point (the post-feat/19 schema); f64 comparisons are not + // a thing any more. + let default_pairs = [ + ("BNB_WS_URL", "wss://example/bnb"), + ("BNB_HTTP_URL", "https://example/bnb"), + ("CHARON_BSC_PRIVATE_RPC_URL", "https://example/bnb-private"), + ("CHARON_BSC_PRIVATE_RPC_AUTH", ""), + ("CHARON_SIGNER_KEY", ""), + ("CHARON_METRICS_AUTH_TOKEN", ""), + ]; + let default_raw = load_with_stubbed_env( + &workspace_root().join("config/default.toml"), + &default_pairs, + ); + let default_cfg = Config::from_str(&default_raw).expect("default.toml parses"); + assert!( + cfg.bot.min_profit_usd_1e6 < default_cfg.bot.min_profit_usd_1e6, + "fork profile min_profit_usd_1e6 ({}) must be strictly lower than default profile ({}) — \ + the fork is a demo-staging surface", + cfg.bot.min_profit_usd_1e6, + default_cfg.bot.min_profit_usd_1e6 + ); + + // `[liquidator.bnb]` placeholder was dropped on feat/24 (commit + // 4969bb7) because its address(0) tripped TxBuilder encoding the + // moment the executor tried to build calldata (#252). Lock that + // in so a future refactor doesn't re-introduce a zero-address row. + assert!( + cfg.liquidator.is_empty(), + "fork profile must not ship a liquidator placeholder — deploy via forge and add \ + [liquidator.bnb] post-fact" + ); } diff --git a/scripts/anvil_fork.sh b/scripts/anvil_fork.sh new file mode 100755 index 0000000..c214cc4 --- /dev/null +++ b/scripts/anvil_fork.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +# +# Boot a local anvil fork of BNB Smart Chain mainnet so the full +# Charon liquidation path (scanner → profit → Aave V3 flashloan → +# Venus liquidate → PancakeSwap swap) can be demonstrated without +# real funds. +# +# Usage: +# ./scripts/anvil_fork.sh # fork at the pinned default block +# FORK_BLOCK=41000000 ./scripts/anvil_fork.sh +# FORK_BLOCK=latest ./scripts/anvil_fork.sh # unpinned (discouraged) +# FORK_RPC=https://custom/bsc ./scripts/anvil_fork.sh +# CHARON_ANVIL_PORT=8546 ./scripts/anvil_fork.sh # avoid a port collision +# +# Environment knobs: +# FORK_RPC — explicit upstream; skips the default probe when set +# FORK_BLOCK — fork at this block; default `DEFAULT_FORK_BLOCK`. +# Set to the literal string `latest` to follow upstream +# head — not recommended for CI or soak tests because +# state drift across runs breaks reproducibility (#242). +# CHARON_ANVIL_PORT — host port for HTTP+WS (default: 8545). This is the +# same variable `config/fork.toml` reads via +# `${CHARON_ANVIL_PORT:-8545}`, so script and config +# agree on the port without the operator editing TOML (#247). +# FORK_CHAIN_ID — preserved chain id (default: 56, BSC mainnet) +# FORK_MINE_INTERVAL_SECS — seconds between background anvil_mine +# calls (default: 30). Set to 0 to disable the keep- +# alive loop entirely (see stale-Chainlink note below). +# +# Foundry version pin (#259): +# Foundry CLI output is reformatted across releases (nightly channel +# changed the `forge --version` template in late 2024). Rather than +# parse a moving target, we compare the raw first-line version +# stamp against `CHARON_REQUIRED_FOUNDRY_VERSION` and warn — not +# hard-fail — if they don't match. The goal is that a fresh clone +# six months from now either sees the known-good stamp or gets a +# loud remediation hint instead of silently running against a +# version whose anvil behavior has drifted. +# +# Override knobs: +# CHARON_REQUIRED_FOUNDRY_VERSION — expected substring in `anvil --version` +# output (default pinned below). +# CHARON_SKIP_FOUNDRY_VERSION_CHECK=1 — bypass the check entirely. Intended +# for CI images that pin Foundry out +# of band; local devs should run +# `foundryup -v ` instead. +# +# Upstream: +# The default upstream is dRPC (free, keyless, archive — historical +# eth_call works against any block). If dRPC is unreachable the +# script exits non-zero rather than falling back to PublicNode; +# PublicNode is not an archive node (~128 blocks of state), so a +# fork built against it silently returns "missing trie node" on +# every historical call and defeats the fork (#246). Override with +# FORK_RPC= to use a different archive provider. +# +# Stale-Chainlink keep-alive (#244): +# Chainlink aggregators on the forked chain stop updating the instant +# the fork is pinned — upstream keeps writing new rounds, but the +# fork's state is frozen at the pin block. Charon's PriceCache +# rejects any feed older than `DEFAULT_MAX_AGE`, so within ~10 minutes +# of fork-time every feed looks stale, the scanner's health-factor +# math can't price collateral, and the Grafana demo degrades to a +# flat graph with zero liquidatable positions. +# +# Mitigation: this script runs a background loop that issues +# `cast rpc anvil_mine 1` every `FORK_MINE_INTERVAL_SECS` against the +# local RPC. Each call advances the fork's wall clock by one block's +# worth of time, which moves `block.timestamp` forward and — because +# Chainlink freshness is measured against `block.timestamp` — keeps +# the feeds inside the cache's freshness window. --block-time 3 keeps +# organic blocks flowing for the listener; this extra nudge exists +# purely to outrun the freshness gate during idle stretches. +# +# Alternative if `cast` is unavailable: set +# `CHARON_PRICE_MAX_AGE_SECS=86400` before starting charon and set +# `FORK_MINE_INTERVAL_SECS=0` here to disable the loop. +# +# Process lifecycle (#240): +# anvil is launched in the background with a tracked PID so a +# `trap cleanup EXIT INT TERM` handler can tear both the node and the +# mine loop down together when the script exits or the operator +# hits Ctrl-C. `wait "$ANVIL_PID"` keeps the script in the foreground +# so Ctrl-C still propagates; the prior `exec anvil` tail left no +# room to background a mining loop alongside the node. + +set -euo pipefail + +# ── Resolve dependencies ───────────────────────────────────────────── +if ! command -v anvil >/dev/null 2>&1; then + echo "anvil not found in PATH. Install Foundry: https://book.getfoundry.sh/getting-started/installation" >&2 + exit 127 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required for the upstream RPC probe." >&2 + exit 127 +fi + +# ── Foundry version pin (#259) ─────────────────────────────────────── +# Warn loudly if the installed anvil doesn't match the known-good +# stamp. We don't hard-fail on mismatch because (a) Foundry release +# cadence is weekly and the version string format has drifted, and +# (b) CI images pin their own versions. Hard-fail only when the +# operator can't produce a version string at all — that's a broken +# install, not a drift. +readonly REQUIRED_FOUNDRY_VERSION="${CHARON_REQUIRED_FOUNDRY_VERSION:-stable}" +if [[ "${CHARON_SKIP_FOUNDRY_VERSION_CHECK:-0}" != "1" ]]; then + # `anvil --version` prints e.g. `anvil 0.3.0-stable (...)` or a + # `nightly (...)` line depending on channel. Grab the first line. + if ! anvil_version_line=$(anvil --version 2>/dev/null | head -n1); then + echo "anvil: failed to read 'anvil --version' output — install appears broken." >&2 + echo " reinstall Foundry: curl -L https://foundry.paradigm.xyz | bash && foundryup" >&2 + exit 127 + fi + if [[ -z "$anvil_version_line" ]]; then + echo "anvil: 'anvil --version' produced empty output — install appears broken." >&2 + exit 127 + fi + if [[ "$anvil_version_line" != *"$REQUIRED_FOUNDRY_VERSION"* ]]; then + echo "anvil: WARNING — installed version may not match the pin." >&2 + echo " required substring: '$REQUIRED_FOUNDRY_VERSION'" >&2 + echo " installed: '$anvil_version_line'" >&2 + echo " remediate: foundryup -i $REQUIRED_FOUNDRY_VERSION" >&2 + echo " or bypass: CHARON_SKIP_FOUNDRY_VERSION_CHECK=1 ./scripts/anvil_fork.sh" >&2 + echo " continuing in 2s — anvil semantics may have drifted." >&2 + sleep 2 + else + echo "anvil: version ok ($anvil_version_line)" + fi +fi + +# cast is only strictly required for the stale-Chainlink keep-alive. +# If it's missing and the loop is enabled, fail loudly — a silent +# fallback would reproduce exactly the Grafana-looks-dead failure mode +# the loop exists to prevent. +readonly MINE_INTERVAL_SECS="${FORK_MINE_INTERVAL_SECS:-30}" +if [[ "$MINE_INTERVAL_SECS" != "0" ]] && ! command -v cast >/dev/null 2>&1; then + echo "cast (Foundry) not found in PATH — required for the Chainlink keep-alive loop." >&2 + echo " install Foundry, or set FORK_MINE_INTERVAL_SECS=0 and run charon with" >&2 + echo " CHARON_PRICE_MAX_AGE_SECS=86400 to bypass the freshness gate instead." >&2 + exit 127 +fi + +# ── Defaults ───────────────────────────────────────────────────────── +readonly PRIMARY_RPC="${FORK_RPC_PRIMARY:-https://bsc.drpc.org}" +# Port name matches `config/fork.toml`'s `${CHARON_ANVIL_PORT:-8545}` +# substitution so `CHARON_ANVIL_PORT=8546 ./anvil_fork.sh` and +# `CHARON_ANVIL_PORT=8546 charon --config config/fork.toml` agree +# without editing TOML (#247). `FORK_PORT` is honored as a legacy +# alias so existing operator muscle memory still works; prefer +# `CHARON_ANVIL_PORT` for new invocations. +readonly PORT="${CHARON_ANVIL_PORT:-${FORK_PORT:-8545}}" +readonly CHAIN_ID="${FORK_CHAIN_ID:-56}" +readonly LOCAL_RPC="http://127.0.0.1:${PORT}" +# Default fork block. Captured 2026-04-23, past every Aave V3 reserve +# activation and every Venus Core Pool vToken deployment the demo +# uses. The fork-test suite on `feat/25-foundry-fork-tests` pins the +# same value so a soak demo and the Foundry regression suite describe +# identical on-chain state. Bump in a dedicated reviewed commit when +# refreshing against a newer baseline. +readonly DEFAULT_FORK_BLOCK="${DEFAULT_FORK_BLOCK:-94000000}" + +probe_rpc() { + # Return 0 iff the RPC answers eth_blockNumber with a non-empty + # hex payload within a reasonable timeout. Tight timeout because a + # slow primary is as bad as a dead one for an interactive demo. + local url="$1" + local body + body=$(curl -sS --max-time 5 -X POST \ + -H 'content-type: application/json' \ + --data '{"jsonrpc":"2.0","id":1,"method":"eth_blockNumber","params":[]}' \ + "$url" 2>/dev/null) || return 1 + + case "$body" in + *'"result":"0x'*) return 0 ;; + *) return 1 ;; + esac +} + +resolve_rpc() { + # Explicit override wins — operator knows best. + if [[ -n "${FORK_RPC:-}" ]]; then + echo "$FORK_RPC" + return + fi + + if probe_rpc "$PRIMARY_RPC"; then + echo "$PRIMARY_RPC" + return + fi + + echo "error: primary RPC $PRIMARY_RPC failed the probe" >&2 + echo " refusing to fall back to a non-archive public provider —" >&2 + echo " forked historical eth_call would return 'missing trie node'." >&2 + echo " pass FORK_RPC= to override." >&2 + exit 1 +} + +readonly RPC="$(resolve_rpc)" + +# ── Anvil launch ───────────────────────────────────────────────────── +ANVIL_ARGS=( + --fork-url "$RPC" + --chain-id "$CHAIN_ID" + --port "$PORT" + --host 0.0.0.0 + # 3s block time tracks BSC's production cadence closely enough that + # block-duration histograms and gas-oracle refresh intervals read + # sensibly during a demo. + --block-time 3 +) + +# Resolve the effective fork block. Unset ⇒ the pinned default (for +# reproducible runs); `latest` ⇒ follow upstream head; anything else ⇒ +# pin at that block. +FORK_BLOCK_EFFECTIVE="${FORK_BLOCK:-$DEFAULT_FORK_BLOCK}" +if [[ "$FORK_BLOCK_EFFECTIVE" != "latest" ]]; then + ANVIL_ARGS+=(--fork-block-number "$FORK_BLOCK_EFFECTIVE") +fi + +echo "anvil: forking chain ${CHAIN_ID} from ${RPC}" +if [[ "$FORK_BLOCK_EFFECTIVE" == "latest" ]]; then + echo "anvil: pinning at upstream head (latest) — unpinned, not reproducible" +else + echo "anvil: pinning at block ${FORK_BLOCK_EFFECTIVE}" +fi +echo "anvil: listening on ${LOCAL_RPC} (HTTP + WS)" +echo "anvil: Ctrl-C to stop" +echo + +# Track background PIDs so the cleanup trap can reap them on any exit +# path — normal termination, operator Ctrl-C, or a shell error under +# `set -e`. Initialize to empty so `kill` in cleanup can no-op safely +# if we never got as far as launching a given child. +ANVIL_PID="" +MINE_PID="" + +cleanup() { + # Disable the trap inside cleanup so a signal arriving mid-teardown + # doesn't re-enter this function. Use `|| true` on kills so an + # already-dead child doesn't trip `set -e` on the way out. + trap - EXIT INT TERM + if [[ -n "$MINE_PID" ]] && kill -0 "$MINE_PID" 2>/dev/null; then + kill "$MINE_PID" 2>/dev/null || true + wait "$MINE_PID" 2>/dev/null || true + fi + if [[ -n "$ANVIL_PID" ]] && kill -0 "$ANVIL_PID" 2>/dev/null; then + kill "$ANVIL_PID" 2>/dev/null || true + wait "$ANVIL_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT INT TERM + +anvil "${ANVIL_ARGS[@]}" & +ANVIL_PID=$! + +# ── Wait for anvil to accept RPC before kicking off the mine loop ──── +# Without this probe the first `cast rpc anvil_mine` would race the +# node startup, log a connection-refused error, and burn a retry +# budget for no reason. Reuse the same eth_blockNumber check the +# upstream probe uses — anvil answers it the moment the HTTP server +# binds. +READINESS_TIMEOUT_SECS=30 +readiness_deadline=$(( $(date +%s) + READINESS_TIMEOUT_SECS )) +while ! probe_rpc "$LOCAL_RPC"; do + if ! kill -0 "$ANVIL_PID" 2>/dev/null; then + echo "anvil: process exited before becoming ready — see output above" >&2 + exit 1 + fi + if (( $(date +%s) >= readiness_deadline )); then + echo "anvil: still not answering eth_blockNumber on ${LOCAL_RPC} after ${READINESS_TIMEOUT_SECS}s" >&2 + exit 1 + fi + sleep 1 +done + +# ── Background keep-alive for Chainlink freshness (#244) ───────────── +if [[ "$MINE_INTERVAL_SECS" != "0" ]]; then + echo "anvil: keep-alive enabled — mining 1 extra block every ${MINE_INTERVAL_SECS}s to keep Chainlink feeds fresh" + ( + # Silence transient errors — if a single anvil_mine call fails + # (e.g., during shutdown) we don't want to take down the whole + # script. The trap on the parent handles real termination. + while sleep "$MINE_INTERVAL_SECS"; do + cast rpc anvil_mine 1 --rpc-url "$LOCAL_RPC" >/dev/null 2>&1 || true + done + ) & + MINE_PID=$! +fi + +# Foreground wait on anvil so Ctrl-C reaches the shell, the trap +# fires, and both children are reaped. `wait` returns the child's +# exit status; `|| true` prevents a non-zero anvil exit from +# short-circuiting the trap under `set -e`. +wait "$ANVIL_PID" || true