From 3fa232a8b70e1c392641b7aff0e6f9e4e342ee3a Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 16:49:18 +0530 Subject: [PATCH 1/6] feat(tooling): anvil BSC mainnet fork for local end-to-end demos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `scripts/anvil_fork.sh` boots an anvil fork of BNB Smart Chain mainnet so the full liquidation path (flashLoan → liquidate → swap) can be demonstrated locally against real Venus + Aave V3 state without touching real capital. Upstream RPC strategy: - primary: https://bsc.drpc.org (free, keyless, archive) - fallback: https://bsc-rpc.publicnode.com (ditto) The script probes the primary with a short-timeout eth_blockNumber call and falls back automatically on timeout or non-JSON responses. `FORK_RPC` overrides the probe entirely for operators running their own archive node. Knobs: - FORK_BLOCK — pin the fork at a specific block (default: latest) - FORK_PORT — override the host port (default: 8545) - FORK_CHAIN_ID — override the preserved chain id (default: 56) Block-time is set to 3s so the scanner's block-duration histogram and gas-oracle refresh cadence read sensibly during a demo. New `config/fork.toml` mirrors `default.toml`'s mainnet addresses (Aave pool, Venus Unitroller, Chainlink feeds) but points the RPC at the local fork, lowers the profit gate so synthetic test positions cross, and leaves the private-RPC entry unset — the local anvil IS the submission surface. Smoke-tested locally: - bash -n passes syntax check - Script boots anvil, mines 3-second blocks against the dRPC fork - curl probe against the fork returns eth_chainId = 0x38 (56) - `charon --config config/fork.toml test-connection --chain bnb` returns a live forked head block `config_profiles.rs` gains a `fork_profile_parses_and_targets_localhost` case so the fork TOML can't regress silently. Closes #48. --- config/fork.toml | 66 ++++++++++++ crates/charon-core/tests/config_profiles.rs | 17 +++ scripts/anvil_fork.sh | 113 ++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 config/fork.toml create mode 100755 scripts/anvil_fork.sh diff --git a/config/fork.toml b/config/fork.toml new file mode 100644 index 0000000..3100fa5 --- /dev/null +++ b/config/fork.toml @@ -0,0 +1,66 @@ +# 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: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 left empty — no private relay for a local +# fork; the local anvil IS the submission surface. +# - `min_profit_usd` is lowered so synthetic / partial positions the +# operator stages manually aren't filtered out during demos. + +[bot] +# Lower gate for demo staging — real ops should use default.toml. +min_profit_usd = 0.01 +max_gas_gwei = 20 +scan_interval_ms = 1000 +liquidatable_threshold = 1.0 +near_liq_threshold = 1.05 + +# ── 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:8545" +http_url = "http://127.0.0.1:8545" +priority_fee_gwei = 1 +# No private relay on a local fork. +# private_rpc_url = "" + +# ── 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" + +# ── Deployed liquidator contracts ───────────────────────────────────────── +# Fork demo can run read-only (no liquidator deployed); once the +# operator deploys `CharonLiquidator.sol` into the fork via forge, +# replace the zero with the deployed address to exercise the +# full route. +[liquidator.bnb] +chain = "bnb" +contract_address = "0x0000000000000000000000000000000000000000" + +# ── Prometheus metrics exporter ─────────────────────────────────────────── +[metrics] +enabled = true +bind = "0.0.0.0:9091" + +# ── Chainlink price feeds (per chain, per asset symbol) ─────────────────── +# Mainnet feed addresses resolve against the fork. +[chainlink.bnb] +BNB = "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE" +BTCB = "0x264990fbd0A4796A3E3d8E37C4d5F87a3aCa5Ebf" +ETH = "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e" +USDT = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320" +USDC = "0x51597f405303C4377E36123cBc172b13269EA163" diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs index bbd2c2a..7e869b7 100644 --- a/crates/charon-core/tests/config_profiles.rs +++ b/crates/charon-core/tests/config_profiles.rs @@ -46,6 +46,23 @@ fn default_profile_parses() { assert!(cfg.metrics.enabled); } +#[test] +fn fork_profile_parses_and_targets_localhost() { + // No env substitution needed — the fork profile hard-codes localhost. + let path = workspace_root().join("config/fork.toml"); + let cfg = Config::load(&path).expect("fork.toml should parse"); + + 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 at the local anvil instance" + ); + assert!( + cfg.flashloan.contains_key("aave_v3_bsc"), + "fork profile keeps Aave V3 — mainnet state inherited by the fork" + ); +} + #[test] fn testnet_profile_parses_and_omits_flashloan() { set_env(&[ diff --git a/scripts/anvil_fork.sh b/scripts/anvil_fork.sh new file mode 100755 index 0000000..b28266c --- /dev/null +++ b/scripts/anvil_fork.sh @@ -0,0 +1,113 @@ +#!/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 latest, primary RPC +# FORK_BLOCK=41000000 ./scripts/anvil_fork.sh +# FORK_RPC=https://custom/bsc ./scripts/anvil_fork.sh +# FORK_PORT=8546 ./scripts/anvil_fork.sh # avoid a port collision +# +# Environment knobs: +# FORK_RPC — explicit upstream; skips probe/fallback when set +# FORK_BLOCK — pin fork at this block (default: latest upstream) +# FORK_PORT — host port for HTTP+WS (default: 8545) +# FORK_CHAIN_ID — preserved chain id (default: 56, BSC mainnet) +# +# Upstream probing: +# When FORK_RPC is unset, the script tests the primary (dRPC) with a +# single eth_blockNumber call. A non-2xx response or timeout falls +# back to PublicNode. Both are free, keyless, and support historical +# state reads — see `docs/Charon_Architecture_Diagrams.html` for the +# research notes. + +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 + +# ── Defaults ───────────────────────────────────────────────────────── +readonly PRIMARY_RPC="${FORK_RPC_PRIMARY:-https://bsc.drpc.org}" +readonly FALLBACK_RPC="${FORK_RPC_FALLBACK:-https://bsc-rpc.publicnode.com}" +readonly PORT="${FORK_PORT:-8545}" +readonly CHAIN_ID="${FORK_CHAIN_ID:-56}" + +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 "primary RPC $PRIMARY_RPC unreachable; falling back to $FALLBACK_RPC" >&2 + if probe_rpc "$FALLBACK_RPC"; then + echo "$FALLBACK_RPC" + return + fi + + echo "both primary ($PRIMARY_RPC) and fallback ($FALLBACK_RPC) RPCs failed the probe" >&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 +) + +if [[ -n "${FORK_BLOCK:-}" ]]; then + ANVIL_ARGS+=(--fork-block-number "$FORK_BLOCK") +fi + +echo "anvil: forking chain ${CHAIN_ID} from ${RPC}" +if [[ -n "${FORK_BLOCK:-}" ]]; then + echo "anvil: pinning at block ${FORK_BLOCK}" +else + echo "anvil: pinning at upstream head (latest)" +fi +echo "anvil: listening on http://127.0.0.1:${PORT} (HTTP + WS)" +echo "anvil: Ctrl-C to stop" +echo + +exec anvil "${ANVIL_ARGS[@]}" From aadfee555a608ed8b069fec1cef9f988dc129a5a Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 16:49:27 +0530 Subject: [PATCH 2/6] docs(readme): seed README.md with quickstart and metrics reference The repo's only tracked markdown file per the project's markdown policy. Covers the three shipped run modes (mainnet / BSC testnet / local anvil fork), the Prometheus exporter surface, and the crate-by-crate layout so a reviewer landing on GitHub doesn't have to dig through Cargo.toml to orient themselves. Metrics table matches the constants exported by `charon-metrics`; kept short so it doesn't drift out of sync with the source of truth. --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..8984a0c --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Charon + +A multi-chain flash-loan liquidation bot. v0.1 targets Venus Protocol on BNB Smart Chain. + +Charon watches borrower health factors in real time, quotes a flash loan when a position goes underwater, simulates the full liquidation round-trip (`flashLoan` → `liquidate` → swap collateral → repay), and submits the transaction once the gross-minus-fees profit clears the operator's USD threshold. + +## Build + +```sh +cargo build --workspace +``` + +Full gate (must pass before every PR): + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +(cd contracts && forge build && forge test) +``` + +## Run modes + +Three config profiles ship in [`config/`](config/). Pick one with `--config`. + +### 1. Mainnet (production) + +```sh +cp .env.example .env # fill in RPC + private keys +charon --config config/default.toml listen +``` + +Requires `BNB_WS_URL`, `BNB_HTTP_URL`, optionally `BSC_PRIVATE_RPC_URL` and `BOT_SIGNER_KEY`. + +### 2. BSC testnet (Chapel, chainId 97) + +Live Venus deployment, zero capital risk. Aave V3 is not on Chapel, so the bot runs read-only: scanner and metrics populate, the opportunity path short-circuits. Useful for metrics dashboards. + +```sh +charon --config config/testnet.toml listen +``` + +Requires `BNB_TESTNET_WS_URL`, `BNB_TESTNET_HTTP_URL` (defaults in `.env.example` point at PublicNode). + +### 3. 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, primary RPC is dRPC +FORK_BLOCK=41000000 ./scripts/anvil_fork.sh # pin a specific block +``` + +Terminal B — run Charon against it: + +```sh +charon --config config/fork.toml listen +``` + +The script probes `https://bsc.drpc.org` first (free, keyless, archive). If the primary is unreachable it falls back to `https://bsc-rpc.publicnode.com`. Override with `FORK_RPC=` when you have your own node. + +## Metrics + +Every profile ships with Prometheus exporter enabled. Scrape `http://:9091/metrics`. + +Key series (full list: [`crates/charon-metrics/src/lib.rs`](crates/charon-metrics/src/lib.rs)): + +| Metric | Type | Labels | +| --- | --- | --- | +| `charon_scanner_blocks_total` | counter | chain | +| `charon_scanner_positions` | gauge | chain, bucket | +| `charon_pipeline_block_duration_seconds` | histogram | chain | +| `charon_executor_simulations_total` | counter | chain, result | +| `charon_executor_opportunities_queued_total` | counter | chain | +| `charon_executor_opportunities_dropped_total` | counter | chain, stage | +| `charon_executor_profit_usd_cents` | histogram | chain | +| `charon_executor_queue_depth` | gauge | — | + +The exporter binds `:9091` (not `:9090`) so it doesn't collide with a co-located Prometheus server. + +## Repository layout + +``` +crates/ + charon-core/ shared types, config loader, profit calc, queue + charon-scanner/ chain listener, health scanner, price cache, mempool watcher + charon-protocols/ Venus adapter (Comptroller + vToken bindings) + charon-flashloan/ Aave V3 adapter + router + charon-executor/ tx builder, simulator, gas oracle, nonce manager, submitter, batcher + charon-metrics/ Prometheus exporter and metric-name constants + charon-cli/ `charon` binary wiring everything together +contracts/ CharonLiquidator.sol + Foundry suite +config/ TOML profiles (default, testnet, fork) +scripts/ operator helpers (anvil_fork.sh, ...) +``` + +## License + +MIT. From ffae80632f528b4ef0cbf2e3fe1eecbb72391b5b Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:10:24 +0530 Subject: [PATCH 3/6] fix(tooling): pin default fork block + drop pruned PublicNode fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anvil_fork.sh previously forked at upstream head whenever FORK_BLOCK was unset, which is every default invocation — CI runs, soak tests, and the Grafana demo all landed on a moving baseline. Introduce DEFAULT_FORK_BLOCK=94_000_000 (matches the fork-test suite pin on feat/25, captured 2026-04-23; past every Aave V3 reserve and Venus vToken activation the demo needs). `FORK_BLOCK=latest` remains available as an opt-in escape hatch; the help text flags it as not reproducible. Drop the PublicNode RPC fallback. PublicNode retains ~128 blocks of state; any forked historical eth_call against it returns "missing trie node" — a silently broken fork is worse than a hard-error. When the dRPC primary fails the probe the script now exits with a message directing the operator to set FORK_RPC=. Closes #242 Closes #246 --- scripts/anvil_fork.sh | 59 ++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 23 deletions(-) diff --git a/scripts/anvil_fork.sh b/scripts/anvil_fork.sh index b28266c..2044a0f 100755 --- a/scripts/anvil_fork.sh +++ b/scripts/anvil_fork.sh @@ -6,23 +6,29 @@ # real funds. # # Usage: -# ./scripts/anvil_fork.sh # fork latest, primary RPC +# ./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 -# FORK_PORT=8546 ./scripts/anvil_fork.sh # avoid a port collision +# FORK_PORT=8546 ./scripts/anvil_fork.sh # avoid a port collision # # Environment knobs: -# FORK_RPC — explicit upstream; skips probe/fallback when set -# FORK_BLOCK — pin fork at this block (default: latest upstream) +# 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). # FORK_PORT — host port for HTTP+WS (default: 8545) # FORK_CHAIN_ID — preserved chain id (default: 56, BSC mainnet) # -# Upstream probing: -# When FORK_RPC is unset, the script tests the primary (dRPC) with a -# single eth_blockNumber call. A non-2xx response or timeout falls -# back to PublicNode. Both are free, keyless, and support historical -# state reads — see `docs/Charon_Architecture_Diagrams.html` for the -# research notes. +# 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. set -euo pipefail @@ -39,9 +45,15 @@ fi # ── Defaults ───────────────────────────────────────────────────────── readonly PRIMARY_RPC="${FORK_RPC_PRIMARY:-https://bsc.drpc.org}" -readonly FALLBACK_RPC="${FORK_RPC_FALLBACK:-https://bsc-rpc.publicnode.com}" readonly PORT="${FORK_PORT:-8545}" readonly CHAIN_ID="${FORK_CHAIN_ID:-56}" +# 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 @@ -72,13 +84,10 @@ resolve_rpc() { return fi - echo "primary RPC $PRIMARY_RPC unreachable; falling back to $FALLBACK_RPC" >&2 - if probe_rpc "$FALLBACK_RPC"; then - echo "$FALLBACK_RPC" - return - fi - - echo "both primary ($PRIMARY_RPC) and fallback ($FALLBACK_RPC) RPCs failed the probe" >&2 + 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 } @@ -96,15 +105,19 @@ ANVIL_ARGS=( --block-time 3 ) -if [[ -n "${FORK_BLOCK:-}" ]]; then - ANVIL_ARGS+=(--fork-block-number "$FORK_BLOCK") +# 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 [[ -n "${FORK_BLOCK:-}" ]]; then - echo "anvil: pinning at block ${FORK_BLOCK}" +if [[ "$FORK_BLOCK_EFFECTIVE" == "latest" ]]; then + echo "anvil: pinning at upstream head (latest) — unpinned, not reproducible" else - echo "anvil: pinning at upstream head (latest)" + echo "anvil: pinning at block ${FORK_BLOCK_EFFECTIVE}" fi echo "anvil: listening on http://127.0.0.1:${PORT} (HTTP + WS)" echo "anvil: Ctrl-C to stop" From 4969bb75538601ea2818dd9260a8777eea2ee8c4 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 18:52:51 +0530 Subject: [PATCH 4/6] =?UTF-8?q?fix(tooling):=20harden=20fork=20profile=20?= =?UTF-8?q?=E2=80=94=20metrics=20loopback,=20drop=20liquidator=20placehold?= =?UTF-8?q?er,=20Chainlink=20keep-alive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit config/fork.toml: - bind metrics to 127.0.0.1:9091 so a laptop demo on public Wi-Fi doesn't silently publish /metrics (unauthenticated) to the LAN. - drop [liquidator.bnb] with its address(0) placeholder; the zero address would reach TxBuilder and fail with an opaque encoding error on the first opportunity. Omission flips the CLI onto the read-only arm in run_listen, matching the pattern testnet.toml already uses for Chapel. Comment block documents how to re-enable after forge create. scripts/anvil_fork.sh: - restructure the tail: anvil now runs in the background with a tracked PID, `trap cleanup EXIT INT TERM` tears both the node and the mine loop down on any exit path, and `wait "\$ANVIL_PID"` keeps the script foreground so Ctrl-C still propagates. - add a background keep-alive loop that issues `cast rpc anvil_mine 1` every FORK_MINE_INTERVAL_SECS (default 30s) so block.timestamp keeps walking forward and Chainlink feeds stay inside PriceCache's freshness window — without this the scanner degrades to zero liquidatable positions within ~10 minutes of fork time and the Grafana demo looks dead. - gate the loop on `cast` availability and expose the CHARON_PRICE_MAX_AGE_SECS fallback in the header comment so an operator without Foundry can still run a read-only demo. - add an eth_blockNumber readiness probe against the local RPC before kicking off the mine loop, so the first anvil_mine call doesn't race node startup and waste a retry budget on connection-refused. Closes #249 Closes #252 Closes #244 Closes #240 --- config/fork.toml | 29 ++++++++--- scripts/anvil_fork.sh | 114 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 10 deletions(-) diff --git a/config/fork.toml b/config/fork.toml index 3100fa5..ffc83f2 100644 --- a/config/fork.toml +++ b/config/fork.toml @@ -43,18 +43,31 @@ chain = "bnb" pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb" # ── Deployed liquidator contracts ───────────────────────────────────────── -# Fork demo can run read-only (no liquidator deployed); once the -# operator deploys `CharonLiquidator.sol` into the fork via forge, -# replace the zero with the deployed address to exercise the -# full route. -[liquidator.bnb] -chain = "bnb" -contract_address = "0x0000000000000000000000000000000000000000" +# 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 = "0.0.0.0:9091" +bind = "127.0.0.1:9091" # ── Chainlink price feeds (per chain, per asset symbol) ─────────────────── # Mainnet feed addresses resolve against the fork. diff --git a/scripts/anvil_fork.sh b/scripts/anvil_fork.sh index 2044a0f..3579623 100755 --- a/scripts/anvil_fork.sh +++ b/scripts/anvil_fork.sh @@ -20,6 +20,9 @@ # state drift across runs breaks reproducibility (#242). # FORK_PORT — host port for HTTP+WS (default: 8545) # 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). # # Upstream: # The default upstream is dRPC (free, keyless, archive — historical @@ -29,6 +32,36 @@ # 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 @@ -43,10 +76,23 @@ if ! command -v curl >/dev/null 2>&1; then exit 127 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}" readonly 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 @@ -119,8 +165,72 @@ if [[ "$FORK_BLOCK_EFFECTIVE" == "latest" ]]; then else echo "anvil: pinning at block ${FORK_BLOCK_EFFECTIVE}" fi -echo "anvil: listening on http://127.0.0.1:${PORT} (HTTP + WS)" +echo "anvil: listening on ${LOCAL_RPC} (HTTP + WS)" echo "anvil: Ctrl-C to stop" echo -exec anvil "${ANVIL_ARGS[@]}" +# 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 From fe60699ad0ac28bbf569c308231299af15e0c928 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 19:01:25 +0530 Subject: [PATCH 5/6] feat(config): fork profile env-driven port + profile_tag guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fork.toml now reads `${CHARON_ANVIL_PORT:-8545}` on both ws_url and http_url so a dev can run a secondary fork on 8546 (or any port) without editing TOML. To support the `:-default` shape, the config loader's `substitute_env_vars` was extended to POSIX-ish parameter expansion semantics: `${VAR}` still errors if unset (preserving the secret-bearing default profile's contract), but `${VAR:-default}` falls back when the variable is unset or empty. Unit-tested for set, unset-with-default, unset-without-default (still errors), empty-with- default, default-overridden, and unterminated-expression cases. Added `bot.profile_tag: Option` with `profile_tag = "fork"` on fork.toml. New `Config::validate()` returns the typed enum `ConfigError::ForkProfileNonLoopbackRpc { chain, field, url }` when the fork profile points at anything other than 127.0.0.1 / ::1 / localhost. main.rs now calls `validate` right after load and exits non-zero if it fails, so a misconfigured env cannot silently land a 0.01 USD profit gate on a mainnet RPC. Non-fork profiles are not restricted — local-geth dev runs remain supported. Extended `fork_profile_parses_and_targets_localhost` to lock in: - chain_id == 56 - ws_url and http_url both start with loopback - metrics.bind is a loopback address (not 0.0.0.0) - min_profit_usd is strictly lower than default.toml's gate - profile_tag is Some("fork") and Config::validate accepts it - liquidator map is empty (no placeholder address(0) regression) Resolves #247, #254, #256. --- config/fork.toml | 20 +- crates/charon-cli/src/main.rs | 10 + crates/charon-core/src/config.rs | 253 +++++++++++++++++++- crates/charon-core/src/lib.rs | 2 +- crates/charon-core/tests/config_profiles.rs | 67 +++++- 5 files changed, 335 insertions(+), 17 deletions(-) diff --git a/config/fork.toml b/config/fork.toml index ffc83f2..b215b80 100644 --- a/config/fork.toml +++ b/config/fork.toml @@ -1,8 +1,9 @@ # 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:8545 and mirrors every mainnet address the default -# profile uses, so addresses here match `config/default.toml` exactly. +# 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. @@ -10,6 +11,13 @@ # fork; the local anvil IS the submission surface. # - `min_profit_usd` 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. @@ -18,6 +26,10 @@ max_gas_gwei = 20 scan_interval_ms = 1000 liquidatable_threshold = 1.0 near_liq_threshold = 1.05 +# 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] @@ -25,8 +37,8 @@ near_liq_threshold = 1.05 # 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:8545" -http_url = "http://127.0.0.1:8545" +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. # private_rpc_url = "" diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index ed07add..27f6128 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -95,6 +95,16 @@ async fn main() -> Result<()> { let config = Config::load(&cli.config) .with_context(|| format!("failed to load config from {}", cli.config.display()))?; + // Profile-specific invariants — e.g. `profile_tag = "fork"` must + // only point at loopback RPCs. Fail fast before we open any WS / + // HTTP connection so an operator who ran `charon --config + // config/fork.toml` with stale env pointing at mainnet sees the + // actionable error instead of partial startup (#254). + if let Err(err) = config.validate() { + tracing::error!(error = %err, "config validation failed"); + std::process::exit(1); + } + info!( chains = config.chain.len(), protocols = config.protocol.len(), diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 515e56f..bb6a71a 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -1,4 +1,5 @@ -//! TOML config loader with `${ENV_VAR}` substitution for secrets. +//! TOML config loader with `${ENV_VAR}` / `${ENV_VAR:-default}` +//! substitution for secrets. //! //! Usage: //! ```no_run @@ -13,6 +14,11 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::path::Path; +/// Profile tag marking the `config/fork.toml` local-anvil profile. Used +/// by [`Config::validate`] to refuse mainnet endpoints under a lowered +/// profit gate. +const PROFILE_TAG_FORK: &str = "fork"; + /// Top-level Charon config loaded from `config/default.toml`. #[derive(Debug, Clone, Deserialize)] pub struct Config { @@ -95,6 +101,13 @@ pub struct BotConfig { /// the bot can fire immediately on the next adverse price move. #[serde(default = "default_near_liq_threshold")] pub near_liq_threshold: f64, + /// Optional profile marker used by [`Config::validate`] to enforce + /// profile-specific invariants at startup. `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. Production profiles leave this unset. + #[serde(default)] + pub profile_tag: Option, } fn default_liquidatable_threshold() -> f64 { @@ -152,11 +165,44 @@ pub struct LiquidatorConfig { pub contract_address: Address, } +/// Typed errors produced by [`Config::validate`]. Kept separate from +/// `anyhow::Error` so callers (main.rs, integration tests) can match on +/// the exact invariant that failed and render remediation copy that +/// names the offending field. New variants get added as new profile +/// guards land. +#[derive(Debug)] +pub enum ConfigError { + /// A `profile_tag = "fork"` profile has a chain whose `ws_url` / + /// `http_url` does not resolve to a loopback host. The attached + /// strings name the chain, field, and offending URL so the operator + /// sees exactly what to fix in the TOML. + ForkProfileNonLoopbackRpc { + chain: String, + field: &'static str, + url: String, + }, +} + +impl std::fmt::Display for ConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConfigError::ForkProfileNonLoopbackRpc { chain, field, url } => write!( + f, + "profile_tag=\"fork\" in config/fork.toml 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." + ), + } + } +} + +impl std::error::Error for ConfigError {} + impl Config { /// Read a TOML config file, substitute `${ENV_VAR}` placeholders, parse. /// /// Returns an error if the file is missing, malformed, or references an - /// environment variable that isn't set. + /// environment variable that isn't set (and has no `:-default` clause). pub fn load(path: impl AsRef) -> anyhow::Result { let path = path.as_ref(); let raw = std::fs::read_to_string(path) @@ -167,11 +213,98 @@ impl Config { .with_context(|| format!("failed to parse TOML at {}", path.display()))?; Ok(config) } + + /// Enforce profile-specific invariants. Call this at startup before + /// opening any RPC connection so a misconfigured profile fails + /// fast with an actionable error rather than quietly pointing a + /// lowered profit gate at a production endpoint. + /// + /// Current rules: + /// - `bot.profile_tag == Some("fork")` ⇒ every chain's `ws_url` and + /// `http_url` must resolve to a loopback host (`127.0.0.1`, + /// `::1`, or `localhost`). A non-fork profile pointing at + /// loopback is *not* rejected — local-geth dev runs are a + /// supported workflow. + pub fn validate(&self) -> Result<(), ConfigError> { + if self.bot.profile_tag.as_deref() == Some(PROFILE_TAG_FORK) { + for (chain_name, chain_cfg) in &self.chain { + for (field, url) in [ + ("ws_url", chain_cfg.ws_url.as_str()), + ("http_url", chain_cfg.http_url.as_str()), + ] { + if !is_loopback_url(url) { + return Err(ConfigError::ForkProfileNonLoopbackRpc { + chain: chain_name.clone(), + field, + url: url.to_string(), + }); + } + } + } + } + Ok(()) + } +} + +/// 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 } -/// Replace every `${NAME}` in `input` with the value of environment variable -/// `NAME`. Returns an error if any referenced variable is unset or if a -/// `${` is not closed by `}`. +/// Replace every `${NAME}` and `${NAME:-default}` occurrence in `input` +/// with the value of environment variable `NAME`, falling back to +/// `default` when the `:-` form is used and the variable is unset or +/// empty. +/// +/// Unset variable without a `:-default` is a hard error — existing +/// behavior preserved for profiles that want to enforce "env must be +/// set" (e.g. secrets in `config/default.toml`). +/// +/// An unterminated `${` (no closing `}`) is also a hard error. fn substitute_env_vars(input: &str) -> anyhow::Result { let mut output = String::with_capacity(input.len()); let mut rest = input; @@ -181,12 +314,116 @@ fn substitute_env_vars(input: &str) -> anyhow::Result { let end = after .find('}') .ok_or_else(|| anyhow!("unterminated `${{` in config"))?; - let var_name = &after[..end]; - let value = - std::env::var(var_name).with_context(|| format!("env var `{var_name}` is not set"))?; + let expr = &after[..end]; + let (var_name, default) = match expr.split_once(":-") { + Some((name, def)) => (name, Some(def)), + None => (expr, None), + }; + let value = match (std::env::var(var_name), default) { + // Set and non-empty: use the env value regardless of default. + (Ok(v), _) if !v.is_empty() => v, + // Set-but-empty or unset with an explicit default: use the default. + // (POSIX `${VAR:-default}` semantics — default applies when unset OR empty.) + (_, Some(def)) => def.to_string(), + // Unset with no default: hard error (preserves prior behavior). + (Err(_), None) => { + return Err(anyhow!("env var `{var_name}` is not set")); + } + // Set-but-empty with no default: keep the empty value to + // preserve prior behavior for secret-bearing profiles. + (Ok(v), None) => v, + }; output.push_str(&value); rest = &after[end + 1..]; } output.push_str(rest); Ok(output) } + +#[cfg(test)] +mod tests { + use super::*; + + // These tests touch process-global env. Using dedicated var names + // per test keeps them safe against parallel test execution — + // cargo only serializes tests inside a single `#[test]` when they + // race on the same key. + fn set_var(k: &str, v: &str) { + // Safety: tests use unique var names per case; no other thread + // reads these concurrently. + unsafe { std::env::set_var(k, v) }; + } + fn unset_var(k: &str) { + // Safety: same reasoning as set_var. + unsafe { std::env::remove_var(k) }; + } + + #[test] + fn env_substitution_plain_var_set() { + set_var("CHARON_TEST_PLAIN", "hello"); + let out = substitute_env_vars("x=${CHARON_TEST_PLAIN}").unwrap(); + assert_eq!(out, "x=hello"); + unset_var("CHARON_TEST_PLAIN"); + } + + #[test] + fn env_substitution_plain_var_unset_errors() { + unset_var("CHARON_TEST_UNSET_NO_DEFAULT"); + let err = substitute_env_vars("x=${CHARON_TEST_UNSET_NO_DEFAULT}") + .expect_err("unset var without default must error"); + assert!( + format!("{err}").contains("CHARON_TEST_UNSET_NO_DEFAULT"), + "error must name the missing var: {err}" + ); + } + + #[test] + fn env_substitution_default_used_when_unset() { + unset_var("CHARON_TEST_UNSET_WITH_DEFAULT"); + let out = substitute_env_vars("p=${CHARON_TEST_UNSET_WITH_DEFAULT:-8545}").unwrap(); + assert_eq!(out, "p=8545"); + } + + #[test] + fn env_substitution_default_overridden_when_set() { + set_var("CHARON_TEST_DEFAULT_OVERRIDDEN", "8546"); + let out = + substitute_env_vars("p=${CHARON_TEST_DEFAULT_OVERRIDDEN:-8545}").unwrap(); + assert_eq!(out, "p=8546"); + unset_var("CHARON_TEST_DEFAULT_OVERRIDDEN"); + } + + #[test] + fn env_substitution_default_used_when_empty() { + set_var("CHARON_TEST_EMPTY_WITH_DEFAULT", ""); + let out = + substitute_env_vars("p=${CHARON_TEST_EMPTY_WITH_DEFAULT:-8545}").unwrap(); + assert_eq!(out, "p=8545"); + unset_var("CHARON_TEST_EMPTY_WITH_DEFAULT"); + } + + #[test] + fn env_substitution_unterminated_errors() { + let err = substitute_env_vars("x=${UNCLOSED").expect_err("must reject unterminated ${"); + assert!(format!("{err}").contains("unterminated")); + } + + #[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/src/lib.rs b/crates/charon-core/src/lib.rs index 692ff7f..7ece3e3 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -7,7 +7,7 @@ pub mod queue; pub mod traits; pub mod types; -pub use config::{Config, MetricsConfig}; +pub use config::{Config, ConfigError, MetricsConfig}; pub use flashloan::{FlashLoanProvider, FlashLoanQuote}; pub use profit::{NetProfit, ProfitInputs, calculate_profit}; pub use queue::{DEFAULT_TTL_BLOCKS, OpportunityQueue}; diff --git a/crates/charon-core/tests/config_profiles.rs b/crates/charon-core/tests/config_profiles.rs index 7e869b7..721920f 100644 --- a/crates/charon-core/tests/config_profiles.rs +++ b/crates/charon-core/tests/config_profiles.rs @@ -48,19 +48,78 @@ fn default_profile_parses() { #[test] fn fork_profile_parses_and_targets_localhost() { - // No env substitution needed — the fork profile hard-codes localhost. - let path = workspace_root().join("config/fork.toml"); - let cfg = Config::load(&path).expect("fork.toml should parse"); + // `CHARON_ANVIL_PORT` has a `:-8545` default in fork.toml (#247), + // so the test doesn't need to pre-set it. Loading default.toml + // below also needs its env vars populated because we compare the + // fork profit gate against the default one. + set_env(&[ + ("BNB_WS_URL", "wss://example/bnb"), + ("BNB_HTTP_URL", "https://example/bnb"), + ("BSC_PRIVATE_RPC_URL", "https://example/bnb-private"), + ]); + + let fork_path = workspace_root().join("config/fork.toml"); + let cfg = Config::load(&fork_path).expect("fork.toml should parse"); + + // Profile-level invariants the bot relies on at startup. + cfg.validate() + .expect("fork profile must pass Config::validate (loopback RPC check)"); 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 at the local anvil instance" + "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. + let default_cfg = + Config::load(workspace_root().join("config/default.toml")).expect("default.toml parses"); + assert!( + cfg.bot.min_profit_usd < default_cfg.bot.min_profit_usd, + "fork profile min_profit_usd ({}) must be strictly lower than default profile ({}) — \ + the fork is a demo-staging surface", + cfg.bot.min_profit_usd, + default_cfg.bot.min_profit_usd + ); + + // `[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" + ); } #[test] From 44386737295dd221bce9a1f4ac38e1e41722c821 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 19:01:38 +0530 Subject: [PATCH 6/6] fix(tooling): anvil_fork.sh pins foundry version and aligns port env Added a non-fatal version check near the top of the script that compares `anvil --version` output against a substring configured by `CHARON_REQUIRED_FOUNDRY_VERSION` (default: "stable"). A mismatch prints a loud warning with the remediation (`foundryup -i `) and sleeps 2s; a missing / empty output is a hard 127 exit. The check is disabled by `CHARON_SKIP_FOUNDRY_VERSION_CHECK=1` for CI images that pin Foundry out of band. Renamed the port knob from `FORK_PORT` to `CHARON_ANVIL_PORT` so the script reads the same variable `config/fork.toml` substitutes via `${CHARON_ANVIL_PORT:-8545}`. `FORK_PORT` kept as a legacy fallback for muscle memory. Header updated to document both. Resolves #259. --- scripts/anvil_fork.sh | 82 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/scripts/anvil_fork.sh b/scripts/anvil_fork.sh index 3579623..c214cc4 100755 --- a/scripts/anvil_fork.sh +++ b/scripts/anvil_fork.sh @@ -10,19 +10,40 @@ # 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 -# FORK_PORT=8546 ./scripts/anvil_fork.sh # avoid a port collision +# 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). -# FORK_PORT — host port for HTTP+WS (default: 8545) -# FORK_CHAIN_ID — preserved chain id (default: 56, BSC mainnet) +# 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). +# 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 @@ -76,6 +97,39 @@ if ! command -v curl >/dev/null 2>&1; then 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 @@ -90,7 +144,13 @@ fi # ── Defaults ───────────────────────────────────────────────────────── readonly PRIMARY_RPC="${FORK_RPC_PRIMARY:-https://bsc.drpc.org}" -readonly PORT="${FORK_PORT:-8545}" +# 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