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> { + let block_id: BlockId = block.into(); + let snap = self.snapshot.read().await.clone(); + let comp = abi::IVenusComptroller::new(self.comptroller, self.provider.clone()); + + let liq = comp + .getAccountLiquidity(borrower) + .block(block_id) + .call() + .await + .with_context(|| format!("getAccountLiquidity({borrower}) failed"))?; + let liquidity = liq._1; + let shortfall = liq._2; + + let assets = comp + .getAssetsIn(borrower) + .block(block_id) + .call() + .await + .with_context(|| format!("getAssetsIn({borrower}) failed"))? + ._0; + if assets.is_empty() { + return Ok(None); + } + + let oracle = abi::IVenusOracle::new(snap.oracle, self.provider.clone()); + let scale = one_e18(); + + let mut best_debt: Option<(Address, U256, U256)> = None; + let mut best_coll: Option<(Address, U256, U256)> = None; + let mut total_borrow_val = U256::ZERO; + + for vtoken in &assets { + let Some(&underlying) = snap.vtoken_to_underlying.get(vtoken) else { + warn!(%vtoken, "vToken not in snapshot — skipping (stale snapshot?)"); + continue; + }; + let vt = abi::IVToken::new(*vtoken, self.provider.clone()); + + let borrow = match vt.borrowBalanceStored(borrower).block(block_id).call().await { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, %borrower, ?err, "borrowBalanceStored failed"); + continue; + } + }; + // View-only underlying balance: vToken shares × exchangeRate / 1e18. + let v_balance = match vt.balanceOf(borrower).block(block_id).call().await { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, %borrower, ?err, "balanceOf failed"); + continue; + } + }; + let exchange_rate = match vt.exchangeRateStored().block(block_id).call().await { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, ?err, "exchangeRateStored failed"); + continue; + } + }; + let supply = v_balance.saturating_mul(exchange_rate) / scale; + + let price = match oracle + .getUnderlyingPrice(*vtoken) + .block(block_id) + .call() + .await + { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, ?err, "oracle.getUnderlyingPrice failed"); + continue; + } + }; + + let borrow_val = borrow.saturating_mul(price); + let supply_val = supply.saturating_mul(price); + total_borrow_val = total_borrow_val.saturating_add(borrow_val); + + if borrow > U256::ZERO && best_debt.as_ref().is_none_or(|x| borrow_val > x.2) { + best_debt = Some((underlying, borrow, borrow_val)); + } + if supply > U256::ZERO && best_coll.as_ref().is_none_or(|x| supply_val > x.2) { + best_coll = Some((underlying, supply, supply_val)); + } + } + + let Some((debt_token, debt_amount, _)) = best_debt else { + return Ok(None); + }; + let Some((collateral_token, collateral_amount, _)) = best_coll else { + return Ok(None); + }; + + // Health factor (1e18-scaled) derived from Comptroller's own + // liquidity / shortfall values. Both are oracle-USD magnitudes. + // HF = effective_collateral / total_borrow_val: + // shortfall > 0: eff_coll = total_borrow_val - shortfall + // otherwise: eff_coll = total_borrow_val + liquidity + let health_factor = compute_health_factor(total_borrow_val, liquidity, shortfall, scale); + + // Liquidation bonus bps from live snapshot. + // mantissa = 1e18 + bonus → bps = (mantissa - 1e18) / 1e14 + let incentive = snap.liquidation_incentive_mantissa; + let bonus_1e18 = incentive.saturating_sub(scale); + let one_e14 = U256::from(10u64).pow(U256::from(14u64)); + let liquidation_bonus_bps = u16::try_from(bonus_1e18 / one_e14) + .unwrap_or(0); + + Ok(Some(Position { + protocol: ProtocolId::Venus, + chain_id: self.chain_id, + borrower, + collateral_token, + debt_token, + collateral_amount, + debt_amount, + health_factor, + liquidation_bonus_bps, + })) + } + + #[allow(dead_code)] + pub(crate) fn provider(&self) -> &ChainProvider { + &self.provider + } +} + +/// Compute the 1e18-scaled health factor from Comptroller account-liquidity +/// values, factored out so `get_health_factor` can reuse it without walking +/// the full position-building path. +fn compute_health_factor( + total_borrow_val: U256, + liquidity: U256, + shortfall: U256, + scale: U256, +) -> U256 { + if total_borrow_val.is_zero() { + // No debt priced this block → treat as healthy marker. + return scale.saturating_mul(U256::from(2u64)); + } + if shortfall > U256::ZERO { + let eff = total_borrow_val.saturating_sub(shortfall); + eff.saturating_mul(scale) / total_borrow_val + } else { + let eff = total_borrow_val.saturating_add(liquidity); + eff.saturating_mul(scale) / total_borrow_val + } +} + +#[async_trait] +impl LendingProtocol for VenusAdapter { + fn id(&self) -> ProtocolId { + ProtocolId::Venus + } + + /// Fetch positions for every borrower concurrently via `FuturesUnordered`, + /// with every sub-call anchored to `block` for snapshot consistency. + /// Concurrency cap is the borrower count; each borrower still issues + /// sequential per-vToken calls, which is the next optimization target + /// (Multicall3 aggregate — follow-up). + async fn fetch_positions( + &self, + borrowers: &[Address], + block: BlockNumberOrTag, + ) -> LendingResult> { + let mut futs = FuturesUnordered::new(); + for &borrower in borrowers { + futs.push(async move { + (borrower, self.fetch_position_inner(borrower, block).await) + }); + } + let mut out = Vec::with_capacity(borrowers.len()); + while let Some((borrower, res)) = futs.next().await { + match res { + Ok(Some(pos)) => out.push(pos), + Ok(None) => {} + Err(err) => warn!(%borrower, ?err, "Venus fetch_position failed, skipping"), + } + } + Ok(out) + } + + /// Return the 1e18-scaled health factor for `borrower` at `block`. + /// + /// Uses the Comptroller's `getAccountLiquidity` directly: that call + /// already aggregates oracle-USD collateral and borrow values across + /// every asset the borrower has entered, so one round-trip is enough + /// for a gating decision. No oracle / vToken fan-out is needed. + async fn get_health_factor( + &self, + borrower: Address, + block: BlockNumberOrTag, + ) -> LendingResult { + let block_id: BlockId = block.into(); + let comp = abi::IVenusComptroller::new(self.comptroller, self.provider.clone()); + let liq = comp + .getAccountLiquidity(borrower) + .block(block_id) + .call() + .await + .map_err(|e| { + LendingProtocolError::Rpc(format!("getAccountLiquidity({borrower}): {e}")) + })?; + let err_code = liq._0; + let liquidity = liq._1; + let shortfall = liq._2; + if !err_code.is_zero() { + return Err(LendingProtocolError::ProtocolState(format!( + "Comptroller.getAccountLiquidity returned non-zero error code {err_code} for {borrower}" + ))); + } + + // `getAccountLiquidity` reports only net liquidity/shortfall, not + // total borrow value. Without the latter the HF ratio is not + // uniquely defined, so a single aggregate call is not sufficient + // for an oracle-exact HF. Reuse the full position walker, which + // anchors every sub-call to `block`, and derive HF from the same + // totals the scanner would compute. + // + // OPTIMIZATION: a Multicall3 batch of (borrowBalanceStored × + // getUnderlyingPrice) per entered market would cut this to one + // RPC round-trip. Out of scope for this change. + let _ = (liquidity, shortfall); + let pos = self + .fetch_position_inner(borrower, block) + .await + .map_err(rpc_err)?; + match pos { + Some(p) => Ok(p.health_factor), + None => { + // No debt → treat as very healthy (2e18). Matches the + // convention used inside `fetch_position_inner`. + Ok(one_e18().saturating_mul(U256::from(2u64))) + } + } + } + + /// Close factor on Venus is a **global** Comptroller parameter + /// (`closeFactorMantissa`), not per-market. We ignore `market` and + /// return the cached value from the latest snapshot. + /// + /// Returns `LendingProtocolError::ProtocolState` if the snapshot is + /// currently being refreshed (write-locked); the caller should retry. + /// This keeps the method synchronous as the trait requires while + /// avoiding a spinning busy-wait. + fn get_close_factor(&self, _market: Address) -> LendingResult { + match self.snapshot.try_read() { + Ok(snap) => Ok(snap.close_factor_mantissa), + Err(_) => Err(LendingProtocolError::ProtocolState( + "Venus snapshot is being refreshed — retry".into(), + )), + } + } + + /// Liquidation incentive on Venus is also a **global** Comptroller + /// parameter (`liquidationIncentiveMantissa`), not per-market. + /// `collateral_market` is accepted to match the trait shape. + async fn get_liquidation_incentive( + &self, + _collateral_market: Address, + ) -> LendingResult { + Ok(self.snapshot.read().await.liquidation_incentive_mantissa) + } + + fn get_liquidation_params(&self, position: &Position) -> LendingResult { + let snap = self.snapshot.try_read().map_err(|_| { + LendingProtocolError::ProtocolState("Venus snapshot is being refreshed — retry".into()) + })?; + let collateral_vtoken = snap + .underlying_to_vtoken + .get(&position.collateral_token) + .copied() + .ok_or_else(|| { + LendingProtocolError::UnsupportedAsset(position.collateral_token) + })?; + let debt_vtoken = snap + .underlying_to_vtoken + .get(&position.debt_token) + .copied() + .ok_or_else(|| LendingProtocolError::UnsupportedAsset(position.debt_token))?; + + let scale = one_e18(); + let repay_amount = position + .debt_amount + .checked_mul(snap.close_factor_mantissa) + .ok_or_else(|| { + LendingProtocolError::ProtocolState("Venus: repay-amount overflow".into()) + })? + / scale; + + if repay_amount.is_zero() { + return Err(LendingProtocolError::InvalidPosition( + "Venus: computed repay_amount is zero (debt or close_factor is zero)".into(), + )); + } + + Ok(LiquidationParams::Venus { + borrower: position.borrower, + collateral_vtoken, + debt_vtoken, + repay_amount, + }) + } + + fn build_liquidation_calldata(&self, params: &LiquidationParams) -> LendingResult> { + encode_liquidate_borrow_calldata(params) + } +} + +fn encode_liquidate_borrow_calldata(params: &LiquidationParams) -> LendingResult> { + // `LiquidationParams` is `#[non_exhaustive]` so the pattern is refutable + // from a downstream crate even though `Venus` is the only variant today. + // Any non-Venus variant is a caller bug — route through the router that + // pairs a `Venus` params struct with this adapter. + let LiquidationParams::Venus { + borrower, + collateral_vtoken, + debt_vtoken: _, + repay_amount, + } = params + else { + return Err(LendingProtocolError::ProtocolState( + "encode_liquidate_borrow_calldata called with non-Venus LiquidationParams".into(), + )); + }; + + let call = abi::IVToken::liquidateBorrowCall { + borrower: *borrower, + repayAmount: *repay_amount, + vTokenCollateral: *collateral_vtoken, + }; + Ok(call.abi_encode()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + #[test] + fn liquidate_borrow_calldata_has_correct_selector() { + let params = LiquidationParams::Venus { + borrower: address!("1111111111111111111111111111111111111111"), + collateral_vtoken: address!("2222222222222222222222222222222222222222"), + debt_vtoken: address!("3333333333333333333333333333333333333333"), + repay_amount: U256::from(42u64), + }; + let data = encode_liquidate_borrow_calldata(¶ms).expect("encode"); + + assert_eq!( + &data[..4], + &abi::IVToken::liquidateBorrowCall::SELECTOR, + "selector mismatch — check ABI definition order" + ); + assert_eq!(data.len(), 4 + 32 * 3); + } +} diff --git a/crates/charon-protocols/tests/venus_connect.rs b/crates/charon-protocols/tests/venus_connect.rs new file mode 100644 index 0000000..ea15d6d --- /dev/null +++ b/crates/charon-protocols/tests/venus_connect.rs @@ -0,0 +1,49 @@ +//! Live connectivity smoke test for `VenusAdapter::connect`. +//! +//! Skipped unless `BNB_WS_URL` is set — CI / offline runs see no failure, +//! local dev gets a real BSC handshake. Kept intentionally thin; richer +//! integration tests against borrower snapshots land with the +//! `LendingProtocol` impl. + +use std::str::FromStr; +use std::sync::Arc; + +use alloy::primitives::{Address, U256}; +use alloy::providers::{ProviderBuilder, WsConnect}; +use charon_protocols::VenusAdapter; + +/// Venus Unitroller on BSC mainnet. +const VENUS_COMPTROLLER_BSC: &str = "0xfD36E2c2a6789Db23113685031d7F16329158384"; + +#[tokio::test] +async fn connect_against_bsc_snapshots_markets() { + let _ = dotenvy::dotenv(); + let Ok(ws_url) = std::env::var("BNB_WS_URL") else { + eprintln!("skipping: BNB_WS_URL not set"); + return; + }; + + let provider = ProviderBuilder::new() + .on_ws(WsConnect::new(ws_url)) + .await + .expect("ws connect"); + let comptroller = Address::from_str(VENUS_COMPTROLLER_BSC).unwrap(); + + let adapter = VenusAdapter::connect(Arc::new(provider), comptroller) + .await + .expect("venus connect"); + + assert!( + !adapter.markets().await.is_empty(), + "Venus Comptroller should expose at least one vToken market" + ); + assert_ne!( + adapter.oracle().await, + Address::ZERO, + "Venus oracle address should be non-zero" + ); + assert!( + adapter.close_factor_mantissa().await > U256::ZERO, + "close factor should be non-zero" + ); +} diff --git a/crates/charon-protocols/tests/venus_fetch.rs b/crates/charon-protocols/tests/venus_fetch.rs new file mode 100644 index 0000000..5f982e6 --- /dev/null +++ b/crates/charon-protocols/tests/venus_fetch.rs @@ -0,0 +1,53 @@ +//! Live `fetch_positions` smoke test against BSC. +//! +//! Skipped without `BNB_WS_URL`. Verifies the pipeline — Comptroller, +//! oracle, per-vToken reads — survives real on-chain state without +//! panicking and returns well-formed `Position` structs (or an empty +//! vec for addresses with no Venus activity). + +use std::str::FromStr; +use std::sync::Arc; + +use alloy::eips::BlockNumberOrTag; +use alloy::primitives::Address; +use alloy::providers::{ProviderBuilder, WsConnect}; +use charon_core::{LendingProtocol, ProtocolId}; +use charon_protocols::VenusAdapter; + +const VENUS_COMPTROLLER_BSC: &str = "0xfD36E2c2a6789Db23113685031d7F16329158384"; + +/// An address with no Venus interaction — should yield an empty result. +const EMPTY_ADDRESS: &str = "0x000000000000000000000000000000000000dEaD"; + +#[tokio::test] +async fn fetch_positions_returns_ok_for_empty_address() { + let _ = dotenvy::dotenv(); + let Ok(ws_url) = std::env::var("BNB_WS_URL") else { + eprintln!("skipping: BNB_WS_URL not set"); + return; + }; + + let provider = ProviderBuilder::new() + .on_ws(WsConnect::new(ws_url)) + .await + .expect("ws connect"); + let comptroller = Address::from_str(VENUS_COMPTROLLER_BSC).unwrap(); + + let adapter = VenusAdapter::connect(Arc::new(provider), comptroller) + .await + .expect("venus connect"); + + let empty = Address::from_str(EMPTY_ADDRESS).unwrap(); + let positions = adapter + .fetch_positions(&[empty], BlockNumberOrTag::Latest) + .await + .expect("fetch_positions should not error on a clean address"); + + // Valid outcomes: no positions at all, or a Position whose fields + // reflect whatever state the address has. Either way, no panic. + for p in &positions { + assert_eq!(p.protocol, ProtocolId::Venus); + assert_eq!(p.chain_id, 56); + assert_eq!(p.borrower, empty); + } +}