Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,12 @@ CHARON_BSC_PRIVATE_RPC_URL=
# to every private-RPC request. Leave empty if the vendor embeds the
# API key in the URL instead of using a header.
CHARON_BSC_PRIVATE_RPC_AUTH=

# BSC testnet (Chapel, chainId 97) RPC endpoints — consumed by the
# `config/testnet.toml` profile only. Public defaults are fine for a
# demo; swap in your own node if you hit rate limits. The `CHARON_*`
# prefix follows the project's env-var namespace convention; the
# mainnet vars above retain their legacy names for backwards
# compatibility.
CHARON_BNB_TESTNET_WS_URL=wss://bsc-testnet-rpc.publicnode.com
CHARON_BNB_TESTNET_HTTP_URL=https://bsc-testnet-rpc.publicnode.com
130 changes: 130 additions & 0 deletions config/testnet.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Charon — BSC testnet (Chapel, chainId 97) profile.
#
# Purpose: run the scanner + Prometheus exporter against a live but
# non-production chain so the Grafana demo can show real block activity
# without touching mainnet keys or capital.
#
# Scope caveat — flash-loan and liquidator sections are intentionally
# OMITTED. Aave V3 is NOT deployed on BSC testnet, so there is no
# flash-loan venue for Charon to route through; without a route, the
# off-chain pipeline short-circuits at the router gate and nothing is
# enqueued. Omitting the sections (rather than filling them with
# placeholder addresses) keeps `Config::validate` happy while making
# the read-only posture explicit.
#
# `allow_public_mempool = true` is REQUIRED here because no private
# RPC endpoint exists on Chapel; `Config::validate` would otherwise
# refuse to start the chain. This flag is a testnet / dev-only
# escape hatch — NEVER enable it on a mainnet profile, because
# broadcasting liquidation calldata to the public mempool is a
# guaranteed front-run.
#
# Environment variables below (dollar-sign + braces) are substituted
# at load time — see `.env.example` for the full list of expected
# vars. `${CHARON_SIGNER_KEY:-}` resolves to an empty string when the
# var is unset, which collapses to `None` on the typed config so the
# bot stays in scan-only mode without the operator setting a literal.

[bot]
# Low threshold so any testnet-scale opportunity can cross the gate.
# 10_000 = $0.01 in fixed-point (USD × 1e6).
min_profit_usd_1e6 = 10000
# 20 gwei ceiling, expressed in wei. Generous for Chapel where traffic
# is light; mirrors mainnet units so the same knob works everywhere.
max_gas_wei = "20000000000"
scan_interval_ms = 1000
# Default bps thresholds (10_000 = HF 1.0000, 10_500 = HF 1.0500) and
# default per-bucket scan cadences (1 / 10 / 100 blocks). Omitted so
# `serde` defaults kick in; that keeps testnet aligned with mainnet if
# the defaults ever shift.
# No signer on testnet — leave empty so the CLI stays in scan-only
# mode (no simulation, no enqueue).
signer_key = "${CHARON_SIGNER_KEY:-}"

# ── Chains ────────────────────────────────────────────────────────────────
[chain.bnb]
chain_id = 97
ws_url = "${CHARON_BNB_TESTNET_WS_URL}"
http_url = "${CHARON_BNB_TESTNET_HTTP_URL}"
priority_fee_gwei = 1
# No private RPC on testnet — env-substitutes to an empty string which
# the typed config coerces to `None`. Pairs with
# `allow_public_mempool = true` below so `Config::validate` accepts
# the unset endpoint without tripping the `PrivateRpcRequired` gate.
private_rpc_url = "${CHARON_BNB_TESTNET_PRIVATE_RPC_URL:-}"
# Testnet / dev only — public-mempool fallback. NEVER set this on a
# mainnet profile.
allow_public_mempool = true

# ── Lending protocols ─────────────────────────────────────────────────────
[protocol.venus]
chain = "bnb"
# Venus Unitroller proxy on BSC testnet (Chapel, Core Pool comptroller).
# Source: VenusProtocol/venus-protocol → deployments/bsctestnet/Unitroller.json
# Verified: 2026-03-02. Chapel contracts redeploy more frequently than BSC
# mainnet during Venus protocol upgrades — re-verify this address after any
# Venus release and bump the date above. A stale address here is silent:
# every RPC call returns empty/revert and the scanner reports zero positions,
# which is indistinguishable from "no liquidatable borrowers."
comptroller = "0x94d1820b2D1c7c7452A163983Dc888CEC546b77D"

# ── Flash-loan sources ────────────────────────────────────────────────────
# Aave V3 is NOT deployed on BSC testnet (Chapel). Intentionally
# omitted so the pipeline short-circuits at the router gate — see the
# file header for the read-only posture this produces.

# ── Deployed liquidator contracts ─────────────────────────────────────────
# No liquidator deployed on Chapel. Intentionally omitted.

