diff --git a/Cargo.lock b/Cargo.lock index 5283b5a..eb1f5a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1071,7 +1071,10 @@ name = "charon-core" version = "0.1.0" dependencies = [ "alloy", + "anyhow", + "async-trait", "serde", + "thiserror 2.0.18", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 32c08da..c9fb52b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,10 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1" +thiserror = "2" + +# Async trait objects +async-trait = "0.1" # CLI clap = { version = "4", features = ["derive"] } diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index eba4c43..ba49653 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -8,3 +8,6 @@ description = "Shared types, traits, and config for Charon" [dependencies] alloy = { workspace = true } serde = { workspace = true } +anyhow = { workspace = true } +async-trait = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index e7ea85d..a7dd0a0 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -1,7 +1,10 @@ //! Charon core — shared types, traits, and config. +pub mod traits; pub mod types; +pub use traits::{LendingProtocol, LendingProtocolError, Result as LendingResult}; pub use types::{ - FlashLoanSource, LiquidationOpportunity, Position, ProtocolId, SwapRoute, + FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, + ProtocolId, SwapRoute, }; diff --git a/crates/charon-core/src/traits.rs b/crates/charon-core/src/traits.rs new file mode 100644 index 0000000..3a64cd0 --- /dev/null +++ b/crates/charon-core/src/traits.rs @@ -0,0 +1,117 @@ +//! Traits that define the contracts between layers of the bot. +//! +//! `LendingProtocol` is the main boundary: each protocol (Venus, Aave, …) +//! implements it, and the scanner/executor consume it without caring which +//! protocol is behind the adapter. + +use alloy::eips::BlockNumberOrTag; +use alloy::primitives::{Address, U256}; +use async_trait::async_trait; + +use crate::types::{LiquidationParams, Position, ProtocolId}; + +/// Structured error for lending-protocol adapter operations. +/// +/// Callers match on the variant to decide retry vs skip vs abort: +/// - `Rpc` — transient transport failure, retry with backoff. +/// - `InvalidPosition` — borrower data is malformed or inconsistent, skip. +/// - `UnsupportedAsset` — asset not listed on this protocol, skip market. +/// - `ProtocolState` — an invariant broken (e.g. vToken ↔ underlying mismatch), +/// escalate / alert. +/// - `Abi` — calldata encode/decode failure, treat as bug. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum LendingProtocolError { + #[error("rpc error: {0}")] + Rpc(String), + #[error("invalid position: {0}")] + InvalidPosition(String), + #[error("unsupported asset: {0}")] + UnsupportedAsset(Address), + #[error("protocol state: {0}")] + ProtocolState(String), + #[error("abi: {0}")] + Abi(String), +} + +/// Shorthand `Result` for trait methods. +pub type Result = std::result::Result; + +/// A lending protocol adapter. +/// +/// - Scanner calls [`fetch_positions`](LendingProtocol::fetch_positions) on +/// each block (or on relevant events) to pick up health-factor changes. +/// - Scanner also calls [`get_health_factor`](LendingProtocol::get_health_factor) +/// for single-borrower refreshes on mempool oracle updates. +/// - Executor calls [`get_liquidation_params`](LendingProtocol::get_liquidation_params) +/// and [`build_liquidation_calldata`](LendingProtocol::build_liquidation_calldata) +/// when a position crosses the liquidation threshold, to encode the +/// on-chain call to `CharonLiquidator.executeLiquidation(...)`. +/// +/// Implementations must be `Send + Sync` so the scanner can hold them in +/// `Arc` and share across tokio tasks. +/// +/// `#[async_trait]` is used for `dyn`-compatibility. Native AFIT (Rust 1.75+) +/// is not object-safe without `trait_variant` bounds, and the scanner stores +/// protocol adapters behind `Arc`; the per-call boxing overhead is +/// negligible relative to the RPC round-trip dominating every method. +#[async_trait] +pub trait LendingProtocol: Send + Sync { + /// Stable identifier for this protocol. + fn id(&self) -> ProtocolId; + + /// Fetch current position state for the given borrowers, pinned to + /// a specific block for snapshot consistency. + /// + /// Scanner passes `BlockNumberOrTag::Latest` for routine scans and a + /// specific block number to reconcile state against an observed head. + /// Mempool-driven paths pass `BlockNumberOrTag::Pending`. + async fn fetch_positions( + &self, + borrowers: &[Address], + block: BlockNumberOrTag, + ) -> Result>; + + /// Return the 1e18-scaled health factor for a single borrower at `block`. + /// + /// Cheaper than `fetch_positions` for gating decisions (e.g. mempool + /// monitor refresh) because only one aggregate call is needed. + async fn get_health_factor( + &self, + borrower: Address, + block: BlockNumberOrTag, + ) -> Result; + + /// Close factor for a market, 1e18-scaled. + /// + /// Venus default is 0.5e18 (50% of debt per liquidation) but is + /// per-market and governed. Aave V3 caps at 0.5e18 structurally. + /// Required by the profit calculator to bound `repay_amount`. + fn get_close_factor(&self, market: Address) -> Result; + + /// Liquidation incentive for the given collateral market, 1e18-scaled. + /// + /// Venus uses `liquidationIncentiveMantissa` (default 1.1e18 = 10% + /// bonus) and it is per-market. Aave uses `LiquidationBonus` in bps. + /// Profit calculator scales seized collateral by this value. + async fn get_liquidation_incentive( + &self, + collateral_market: Address, + ) -> Result; + + /// Compute protocol-specific liquidation parameters for a position. + /// + /// Handles close-factor math (Aave's 50% cap, Compound's 100% absorb, + /// etc.) and resolves any protocol-specific token addresses (e.g., Venus + /// vToken addresses). + fn get_liquidation_params( + &self, + position: &Position, + ) -> Result; + + /// Encode the ABI calldata for `CharonLiquidator.executeLiquidation(...)`. + fn build_liquidation_calldata( + &self, + params: &LiquidationParams, + ) -> Result>; +} diff --git a/crates/charon-core/src/types.rs b/crates/charon-core/src/types.rs index 1fde6fd..251bea4 100644 --- a/crates/charon-core/src/types.rs +++ b/crates/charon-core/src/types.rs @@ -61,6 +61,30 @@ pub struct SwapRoute { pub pool_fee: u32, } +/// Protocol-specific parameters needed to build a liquidation call. +/// +/// Every lending protocol has its own quirks (Aave allows partial liquidation, +/// Compound absorbs 100%, Venus uses vToken addresses, etc.). Each variant +/// captures exactly the fields its protocol needs — no shared bag of options. +/// +/// Marked `#[non_exhaustive]` at both enum and variant level so adding new +/// variants (AaveV3, Compound, …) or new fields on existing variants is not +/// a semver-breaking change for downstream exhaustive matches. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[non_exhaustive] +pub enum LiquidationParams { + #[non_exhaustive] + Venus { + borrower: Address, + /// vToken of the collateral asset (the token seized). + collateral_vtoken: Address, + /// vToken of the debt asset (the token repaid). + debt_vtoken: Address, + /// Amount of debt to repay, in underlying-debt-token units (not vToken units). + repay_amount: U256, + }, +} + /// A profitable liquidation that has passed all off-chain gates and is /// ready to be built into a transaction. #[derive(Debug, Clone, Serialize, Deserialize)]