From 2bbe1875956c7242bccf5ba093d0a6e79c640b75 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 18:47:12 +0530 Subject: [PATCH 1/7] feat(protocols): scaffold charon-protocols crate with VenusAdapter stub (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First commit in the #8 series. Lays out the crate layout so subsequent commits can land ABIs, RPC wiring, and the LendingProtocol impl without simultaneously introducing new module boundaries. - New `charon-protocols` workspace member with a single `venus` module - `VenusAdapter` is a minimal struct holding the Comptroller address; provider, vToken discovery, and trait impl land in follow-ups - No scanner/executor consumption yet — zero behaviour change for existing commands (`listen`, `test-connection`) --- Cargo.lock | 11 +++++++++ Cargo.toml | 2 ++ crates/charon-protocols/Cargo.toml | 13 ++++++++++ crates/charon-protocols/src/lib.rs | 14 +++++++++++ crates/charon-protocols/src/venus.rs | 36 ++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+) create mode 100644 crates/charon-protocols/Cargo.toml create mode 100644 crates/charon-protocols/src/lib.rs create mode 100644 crates/charon-protocols/src/venus.rs diff --git a/Cargo.lock b/Cargo.lock index d5646e9..55bee3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1146,6 +1146,17 @@ dependencies = [ "toml", ] +[[package]] +name = "charon-protocols" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "async-trait", + "charon-core", + "tracing", +] + [[package]] name = "charon-scanner" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 906efc8..18258e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/charon-core", + "crates/charon-protocols", "crates/charon-scanner", "crates/charon-cli", ] @@ -45,4 +46,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-protocols/Cargo.toml b/crates/charon-protocols/Cargo.toml new file mode 100644 index 0000000..d734491 --- /dev/null +++ b/crates/charon-protocols/Cargo.toml @@ -0,0 +1,13 @@ +[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 } 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..b705f81 --- /dev/null +++ b/crates/charon-protocols/src/venus.rs @@ -0,0 +1,36 @@ +//! 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)`. +//! +//! This file is a scaffold — ABIs, provider wiring, and the +//! [`LendingProtocol`](charon_core::LendingProtocol) implementation land +//! across the next commits in the #8 series. + +use alloy::primitives::Address; + +/// Venus adapter — see module docs. +/// +/// Holds the Comptroller address for the chain it's running on. Further +/// fields (pub-sub provider, cached vToken list, price oracle address) +/// are added alongside the ABI bindings in the next commit. +#[derive(Debug, Clone)] +pub struct VenusAdapter { + /// Address of the Venus Unitroller (main Comptroller proxy). + pub comptroller: Address, +} + +impl VenusAdapter { + /// Build an adapter pointing at the given Venus Comptroller. + /// + /// This is intentionally minimal for now; the async constructor that + /// also discovers vToken markets and the price oracle lands in the + /// next commit. + pub fn new(comptroller: Address) -> Self { + Self { comptroller } + } +} From 94b5391a4bced669f5e929bc9d65a42e3d81ec77 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 18:49:24 +0530 Subject: [PATCH 2/7] feat(protocols): add Venus Comptroller + VToken + Oracle ABIs via sol! (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second commit in the #8 series. Defines typed RPC bindings for every Venus method the scanner and executor need, using alloy's `sol!` macro with `#[sol(rpc)]` so each interface gets a `new(address, provider)` constructor and per-method decoding for free. - `IVenusComptroller`: getAccountLiquidity, getAssetsIn, getAllMarkets, closeFactorMantissa, liquidationIncentiveMantissa, oracle - `IVToken`: underlying, balanceOf, borrowBalanceStored/Current, balanceOfUnderlying, exchangeRateStored, decimals, symbol, liquidateBorrow - `IVenusOracle`: getUnderlyingPrice (Compound-style scaling) Still no runtime logic — bindings only. VenusAdapter continues to be a scaffold struct; the LendingProtocol impl lands next commit. --- crates/charon-protocols/src/venus.rs | 96 ++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs index b705f81..37bc42e 100644 --- a/crates/charon-protocols/src/venus.rs +++ b/crates/charon-protocols/src/venus.rs @@ -7,17 +7,105 @@ //! into the shared `Position` type and encodes liquidation calls through //! `VToken.liquidateBorrow(borrower, repayAmount, vTokenCollateral)`. //! -//! This file is a scaffold — ABIs, provider wiring, and the -//! [`LendingProtocol`](charon_core::LendingProtocol) implementation land -//! across the next commits in the #8 series. +//! This file is a scaffold — the `LendingProtocol` impl lands alongside +//! the provider wiring in the next commit. use alloy::primitives::Address; +use alloy::sol; + +/// On-chain ABI bindings used by the Venus adapter. +/// +/// `#[sol(rpc)]` generates typed `new(address, provider)` constructors so +/// each call — `getAccountLiquidity`, `liquidateBorrow`, … — is one +/// method on the returned instance, with arguments and return values +/// decoded through `alloy`'s codec. +/// +/// Method surface is kept to exactly what the scanner and executor need; +/// we add more entries here as downstream code demands them. +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); + + /// Bonus paid to liquidators (scaled 1e18, e.g. 1.1e18 = 10%). + 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. + /// + /// Mutating methods (`borrowBalanceCurrent`, `balanceOfUnderlying`) + /// accrue interest before returning; we call them via `eth_call` + /// so state is simulated, not committed. + #[sol(rpc)] + interface IVToken { + /// Underlying ERC-20 address (missing on `vBNB` — native wrapped). + 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); + + /// Current borrow balance with interest accrued. + function borrowBalanceCurrent(address account) external returns (uint256); + + /// Collateral expressed in underlying units, interest-accrued. + function balanceOfUnderlying(address owner) external 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); + } + } +} /// Venus adapter — see module docs. /// /// Holds the Comptroller address for the chain it's running on. Further /// fields (pub-sub provider, cached vToken list, price oracle address) -/// are added alongside the ABI bindings in the next commit. +/// are added alongside the provider wiring in the next commit. #[derive(Debug, Clone)] pub struct VenusAdapter { /// Address of the Venus Unitroller (main Comptroller proxy). From 8024e18db0fd6e5306f482bd4e1811327c585b37 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 18:55:44 +0530 Subject: [PATCH 3/7] feat(protocols): VenusAdapter async connect + market snapshot (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third commit in the #8 series. Wires the adapter to a shared pub-sub provider and caches Venus's rarely-changing market config at connect time, so per-block calls don't re-hit the Comptroller for invariants. - `VenusAdapter::connect(provider, comptroller)` fetches the oracle address, vToken market list, and close factor via three read-only RPCs; stores them on the struct - Shared provider held as `Arc>` so the scanner can hand the same provider to multiple adapters cheaply - Live integration test (`tests/venus_connect.rs`) hits BSC mainnet when `BNB_WS_URL` is set, skipped otherwise — proves the Comptroller roundtrip works end to end Venus's Diamond Comptroller does not expose `liquidationIncentiveMantissa()` globally (per-market in Diamond facets); that lookup is deferred to the liquidation builder in a later commit rather than bundled into `connect`. --- crates/charon-protocols/Cargo.toml | 4 + crates/charon-protocols/src/venus.rs | 97 ++++++++++++++++--- .../charon-protocols/tests/venus_connect.rs | 49 ++++++++++ 3 files changed, 134 insertions(+), 16 deletions(-) create mode 100644 crates/charon-protocols/tests/venus_connect.rs diff --git a/crates/charon-protocols/Cargo.toml b/crates/charon-protocols/Cargo.toml index d734491..493031a 100644 --- a/crates/charon-protocols/Cargo.toml +++ b/crates/charon-protocols/Cargo.toml @@ -11,3 +11,7 @@ alloy = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +dotenvy = { workspace = true } diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs index 37bc42e..8dfe60e 100644 --- a/crates/charon-protocols/src/venus.rs +++ b/crates/charon-protocols/src/venus.rs @@ -7,11 +7,18 @@ //! into the shared `Position` type and encodes liquidation calls through //! `VToken.liquidateBorrow(borrower, repayAmount, vTokenCollateral)`. //! -//! This file is a scaffold — the `LendingProtocol` impl lands alongside -//! the provider wiring in the next commit. +//! The `LendingProtocol` impl lands in the next commit — this file +//! wires up the async constructor that snapshots market config from the +//! Comptroller (markets, oracle, close factor, liquidation incentive). -use alloy::primitives::Address; +use std::sync::Arc; + +use alloy::primitives::{Address, U256}; +use alloy::providers::RootProvider; +use alloy::pubsub::PubSubFrontend; use alloy::sol; +use anyhow::{Context, Result}; +use tracing::{debug, info}; /// On-chain ABI bindings used by the Venus adapter. /// @@ -45,9 +52,6 @@ pub mod abi { /// Max fraction of debt liquidatable per call (scaled 1e18). function closeFactorMantissa() external view returns (uint256); - /// Bonus paid to liquidators (scaled 1e18, e.g. 1.1e18 = 10%). - function liquidationIncentiveMantissa() external view returns (uint256); - /// Address of the Venus price oracle. function oracle() external view returns (address); } @@ -101,24 +105,85 @@ pub mod abi { } } +/// Shared pub-sub provider — adapters are cheap to clone and keep their +/// own `Arc` so the scanner can hand out multiple adapters without +/// re-opening a WebSocket per protocol. +pub type ChainProvider = Arc>; + /// Venus adapter — see module docs. -/// -/// Holds the Comptroller address for the chain it's running on. Further -/// fields (pub-sub provider, cached vToken list, price oracle address) -/// are added alongside the provider wiring in the next commit. #[derive(Debug, Clone)] pub struct VenusAdapter { /// Address of the Venus Unitroller (main Comptroller proxy). pub comptroller: Address, + /// Price oracle address, discovered from the Comptroller. + pub oracle: Address, + /// vToken markets registered on the Comptroller at connect time. + pub markets: Vec
, + /// Close factor (1e18-scaled fraction of debt liquidatable per call). + pub close_factor_mantissa: U256, + /// Shared pub-sub provider for all downstream RPC calls. + provider: ChainProvider, } impl VenusAdapter { - /// Build an adapter pointing at the given Venus Comptroller. + /// Connect to the Venus Comptroller and snapshot its market config. /// - /// This is intentionally minimal for now; the async constructor that - /// also discovers vToken markets and the price oracle lands in the - /// next commit. - pub fn new(comptroller: Address) -> Self { - Self { comptroller } + /// Performs three read-only RPCs in sequence: `oracle`, + /// `getAllMarkets`, `closeFactorMantissa`. These values are static + /// enough over a bot's lifetime that caching them at connect time + /// saves one round-trip per block without meaningful staleness risk + /// (Venus governance updates are rare and observable). + /// + /// Per-market liquidation incentive is resolved lazily when a + /// liquidation is being built, because Venus's Diamond Comptroller + /// exposes it per-vToken rather than as a global constant. + pub async fn connect(provider: ChainProvider, comptroller: Address) -> Result { + debug!(%comptroller, "connecting Venus adapter"); + + 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; + + info!( + %comptroller, + %oracle, + market_count = markets.len(), + close_factor = %close_factor_mantissa, + "Venus adapter connected" + ); + + Ok(Self { + comptroller, + oracle, + markets, + close_factor_mantissa, + provider, + }) + } + + /// Borrow the shared provider — used by downstream call-builders + /// inside the `LendingProtocol` impl (next commit). + #[allow(dead_code)] + pub(crate) fn provider(&self) -> &ChainProvider { + &self.provider } } diff --git a/crates/charon-protocols/tests/venus_connect.rs b/crates/charon-protocols/tests/venus_connect.rs new file mode 100644 index 0000000..bbd3df4 --- /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.is_empty(), + "Venus Comptroller should expose at least one vToken market" + ); + assert_ne!( + adapter.oracle, + Address::ZERO, + "Venus oracle address should be non-zero" + ); + assert!( + adapter.close_factor_mantissa > U256::ZERO, + "close factor should be non-zero" + ); +} From eb79f7aecb9b27d61cee98b07c323e39b3d8908a Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 19:02:43 +0530 Subject: [PATCH 4/7] feat(protocols): Venus LendingProtocol::fetch_positions over BSC (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth commit in the #8 series. Implements on-chain position discovery for Venus — the core translation that turns Compound V2's `(errorCode, liquidity, shortfall)` + per-vToken balances into the shared `Position` shape the scanner consumes. - `VenusAdapter::connect` now also resolves every vToken's `underlying()` address and caches both directions of the map; vBNB-style native-wrapping markets (no `underlying()`) are skipped with a debug log and left unmapped - Adapter tracks its own `chain_id` via `eth_chainId` for position metadata - `fetch_position_inner` walks `getAssetsIn`, reads per-vToken `borrowBalanceStored` + `balanceOfUnderlying` + oracle price, and picks the single biggest-value debt vToken and biggest-value collateral vToken as the Position to report - Per-asset failures (oracle revert, missing market) are logged and skipped so one broken market can't blank an entire borrower - Health factor synthesised as a binary 0 / 2e18 signal from Venus's `shortfall`: enough for the scanner's `< 1e18` predicate, precise HF arithmetic deferred to the 3-bucket scanner (#9) - `LendingProtocol::get_liquidation_params` and `build_liquidation_calldata` are stubbed with explicit errors — they land in the next commit alongside per-market liquidation incentive lookup Live integration test (`tests/venus_fetch.rs`) calls `fetch_positions` for a clean address on BSC mainnet, verifying the full pipeline returns well-formed (or empty) results without panicking. --- crates/charon-protocols/src/venus.rs | 200 +++++++++++++++++-- crates/charon-protocols/tests/venus_fetch.rs | 52 +++++ 2 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 crates/charon-protocols/tests/venus_fetch.rs diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs index 8dfe60e..c260848 100644 --- a/crates/charon-protocols/src/venus.rs +++ b/crates/charon-protocols/src/venus.rs @@ -7,18 +7,21 @@ //! into the shared `Position` type and encodes liquidation calls through //! `VToken.liquidateBorrow(borrower, repayAmount, vTokenCollateral)`. //! -//! The `LendingProtocol` impl lands in the next commit — this file -//! wires up the async constructor that snapshots market config from the -//! Comptroller (markets, oracle, close factor, liquidation incentive). +//! The liquidation-calldata side of the [`LendingProtocol`] impl lands in +//! the next commit; this file covers position discovery and the +//! health-factor synthesis. +use std::collections::HashMap; use std::sync::Arc; use alloy::primitives::{Address, U256}; -use alloy::providers::RootProvider; +use alloy::providers::{Provider, RootProvider}; use alloy::pubsub::PubSubFrontend; use alloy::sol; use anyhow::{Context, Result}; -use tracing::{debug, info}; +use async_trait::async_trait; +use charon_core::{LendingProtocol, LiquidationParams, Position, ProtocolId}; +use tracing::{debug, info, warn}; /// On-chain ABI bindings used by the Venus adapter. /// @@ -121,6 +124,12 @@ pub struct VenusAdapter { pub markets: Vec
, /// Close factor (1e18-scaled fraction of debt liquidatable per call). pub close_factor_mantissa: U256, + /// Chain id of the network this adapter runs on (56 on BSC). + pub chain_id: u64, + /// `underlying ERC-20 → vToken` lookup, built at connect time. + pub underlying_to_vtoken: HashMap, + /// `vToken → underlying ERC-20` lookup (reverse of the above). + pub vtoken_to_underlying: HashMap, /// Shared pub-sub provider for all downstream RPC calls. provider: ChainProvider, } @@ -128,15 +137,12 @@ pub struct VenusAdapter { impl VenusAdapter { /// Connect to the Venus Comptroller and snapshot its market config. /// - /// Performs three read-only RPCs in sequence: `oracle`, - /// `getAllMarkets`, `closeFactorMantissa`. These values are static - /// enough over a bot's lifetime that caching them at connect time - /// saves one round-trip per block without meaningful staleness risk - /// (Venus governance updates are rare and observable). - /// - /// Per-market liquidation incentive is resolved lazily when a - /// liquidation is being built, because Venus's Diamond Comptroller - /// exposes it per-vToken rather than as a global constant. + /// On top of the three Comptroller reads (`oracle`, `getAllMarkets`, + /// `closeFactorMantissa`) this also walks every vToken to resolve + /// its `underlying()` ERC-20 address and build both directions of + /// the lookup map. vToken contracts whose `underlying()` reverts + /// (e.g. `vBNB`, which wraps native BNB) are skipped — that market + /// is simply unavailable to the adapter until native wrapping lands. pub async fn connect(provider: ChainProvider, comptroller: Address) -> Result { debug!(%comptroller, "connecting Venus adapter"); @@ -163,10 +169,35 @@ impl VenusAdapter { .context("Venus: Comptroller.closeFactorMantissa() failed")? ._0; + let chain_id = provider + .get_chain_id() + .await + .context("Venus: eth_chainId failed")?; + + let mut underlying_to_vtoken = HashMap::with_capacity(markets.len()); + let mut vtoken_to_underlying = HashMap::with_capacity(markets.len()); + for &vtoken in &markets { + 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) => { + debug!( + %vtoken, err = ?err, + "vToken has no underlying() — likely native-wrapping market (skipped)" + ); + } + } + } + info!( %comptroller, %oracle, + chain_id, market_count = markets.len(), + mapped_markets = underlying_to_vtoken.len(), close_factor = %close_factor_mantissa, "Venus adapter connected" ); @@ -176,14 +207,153 @@ impl VenusAdapter { oracle, markets, close_factor_mantissa, + chain_id, + underlying_to_vtoken, + vtoken_to_underlying, provider, }) } + /// Fetch one borrower's largest debt/collateral pair, if any. + /// + /// Walks `getAssetsIn(borrower)`, reads per-vToken borrow + supply + /// balances and oracle prices, and picks the single biggest debt + /// vToken plus the single biggest collateral vToken. Returns `None` + /// when the borrower has no positions or has missing price data on + /// every asset. Per-asset errors are logged but non-fatal so one + /// broken market doesn't blank the entire account. + async fn fetch_position_inner(&self, borrower: Address) -> Result> { + let comp = abi::IVenusComptroller::new(self.comptroller, self.provider.clone()); + + let liq = comp + .getAccountLiquidity(borrower) + .call() + .await + .with_context(|| format!("getAccountLiquidity({borrower}) failed"))?; + let shortfall = liq._2; + + let assets = comp + .getAssetsIn(borrower) + .call() + .await + .with_context(|| format!("getAssetsIn({borrower}) failed"))? + ._0; + if assets.is_empty() { + return Ok(None); + } + + let oracle = abi::IVenusOracle::new(self.oracle, self.provider.clone()); + + // (underlying address, amount in underlying units, rough USD value) + // USD value is a scaled magnitude used only for ranking, not reported. + let mut best_debt: Option<(Address, U256, U256)> = None; + let mut best_coll: Option<(Address, U256, U256)> = None; + + for vtoken in &assets { + let Some(&underlying) = self.vtoken_to_underlying.get(vtoken) else { + continue; + }; + let vt = abi::IVToken::new(*vtoken, self.provider.clone()); + + let borrow = match vt.borrowBalanceStored(borrower).call().await { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, %borrower, ?err, "borrowBalanceStored failed"); + continue; + } + }; + let supply = match vt.balanceOfUnderlying(borrower).call().await { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, %borrower, ?err, "balanceOfUnderlying failed"); + continue; + } + }; + let price = match oracle.getUnderlyingPrice(*vtoken).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); + + 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); + }; + + // Binary health factor: 0 when Venus reports shortfall (fully + // liquidatable), otherwise 2e18 as a healthy marker. The scanner + // only needs the `< 1e18` predicate to bucket positions; precise + // HF arithmetic is a follow-up (#9). + let one_e18 = U256::from(10u64).pow(U256::from(18u64)); + let health_factor = if shortfall > U256::ZERO { + U256::ZERO + } else { + one_e18 * U256::from(2u64) + }; + + // Placeholder bonus — Venus per-market liquidation incentive is + // resolved in Part E when we build the actual liquidation call. + let liquidation_bonus_bps = 1000; + + 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, + })) + } + /// Borrow the shared provider — used by downstream call-builders - /// inside the `LendingProtocol` impl (next commit). + /// inside the `LendingProtocol` impl. #[allow(dead_code)] pub(crate) fn provider(&self) -> &ChainProvider { &self.provider } } + +#[async_trait] +impl LendingProtocol for VenusAdapter { + fn id(&self) -> ProtocolId { + ProtocolId::Venus + } + + async fn fetch_positions(&self, borrowers: &[Address]) -> Result> { + let mut out = Vec::with_capacity(borrowers.len()); + for &borrower in borrowers { + match self.fetch_position_inner(borrower).await { + Ok(Some(pos)) => out.push(pos), + Ok(None) => {} + Err(err) => warn!(%borrower, ?err, "Venus fetch_position failed, skipping"), + } + } + Ok(out) + } + + fn get_liquidation_params(&self, _position: &Position) -> Result { + anyhow::bail!("Venus::get_liquidation_params lands in the next #8 commit") + } + + fn build_liquidation_calldata(&self, _params: &LiquidationParams) -> Result> { + anyhow::bail!("Venus::build_liquidation_calldata lands in the next #8 commit") + } +} diff --git a/crates/charon-protocols/tests/venus_fetch.rs b/crates/charon-protocols/tests/venus_fetch.rs new file mode 100644 index 0000000..0b1521b --- /dev/null +++ b/crates/charon-protocols/tests/venus_fetch.rs @@ -0,0 +1,52 @@ +//! 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::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]) + .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); + } +} From 086aa0ddc75c314e8b4c4337fc311a5720b03f32 Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 19:05:40 +0530 Subject: [PATCH 5/7] feat(protocols): Venus liquidation params + liquidateBorrow calldata (#8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth commit in the #8 series. Completes the `LendingProtocol` impl on the Rust side — the trait no longer returns `bail!("unimplemented")` and an opportunity can now be turned into ready-to-sign bytes. - `get_liquidation_params` maps underlying ERC-20 debt / collateral addresses on the `Position` back to Venus vToken addresses via the adapter's lookup map, and caps `repay_amount` at `debt_amount × close_factor / 1e18` (50% on BSC Venus). Refuses to emit a zero-repay params struct. - `build_liquidation_calldata` delegates to a free `encode_liquidate_borrow_calldata` helper that encodes `VToken.liquidateBorrow(borrower, repayAmount, vTokenCollateral)` via alloy's generated call struct + `SolCall::abi_encode`. Split as a free function so it can be unit-tested without constructing a full adapter. - Unit test asserts the 4-byte selector matches alloy's `liquidateBorrowCall::SELECTOR` constant and that calldata length is selector + 3 × 32-byte slots — catches any accidental ABI drift (wrong arg order, extra params). The outer wrapping into `CharonLiquidator.executeLiquidation(...)` is intentionally *not* done here; that calldata shape depends on the Solidity contract, which is a separate milestone (M2 Execution). Shipping the inner Venus-specific calldata now unblocks scanner + profit-calc work without waiting on the contract. --- crates/charon-protocols/src/venus.rs | 104 +++++++++++++++++++++++++-- 1 file changed, 100 insertions(+), 4 deletions(-) diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs index c260848..c1720c0 100644 --- a/crates/charon-protocols/src/venus.rs +++ b/crates/charon-protocols/src/venus.rs @@ -18,6 +18,7 @@ use alloy::primitives::{Address, U256}; 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, LiquidationParams, Position, ProtocolId}; @@ -349,11 +350,106 @@ impl LendingProtocol for VenusAdapter { Ok(out) } - fn get_liquidation_params(&self, _position: &Position) -> Result { - anyhow::bail!("Venus::get_liquidation_params lands in the next #8 commit") + fn get_liquidation_params(&self, position: &Position) -> Result { + let collateral_vtoken = self + .underlying_to_vtoken + .get(&position.collateral_token) + .copied() + .with_context(|| { + format!( + "Venus: no vToken mapped for collateral underlying {}", + position.collateral_token + ) + })?; + let debt_vtoken = self + .underlying_to_vtoken + .get(&position.debt_token) + .copied() + .with_context(|| { + format!( + "Venus: no vToken mapped for debt underlying {}", + position.debt_token + ) + })?; + + // Venus liquidation repay cap: `debt_amount × close_factor / 1e18`. + // Close factor on BSC Venus Diamond is 0.5e18 → repay half the debt. + let one_e18 = U256::from(10u64).pow(U256::from(18u64)); + let repay_amount = position + .debt_amount + .checked_mul(self.close_factor_mantissa) + .context("Venus: repay-amount overflow")? + / one_e18; + + if repay_amount.is_zero() { + anyhow::bail!("Venus: computed repay_amount is zero (debt or close_factor is zero)"); + } + + Ok(LiquidationParams::Venus { + borrower: position.borrower, + collateral_vtoken, + debt_vtoken, + repay_amount, + }) } - fn build_liquidation_calldata(&self, _params: &LiquidationParams) -> Result> { - anyhow::bail!("Venus::build_liquidation_calldata lands in the next #8 commit") + fn build_liquidation_calldata(&self, params: &LiquidationParams) -> Result> { + encode_liquidate_borrow_calldata(params) + } +} + +/// Encode the raw Venus `VToken.liquidateBorrow(borrower, repayAmount, +/// vTokenCollateral)` call. +/// +/// This is the inner calldata that `CharonLiquidator.sol` will re-emit +/// toward the debt vToken inside its flash-loan callback; the outer +/// wrapping into `CharonLiquidator.executeLiquidation(...)` is added +/// when the on-chain contract is wired in (separate milestone). +/// +/// Split out as a free function so unit tests can exercise the encoder +/// without constructing a full `VenusAdapter` (which needs a live WS +/// provider). +fn encode_liquidate_borrow_calldata(params: &LiquidationParams) -> Result> { + let LiquidationParams::Venus { + borrower, + collateral_vtoken, + debt_vtoken: _, + repay_amount, + } = params; + + 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"); + + // Selector = keccak256("liquidateBorrow(address,uint256,address)")[:4] + // == 0xf5e3c462. Alloy's generated SELECTOR constant is the + // canonical source; we pin against it to catch accidental ABI + // drift (wrong argument order, extra params, …). + assert_eq!( + &data[..4], + &abi::IVToken::liquidateBorrowCall::SELECTOR, + "selector mismatch — check ABI definition order" + ); + // 4 bytes selector + 3 × 32-byte slots for the args. + assert_eq!(data.len(), 4 + 32 * 3); } } From 20d3efca53db0c80ccc2a420bef064ad01a05cde Mon Sep 17 00:00:00 2001 From: obchain Date: Mon, 20 Apr 2026 19:09:29 +0530 Subject: [PATCH 6/7] feat(cli): wire Venus adapter into the listen subcommand (closes #8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final commit in the #8 series. The adapter built across Parts A–E is now driven by real block events: `charon listen` constructs a `VenusAdapter` at startup and, for every new BSC block from the existing `BlockListener`, runs one `fetch_positions` scan against a caller-supplied borrower list. - `listen --borrower 0x…` flag (repeatable, empty by default) seeds the scan list. Full borrower discovery from indexed `Borrow` events is its own task, tracked under the health-scanner milestone (#9) - Per-block structured log: `venus scan chain=… block=… tracked=… returned=… scan_ms=…` - Adapter and listener use separate WebSocket connections for now; sharing a single pub-sub provider is a cheap optimisation once the scanner owns the full runtime (#9) - `charon-cli` now depends on `charon-protocols` and the alloy primitives crate for address parsing via clap Live soak on BSC: 25 blocks / 25 scans in 30 s, zero warnings, Venus adapter snapshot reports 48 markets with 47 mapped (vBNB skipped, as intended). --- crates/charon-cli/Cargo.toml | 2 + crates/charon-cli/src/main.rs | 93 +++++++++++++++++++++++++---------- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/crates/charon-cli/Cargo.toml b/crates/charon-cli/Cargo.toml index e80e544..b971ac8 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 032e6ed..7e5f059 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -2,13 +2,18 @@ //! //! ```text //! 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::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; @@ -34,11 +39,17 @@ struct Cli { #[derive(Subcommand, Debug)] enum Command { - /// Spawn one block listener per configured chain and print new blocks. + /// Spawn block listeners + run the Venus adapter every new block. /// - /// 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. + Listen { + /// Addresses to scan on every new block. Repeat the flag for + /// multiple borrowers. Empty list is allowed (adapter still + /// connects so the operator can confirm the WS pipeline). + #[arg(long = "borrower")] + borrowers: Vec
, + }, /// Connect to a configured chain and print its latest block number. TestConnection { @@ -76,7 +87,7 @@ 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 .chain @@ -91,15 +102,37 @@ async fn main() -> Result<()> { Ok(()) } -/// Spawn one `BlockListener` per configured chain, drain the shared -/// `ChainEvent` channel, and exit on Ctrl-C. -async fn run_listen(config: Config) -> Result<()> { - if config.chain.is_empty() { - anyhow::bail!("no chains configured — nothing to listen to"); - } +/// Spawn block listeners, wire up the Venus adapter, and for every new +/// block scan the supplied borrower list. For v0.1 the protocol is +/// hard-wired to Venus on BNB Chain — matching the config scope. +async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { + // 1. Venus adapter — connects to BNB over WebSocket and snapshots + // Comptroller config (markets, oracle, close factor). + let bnb = config + .chain + .get("bnb") + .context("chain 'bnb' not configured — required for v0.1")?; + let venus_cfg = config + .protocol + .get("venus") + .context("protocol 'venus' not configured — required for v0.1")?; + + let adapter_ws = ProviderBuilder::new() + .on_ws(WsConnect::new(&bnb.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 (tx, mut rx) = mpsc::channel::(CHAIN_EVENT_CHANNEL); + info!( + borrower_count = borrowers.len(), + market_count = adapter.markets.len(), + "venus adapter ready" + ); + // 2. Block listeners — one per configured chain, fan-in to a shared + // mpsc. Each listener owns its own reconnect loop. + let (tx, mut rx) = mpsc::channel::(CHAIN_EVENT_CHANNEL); for (name, chain_cfg) in config.chain { let listener = BlockListener::new(name.clone(), chain_cfg, tx.clone()); tokio::spawn(async move { @@ -108,31 +141,39 @@ async fn run_listen(config: Config) -> Result<()> { } }); } - // Drop our sender so the channel closes when every listener exits. drop(tx); info!("listen: draining chain events (Ctrl-C to stop)"); + // 3. Drain loop: on every new block, run one Venus scan. tokio::select! { _ = async { while let Some(event) = rx.recv().await { match event { ChainEvent::NewBlock { chain, number, timestamp } => { - tracing::debug!( - chain = %chain, - block = number, - timestamp = timestamp, - "cli drained event" - ); + let start = std::time::Instant::now(); + match adapter.fetch_positions(&borrowers).await { + Ok(positions) => { + info!( + chain = %chain, + block = number, + timestamp = timestamp, + 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" + ), + } } } } - } => { - info!("all listeners exited"); - } - _ = tokio::signal::ctrl_c() => { - info!("ctrl-c received, shutting down"); - } + } => info!("all listeners exited"), + _ = tokio::signal::ctrl_c() => info!("ctrl-c received, shutting down"), } Ok(()) From 060fb96e438828d47fe35ff6f588d875fd506c87 Mon Sep 17 00:00:00 2001 From: obchain Date: Wed, 22 Apr 2026 20:32:05 +0530 Subject: [PATCH 7/7] feat(protocols): pure-view scans, real HF, vBNB mapping, fetched incentive, concurrent borrowers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace balanceOfUnderlying (non-view, accrues interest) with balanceOf * exchangeRateStored / 1e18 on the scan path. Works against rate-limited and view-only RPC proxies that reject state-mutating eth_calls. balanceOfUnderlying + borrowBalanceCurrent removed from the ABI entirely to prevent future accidental use. - Real health factor derived from the Comptroller's own liquidity and shortfall values plus the per-block sum of borrow * price: HF = (total_borrow_val +/- liquidity_or_shortfall) / total_borrow_val, 1e18-scaled. Replaces the 0/2e18 binary placeholder so bucket classifiers can rank positions by urgency. - vBNB special case: vBNB (0xA07c…) has no underlying() and used to be silently skipped, invisibling BSC's largest Venus market. connect() now maps vBNB -> Wrapped BNB (0xbb4C…) in both directions so BNB- collateral borrowers are scanned and can be liquidated. - Add liquidationIncentiveMantissa() to the Comptroller ABI, fetch it at connect alongside closeFactorMantissa, and derive liquidation_bonus_bps from the live value at scan time: bps = (mantissa - 1e18) / 1e14. Governance can change the incentive without the bot running on a stale hardcoded 1000 bps. - VenusAdapter fields are now pub(crate) / private, guarded by a tokio RwLock so they can be refreshed atomically. A new refresh() async method re-queries Comptroller state and swaps the snapshot; operators (or a timer/event watcher) call it to pick up governance changes and newly listed markets without restart. markets(), oracle(), close_factor_mantissa(), liquidation_incentive_mantissa() expose read-only accessors. - fetch_positions now processes borrowers concurrently through FuturesUnordered so the scan walltime drops from sequential 50+ borrowers to the slowest single borrower. Follow-up issue should introduce Multicall3 aggregate for per-borrower per-vToken reads. Closes #97 #98 #99 #100 #101 #102 --- Cargo.lock | 5 + crates/charon-cli/src/main.rs | 2 +- crates/charon-protocols/Cargo.toml | 2 + crates/charon-protocols/src/venus.rs | 287 ++++++++++++++++----------- 4 files changed, 176 insertions(+), 120 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 55bee3e..217dbd4 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", @@ -1154,6 +1156,9 @@ dependencies = [ "anyhow", "async-trait", "charon-core", + "dotenvy", + "futures-util", + "tokio", "tracing", ] diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 7e5f059..1225f14 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -126,7 +126,7 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { info!( borrower_count = borrowers.len(), - market_count = adapter.markets.len(), + market_count = adapter.markets().await.len(), "venus adapter ready" ); diff --git a/crates/charon-protocols/Cargo.toml b/crates/charon-protocols/Cargo.toml index 493031a..d059054 100644 --- a/crates/charon-protocols/Cargo.toml +++ b/crates/charon-protocols/Cargo.toml @@ -11,6 +11,8 @@ 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 } diff --git a/crates/charon-protocols/src/venus.rs b/crates/charon-protocols/src/venus.rs index c1720c0..313efd9 100644 --- a/crates/charon-protocols/src/venus.rs +++ b/crates/charon-protocols/src/venus.rs @@ -6,15 +6,11 @@ //! 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)`. -//! -//! The liquidation-calldata side of the [`LendingProtocol`] impl lands in -//! the next commit; this file covers position discovery and the -//! health-factor synthesis. use std::collections::HashMap; use std::sync::Arc; -use alloy::primitives::{Address, U256}; +use alloy::primitives::{Address, U256, address}; use alloy::providers::{Provider, RootProvider}; use alloy::pubsub::PubSubFrontend; use alloy::sol; @@ -22,17 +18,21 @@ use alloy::sol_types::SolCall; use anyhow::{Context, Result}; use async_trait::async_trait; use charon_core::{LendingProtocol, 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)) +} + /// On-chain ABI bindings used by the Venus adapter. -/// -/// `#[sol(rpc)]` generates typed `new(address, provider)` constructors so -/// each call — `getAccountLiquidity`, `liquidateBorrow`, … — is one -/// method on the returned instance, with arguments and return values -/// decoded through `alloy`'s codec. -/// -/// Method surface is kept to exactly what the scanner and executor need; -/// we add more entries here as downstream code demands them. pub mod abi { use super::sol; @@ -56,18 +56,22 @@ pub mod abi { /// 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. /// - /// Mutating methods (`borrowBalanceCurrent`, `balanceOfUnderlying`) - /// accrue interest before returning; we call them via `eth_call` - /// so state is simulated, not committed. + /// 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 wrapped). + /// Underlying ERC-20 address (missing on `vBNB` — native BNB). function underlying() external view returns (address); /// vToken share balance of `owner`. @@ -77,12 +81,6 @@ pub mod abi { function borrowBalanceStored(address account) external view returns (uint256); - /// Current borrow balance with interest accrued. - function borrowBalanceCurrent(address account) external returns (uint256); - - /// Collateral expressed in underlying units, interest-accrued. - function balanceOfUnderlying(address owner) external returns (uint256); - /// vToken → underlying exchange rate (scaled 1e18 + underlying decimals). function exchangeRateStored() external view returns (uint256); @@ -109,44 +107,73 @@ pub mod abi { } } -/// Shared pub-sub provider — adapters are cheap to clone and keep their -/// own `Arc` so the scanner can hand out multiple adapters without -/// re-opening a WebSocket per protocol. 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 { - /// Address of the Venus Unitroller (main Comptroller proxy). - pub comptroller: Address, - /// Price oracle address, discovered from the Comptroller. - pub oracle: Address, - /// vToken markets registered on the Comptroller at connect time. - pub markets: Vec
, - /// Close factor (1e18-scaled fraction of debt liquidatable per call). - pub close_factor_mantissa: U256, - /// Chain id of the network this adapter runs on (56 on BSC). - pub chain_id: u64, - /// `underlying ERC-20 → vToken` lookup, built at connect time. - pub underlying_to_vtoken: HashMap, - /// `vToken → underlying ERC-20` lookup (reverse of the above). - pub vtoken_to_underlying: HashMap, - /// Shared pub-sub provider for all downstream RPC calls. + comptroller: Address, + chain_id: u64, + snapshot: Arc>, provider: ChainProvider, } impl VenusAdapter { /// Connect to the Venus Comptroller and snapshot its market config. - /// - /// On top of the three Comptroller reads (`oracle`, `getAllMarkets`, - /// `closeFactorMantissa`) this also walks every vToken to resolve - /// its `underlying()` ERC-20 address and build both directions of - /// the lookup map. vToken contracts whose `underlying()` reverts - /// (e.g. `vBNB`, which wraps native BNB) are skipped — that market - /// is simply unavailable to the adapter until native wrapping lands. 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 @@ -155,29 +182,33 @@ impl VenusAdapter { .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 chain_id = provider - .get_chain_id() + let liquidation_incentive_mantissa = comp + .liquidationIncentiveMantissa() + .call() .await - .context("Venus: eth_chainId failed")?; + .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) => { @@ -185,45 +216,47 @@ impl VenusAdapter { vtoken_to_underlying.insert(vtoken, r._0); } Err(err) => { - debug!( + warn!( %vtoken, err = ?err, - "vToken has no underlying() — likely native-wrapping market (skipped)" + "vToken has no underlying() and is not the known vBNB market — scanner will ignore it" ); } } } - info!( - %comptroller, - %oracle, - chain_id, - market_count = markets.len(), - mapped_markets = underlying_to_vtoken.len(), - close_factor = %close_factor_mantissa, - "Venus adapter connected" - ); - - Ok(Self { - comptroller, + Ok(VenusSnapshot { oracle, markets, close_factor_mantissa, - chain_id, + liquidation_incentive_mantissa, underlying_to_vtoken, vtoken_to_underlying, - provider, }) } + /// 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. /// /// Walks `getAssetsIn(borrower)`, reads per-vToken borrow + supply - /// balances and oracle prices, and picks the single biggest debt - /// vToken plus the single biggest collateral vToken. Returns `None` - /// when the borrower has no positions or has missing price data on - /// every asset. Per-asset errors are logged but non-fatal so one - /// broken market doesn't blank the entire account. + /// 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) -> Result> { + let snap = self.snapshot.read().await.clone(); let comp = abi::IVenusComptroller::new(self.comptroller, self.provider.clone()); let liq = comp @@ -231,6 +264,7 @@ impl VenusAdapter { .call() .await .with_context(|| format!("getAccountLiquidity({borrower}) failed"))?; + let liquidity = liq._1; let shortfall = liq._2; let assets = comp @@ -243,15 +277,16 @@ impl VenusAdapter { return Ok(None); } - let oracle = abi::IVenusOracle::new(self.oracle, self.provider.clone()); + let oracle = abi::IVenusOracle::new(snap.oracle, self.provider.clone()); + let scale = one_e18(); - // (underlying address, amount in underlying units, rough USD value) - // USD value is a scaled magnitude used only for ranking, not reported. 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) = self.vtoken_to_underlying.get(vtoken) else { + 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()); @@ -263,13 +298,23 @@ impl VenusAdapter { continue; } }; - let supply = match vt.balanceOfUnderlying(borrower).call().await { + // View-only underlying balance: vToken shares × exchangeRate / 1e18. + let v_balance = match vt.balanceOf(borrower).call().await { + Ok(r) => r._0, + Err(err) => { + warn!(%vtoken, %borrower, ?err, "balanceOf failed"); + continue; + } + }; + let exchange_rate = match vt.exchangeRateStored().call().await { Ok(r) => r._0, Err(err) => { - warn!(%vtoken, %borrower, ?err, "balanceOfUnderlying failed"); + warn!(%vtoken, ?err, "exchangeRateStored failed"); continue; } }; + let supply = v_balance.saturating_mul(exchange_rate) / scale; + let price = match oracle.getUnderlyingPrice(*vtoken).call().await { Ok(r) => r._0, Err(err) => { @@ -280,6 +325,7 @@ impl VenusAdapter { 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)); @@ -296,20 +342,29 @@ impl VenusAdapter { return Ok(None); }; - // Binary health factor: 0 when Venus reports shortfall (fully - // liquidatable), otherwise 2e18 as a healthy marker. The scanner - // only needs the `< 1e18` predicate to bucket positions; precise - // HF arithmetic is a follow-up (#9). - let one_e18 = U256::from(10u64).pow(U256::from(18u64)); - let health_factor = if shortfall > U256::ZERO { - U256::ZERO + // 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 = if total_borrow_val.is_zero() { + // No debt priced this block → treat as healthy marker. + scale.saturating_mul(U256::from(2u64)) + } else if shortfall > U256::ZERO { + let eff = total_borrow_val.saturating_sub(shortfall); + eff.saturating_mul(scale) / total_borrow_val } else { - one_e18 * U256::from(2u64) + let eff = total_borrow_val.saturating_add(liquidity); + eff.saturating_mul(scale) / total_borrow_val }; - // Placeholder bonus — Venus per-market liquidation incentive is - // resolved in Part E when we build the actual liquidation call. - let liquidation_bonus_bps = 1000; + // 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, @@ -324,8 +379,6 @@ impl VenusAdapter { })) } - /// Borrow the shared provider — used by downstream call-builders - /// inside the `LendingProtocol` impl. #[allow(dead_code)] pub(crate) fn provider(&self) -> &ChainProvider { &self.provider @@ -338,10 +391,20 @@ impl LendingProtocol for VenusAdapter { ProtocolId::Venus } + /// Fetch positions for every borrower concurrently via `FuturesUnordered`. + /// 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]) -> Result> { - let mut out = Vec::with_capacity(borrowers.len()); + let mut futs = FuturesUnordered::new(); for &borrower in borrowers { - match self.fetch_position_inner(borrower).await { + futs.push(async move { + (borrower, self.fetch_position_inner(borrower).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"), @@ -351,7 +414,11 @@ impl LendingProtocol for VenusAdapter { } fn get_liquidation_params(&self, position: &Position) -> Result { - let collateral_vtoken = self + let snap = self + .snapshot + .try_read() + .context("Venus: snapshot is being refreshed — retry")?; + let collateral_vtoken = snap .underlying_to_vtoken .get(&position.collateral_token) .copied() @@ -361,7 +428,7 @@ impl LendingProtocol for VenusAdapter { position.collateral_token ) })?; - let debt_vtoken = self + let debt_vtoken = snap .underlying_to_vtoken .get(&position.debt_token) .copied() @@ -372,14 +439,12 @@ impl LendingProtocol for VenusAdapter { ) })?; - // Venus liquidation repay cap: `debt_amount × close_factor / 1e18`. - // Close factor on BSC Venus Diamond is 0.5e18 → repay half the debt. - let one_e18 = U256::from(10u64).pow(U256::from(18u64)); + let scale = one_e18(); let repay_amount = position .debt_amount - .checked_mul(self.close_factor_mantissa) + .checked_mul(snap.close_factor_mantissa) .context("Venus: repay-amount overflow")? - / one_e18; + / scale; if repay_amount.is_zero() { anyhow::bail!("Venus: computed repay_amount is zero (debt or close_factor is zero)"); @@ -398,17 +463,6 @@ impl LendingProtocol for VenusAdapter { } } -/// Encode the raw Venus `VToken.liquidateBorrow(borrower, repayAmount, -/// vTokenCollateral)` call. -/// -/// This is the inner calldata that `CharonLiquidator.sol` will re-emit -/// toward the debt vToken inside its flash-loan callback; the outer -/// wrapping into `CharonLiquidator.executeLiquidation(...)` is added -/// when the on-chain contract is wired in (separate milestone). -/// -/// Split out as a free function so unit tests can exercise the encoder -/// without constructing a full `VenusAdapter` (which needs a live WS -/// provider). fn encode_liquidate_borrow_calldata(params: &LiquidationParams) -> Result> { let LiquidationParams::Venus { borrower, @@ -440,16 +494,11 @@ mod tests { }; let data = encode_liquidate_borrow_calldata(¶ms).expect("encode"); - // Selector = keccak256("liquidateBorrow(address,uint256,address)")[:4] - // == 0xf5e3c462. Alloy's generated SELECTOR constant is the - // canonical source; we pin against it to catch accidental ABI - // drift (wrong argument order, extra params, …). assert_eq!( &data[..4], &abi::IVToken::liquidateBorrowCall::SELECTOR, "selector mismatch — check ABI definition order" ); - // 4 bytes selector + 3 × 32-byte slots for the args. assert_eq!(data.len(), 4 + 32 * 3); } }