diff --git a/Cargo.lock b/Cargo.lock
index 9b6b394..51eaa0f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1125,8 +1125,10 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
name = "charon-cli"
version = "0.1.0"
dependencies = [
+ "alloy",
"anyhow",
"charon-core",
+ "charon-protocols",
"charon-scanner",
"clap",
"dotenvy",
@@ -1147,6 +1149,20 @@ dependencies = [
"toml",
]
+[[package]]
+name = "charon-protocols"
+version = "0.1.0"
+dependencies = [
+ "alloy",
+ "anyhow",
+ "async-trait",
+ "charon-core",
+ "dotenvy",
+ "futures-util",
+ "tokio",
+ "tracing",
+]
+
[[package]]
name = "charon-scanner"
version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
index c4b3981..feb62d3 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,6 +2,7 @@
resolver = "3"
members = [
"crates/charon-core",
+ "crates/charon-protocols",
"crates/charon-scanner",
"crates/charon-cli",
]
@@ -61,4 +62,5 @@ dotenvy = "0.15"
# Internal crates
charon-core = { path = "crates/charon-core" }
+charon-protocols = { path = "crates/charon-protocols" }
charon-scanner = { path = "crates/charon-scanner" }
diff --git a/crates/charon-cli/Cargo.toml b/crates/charon-cli/Cargo.toml
index b1736a9..ff140bd 100644
--- a/crates/charon-cli/Cargo.toml
+++ b/crates/charon-cli/Cargo.toml
@@ -11,7 +11,9 @@ path = "src/main.rs"
[dependencies]
charon-core = { workspace = true }
+charon-protocols = { workspace = true }
charon-scanner = { workspace = true }
+alloy = { workspace = true }
clap = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs
index 143ed5a..6d44e69 100644
--- a/crates/charon-cli/src/main.rs
+++ b/crates/charon-cli/src/main.rs
@@ -3,13 +3,19 @@
//! ```text
//! CHARON_CONFIG=/etc/charon/default.toml charon listen
//! charon --config config/default.toml listen
+//! charon --config config/default.toml listen --borrower 0xABC…
//! charon --config config/default.toml test-connection --chain bnb
//! ```
use std::path::PathBuf;
+use std::sync::Arc;
+use alloy::eips::BlockNumberOrTag;
+use alloy::primitives::Address;
+use alloy::providers::{ProviderBuilder, WsConnect};
use anyhow::{Context, Result};
-use charon_core::Config;
+use charon_core::{Config, LendingProtocol};
+use charon_protocols::VenusAdapter;
use charon_scanner::{BlockListener, ChainEvent, ChainProvider};
use clap::{Parser, Subcommand};
use tokio::sync::mpsc;
@@ -40,11 +46,20 @@ struct Cli {
#[derive(Subcommand, Debug)]
enum Command {
- /// Spawn one block listener per configured chain and drain chain events.
+ /// Spawn one block listener per configured chain, drain chain events,
+ /// and run the Venus adapter every new block for the supplied borrower
+ /// list.
///
- /// Downstream pipeline (scanner → profit calc → executor) consumes
- /// the same channel once those layers land.
- Listen,
+ /// Borrower discovery from indexed events is a follow-up; pass
+ /// `--borrower 0x…` one or more times to seed a test list. An empty
+ /// list is allowed — the adapter still connects so the operator can
+ /// confirm the WS pipeline.
+ Listen {
+ /// Addresses to scan on every new block. Repeat the flag for
+ /// multiple borrowers.
+ #[arg(long = "borrower")]
+ borrowers: Vec
,
+ },
/// Connect to a configured chain and print its latest block number.
TestConnection {
@@ -89,8 +104,8 @@ async fn main() -> Result<()> {
);
match cli.command {
- Command::Listen => {
- run_listen(&config).await?;
+ Command::Listen { borrowers } => {
+ run_listen(&config, borrowers).await?;
}
Command::TestConnection { chain } => {
let chain_cfg = config
@@ -110,11 +125,47 @@ async fn main() -> Result<()> {
/// configured chain, drains the shared `ChainEvent` channel, and exits
/// cleanly on SIGINT or SIGTERM so the Docker `stop` → SIGTERM → SIGKILL
/// sequence never tears mid-operation.
-async fn run_listen(config: &Config) -> Result<()> {
+///
+/// For every `NewBlock` event on a chain with a `[protocol.venus]` entry,
+/// the Venus adapter scans the supplied borrower list anchored at the
+/// observed block. Chains without a Venus protocol config still flow
+/// through the drain loop but trigger no protocol scans (v0.1 scope).
+async fn run_listen(config: &Config, borrowers: Vec) -> Result<()> {
if config.chain.is_empty() {
anyhow::bail!("no chains configured — nothing to listen to");
}
+ // Venus adapter is currently single-chain (BNB) per config scope.
+ // Build it 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)> = 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?);
+ info!(
+ chain = %chain_name,
+ borrower_count = borrowers.len(),
+ market_count = adapter.markets().await.len(),
+ "venus adapter ready"
+ );
+ Some((chain_name.clone(), adapter))
+ }
+ None => {
+ info!("no [protocol.venus] configured — listener will drain events without scanning");
+ None
+ }
+ };
+
let (tx, mut rx) = mpsc::channel::(CHAIN_EVENT_CHANNEL);
let mut listeners: tokio::task::JoinSet<(String, Result<()>)> =
tokio::task::JoinSet::new();
@@ -144,6 +195,34 @@ async fn run_listen(config: &Config) -> Result<()> {
backfill,
"cli drained event"
);
+ // Route to Venus scan only when this event is for
+ // the chain the Venus adapter was configured on.
+ if let Some((venus_chain, adapter)) = venus_adapter.as_ref() {
+ if venus_chain == &chain {
+ let start = std::time::Instant::now();
+ let block_tag = BlockNumberOrTag::Number(number);
+ match adapter.fetch_positions(&borrowers, block_tag).await {
+ Ok(positions) => {
+ info!(
+ chain = %chain,
+ block = number,
+ timestamp,
+ backfill,
+ tracked = borrowers.len(),
+ returned = positions.len(),
+ scan_ms = start.elapsed().as_millis() as u64,
+ "venus scan"
+ );
+ }
+ Err(err) => warn!(
+ chain = %chain,
+ block = number,
+ error = ?err,
+ "venus scan failed"
+ ),
+ }
+ }
+ }
}
_ => {}
}
diff --git a/crates/charon-core/src/types.rs b/crates/charon-core/src/types.rs
index 9e3f81a..e58a246 100644
--- a/crates/charon-core/src/types.rs
+++ b/crates/charon-core/src/types.rs
@@ -89,7 +89,16 @@ pub struct SwapRoute {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum LiquidationParams {
- #[non_exhaustive]
+ // NOTE: this variant is intentionally *not* `#[non_exhaustive]`.
+ // The `LendingProtocol::get_liquidation_params` trait method is
+ // implemented outside `charon-core` (each adapter crate returns a
+ // protocol-specific variant), which requires each variant to be
+ // constructible via a struct expression from downstream crates.
+ // Enum-level `#[non_exhaustive]` still prevents breakage from adding
+ // new variants; that is the only forward-compat guarantee we need
+ // here. Adding or renaming a field on `Venus` is a semver break by
+ // design — every adapter call site must be audited when liquidation
+ // mechanics change.
Venus {
borrower: Address,
/// vToken of the collateral asset (the token seized).
diff --git a/crates/charon-protocols/Cargo.toml b/crates/charon-protocols/Cargo.toml
new file mode 100644
index 0000000..d059054
--- /dev/null
+++ b/crates/charon-protocols/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "charon-protocols"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+description = "Lending-protocol adapters for Charon (Venus, Aave, Compound, Morpho, …)"
+
+[dependencies]
+charon-core = { workspace = true }
+alloy = { workspace = true }
+anyhow = { workspace = true }
+async-trait = { workspace = true }
+tracing = { workspace = true }
+tokio = { workspace = true }
+futures-util = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true }
+dotenvy = { workspace = true }
diff --git a/crates/charon-protocols/src/lib.rs b/crates/charon-protocols/src/lib.rs
new file mode 100644
index 0000000..e7b7ec4
--- /dev/null
+++ b/crates/charon-protocols/src/lib.rs
@@ -0,0 +1,14 @@
+//! Charon protocol adapters.
+//!
+//! One module per lending protocol. Each module defines a struct that
+//! implements the [`LendingProtocol`](charon_core::LendingProtocol) trait
+//! — the scanner and executor talk to these structs instead of protocol-
+//! specific RPCs directly, so adding a new protocol is a self-contained
+//! change here with no scanner edits required.
+//!
+//! For v0.1 only the Venus adapter is wired up; Aave / Compound / Morpho
+//! adapters land in later milestones.
+
+pub mod venus;
+
+pub use venus::VenusAdapter;
diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs
new file mode 100644
index 0000000..6a653f5
--- /dev/null
+++ b/crates/charon-protocols/src/venus.rs
@@ -0,0 +1,638 @@
+//! Venus Protocol adapter (BNB Chain).
+//!
+//! Venus is a Compound V2 fork running on BSC. Underwater accounts are
+//! surfaced via `Comptroller.getAccountLiquidity(borrower)` which returns
+//! a `(errorCode, liquidity, shortfall)` tuple; a non-zero `shortfall`
+//! means the account is liquidatable. The adapter translates that shape
+//! into the shared `Position` type and encodes liquidation calls through
+//! `VToken.liquidateBorrow(borrower, repayAmount, vTokenCollateral)`.
+//!
+//! All view calls accept a `BlockNumberOrTag` so the scanner can pin a
+//! snapshot to an observed head and avoid oracle/exchange-rate drift
+//! between reads. Internally we convert to `alloy::eips::BlockId` which
+//! is the argument type the sol!-generated call builder expects.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use alloy::eips::{BlockId, BlockNumberOrTag};
+use alloy::primitives::{Address, U256, address};
+use alloy::providers::{Provider, RootProvider};
+use alloy::pubsub::PubSubFrontend;
+use alloy::sol;
+use alloy::sol_types::SolCall;
+use anyhow::{Context, Result};
+use async_trait::async_trait;
+use charon_core::{
+ LendingProtocol, LendingProtocolError, LendingResult, LiquidationParams, Position, ProtocolId,
+};
+use futures_util::stream::{FuturesUnordered, StreamExt};
+use tokio::sync::RwLock;
+use tracing::{debug, info, warn};
+
+/// vBNB does not implement `underlying()` — BSC's native BNB market. Map it
+/// to the canonical Wrapped BNB token so oracle and router paths still work.
+const VBNB: Address = address!("A07c5b74C9B40447a954e1466938b865b6BBea36");
+const WBNB: Address = address!("bb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c");
+
+/// 1e18 — reused constant to avoid re-computing inside tight loops.
+fn one_e18() -> U256 {
+ U256::from(10u64).pow(U256::from(18u64))
+}
+
+/// Map any internal `anyhow::Error` produced inside helper paths to the
+/// `LendingProtocolError::Rpc` variant. RPC failures dominate this adapter's
+/// error surface; callers that need finer distinctions should construct
+/// `LendingProtocolError` directly.
+fn rpc_err(e: E) -> LendingProtocolError {
+ LendingProtocolError::Rpc(e.to_string())
+}
+
+/// On-chain ABI bindings used by the Venus adapter.
+pub mod abi {
+ use super::sol;
+
+ sol! {
+ /// Venus Unitroller / Comptroller — risk engine and market registry.
+ #[sol(rpc)]
+ interface IVenusComptroller {
+ /// Returns `(errorCode, liquidity, shortfall)`. `shortfall > 0`
+ /// means the account can be liquidated.
+ function getAccountLiquidity(address account)
+ external view returns (uint256, uint256, uint256);
+
+ /// vTokens the account has entered as collateral.
+ function getAssetsIn(address account)
+ external view returns (address[] memory);
+
+ /// All vToken markets registered on this Comptroller.
+ function getAllMarkets()
+ external view returns (address[] memory);
+
+ /// Max fraction of debt liquidatable per call (scaled 1e18).
+ function closeFactorMantissa() external view returns (uint256);
+
+ /// Liquidation incentive (bonus) paid to liquidators, scaled 1e18.
+ /// 1.1e18 = 10% bonus. Governance-set; refreshed on demand.
+ function liquidationIncentiveMantissa() external view returns (uint256);
+
+ /// Address of the Venus price oracle.
+ function oracle() external view returns (address);
+ }
+
+ /// Venus market token — holds collateral and tracks borrow state.
+ ///
+ /// Only pure view methods are exposed so every scan-path call is
+ /// safe on rate-limited proxies that reject state-mutating
+ /// `eth_call`s.
+ #[sol(rpc)]
+ interface IVToken {
+ /// Underlying ERC-20 address (missing on `vBNB` — native BNB).
+ function underlying() external view returns (address);
+
+ /// vToken share balance of `owner`.
+ function balanceOf(address owner) external view returns (uint256);
+
+ /// Cached borrow balance — fast but stale by up to one accrual.
+ function borrowBalanceStored(address account)
+ external view returns (uint256);
+
+ /// vToken → underlying exchange rate (scaled 1e18 + underlying decimals).
+ function exchangeRateStored() external view returns (uint256);
+
+ function decimals() external view returns (uint8);
+ function symbol() external view returns (string memory);
+
+ /// Repay `repayAmount` of the borrower's debt and seize collateral
+ /// in `vTokenCollateral`. Called by `CharonLiquidator.sol` inside
+ /// the flash-loan callback.
+ function liquidateBorrow(
+ address borrower,
+ uint256 repayAmount,
+ address vTokenCollateral
+ ) external returns (uint256);
+ }
+
+ /// Venus price oracle — returns USD price per vToken's underlying.
+ #[sol(rpc)]
+ interface IVenusOracle {
+ /// Price scaled by `1e(36 - underlyingDecimals)` (Compound convention).
+ function getUnderlyingPrice(address vToken)
+ external view returns (uint256);
+ }
+ }
+}
+
+pub type ChainProvider = Arc>;
+
+/// Mutable snapshot the adapter refreshes from the Comptroller on demand.
+#[derive(Debug, Clone)]
+struct VenusSnapshot {
+ oracle: Address,
+ markets: Vec,
+ close_factor_mantissa: U256,
+ liquidation_incentive_mantissa: U256,
+ underlying_to_vtoken: HashMap,
+ vtoken_to_underlying: HashMap,
+}
+
+/// Venus adapter — see module docs.
+#[derive(Debug, Clone)]
+pub struct VenusAdapter {
+ comptroller: Address,
+ chain_id: u64,
+ snapshot: Arc>,
+ provider: ChainProvider,
+}
+
+impl VenusAdapter {
+ /// Connect to the Venus Comptroller and snapshot its market config.
+ pub async fn connect(provider: ChainProvider, comptroller: Address) -> Result {
+ debug!(%comptroller, "connecting Venus adapter");
+
+ let chain_id = provider
+ .get_chain_id()
+ .await
+ .context("Venus: eth_chainId failed")?;
+
+ let snapshot = Self::take_snapshot(&provider, comptroller).await?;
+ info!(
+ %comptroller,
+ oracle = %snapshot.oracle,
+ chain_id,
+ market_count = snapshot.markets.len(),
+ mapped_markets = snapshot.underlying_to_vtoken.len(),
+ close_factor = %snapshot.close_factor_mantissa,
+ liquidation_incentive = %snapshot.liquidation_incentive_mantissa,
+ "Venus adapter connected"
+ );
+
+ Ok(Self {
+ comptroller,
+ chain_id,
+ snapshot: Arc::new(RwLock::new(snapshot)),
+ provider,
+ })
+ }
+
+ /// Re-query the Comptroller for oracle / close factor / incentive /
+ /// market list and rebuild the lookup maps. Safe to call on a timer
+ /// or in response to a `NewMarket` / `NewPriceOracle` event.
+ pub async fn refresh(&self) -> Result<()> {
+ let fresh = Self::take_snapshot(&self.provider, self.comptroller).await?;
+ let mut guard = self.snapshot.write().await;
+ *guard = fresh;
+ debug!("Venus snapshot refreshed");
+ Ok(())
+ }
+
+ async fn take_snapshot(
+ provider: &ChainProvider,
+ comptroller: Address,
+ ) -> Result {
+ let comp = abi::IVenusComptroller::new(comptroller, provider.clone());
+
+ let oracle = comp
+ .oracle()
+ .call()
+ .await
+ .context("Venus: Comptroller.oracle() failed")?
+ ._0;
+ let markets = comp
+ .getAllMarkets()
+ .call()
+ .await
+ .context("Venus: Comptroller.getAllMarkets() failed")?
+ ._0;
+ let close_factor_mantissa = comp
+ .closeFactorMantissa()
+ .call()
+ .await
+ .context("Venus: Comptroller.closeFactorMantissa() failed")?
+ ._0;
+ let liquidation_incentive_mantissa = comp
+ .liquidationIncentiveMantissa()
+ .call()
+ .await
+ .context("Venus: Comptroller.liquidationIncentiveMantissa() failed")?
+ ._0;
+
+ let mut underlying_to_vtoken = HashMap::with_capacity(markets.len());
+ let mut vtoken_to_underlying = HashMap::with_capacity(markets.len());
+ for &vtoken in &markets {
+ if vtoken == VBNB {
+ underlying_to_vtoken.insert(WBNB, VBNB);
+ vtoken_to_underlying.insert(VBNB, WBNB);
+ continue;
+ }
+ let vt = abi::IVToken::new(vtoken, provider.clone());
+ match vt.underlying().call().await {
+ Ok(r) => {
+ underlying_to_vtoken.insert(r._0, vtoken);
+ vtoken_to_underlying.insert(vtoken, r._0);
+ }
+ Err(err) => {
+ warn!(
+ %vtoken, err = ?err,
+ "vToken has no underlying() and is not the known vBNB market — scanner will ignore it"
+ );
+ }
+ }
+ }
+
+ Ok(VenusSnapshot {
+ oracle,
+ markets,
+ close_factor_mantissa,
+ liquidation_incentive_mantissa,
+ underlying_to_vtoken,
+ vtoken_to_underlying,
+ })
+ }
+
+ /// Read accessors for downstream crates. Held behind an async RwLock
+ /// because `refresh()` swaps the snapshot atomically.
+ pub async fn markets(&self) -> Vec {
+ self.snapshot.read().await.markets.clone()
+ }
+ pub async fn oracle(&self) -> Address {
+ self.snapshot.read().await.oracle
+ }
+ pub async fn close_factor_mantissa(&self) -> U256 {
+ self.snapshot.read().await.close_factor_mantissa
+ }
+ pub async fn liquidation_incentive_mantissa(&self) -> U256 {
+ self.snapshot.read().await.liquidation_incentive_mantissa
+ }
+
+ /// Fetch one borrower's largest debt/collateral pair, if any, with every
+ /// sub-call anchored to `block` so oracle price, exchange rate, borrow
+ /// balance, and liquidity are read from the same chain state.
+ ///
+ /// Walks `getAssetsIn(borrower)`, reads per-vToken borrow + supply
+ /// balances and oracle prices through pure view methods only
+ /// (`balanceOf * exchangeRateStored / 1e18`; never `balanceOfUnderlying`
+ /// which triggers `accrueInterest` and breaks on view-only endpoints).
+ async fn fetch_position_inner(
+ &self,
+ borrower: Address,
+ block: BlockNumberOrTag,
+ ) -> Result