feat(config): BSC testnet (Chapel) profile + optional flashloan#51
Merged
feat(config): BSC testnet (Chapel) profile + optional flashloan#51
Conversation
Drop the hard requirement that `config.flashloan` and `config.liquidator` are populated. Both gain `#[serde(default)]` so TOML profiles can omit the sections entirely — the in-memory maps become empty rather than forcing a parse error. `run_listen` treats the pair as a combined "opportunity path" switch. When either side is missing: - the flash-loan router is not built (Aave's `connect` hits `FLASHLOAN_PREMIUM_TOTAL()` on the pool, so a placeholder pool address would panic the startup; omitting the section is the only honest option), - the tx builder + simulator are disabled, - the block listener, scanner, and Prometheus metrics stay fully active so the operator can still watch chain and position health evolve. This unblocks the upcoming BSC testnet profile where Aave V3 is not deployed, and also makes `config/default.toml`'s zero-address liquidator stop being a silent footgun — it now has to be wired end-to-end before the opportunity arm kicks in.
New `config/testnet.toml` targeting the live Venus deployment on BSC testnet — intended for the Grafana demo track, where the goal is to show real block activity and metrics without touching mainnet capital. Addresses source: VenusProtocol/venus-protocol deployments/bsctestnet (actively maintained; last redeploy 2026-03-02). - Unitroller / Comptroller proxy: 0x94d1820b…b77D The profile intentionally omits `[flashloan]` and `[liquidator]`. Aave V3 is NOT deployed on Chapel, so there is no flash-loan venue for Charon to route through; the bot runs read-only (scanner + metrics exporter + block listener), which is exactly the surface the Grafana dashboard is built against. Env vars added to `.env.example`: - `BNB_TESTNET_WS_URL` / `BNB_TESTNET_HTTP_URL` (default to PublicNode's free testnet RPC). New integration test `config_profiles.rs` loads both the default and testnet profiles and asserts chain-id, flash-loan omission, and metrics-enabled shape — catches regressions in the env-substitution and serde-default paths without needing a live RPC. Closes #47.
This was referenced Apr 22, 2026
flashloan and liquidator are both serde-default HashMaps so testnet profiles can omit them and run read-only. A mainnet operator who keeps one side and drops the other silently gets the same behaviour — bot starts, every opportunity short-circuits at the missing half, no log or error distinguishes accident from intent. Add Config::validate() called at the tail of Config::load(). The check pairs flashloan and liquidator by the inner `chain` field (not the map key, which is an operator-chosen label) and reports every mismatch in one sorted error so an operator misconfiguring two chains sees both at once instead of re-running to discover the second. Seven unit tests cover paired, empty, and half-wired cases plus the map-key-vs-inner-field invariant. Closes #243
run_listen previously panicked at startup on any profile whose chain key differed from "bnb" — the hard-coded lookup fired before the read-only gate and before Config::validate could flag anything. Any operator running `charon --config config/testnet.toml listen` hit `Error: chain 'bnb' not configured` and the bot exited, even though the profile was deliberately read-only. Add BotConfig.chain (String, default "bnb") and resolve chain, flashloan, liquidator, and chainlink lookups through it. Flashloan and liquidator maps are now matched on their inner `chain` field rather than the map key, aligning with Config::validate (#243) so an operator-chosen label like `[liquidator.charon_bnb_v1]` cannot pass validation and then silently short-circuit at runtime. Closes #239
Read-only profiles (testnet, or any chain with flashloan+liquidator
both absent) keep the scanner running so operators can watch
position health on Grafana. The Liquidatable bucket grows as
distinct borrowers cross the threshold, which is expected under
read-only but alarming under full. Dashboards had no way to tell
the two apart.
Publish charon_run_mode{mode="full"|"read_only"} as a gauge; both
series are always present (1/0 toggle) so PromQL selectors don't
see dropouts across transitions. Emit a single startup info log
carrying the chain and mode. Config::validate (#243) guarantees the
half-wired state is rejected at load time, so the full/read-only
decision in run_listen is a complete dichotomy.
Closes #241
BNB_TESTNET_WS_URL / BNB_TESTNET_HTTP_URL violate the repo-wide CHARON_* namespace convention enforced on every new env var since PR #42 and PR #44. The existing mainnet BNB_* vars predate the convention and stay unrenamed pending a separate repo-wide pass — only the testnet pair, landed this branch, shifts to the compliant prefix. Renames `config/testnet.toml` substitution references and updates `.env.example` with a short note pointing at #245 so future readers see the scope. The `config_profiles` integration test exports the new names. Closes #245
FlashLoanConfig and LiquidatorConfig became optional on this branch (#[serde(default)] at Config level), so a field-level typo like `poo = "0x..."` instead of `pool` would silently deserialize to a zero-address default and the operator would learn about the misspell only when every liquidation silently short-circuits at runtime. Add `#[serde(deny_unknown_fields)]` to FlashLoanConfig and LiquidatorConfig, and extend the same hardening to MetricsConfig, BotConfig, ChainConfig, and ProtocolConfig — every other struct with defaulted fields has the same silent-typo surface (bnd instead of bind, liquidatable_threshhold instead of liquidatable_threshold, privte_rpc_url instead of private_rpc_url). Cross-reference rationale from FlashLoanConfig so the rule lives in one place. Both shipped profiles (default.toml, testnet.toml) still load clean, verified by the config_profiles integration test. Closes #250
Two operator-visibility fixes in run_listen that land together because they share the same hot-path in the startup/shutdown sequence: Empty Chainlink feed map (#251). When the chain-key lookup into `[chainlink.*]` returns no entries, the PriceCache is built with zero feeds and every price resolves via the protocol oracle (Venus ResilientOracle on BSC). On Chapel that oracle is synthetic; on mainnet an empty feed map is almost always a misconfiguration. Emit a startup warn! naming the chain, explicitly distinguishing the testnet (expected) and mainnet (misconfig) cases, so operators see the caveat before wondering why the Liquidatable bucket stays empty. SIGTERM shutdown (#253). `docker stop`, `systemctl stop`, and `kubectl delete pod` all send SIGTERM — the previous `select!` only listened on SIGINT via `ctrl_c()`, so container lifecycle events killed the Tokio runtime without draining WS connections or flushing the Prometheus recorder. Register a SIGTERM handler via `tokio::signal::unix::signal(SignalKind::terminate())` before the main select and add an arm that logs + exits cleanly. Startup log updated to advertise both triggers. Closes #251 Closes #253
ChainProvider::connect now reads eth_chainId after the WS handshake and aborts startup if the RPC reports a chain id different from the one declared in ChainConfig. Previously a testnet profile paired with a mainnet RPC URL (or vice versa) would connect silently and then hit the wrong network's addresses with zero visible symptom. Closes #248
Adds a module-level rustdoc paragraph stating that the profile smoke-tests never open a socket, never construct a ChainProvider, and never call an RPC method, and directs contributors toward #[ignore] + env-var guards for any live-IO test. This keeps `cargo test --workspace` green on clean CI checkouts that have no live Chapel/BSC endpoint. Closes #258
…y date Expands the inline comment above `comptroller` in config/testnet.toml to name the source artifact (VenusProtocol/venus-protocol deployments/bsctestnet/Unitroller.json), the verification date (2026-03-02), the re-verify trigger (Venus releases — Chapel contracts redeploy more often than mainnet), and the silent failure mode when the address goes stale (scanner reports zero positions, same shape as "no liquidatable borrowers"). Closes #260
When the bot starts with flashloan or liquidator absent, emit an explicit info! banner stating the bot is in READ-ONLY mode and will not submit liquidations. The existing structured log already carries the mode as a field and the `charon_run_mode` Prometheus gauge was landed earlier; this banner is the line that catches a skimming eye in `docker logs` so an operator watching Grafana stops wondering whether the zeroed execution counters mean "no opportunities" or "bot wouldn't act anyway." Closes #262
Lands the Chainlink feed section on the Chapel profile with a full header describing source of truth (data.chain.link, BNB Chain Testnet filter), verification cadence, and the silent-failure mode when an address drifts (PriceCache reverts/returns zero → scanner falls back to Venus's ResilientOracle for that symbol, no hard error). Per-symbol entries are TODO-stubbed for BNB/BTC/ETH/USDT/ USDC/DAI/LINK — an operator must confirm each proxy on the Chainlink directory before filling them in; we do not invent addresses for a silent-failure surface. The matching startup warn! when price_feeds.is_empty() was already landed in crates/charon-cli/src/main.rs. Refs #237 (leaves the issue open pending per-symbol address verification on data.chain.link)
Config::load now returns Result<Self, ConfigError>. Variants:
- FileRead (missing/unreadable file, wraps io::Error)
- EnvVarMissing (unset ${VAR} substitution)
- UnterminatedPlaceholder (lone ${ with no closing })
- TomlParse (post-substitution TOML parse failure)
- HalfWired (chain present on one of flashloan/liquidator)
Every variant carries the config path; HalfWired also names the
section present vs missing. Enum is #[non_exhaustive] so new
variants can land without a semver bump. CLI keeps its top-level
anyhow::Result — ConfigError converts automatically via its Error
impl, and the CLI gains typed matching later if it wants actionable
recovery.
Added one unit test per variant plus retained the validate happy
path + paired-chain coverage.
thiserror added to [workspace.dependencies] and charon-core.
Closes #255
Fills [chainlink.bnb] on the testnet profile with the canonical Chainlink aggregator proxies for BNB/USD, BTC/USD, ETH/USD, USDT/USD, USDC/USD, DAI/USD, and LINK/USD on BSC Testnet (Chapel, chainId 97). Addresses cross-checked against docs.chain.link (BNB Chain Testnet tab) on 2026-04-23. Symbol keys match the Venus market short names the scanner uses when it calls PriceCache::get — a mismatch would silently miss the feed and fall through to Venus's ResilientOracle (which on Chapel serves synthetic prices), which defeats the purpose of wiring Chainlink in. The verification-cadence header above the section already documents the silent-failure mode so operators know what to look for if a feed drifts. Closes #237
# Conflicts: # .env.example # Cargo.lock # Cargo.toml # crates/charon-cli/src/main.rs # crates/charon-core/src/config.rs # crates/charon-metrics/src/lib.rs # crates/charon-scanner/src/provider.rs
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
config.flashloanandconfig.liquidatoroptional via#[serde(default)]so profiles targeting chains without a flash-loan venue can omit both sectionsrun_listenon both being present; when absent, the bot runs read-only (block listener + scanner + Prometheus metrics stay fully active)config/testnet.tomlwith live Venus Chapel deployment addresses and two new env vars for the testnet RPCWhy testnet at all
Grafana demo path A: show live block scanning, bucket classification, and metrics flowing into a dashboard without touching mainnet capital or keys. Path B (mainnet fork) lands in the next PR.
Why flashloan has to be truly optional, not placeholder
AaveFlashLoan::connecthits the pool'sFLASHLOAN_PREMIUM_TOTAL()view to read the fee. A placeholder address would panic startup. Omitting the section is the only honest option.Venus Chapel addresses used
Source: VenusProtocol/venus-protocol deployments/bsctestnet — actively maintained, last redeploy 2026-03-02.
0x94d1820b2D1c7c7452A163983Dc888CEC546b77DTest plan
cargo build --workspacecleancargo clippy --workspace --all-targets -- -D warningscleancargo fmt --all --checkcleancargo test --workspacegreen (newconfig_profilestests 2/2)charon --config config/testnet.toml test-connectionreturns the Chapel head block (runs in a session withBNB_TESTNET_*_URLset)charon --config config/testnet.toml listenruns without error and:9091/metricsshows livecharon_scanner_blocks_total{chain="bnb"}Stacked PR
Base is
feat/22-prometheus-metrics(PR #50). Merging before its parent will tangle the diff.Closes #47.