feat(tooling): anvil BSC mainnet fork + README seed#52
Merged
Conversation
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.
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.
This was referenced Apr 22, 2026
Closed
Closed
Closed
Closed
Closed
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=<archive-url>. Closes #242 Closes #246
… placeholder, Chainlink keep-alive 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
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<String>` 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.
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 <ver>`)
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.
# Conflicts: # README.md # crates/charon-cli/src/main.rs # crates/charon-core/src/config.rs # crates/charon-core/src/lib.rs
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
scripts/anvil_fork.sh— boots an anvil fork of BNB mainnet against a probed upstream (dRPC primary, PublicNode fallback), preserves chainId 56, 3s block timeconfig/fork.toml— mirrors mainnet addresses but points the RPC at 127.0.0.1:8545 and lowers the profit gate for demo stagingREADME.mdon the repo (the single tracked markdown per policy) with the quickstart for all three run modes and the metrics-series referencefork_profile_parses_and_targets_localhostso the new TOML can't regress silentlyWhy this PR matters for the demo
Path B of the Grafana demo plan (the one that actually exercises the full liquidation pipeline) needs a local archive node. Free public BSC archive RPCs are rare; this script picks from the two that work keyless and falls back on failure so the demo is reproducible from a clean laptop.
Smoke-tested manually
bash -n scripts/anvil_fork.sh— OKcharon --config config/fork.toml test-connection --chain bnb→ live forked head block:9091,charon_build_info{version,git_sha}scrapes cleanlyNot yet smoke-tested (follow-up)
.stale chainlink feedwarnings fire against the fork because ChainlinkupdatedAtis frozen at fork time while wall-clock keeps moving. Not a script bug; a known consequence of forking without advancing the fork's time. Either relaxDEFAULT_MAX_AGEfor fork profile or advance anvil's time — flagged for a follow-up tweak, not blocking this PR.Test plan
cargo build --workspacecleancargo clippy --workspace --all-targets -- -D warningscleancargo fmt --all --checkcleancargo test --workspace— 3 profile tests pass (default, testnet, fork)test-connectionreturns forked headStacked PR
Base is
feat/23-testnet-config(PR #51). Merge order: #46 → #50 → #51 → this.Closes #48.