Skip to content

feat(config): BSC testnet (Chapel) profile + optional flashloan#51

Merged
obchain merged 16 commits intomainfrom
feat/23-testnet-config
Apr 24, 2026
Merged

feat(config): BSC testnet (Chapel) profile + optional flashloan#51
obchain merged 16 commits intomainfrom
feat/23-testnet-config

Conversation

@obchain
Copy link
Copy Markdown
Owner

@obchain obchain commented Apr 22, 2026

Summary

  • Make config.flashloan and config.liquidator optional via #[serde(default)] so profiles targeting chains without a flash-loan venue can omit both sections
  • Gate the opportunity-processing arm in run_listen on both being present; when absent, the bot runs read-only (block listener + scanner + Prometheus metrics stay fully active)
  • New config/testnet.toml with live Venus Chapel deployment addresses and two new env vars for the testnet RPC
  • New integration test exercising both profiles

Why 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::connect hits the pool's FLASHLOAN_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.

  • Unitroller / Comptroller proxy: 0x94d1820b2D1c7c7452A163983Dc888CEC546b77D

Test plan

  • cargo build --workspace clean
  • cargo clippy --workspace --all-targets -- -D warnings clean
  • cargo fmt --all --check clean
  • cargo test --workspace green (new config_profiles tests 2/2)
  • Manual smoke: charon --config config/testnet.toml test-connection returns the Chapel head block (runs in a session with BNB_TESTNET_*_URL set)
  • Manual smoke: charon --config config/testnet.toml listen runs without error and :9091/metrics shows live charon_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.

obchain added 2 commits April 22, 2026 16:34
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
obchain added 9 commits April 23, 2026 16:54
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
obchain added 4 commits April 23, 2026 18:40
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
@obchain obchain changed the base branch from feat/22-prometheus-metrics to main April 24, 2026 13:48
# 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
@obchain obchain merged commit c378502 into main Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[telemetry] Grafana Cloud dashboard JSON for Charon metrics

1 participant