diff --git a/Cargo.lock b/Cargo.lock index 710cb14..6c1f2ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1150,6 +1150,20 @@ dependencies = [ "toml", ] +[[package]] +name = "charon-flashloan" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "async-trait", + "charon-core", + "dotenvy", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "charon-protocols" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 326d454..974cf66 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/charon-core", + "crates/charon-flashloan", "crates/charon-protocols", "crates/charon-scanner", "crates/charon-cli", @@ -65,5 +66,6 @@ dotenvy = "0.15" # Internal crates charon-core = { path = "crates/charon-core" } +charon-flashloan = { path = "crates/charon-flashloan" } charon-protocols = { path = "crates/charon-protocols" } charon-scanner = { path = "crates/charon-scanner" } diff --git a/config/default.toml b/config/default.toml index 0809fce..bd7aa5d 100644 --- a/config/default.toml +++ b/config/default.toml @@ -38,6 +38,9 @@ comptroller = "0xfd36e2c2a6789db23113685031d7f16329158384" chain = "bnb" # Aave V3 Pool on BSC (used for flashLoanSimple — 0.05% fee) pool = "0x6807dc923806fe8fd134338eabca509979a7e0cb" +# Aave V3 PoolDataProvider on BSC — resolves aTokens and reserve +# configuration bitmaps (paused / frozen flags) for the adapter. +data_provider = "0x41393e5e337606dc3821075Af65AeE84D7688CBD" # ── Deployed liquidator contracts ───────────────────────────────────────── # Populated once CharonLiquidator.sol is deployed on BSC mainnet. Do not diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index 3bce3fe..c44928a 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -12,6 +12,6 @@ workspace = true alloy = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } -thiserror = { workspace = true } diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index ac1b6e0..da2f17b 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -175,6 +175,12 @@ pub struct FlashLoanConfig { pub chain: String, /// Pool / vault address (Aave V3 Pool, Balancer Vault, etc.). pub pool: Address, + /// Optional auxiliary data-provider address used by some sources + /// to resolve per-asset state (e.g. Aave V3 `PoolDataProvider` + /// for aToken lookup and reserve configuration bitmaps). `None` + /// for sources that don't need one (Balancer, Uniswap). + #[serde(default)] + pub data_provider: Option
, } /// Address of the deployed `CharonLiquidator` contract on a chain. diff --git a/crates/charon-core/src/flashloan.rs b/crates/charon-core/src/flashloan.rs new file mode 100644 index 0000000..a9619ea --- /dev/null +++ b/crates/charon-core/src/flashloan.rs @@ -0,0 +1,158 @@ +//! Flash-loan provider abstraction. +//! +//! Every flash-loan source (Balancer V2, Aave V3, Uniswap V3, ...) plugs +//! in through the [`FlashLoanProvider`] trait. The router (in +//! `charon-flashloan`) walks a list of providers in fee-priority order +//! and picks the cheapest source with enough liquidity for the token + +//! amount it needs to borrow. +//! +//! # Fee-rate unit +//! +//! All fee rates in this module are expressed in **millionths (1e6)**, +//! the same convention Uniswap uses for pool-fee tiers. Examples: +//! +//! * Balancer V2 flash loan: `0` (fee-free) +//! * Aave V3 flash loan: `500` = 0.05% +//! * Uniswap V3 0.30% pool: `3_000` +//! * Uniswap V3 1.00% pool: `10_000` +//! +//! This intentionally differs from Aave's on-chain encoding, which +//! stores the premium as 4-decimal percent (e.g. `5` means 0.05%). +//! Adapters are expected to convert into millionths at construction. +//! +//! The trait is kept deliberately thin: +//! +//! * `available_liquidity` — can the source cover the requested amount? +//! * `fee_rate_millionths` — how expensive is borrowing from this source? +//! * `quote` — one-shot helper that rolls the two checks above into a +//! ready-to-use [`FlashLoanQuote`], or `None` when the source cannot +//! serve this borrow. +//! * `build_flashloan_calldata` — encode the outer call to the source +//! (e.g. `Pool.flashLoanSimple`, `Vault.flashLoan`) that wraps the +//! inner liquidation parameters the protocol adapter produced. + +use alloy::primitives::{Address, U256}; +use async_trait::async_trait; +use thiserror::Error; + +use crate::types::FlashLoanSource; + +/// Errors returned by [`FlashLoanProvider`] implementations. +/// +/// The variants capture the failure modes the router needs to +/// distinguish (e.g. a paused reserve is not the same as a transient +/// RPC hiccup). `#[non_exhaustive]` so new sources can extend the +/// taxonomy without breaking downstream `match`es. +#[derive(Debug, Error)] +#[non_exhaustive] +pub enum FlashLoanError { + /// The source does not hold enough liquidity of the requested + /// token to cover the borrow. + #[error("insufficient liquidity: have {have}, need {need}")] + InsufficientLiquidity { have: U256, need: U256 }, + + /// The reserve is paused or frozen on the source (Aave V3 pauses + /// reserves during incidents, freezes them during deprecation). + #[error("reserve paused or frozen for asset {asset}")] + ReservePaused { asset: Address }, + + /// The adapter was connected to the wrong chain. + #[error("chain id mismatch: expected {expected}, got {actual}")] + ChainIdMismatch { expected: u64, actual: u64 }, + + /// An RPC call failed (timeout, provider error, decode failure, ...). + #[error("rpc error: {0}")] + Rpc(String), + + /// Any other unclassified failure. + #[error("flash-loan provider error: {0}")] + Other(String), +} + +impl FlashLoanError { + /// Convenience for wrapping an `anyhow::Error` into + /// [`FlashLoanError::Rpc`] at call sites that still bubble up + /// generic RPC failures. + pub fn rpc(err: E) -> Self { + Self::Rpc(err.to_string()) + } + + /// Convenience for unclassified failures. + pub fn other(err: E) -> Self { + Self::Other(err.to_string()) + } +} + +/// Snapshot of a single flash-loan opportunity from one source. +/// +/// The router produces these for the top-ranked liquidations; the tx +/// builder consumes them alongside the inner liquidation parameters to +/// encode the final on-chain call. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FlashLoanQuote { + pub source: FlashLoanSource, + /// Chain the source is deployed on. + pub chain_id: u64, + /// Token being borrowed (the debt token of the liquidation target). + pub token: Address, + /// Amount to borrow, in the token's smallest unit. + pub amount: U256, + /// Absolute fee to repay alongside `amount`, same units as `amount`. + pub fee: U256, + /// Fee rate in millionths (1e6). `500` = 0.05% (Aave V3). `0` = + /// Balancer / Maker flash mint. See module docs for unit rationale. + pub fee_rate_millionths: u32, + /// Address to call to initiate the flash loan + /// (Aave pool, Balancer vault, Uniswap pool, ...). + pub pool_address: Address, +} + +/// Flash-loan source adapter. +/// +/// Implementations live in `charon-flashloan` (one per source) and are +/// consumed by the router as trait objects. The trait is `Send + Sync` +/// so a provider can be shared across the block listener, scanner, and +/// executor tasks without copying state. +/// +/// All fees are returned in **millionths (1e6)** — see module docs. +#[async_trait] +pub trait FlashLoanProvider: Send + Sync { + /// Which concrete source this provider wraps. + fn source(&self) -> FlashLoanSource; + + /// Chain id the source is deployed on. + fn chain_id(&self) -> u64; + + /// Current liquidity available for `token`, in its smallest unit. + /// Returns `0` when the source does not support the token at all, + /// or [`FlashLoanError::ReservePaused`] when the reserve is + /// administratively disabled. + async fn available_liquidity(&self, token: Address) -> Result; + + /// Fee rate in millionths (1e6). `500` = 0.05% (Aave V3). `0` = + /// Balancer / Maker flash mint. See module docs. + fn fee_rate_millionths(&self) -> u32; + + /// Roll `available_liquidity` + `fee_rate_millionths` into a ready + /// quote for the requested borrow, or `None` when the source + /// cannot cover the amount on this chain/token. + async fn quote( + &self, + token: Address, + amount: U256, + ) -> Result, FlashLoanError>; + + /// Encode the outer flash-loan initiation call. + /// + /// `liquidation_params` is whatever the flash-loan recipient + /// contract (`CharonLiquidator.sol`) needs inside its callback — + /// typically the ABI-encoded protocol adapter liquidation + /// parameters. It MUST be non-empty; every real + /// `executeOperation` implementation decodes this payload and will + /// revert on an empty buffer. + fn build_flashloan_calldata( + &self, + quote: &FlashLoanQuote, + liquidation_params: &[u8], + ) -> Result, FlashLoanError>; +} diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index 6838413..f63763a 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -1,10 +1,12 @@ //! Charon core — shared types, traits, and config. pub mod config; +pub mod flashloan; pub mod traits; pub mod types; pub use config::{Config, ConfigError}; +pub use flashloan::{FlashLoanError, FlashLoanProvider, FlashLoanQuote}; pub use traits::{LendingProtocol, LendingProtocolError, Result as LendingResult}; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, diff --git a/crates/charon-flashloan/Cargo.toml b/crates/charon-flashloan/Cargo.toml new file mode 100644 index 0000000..54e782a --- /dev/null +++ b/crates/charon-flashloan/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "charon-flashloan" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Flash-loan source adapters and router for Charon" + +[dependencies] +charon-core = { workspace = true } +alloy = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } +dotenvy = { workspace = true } diff --git a/crates/charon-flashloan/src/aave.rs b/crates/charon-flashloan/src/aave.rs new file mode 100644 index 0000000..a837ba6 --- /dev/null +++ b/crates/charon-flashloan/src/aave.rs @@ -0,0 +1,381 @@ +//! Aave V3 flash-loan adapter. +//! +//! Aave V3 is the default flash-loan source on BSC for Charon v0.1 — +//! Balancer is not deployed there, so the router falls straight to Aave +//! for every liquidation. The adapter reads the current +//! `FLASHLOAN_PREMIUM_TOTAL` (Aave encodes it as 4-decimal percent, +//! e.g. `5` means 0.05%) at connect time and converts it to the +//! workspace-wide millionths (1e6) convention by multiplying by 100 +//! (Aave `5` -> `500` millionths). It also uses the Aave +//! `PoolDataProvider` to resolve each asset's aToken (whose underlying +//! balance is the liquidity ceiling for a flash loan) and to read the +//! reserve configuration bitmap so paused / frozen reserves are +//! rejected before the router even tries to build calldata. + +use std::sync::Arc; + +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::{FlashLoanError, FlashLoanProvider, FlashLoanQuote, FlashLoanSource}; +use tracing::{debug, info}; + +/// BSC mainnet chain id. The adapter refuses to connect to anything +/// else — Aave V3 is deployed on many chains but the current config +/// and receiver only target BNB Chain. +pub const BSC_CHAIN_ID: u64 = 56; + +/// Aave encodes the flash-loan premium as 4-decimal percent (e.g. `5` +/// means 0.05%). We store fee rates in millionths (1e6) workspace-wide, +/// so multiply Aave's value by this factor at conversion time. +const AAVE_PREMIUM_TO_MILLIONTHS: u32 = 100; + +/// Denominator used by Aave when applying the premium on-chain. Kept +/// private — external callers should read `fee` out of the quote +/// rather than recomputing it. +const AAVE_PREMIUM_DENOMINATOR: u64 = 10_000; + +/// Aave V3 reserve configuration bitmap layout. Only the bits the +/// adapter cares about are extracted; see the Aave V3 +/// `ReserveConfiguration.sol` library for the full layout. +const RESERVE_FROZEN_BIT: u32 = 57; +const RESERVE_PAUSED_BIT: u32 = 60; + +sol! { + /// Aave V3 Pool — flash-loan entry point. + #[sol(rpc)] + interface IAaveV3Pool { + /// Flash-loan a single asset; the receiver's `executeOperation` + /// is called inside the same tx and must approve `Pool` for + /// `amount + premium` before returning. + function flashLoanSimple( + address receiverAddress, + address asset, + uint256 amount, + bytes calldata params, + uint16 referralCode + ) external; + + /// Flash-loan premium in 4-decimal percent (e.g. `5` = 0.05%). + function FLASHLOAN_PREMIUM_TOTAL() external view returns (uint128); + } + + /// Aave V3 PoolDataProvider — resolves asset → aToken / debt tokens + /// and exposes the packed reserve configuration bitmap. + #[sol(rpc)] + interface IAaveV3DataProvider { + function getReserveTokensAddresses(address asset) + external view returns ( + address aTokenAddress, + address stableDebtTokenAddress, + address variableDebtTokenAddress + ); + + function getReserveConfigurationData(address asset) + external view returns ( + uint256 decimals, + uint256 ltv, + uint256 liquidationThreshold, + uint256 liquidationBonus, + uint256 reserveFactor, + bool usageAsCollateralEnabled, + bool borrowingEnabled, + bool stableBorrowRateEnabled, + bool isActive, + bool isFrozen + ); + + /// Packed configuration bitmap — Aave stores paused (bit 60) + /// and frozen (bit 57) flags here. The per-field accessors + /// above are convenient but don't currently expose `paused`, + /// so we read the bitmap directly. + function getReserveData(address asset) + external view returns ( + uint256 configuration, + uint128 liquidityIndex, + uint128 currentLiquidityRate, + uint128 variableBorrowIndex, + uint128 currentVariableBorrowRate, + uint128 currentStableBorrowRate, + uint40 lastUpdateTimestamp, + uint16 id, + address aTokenAddress, + address stableDebtTokenAddress, + address variableDebtTokenAddress, + address interestRateStrategyAddress, + uint128 accruedToTreasury, + uint128 unbacked, + uint128 isolationModeTotalDebt + ); + } + + /// ERC-20 surface we need (balance-of only). + #[sol(rpc)] + interface IERC20 { + function balanceOf(address account) external view returns (uint256); + } +} + +/// Aave V3 flash-loan adapter. +/// +/// Paired with a single liquidator receiver — the +/// `CharonLiquidator.sol` contract deployed for this operator. The +/// adapter does not own any keys or tokens; it only encodes calls and +/// reads on-chain state. +#[derive(Clone)] +pub struct AaveFlashLoan { + provider: Arc>, + pool: Address, + data_provider: Address, + /// Receiver = `CharonLiquidator.sol`. Must implement + /// `IFlashLoanSimpleReceiver.executeOperation` to handle the + /// callback. + receiver: Address, + chain_id: u64, + /// Fee rate in millionths (1e6). Aave V3 BSC is typically + /// `500` (= 0.05%). + fee_rate_millionths: u32, + /// Aave's raw premium in 4-decimal percent (needed to recompute + /// the absolute fee at quote time without losing precision). + aave_premium: u32, +} + +impl AaveFlashLoan { + /// Connect to the pool, cache its current flash-loan premium, and + /// verify the chain id. + /// + /// `data_provider` is the Aave V3 `PoolDataProvider` address for + /// the chain — plumbed in from [`charon_core::config::FlashLoanConfig`] + /// so multi-chain expansion is a config change, not a code change. + pub async fn connect( + provider: Arc>, + pool: Address, + data_provider: Address, + receiver: Address, + ) -> Result { + debug!(%pool, %data_provider, %receiver, "connecting Aave V3 flash-loan adapter"); + + let chain_id = provider + .get_chain_id() + .await + .context("Aave V3: eth_chainId failed")?; + anyhow::ensure!( + chain_id == BSC_CHAIN_ID, + "Aave V3 adapter is BSC-only for v0.1: expected chain_id {BSC_CHAIN_ID}, got {chain_id}" + ); + + let pool_if = IAaveV3Pool::new(pool, provider.clone()); + let premium = pool_if + .FLASHLOAN_PREMIUM_TOTAL() + .call() + .await + .context("Aave V3: FLASHLOAN_PREMIUM_TOTAL() failed")? + ._0; + let aave_premium = u32::try_from(premium) + .context("Aave V3: premium does not fit in u32 — unexpected value")?; + // Aave 5 (0.05%) -> 500 millionths. + let fee_rate_millionths = aave_premium + .checked_mul(AAVE_PREMIUM_TO_MILLIONTHS) + .context("Aave V3: premium -> millionths overflow")?; + + info!( + %pool, + %data_provider, + %receiver, + chain_id, + aave_premium, + fee_rate_millionths, + "Aave V3 flash-loan adapter ready" + ); + + Ok(Self { + provider, + pool, + data_provider, + receiver, + chain_id, + fee_rate_millionths, + aave_premium, + }) + } + + /// Return the aToken address for `asset`. Falls back to `None` when + /// Aave does not list the asset on this chain (call reverts or + /// returns the zero address). + async fn atoken_for(&self, asset: Address) -> Result, FlashLoanError> { + let dp = IAaveV3DataProvider::new(self.data_provider, self.provider.clone()); + match dp.getReserveTokensAddresses(asset).call().await { + Ok(r) => { + let atoken = r.aTokenAddress; + if atoken == Address::ZERO { + Ok(None) + } else { + Ok(Some(atoken)) + } + } + Err(_) => Ok(None), + } + } + + /// Check the reserve's packed configuration for paused / frozen + /// flags. Either one makes a flash loan revert on Aave, so the + /// adapter rejects the borrow before even asking for liquidity. + async fn assert_reserve_open(&self, asset: Address) -> Result<(), FlashLoanError> { + let dp = IAaveV3DataProvider::new(self.data_provider, self.provider.clone()); + let cfg = dp + .getReserveConfigurationData(asset) + .call() + .await + .map_err(|e| FlashLoanError::rpc(format!("getReserveConfigurationData: {e}")))?; + if !cfg.isActive || cfg.isFrozen { + return Err(FlashLoanError::ReservePaused { asset }); + } + // Paused is not exposed via the typed accessor, so read the + // packed bitmap and check bit 60 ourselves. + let data = dp + .getReserveData(asset) + .call() + .await + .map_err(|e| FlashLoanError::rpc(format!("getReserveData: {e}")))?; + if bit_is_set(data.configuration, RESERVE_PAUSED_BIT) + || bit_is_set(data.configuration, RESERVE_FROZEN_BIT) + { + return Err(FlashLoanError::ReservePaused { asset }); + } + Ok(()) + } +} + +/// Return true when bit `index` is set in the Aave packed +/// configuration `U256`. +fn bit_is_set(bitmap: U256, index: u32) -> bool { + (bitmap >> index) & U256::from(1u8) == U256::from(1u8) +} + +#[async_trait] +impl FlashLoanProvider for AaveFlashLoan { + fn source(&self) -> FlashLoanSource { + FlashLoanSource::AaveV3 + } + + fn chain_id(&self) -> u64 { + self.chain_id + } + + fn fee_rate_millionths(&self) -> u32 { + self.fee_rate_millionths + } + + async fn available_liquidity(&self, token: Address) -> Result { + self.assert_reserve_open(token).await?; + let Some(atoken) = self.atoken_for(token).await? else { + return Ok(U256::ZERO); + }; + let erc20 = IERC20::new(token, self.provider.clone()); + let bal = erc20 + .balanceOf(atoken) + .call() + .await + .map_err(|e| FlashLoanError::rpc(format!("balanceOf({atoken}): {e}")))? + ._0; + Ok(bal) + } + + async fn quote( + &self, + token: Address, + amount: U256, + ) -> Result, FlashLoanError> { + let liquidity = self.available_liquidity(token).await?; + if liquidity < amount { + return Ok(None); + } + // fee = amount * aave_premium / 10_000 (Aave's canonical math, + // preserved exactly — we don't round-trip through millionths). + let fee = amount + .checked_mul(U256::from(self.aave_premium)) + .ok_or_else(|| FlashLoanError::other("fee multiplication overflow"))? + / U256::from(AAVE_PREMIUM_DENOMINATOR); + Ok(Some(FlashLoanQuote { + source: FlashLoanSource::AaveV3, + chain_id: self.chain_id, + token, + amount, + fee, + fee_rate_millionths: self.fee_rate_millionths, + pool_address: self.pool, + })) + } + + fn build_flashloan_calldata( + &self, + quote: &FlashLoanQuote, + liquidation_params: &[u8], + ) -> Result, FlashLoanError> { + if liquidation_params.is_empty() { + return Err(FlashLoanError::other( + "build_flashloan_calldata: liquidation_params is empty; \ + executeOperation would revert on ABI decode", + )); + } + let call = IAaveV3Pool::flashLoanSimpleCall { + receiverAddress: self.receiver, + asset: quote.token, + amount: quote.amount, + params: liquidation_params.to_vec().into(), + referralCode: 0, + }; + Ok(call.abi_encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + #[test] + fn flash_loan_simple_calldata_has_correct_selector() { + let quote = FlashLoanQuote { + source: FlashLoanSource::AaveV3, + chain_id: 56, + token: address!("1111111111111111111111111111111111111111"), + amount: U256::from(1_000u64), + fee: U256::from(5u64), + fee_rate_millionths: 500, + pool_address: address!("2222222222222222222222222222222222222222"), + }; + // Standalone encoder mirror of `build_flashloan_calldata` so we + // can test without constructing the full adapter (needs a real + // WS provider). + let call = IAaveV3Pool::flashLoanSimpleCall { + receiverAddress: address!("3333333333333333333333333333333333333333"), + asset: quote.token, + amount: quote.amount, + params: vec![0xDE, 0xAD, 0xBE, 0xEF].into(), + referralCode: 0, + }; + let bytes = call.abi_encode(); + assert_eq!( + &bytes[..4], + &IAaveV3Pool::flashLoanSimpleCall::SELECTOR, + "selector mismatch — check flashLoanSimple arg order" + ); + } + + #[test] + fn bit_is_set_reads_paused_and_frozen_bits() { + let paused = U256::from(1u64) << RESERVE_PAUSED_BIT; + let frozen = U256::from(1u64) << RESERVE_FROZEN_BIT; + assert!(bit_is_set(paused, RESERVE_PAUSED_BIT)); + assert!(!bit_is_set(paused, RESERVE_FROZEN_BIT)); + assert!(bit_is_set(frozen, RESERVE_FROZEN_BIT)); + assert!(!bit_is_set(frozen, RESERVE_PAUSED_BIT)); + assert!(!bit_is_set(U256::ZERO, RESERVE_PAUSED_BIT)); + assert!(!bit_is_set(U256::ZERO, RESERVE_FROZEN_BIT)); + } +} diff --git a/crates/charon-flashloan/src/lib.rs b/crates/charon-flashloan/src/lib.rs new file mode 100644 index 0000000..b972804 --- /dev/null +++ b/crates/charon-flashloan/src/lib.rs @@ -0,0 +1,13 @@ +//! Flash-loan source adapters + router. +//! +//! One module per source. Each implements +//! [`charon_core::FlashLoanProvider`] so the router can treat them +//! uniformly. For v0.1 only the Aave V3 adapter on BNB Chain is wired +//! up; Balancer V2 and Uniswap V3 flash-swap adapters land alongside +//! multi-chain expansion. + +pub mod aave; +pub mod router; + +pub use aave::AaveFlashLoan; +pub use router::FlashLoanRouter; diff --git a/crates/charon-flashloan/src/router.rs b/crates/charon-flashloan/src/router.rs new file mode 100644 index 0000000..191ea8e --- /dev/null +++ b/crates/charon-flashloan/src/router.rs @@ -0,0 +1,289 @@ +//! Flash-loan router. +//! +//! Walks configured [`FlashLoanProvider`]s in ascending fee-rate order +//! (Aave V3 0.05% -> PancakeSwap V3 pool fee tier). Returns the first +//! source that can cover the requested borrow. If none can, returns +//! `None` — the caller skips the liquidation rather than sourcing +//! capital from elsewhere. +//! +//! BSC-only for v0.1: the two entries are Aave V3 (`flashLoanSimple`, +//! 5 bps) and PancakeSwap V3 (flash-swap, pool-tier fee). The provider +//! list is built from config so onboarding additional sources or chains +//! is a config change, not a refactor. + +use std::sync::Arc; + +use alloy::primitives::{Address, U256}; +use charon_core::{FlashLoanProvider, FlashLoanQuote}; +use tracing::{debug, info, warn}; + +/// Fee-priority flash-loan router. +/// +/// Built once from a pre-built list of provider handles. Cloning the +/// router is cheap — providers sit behind `Arc`. +/// +/// Providers are sorted at construction by `fee_rate_millionths` +/// ascending; ties are broken by *best-known* available liquidity +/// descending at sort time. The liquidity snapshot is pulled once +/// during construction via [`sort_by_liquidity`] — callers that need +/// live-weighted ordering should rebuild the router. +pub struct FlashLoanRouter { + providers: Vec>, +} + +impl FlashLoanRouter { + /// Construct a router, sorting providers by `fee_rate_millionths` + /// ascending so the cheapest source is tried first. + /// + /// No tiebreaker is applied here because that would require an + /// async liquidity probe at construction; see + /// [`Self::with_liquidity_tiebreaker`] for an async constructor + /// that breaks fee-rate ties by live available liquidity desc. + pub fn new(mut providers: Vec>) -> Self { + providers.sort_by_key(|p| p.fee_rate_millionths()); + Self { providers } + } + + /// Like [`Self::new`] but probes each provider for + /// `available_liquidity(token)` and uses the result as a + /// tiebreaker when two providers advertise the same fee rate. + /// Providers that error out are treated as zero-liquidity and + /// sorted last within their fee-rate bucket. + /// + /// Order: `fee_rate_millionths ASC, available_liquidity DESC`. + pub async fn with_liquidity_tiebreaker( + providers: Vec>, + token: Address, + ) -> Self { + let mut rated: Vec<(Arc, U256)> = + Vec::with_capacity(providers.len()); + for p in providers { + let liq = p.available_liquidity(token).await.unwrap_or(U256::ZERO); + rated.push((p, liq)); + } + rated.sort_by(|a, b| { + a.0.fee_rate_millionths() + .cmp(&b.0.fee_rate_millionths()) + .then_with(|| b.1.cmp(&a.1)) + }); + Self { + providers: rated.into_iter().map(|(p, _)| p).collect(), + } + } + + /// Providers the router will consider, in the order it tries them. + pub fn providers(&self) -> &[Arc] { + &self.providers + } + + /// Pick the cheapest provider that can cover `amount` of `token`. + /// + /// Per-provider failures (RPC error, insufficient liquidity, paused + /// reserve) are logged and the walk continues — one dark source + /// shouldn't block liquidation if a cheaper one can't cover but a + /// pricier one can. + pub async fn route(&self, token: Address, amount: U256) -> Option { + for provider in &self.providers { + let source = provider.source(); + let fee_rate_millionths = provider.fee_rate_millionths(); + match provider.quote(token, amount).await { + Ok(Some(quote)) => { + info!( + source = ?source, + fee_rate_millionths, + token = %token, + amount = %amount, + "flash-loan source selected" + ); + return Some(quote); + } + Ok(None) => { + debug!( + source = ?source, + fee_rate_millionths, + token = %token, + amount = %amount, + "source skipped: insufficient liquidity" + ); + } + Err(err) => { + warn!( + source = ?source, + fee_rate_millionths, + token = %token, + ?err, + "source skipped: quote failed" + ); + } + } + } + warn!( + token = %token, + amount = %amount, + provider_count = self.providers.len(), + "no flash-loan source could cover the borrow" + ); + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + use async_trait::async_trait; + use charon_core::{FlashLoanError, FlashLoanSource}; + + /// In-memory provider for router tests — skips all RPC. + struct StubProvider { + source: FlashLoanSource, + fee_rate_millionths: u32, + liquidity: U256, + chain: u64, + } + + #[async_trait] + impl FlashLoanProvider for StubProvider { + fn source(&self) -> FlashLoanSource { + self.source + } + fn chain_id(&self) -> u64 { + self.chain + } + fn fee_rate_millionths(&self) -> u32 { + self.fee_rate_millionths + } + async fn available_liquidity(&self, _t: Address) -> Result { + Ok(self.liquidity) + } + async fn quote( + &self, + token: Address, + amount: U256, + ) -> Result, FlashLoanError> { + if self.liquidity < amount { + return Ok(None); + } + let fee = amount * U256::from(self.fee_rate_millionths) / U256::from(1_000_000u64); + Ok(Some(FlashLoanQuote { + source: self.source, + chain_id: self.chain, + token, + amount, + fee, + fee_rate_millionths: self.fee_rate_millionths, + pool_address: Address::ZERO, + })) + } + fn build_flashloan_calldata( + &self, + _q: &FlashLoanQuote, + inner: &[u8], + ) -> Result, FlashLoanError> { + if inner.is_empty() { + return Err(FlashLoanError::other("empty liquidation_params")); + } + Ok(inner.to_vec()) + } + } + + fn token() -> Address { + address!("1111111111111111111111111111111111111111") + } + + #[tokio::test] + async fn picks_cheapest_source_with_sufficient_liquidity() { + // Cheaper source: Aave V3 at 5 bps (500 millionths). + let aave = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_rate_millionths: 500, + liquidity: U256::from(1_000u64), + chain: 56, + }); + // Pricier source: PancakeSwap V3 at the 25 bps pool tier (2500 millionths). + let pancake = Arc::new(StubProvider { + source: FlashLoanSource::PancakeSwapV3, + fee_rate_millionths: 2500, + liquidity: U256::from(1_000_000u64), + chain: 56, + }); + + // Pass them in reverse order; router should sort internally. + let router = FlashLoanRouter::new(vec![pancake, aave]); + let quote = router + .route(token(), U256::from(500u64)) + .await + .expect("route"); + assert_eq!(quote.source, FlashLoanSource::AaveV3); + assert_eq!(quote.fee_rate_millionths, 500); + } + + #[tokio::test] + async fn falls_through_to_next_source_when_cheaper_has_no_liquidity() { + // Cheaper source (Aave at 5 bps) cannot cover the ask. + let aave = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_rate_millionths: 500, + liquidity: U256::from(10u64), // too small + chain: 56, + }); + // Pricier fallback (PancakeSwap V3 at 25 bps) has deep liquidity. + let pancake = Arc::new(StubProvider { + source: FlashLoanSource::PancakeSwapV3, + fee_rate_millionths: 2500, + liquidity: U256::from(1_000_000u64), + chain: 56, + }); + let router = FlashLoanRouter::new(vec![aave, pancake]); + let quote = router + .route(token(), U256::from(500u64)) + .await + .expect("route"); + assert_eq!(quote.source, FlashLoanSource::PancakeSwapV3); + } + + #[tokio::test] + async fn returns_none_when_no_source_has_liquidity() { + let aave = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_rate_millionths: 500, + liquidity: U256::ZERO, + chain: 56, + }); + let pancake = Arc::new(StubProvider { + source: FlashLoanSource::PancakeSwapV3, + fee_rate_millionths: 2500, + liquidity: U256::from(100u64), + chain: 56, + }); + let router = FlashLoanRouter::new(vec![aave, pancake]); + assert!(router.route(token(), U256::from(10_000u64)).await.is_none()); + } + + #[tokio::test] + async fn returns_none_for_empty_router() { + let router = FlashLoanRouter::new(Vec::new()); + assert!(router.route(token(), U256::from(1u64)).await.is_none()); + } + + #[tokio::test] + async fn liquidity_tiebreaker_prefers_deeper_pool_at_same_fee() { + // Two sources advertising the same fee-rate; tiebreaker should + // push the deeper-liquidity one to the front. + let shallow = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_rate_millionths: 500, + liquidity: U256::from(1_000u64), + chain: 56, + }); + let deep = Arc::new(StubProvider { + source: FlashLoanSource::PancakeSwapV3, + fee_rate_millionths: 500, + liquidity: U256::from(1_000_000u64), + chain: 56, + }); + let router = FlashLoanRouter::with_liquidity_tiebreaker(vec![shallow, deep], token()).await; + assert_eq!(router.providers()[0].source(), FlashLoanSource::PancakeSwapV3); + assert_eq!(router.providers()[1].source(), FlashLoanSource::AaveV3); + } +} diff --git a/crates/charon-flashloan/tests/aave_live.rs b/crates/charon-flashloan/tests/aave_live.rs new file mode 100644 index 0000000..c3d3cf9 --- /dev/null +++ b/crates/charon-flashloan/tests/aave_live.rs @@ -0,0 +1,72 @@ +//! Live Aave V3 flash-loan adapter smoke test on BSC. +//! +//! `#[ignore]`-gated: run with `cargo test -p charon-flashloan -- --ignored` +//! and `BNB_WS_URL` set. Exercises the full adapter wiring: pool +//! handshake, premium read, data-provider lookup, aToken balance. + +use std::str::FromStr; +use std::sync::Arc; + +use alloy::primitives::{Address, U256}; +use alloy::providers::{ProviderBuilder, WsConnect}; +use charon_core::{FlashLoanProvider, FlashLoanSource}; +use charon_flashloan::AaveFlashLoan; + +const AAVE_V3_BSC_POOL: &str = "0x6807dc923806fe8fd134338eabca509979a7e0cb"; +const AAVE_V3_BSC_DATA_PROVIDER: &str = "0x41393e5e337606dc3821075Af65AeE84D7688CBD"; +/// Burn address used as a stand-in receiver — live calldata emission +/// isn't checked here, only read-side behaviour. +const DUMMY_RECEIVER: &str = "0x000000000000000000000000000000000000dEaD"; +/// USDT on BSC — Venus's primary debt asset, known to be an Aave reserve. +const BSC_USDT: &str = "0x55d398326f99059fF775485246999027B3197955"; + +#[tokio::test] +#[ignore = "hits live BSC RPC; requires BNB_WS_URL"] +async fn connects_and_quotes_bsc_usdt() { + 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 adapter = AaveFlashLoan::connect( + Arc::new(provider), + Address::from_str(AAVE_V3_BSC_POOL).unwrap(), + Address::from_str(AAVE_V3_BSC_DATA_PROVIDER).unwrap(), + Address::from_str(DUMMY_RECEIVER).unwrap(), + ) + .await + .expect("aave connect"); + + assert_eq!(adapter.source(), FlashLoanSource::AaveV3); + assert_eq!(adapter.chain_id(), 56); + assert!( + adapter.fee_rate_millionths() > 0, + "Aave V3 flash premium expected > 0" + ); + + let usdt = Address::from_str(BSC_USDT).unwrap(); + let liquidity = adapter + .available_liquidity(usdt) + .await + .expect("available_liquidity USDT"); + assert!(liquidity > U256::ZERO, "BSC USDT aToken should hold > 0"); + + // 10 USDT (18 decimals) — well within typical Aave BSC liquidity. + let amount = U256::from(10u64) * U256::from(10u64).pow(U256::from(18u64)); + let quote = adapter + .quote(usdt, amount) + .await + .expect("quote USDT") + .expect("quote should be Some for small amount"); + + assert_eq!(quote.source, FlashLoanSource::AaveV3); + assert_eq!(quote.token, usdt); + assert_eq!(quote.amount, amount); + assert_eq!(quote.fee_rate_millionths, adapter.fee_rate_millionths()); +}