# ── Prometheus metrics exporter ───────────────────────────────────────────
# Bound to loopback so the endpoint is unreachable from the public
# internet on a bare VPS — `Config::validate` would otherwise require
# an `auth_token` when bound to a non-loopback address, and adding a
# shared secret into a committed testnet profile defeats the point of
# the token. Scrape via reverse proxy / SSH tunnel / compose network.
[metrics]
enabled = true
bind = "127.0.0.1:9091"

# ── Chainlink price feeds (per chain, per asset symbol) ───────────────────
#
# Source of truth: Chainlink's public data-feeds directory at
# https://data.chain.link (network filter: "BNB Chain Testnet"). Only
# aggregator proxy addresses that are *currently listed* on that page
# for Chapel (chainId 97) should appear below — never a historical
# mirror, blog post, or third-party aggregator.
#
# Verification date: 2026-04-23 (addresses below cross-checked against
# docs.chain.link/data-feeds/price-feeds, BNB Chain Testnet tab).
#
# Re-verification cadence: Chainlink rarely redeploys feeds on Chapel,
# but testnet feeds are not covered by the same SLA as mainnet and can
# be retired or repointed between releases. Spot-check this section on
# data.chain.link before every demo and after any Chainlink testnet
# announcement; bump the verification date above when you do.
#
# Silent-failure mode if an address drifts: PriceCache::refresh calls
# `latestRoundData()` on whatever address is wired here. If the address
# is wrong, not a contract, or the ABI no longer matches, the call
# reverts or returns zero — the cache silently produces no value for
# that symbol, and the scanner falls back to Venus's ResilientOracle
# for that asset. There is no hard error and no panic; the only
# externally visible symptom is that the Chainlink-vs-protocol price
# cross-check quietly degrades to a single-source read, which defeats
# the purpose of wiring Chainlink in the first place. Treat any
# unexplained absence of a symbol from the cache as a drifted address
# until proven otherwise.
#
# Symbol keys match the Venus market short names used elsewhere in
# this config (BNB = wrapped BNB, BTCB = Bitcoin BEP-20, ETH = Ether
# BEP-20, USDT/USDC/DAI = stablecoin, LINK = Chainlink). PriceCache
# looks these up by exact string so a mismatch between the key here
# and the symbol passed to `get()` silently misses the feed.
[chainlink.bnb]
BNB = "0x2514895c72f50D8bd4B4F9b1110F0D6bD2c97526" # BNB / USD — Chainlink Chapel aggregator proxy
BTCB = "0x5741306c21795FdCBb9b265Ea0255F499DFe515C" # BTC / USD — Chainlink Chapel aggregator proxy
ETH = "0x143db3CEEfbdfe5631aDD3E50f7614B6ba708BA7" # ETH / USD — Chainlink Chapel aggregator proxy
USDT = "0xEca2605f0BCF2BA5966372C99837b1F182d3D620" # USDT / USD — Chainlink Chapel aggregator proxy
USDC = "0x90c069C4538adAc136E051052E14c1cD799C41B7" # USDC / USD — Chainlink Chapel aggregator proxy
DAI = "0xE4eE17114774713d2De0eC0f035d4F7665fc025D" # DAI / USD — Chainlink Chapel aggregator proxy
LINK = "0x1B329402Cb1825C6F30A0d92aB9E2862BE47333f" # LINK / USD — Chainlink Chapel aggregator proxy
17 changes: 17 additions & 0 deletions crates/charon-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,25 @@ pub struct Config {
/// Lending protocols keyed by short name (e.g. `"venus"`).
pub protocol: HashMap<String, ProtocolConfig>,
/// Flash-loan sources keyed by short name (e.g. `"aave_v3_bsc"`).
///
/// `#[serde(default)]` so profiles targeting chains with no
/// flash-loan venue (e.g. BSC testnet / Chapel, where Aave V3 is
/// not deployed) can omit the section entirely. When empty, the
/// off-chain pipeline short-circuits at the router gate: the
/// scanner still runs, but no opportunity is enqueued because
/// [`FlashLoanRouter::route`] has no source to quote. Mainnet
/// profiles continue to populate this section in the usual way;
/// the default does not relax any mainnet invariant.
#[serde(default)]
pub flashloan: HashMap<String, FlashLoanConfig>,
/// Deployed liquidator contracts keyed by chain name.
///
/// `#[serde(default)]` so profiles without a deployed liquidator
/// (testnet, or mainnet pre-deploy) can omit the section without
/// wedging the loader. Absence forces read-only mode: the CLI
/// refuses to build a `TxBuilder` because it has no receiver
/// address, so no calldata is signed or simulated.
#[serde(default)]
pub liquidator: HashMap<String, LiquidatorConfig>,
/// Chainlink feed addresses per chain, keyed by asset symbol
/// (e.g. `chainlink.bnb.BNB = "0x…"`). Missing key = no feed
Expand Down
114 changes: 114 additions & 0 deletions crates/charon-core/tests/config_profiles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//! Profile smoke-tests: every shipped `config/*.toml` must parse
//! cleanly once its referenced environment variables are populated.
//!
//! These tests are pure deserialization — they exercise only TOML
//! parse + struct validation and never open a socket, never construct
//! a `ChainProvider`, and never call any RPC method. That is a hard
//! invariant so `cargo test --workspace` stays green on clean CI
//! checkouts that have no live Chapel/BSC endpoint (see #258). Any
//! test that would touch live IO belongs behind `#[ignore]` with an
//! env-var guard (e.g. `CHARON_INTEGRATION_TEST=1`), not here.
//!
//! The workspace forbids `unsafe_code` (see top-level `Cargo.toml`),
//! which rules out `std::env::set_var` — that call is `unsafe` under
//! Rust 2024. To avoid process-global env mutation entirely, these
//! tests read the TOML file directly, perform the `${VAR}` ⇒ stub
//! substitution in a local string, and hand the result to
//! `Config::from_str`. Both functions are public, both exercise the
//! exact validation path `Config::load` uses.

use std::fs;
use std::path::PathBuf;

use charon_core::Config;

fn workspace_root() -> PathBuf {
// `CARGO_MANIFEST_DIR` points to `crates/charon-core/`; walk two
// parents up to reach the workspace root where `config/` lives.
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest
.parent()
.and_then(|p| p.parent())
.expect("charon-core sits two levels below the workspace root")
.to_path_buf()
}

/// Read `path` and replace every `${VAR}` / `${VAR:-default}` token
/// using `pairs` instead of the real process environment. Leaves
/// `${VAR:-default}` placeholders whose `VAR` is not in `pairs` to
/// resolve via their embedded default. Unknown tokens without a
/// default trigger a test-time panic — a missing stub for a required
/// var is almost always a test-bug rather than a config issue.
fn load_with_stubbed_env(path: &PathBuf, pairs: &[(&str, &str)]) -> String {
let raw = fs::read_to_string(path).expect("read config toml");
let mut out = String::with_capacity(raw.len());
let mut rest = raw.as_str();
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
let end = after.find('}').expect("unterminated placeholder in fixture");
let token = &after[..end];
let (name, default) = match token.split_once(":-") {
Some((n, d)) => (n, Some(d)),
None => (token, None),
};
let value = pairs
.iter()
.find_map(|(k, v)| (*k == name).then_some(*v))
.or(default)
.unwrap_or_else(|| panic!("env var `{name}` not stubbed and has no default"));
out.push_str(value);
rest = &after[end + 1..];
}
out.push_str(rest);
out
}

