Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,13 @@ pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb"
# ── Deployed liquidator contracts ─────────────────────────────────────────
# Populated once CharonLiquidator.sol is deployed on BSC mainnet. Do not
# add a zero-address placeholder — config validation rejects it.

# ── Chainlink price feeds (per chain, per asset symbol) ───────────────────
# Only feeds listed here are polled by the PriceCache. Add more as new
# Venus markets become relevant. Feed addresses from docs.chain.link.
[chainlink.bnb]
BNB = "0x0567F2323251f0Aab15c8dFb1967E4e8A7D42aeE"
BTCB = "0x264990fbd0A4796A3E3d8E37022BdAf1A5a4C1f0" # BTC / USD (canonical, docs.chain.link)
ETH = "0x9ef1B8c0E4F7dc8bF5719Ea496883DC6401d5b2e"
USDT = "0xB97Ad0E74fa7d920791E90258A6E2085088b4320"
USDC = "0x51597f405303C4377E36123cBc172b13269EA163"
142 changes: 91 additions & 51 deletions crates/charon-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ use anyhow::{Context, Result};
use charon_core::{Config, LendingProtocol};
use charon_protocols::VenusAdapter;
use charon_scanner::{
BlockListener, ChainEvent, ChainProvider, HealthScanner, PositionBucket, ScanScheduler,
BlockListener, ChainEvent, ChainProvider, DEFAULT_MAX_AGE, HealthScanner, PositionBucket,
PriceCache, ScanScheduler,
};
use clap::{Parser, Subcommand};
use tokio::sync::mpsc;
Expand Down Expand Up @@ -131,7 +132,11 @@ async fn main() -> Result<()> {
/// For every `NewBlock` event on a chain with a `[protocol.venus]` entry
/// the Venus adapter fetches positions anchored at the observed block,
/// pushes them through the bucketed [`HealthScanner`], and limits fetches
/// to buckets whose cadence fires this block via [`ScanScheduler`].
/// to buckets whose cadence fires this block via [`ScanScheduler`]. A
/// per-chain [`PriceCache`] is also refreshed on each scan tick so
/// downstream profit-ranking has a fresh Chainlink view; consumers are
/// wired up in a follow-up task (positions are opportunities only, no
/// profit calc yet).
/// Chains without a Venus protocol config still flow through the drain
/// loop but trigger no protocol scans (v0.1 scope).
///
Expand All @@ -144,55 +149,84 @@ async fn run_listen(config: &Config, borrowers: Vec<Address>) -> Result<()> {
anyhow::bail!("no chains configured — nothing to listen to");
}

// Venus adapter + bucketed scanner + cadence scheduler are currently
// single-chain (BNB) per config scope. Build them only if
// `[protocol.venus]` exists and its target chain is configured;
// otherwise run the listener pipeline without a scanner.
let venus_adapter: Option<(String, Arc<VenusAdapter>, Arc<HealthScanner>, ScanScheduler)> =
match config.protocol.get("venus") {
Some(venus_cfg) => {
let chain_name = &venus_cfg.chain;
let chain_cfg = config.chain.get(chain_name).with_context(|| {
format!(
"protocol 'venus' references chain '{chain_name}' which is not in [chain.*]"
)
})?;
let adapter_ws = ProviderBuilder::new()
.on_ws(WsConnect::new(&chain_cfg.ws_url))
.await
.context("venus adapter: failed to connect over ws")?;
let adapter = Arc::new(
VenusAdapter::connect(Arc::new(adapter_ws), venus_cfg.comptroller).await?,
);
let scanner = Arc::new(HealthScanner::new(
config.bot.liquidatable_threshold_bps,
config.bot.near_liq_threshold_bps,
)?);
let sched = ScanScheduler::new(
config.bot.hot_scan_blocks,
config.bot.warm_scan_blocks,
config.bot.cold_scan_blocks,
);
info!(
chain = %chain_name,
borrower_count = borrowers.len(),
market_count = adapter.markets().await.len(),
liquidatable_bps = config.bot.liquidatable_threshold_bps,
near_liq_bps = config.bot.near_liq_threshold_bps,
hot_blocks = sched.hot,
warm_blocks = sched.warm,
cold_blocks = sched.cold,
"venus adapter + scanner ready"
);
Some((chain_name.clone(), adapter, scanner, sched))
}
None => {
info!(
"no [protocol.venus] configured — listener will drain events without scanning"
);
None
// Venus adapter + bucketed scanner + cadence scheduler + Chainlink
// price cache are currently single-chain (BNB) per config scope.
// Build them only if `[protocol.venus]` exists and its target chain
// is configured; otherwise run the listener pipeline without a
// scanner.
let venus_adapter: Option<(
String,
Arc<VenusAdapter>,
Arc<HealthScanner>,
ScanScheduler,
Arc<PriceCache>,
)> = match config.protocol.get("venus") {
Some(venus_cfg) => {
let chain_name = &venus_cfg.chain;
let chain_cfg = config.chain.get(chain_name).with_context(|| {
format!(
"protocol 'venus' references chain '{chain_name}' which is not in [chain.*]"
)
})?;
let adapter_ws = ProviderBuilder::new()
.on_ws(WsConnect::new(&chain_cfg.ws_url))
.await
.context("venus adapter: failed to connect over ws")?;
let adapter_ws = Arc::new(adapter_ws);
let adapter =
Arc::new(VenusAdapter::connect(adapter_ws.clone(), venus_cfg.comptroller).await?);
let scanner = Arc::new(HealthScanner::new(
config.bot.liquidatable_threshold_bps,
config.bot.near_liq_threshold_bps,
)?);
let sched = ScanScheduler::new(
config.bot.hot_scan_blocks,
config.bot.warm_scan_blocks,
config.bot.cold_scan_blocks,
);

// Chainlink price cache — feeds are configured per chain under
// `[chainlink.<chain>]`. Empty map = no feeds configured, cache
// stays idle and downstream stages fall back to protocol oracle.
// Reuses the Venus adapter's WS provider to avoid a second
// upstream connection; lifetime is tied to the scan task via Arc.
let price_feeds = config.chainlink.get(chain_name).cloned().unwrap_or_default();
let prices = Arc::new(PriceCache::new(adapter_ws, price_feeds, DEFAULT_MAX_AGE));
// Best-effort warm-up — individual feed failures are logged
// inside `refresh_all` so startup never hard-fails on a
// transient Chainlink blip.
prices.refresh_all().await;
let fresh_feeds: Vec<String> = prices.symbols().map(str::to_string).collect();
for sym in &fresh_feeds {
if let Some(p) = prices.get(sym) {
info!(
symbol = %sym,
price = %p.price,
decimals = p.decimals,
"chainlink feed"
);
}
}
};

info!(
chain = %chain_name,
borrower_count = borrowers.len(),
market_count = adapter.markets().await.len(),
feed_count = fresh_feeds.len(),
liquidatable_bps = config.bot.liquidatable_threshold_bps,
near_liq_bps = config.bot.near_liq_threshold_bps,
hot_blocks = sched.hot,
warm_blocks = sched.warm,
cold_blocks = sched.cold,
"venus adapter + scanner + price cache ready"
);
Some((chain_name.clone(), adapter, scanner, sched, prices))
}
None => {
info!("no [protocol.venus] configured — listener will drain events without scanning");
None
}
};

let (tx, mut rx) = mpsc::channel::<ChainEvent>(CHAIN_EVENT_CHANNEL);
let mut listeners: tokio::task::JoinSet<(String, Result<()>)> = tokio::task::JoinSet::new();
Expand Down Expand Up @@ -232,7 +266,7 @@ async fn run_listen(config: &Config, borrowers: Vec<Address>) -> Result<()> {
// snapshot the final state of the missed range.
continue;
}
if let Some((venus_chain, adapter, scanner, sched)) =
if let Some((venus_chain, adapter, scanner, sched, prices)) =
venus_adapter.as_ref()
{
if venus_chain != &chain {
Expand All @@ -258,6 +292,12 @@ async fn run_listen(config: &Config, borrowers: Vec<Address>) -> Result<()> {
if scan_set.is_empty() {
continue;
}
// Refresh Chainlink prices on every real scan
// tick so downstream profit-ranking (wired in a
// follow-up task) reads sub-heartbeat feeds.
// Individual feed failures are logged inside
// `refresh_all` and do not abort the scan.
prices.refresh_all().await;
let block_tag = BlockNumberOrTag::Number(number);
match adapter.fetch_positions(&scan_set, block_tag).await {
Ok(positions) => {
Expand Down
5 changes: 5 additions & 0 deletions crates/charon-core/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ pub struct Config {
pub flashloan: HashMap<String, FlashLoanConfig>,
/// Deployed liquidator contracts keyed by chain name.
pub liquidator: HashMap<String, LiquidatorConfig>,
/// Chainlink feed addresses per chain, keyed by asset symbol
/// (e.g. `chainlink.bnb.BNB = "0x…"`). Missing key = no feed
/// configured, scanner falls back to protocol oracle.
#[serde(default)]
pub chainlink: HashMap<String, HashMap<String, Address>>,
}

impl fmt::Debug for Config {
Expand Down
3 changes: 3 additions & 0 deletions crates/charon-scanner/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,8 @@ dashmap = { workspace = true }
rand = { workspace = true }
metrics = { workspace = true }

[dev-dependencies]
dotenvy = { workspace = true }

[lints]
workspace = true
8 changes: 4 additions & 4 deletions crates/charon-scanner/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
//! Charon scanner — chain listener and health-factor scanner.
//! Charon scanner — chain listener, health-factor scanner, and price cache.

pub mod listener;
pub mod oracle;
pub mod provider;
pub mod scanner;

pub use listener::{BlockListener, ChainEvent};
pub use oracle::{CachedPrice, DEFAULT_MAX_AGE, PriceCache};
pub use provider::{ChainProvider, ChainProviderT, MockChainProvider};
pub use scanner::{
BucketCounts, BucketedPosition, HealthScanner, PositionBucket, ScanScheduler,
};
pub use scanner::{BucketCounts, BucketedPosition, HealthScanner, PositionBucket, ScanScheduler};
Loading