#[test]
fn default_profile_parses() {
let pairs = [
("BNB_WS_URL", "wss://example/bnb"),
("BNB_HTTP_URL", "https://example/bnb"),
("CHARON_BSC_PRIVATE_RPC_URL", "https://example/bnb-private"),
("CHARON_BSC_PRIVATE_RPC_AUTH", ""),
("CHARON_SIGNER_KEY", ""),
// `${CHARON_METRICS_AUTH_TOKEN}` sits inside a commented-out
// line in default.toml, but `substitute_env_vars` is a raw
// text scan — it replaces the token regardless of the
// surrounding TOML comment — so the stub has to satisfy it.
("CHARON_METRICS_AUTH_TOKEN", ""),
];
let raw = load_with_stubbed_env(&workspace_root().join("config/default.toml"), &pairs);
let cfg = Config::from_str(&raw).expect("default.toml should parse");

assert_eq!(cfg.chain["bnb"].chain_id, 56);
assert!(cfg.flashloan.contains_key("aave_v3_bsc"));
assert!(cfg.metrics.enabled);
}

#[test]
fn testnet_profile_parses_and_omits_flashloan() {
let pairs = [
("CHARON_BNB_TESTNET_WS_URL", "wss://example/chapel"),
("CHARON_BNB_TESTNET_HTTP_URL", "https://example/chapel"),
("CHARON_BNB_TESTNET_PRIVATE_RPC_URL", ""),
("CHARON_SIGNER_KEY", ""),
];
let raw = load_with_stubbed_env(&workspace_root().join("config/testnet.toml"), &pairs);
let cfg = Config::from_str(&raw).expect("testnet.toml should parse");

assert_eq!(cfg.chain["bnb"].chain_id, 97);
assert!(
cfg.flashloan.is_empty(),
"testnet profile must omit flashloan — Aave V3 is not deployed on Chapel"
);
assert!(
cfg.liquidator.is_empty(),
"testnet profile has no deployed liquidator"
);
assert!(cfg.metrics.enabled);
assert!(
cfg.chain["bnb"].allow_public_mempool,
"testnet profile must opt in to public mempool — no private RPC on Chapel"
);
}