From a0a5f8e53188e3f2e1252102ec8b0f2e5399ab33 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 21 Apr 2026 11:47:57 +0530 Subject: [PATCH 1/4] feat(core): FlashLoanProvider trait + FlashLoanQuote struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shared abstraction for every flash-loan source. Router in `charon-flashloan` will walk a list of these in fee-priority order (Balancer 0% → Aave 0.05% → Uniswap pool fee), pick the cheapest source with sufficient liquidity, and hand the resulting quote to the tx builder. - `FlashLoanQuote` — source, chain, token, amount, absolute fee, fee_bps, pool address - `FlashLoanProvider` async trait: - `source` / `chain_id` / `fee_rate_bps` — static metadata - `available_liquidity(token)` — on-chain reserve lookup - `quote(token, amount)` — one-shot fitness check + pricing - `build_flashloan_calldata(quote, inner_calldata)` — outer call wrapping the protocol adapter's liquidation bytes --- crates/charon-core/src/flashloan.rs | 84 +++++++++++++++++++++++++++++ crates/charon-core/src/lib.rs | 2 + 2 files changed, 86 insertions(+) create mode 100644 crates/charon-core/src/flashloan.rs diff --git a/crates/charon-core/src/flashloan.rs b/crates/charon-core/src/flashloan.rs new file mode 100644 index 0000000..9eb8bc5 --- /dev/null +++ b/crates/charon-core/src/flashloan.rs @@ -0,0 +1,84 @@ +//! 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. +//! +//! The trait is kept deliberately thin: +//! +//! * `available_liquidity` — can the source cover the requested amount? +//! * `fee_rate` — 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 calldata the protocol adapter produced. + +use alloy::primitives::{Address, U256}; +use async_trait::async_trait; + +use crate::types::FlashLoanSource; + +/// 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 calldata 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 basis points (e.g. `5` = 0.05%). Balancer is `0`. + pub fee_bps: u16, + /// 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. +#[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. + async fn available_liquidity(&self, token: Address) -> anyhow::Result; + + /// Fee rate in basis points. `5` = 0.05% (Aave V3). `0` = Balancer / + /// Maker flash mint. + fn fee_rate_bps(&self) -> u16; + + /// Roll `available_liquidity` + `fee_rate_bps` 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) -> anyhow::Result>; + + /// Encode the outer flash-loan initiation call. `inner_calldata` is + /// whatever the flash-loan recipient contract (`CharonLiquidator.sol`) + /// needs inside its callback — typically the protocol adapter's + /// liquidation calldata. + fn build_flashloan_calldata( + &self, + quote: &FlashLoanQuote, + inner_calldata: &[u8], + ) -> anyhow::Result>; +} diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index 476d796..0d7b441 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; +pub use flashloan::{FlashLoanProvider, FlashLoanQuote}; pub use traits::LendingProtocol; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, From 1b9281576b6b087b9a1006689e8d222dbddf8d53 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 21 Apr 2026 11:58:54 +0530 Subject: [PATCH 2/4] feat(flashloan): Aave V3 flash-loan adapter on BSC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First adapter implementing `charon_core::FlashLoanProvider`. Lives in the new `charon-flashloan` crate alongside future Balancer / Uniswap sources; router lands next. - `AaveFlashLoan::connect(provider, pool, receiver)`: - caches `FLASHLOAN_PREMIUM_TOTAL` → `fee_bps` - caches `eth_chainId` - holds the receiver (`CharonLiquidator.sol`) for calldata emission - `available_liquidity(token)`: resolves asset → aToken via `PoolDataProvider.getReserveTokensAddresses`, then reads the aToken's underlying balance. Missing reserves return `U256::ZERO`. - `quote(token, amount)`: checks liquidity, computes absolute fee (amount × fee_bps / 10_000), returns `None` if undersized. - `build_flashloan_calldata` encodes `Pool.flashLoanSimple(receiver, asset, amount, params, 0)` with the inner liquidation bytes as params. - BSC `PoolDataProvider` address is hardcoded (v0.1 single-chain scope); moves into config when multi-chain arrives. - Unit test pins the selector; live integration test hits BSC mainnet and verifies USDT liquidity + quote shape. --- Cargo.lock | 13 ++ Cargo.toml | 2 + crates/charon-flashloan/Cargo.toml | 17 ++ crates/charon-flashloan/src/aave.rs | 245 +++++++++++++++++++++ crates/charon-flashloan/src/lib.rs | 11 + crates/charon-flashloan/tests/aave_live.rs | 68 ++++++ 6 files changed, 356 insertions(+) create mode 100644 crates/charon-flashloan/Cargo.toml create mode 100644 crates/charon-flashloan/src/aave.rs create mode 100644 crates/charon-flashloan/src/lib.rs create mode 100644 crates/charon-flashloan/tests/aave_live.rs diff --git a/Cargo.lock b/Cargo.lock index 16c953f..06585ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,19 @@ dependencies = [ "toml", ] +[[package]] +name = "charon-flashloan" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "async-trait", + "charon-core", + "dotenvy", + "tokio", + "tracing", +] + [[package]] name = "charon-protocols" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c258c3a..2fce7a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/charon-core", + "crates/charon-flashloan", "crates/charon-protocols", "crates/charon-scanner", "crates/charon-cli", @@ -49,5 +50,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/crates/charon-flashloan/Cargo.toml b/crates/charon-flashloan/Cargo.toml new file mode 100644 index 0000000..dcc64e5 --- /dev/null +++ b/crates/charon-flashloan/Cargo.toml @@ -0,0 +1,17 @@ +[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 } +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..3d64a71 --- /dev/null +++ b/crates/charon-flashloan/src/aave.rs @@ -0,0 +1,245 @@ +//! 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` (basis points) at connect time and uses +//! the Aave `PoolDataProvider` to resolve each asset's aToken, whose +//! underlying balance is the liquidity ceiling for a flash loan. + +use std::sync::Arc; + +use alloy::primitives::{Address, U256, address}; +use alloy::providers::{Provider, RootProvider}; +use alloy::pubsub::PubSubFrontend; +use alloy::sol; +use alloy::sol_types::SolCall; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use charon_core::{FlashLoanProvider, FlashLoanQuote, FlashLoanSource}; +use tracing::{debug, info}; + +/// Aave V3 `PoolDataProvider` on BSC mainnet. +/// +/// Hardcoded because v0.1 targets a single chain; when multi-chain +/// expansion lands this moves into `FlashLoanConfig`. +pub const AAVE_V3_BSC_DATA_PROVIDER: Address = address!("23dF2a19384231aFD114b036C14b6b03324D79BC"); + +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 basis points (e.g. `5` = 0.05%). + function FLASHLOAN_PREMIUM_TOTAL() external view returns (uint128); + } + + /// Aave V3 PoolDataProvider — resolves asset → aToken / debt tokens. + #[sol(rpc)] + interface IAaveV3DataProvider { + function getReserveTokensAddresses(address asset) + external view returns ( + address aTokenAddress, + address stableDebtTokenAddress, + address variableDebtTokenAddress + ); + } + + /// 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_bps: u16, +} + +impl AaveFlashLoan { + /// Connect to the pool, cache its current flash-loan premium, and + /// verify the chain id. The data provider address defaults to the + /// BSC constant above; other chains will need an explicit override + /// once multi-chain support lands. + pub async fn connect( + provider: Arc>, + pool: Address, + receiver: Address, + ) -> Result { + debug!(%pool, %receiver, "connecting Aave V3 flash-loan adapter"); + + 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 fee_bps = u16::try_from(premium) + .context("Aave V3: premium does not fit in u16 bps — unexpected value")?; + + let chain_id = provider + .get_chain_id() + .await + .context("Aave V3: eth_chainId failed")?; + + info!( + %pool, + %receiver, + chain_id, + fee_bps, + "Aave V3 flash-loan adapter ready" + ); + + Ok(Self { + provider, + pool, + data_provider: AAVE_V3_BSC_DATA_PROVIDER, + receiver, + chain_id, + fee_bps, + }) + } + + /// 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> { + 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), + } + } +} + +#[async_trait] +impl FlashLoanProvider for AaveFlashLoan { + fn source(&self) -> FlashLoanSource { + FlashLoanSource::AaveV3 + } + + fn chain_id(&self) -> u64 { + self.chain_id + } + + fn fee_rate_bps(&self) -> u16 { + self.fee_bps + } + + async fn available_liquidity(&self, token: Address) -> Result { + 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 + .with_context(|| format!("Aave V3: balanceOf({atoken}) failed"))? + ._0; + Ok(bal) + } + + async fn quote(&self, token: Address, amount: U256) -> Result> { + let liquidity = self.available_liquidity(token).await?; + if liquidity < amount { + return Ok(None); + } + // fee = amount * fee_bps / 10_000 + let fee = amount + .checked_mul(U256::from(self.fee_bps)) + .context("Aave V3: fee multiplication overflow")? + / U256::from(10_000u64); + Ok(Some(FlashLoanQuote { + source: FlashLoanSource::AaveV3, + chain_id: self.chain_id, + token, + amount, + fee, + fee_bps: self.fee_bps, + pool_address: self.pool, + })) + } + + fn build_flashloan_calldata( + &self, + quote: &FlashLoanQuote, + inner_calldata: &[u8], + ) -> Result> { + let call = IAaveV3Pool::flashLoanSimpleCall { + receiverAddress: self.receiver, + asset: quote.token, + amount: quote.amount, + params: inner_calldata.to_vec().into(), + referralCode: 0, + }; + Ok(call.abi_encode()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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_bps: 5, + 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" + ); + } +} diff --git a/crates/charon-flashloan/src/lib.rs b/crates/charon-flashloan/src/lib.rs new file mode 100644 index 0000000..4b8df25 --- /dev/null +++ b/crates/charon-flashloan/src/lib.rs @@ -0,0 +1,11 @@ +//! 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 use aave::AaveFlashLoan; diff --git a/crates/charon-flashloan/tests/aave_live.rs b/crates/charon-flashloan/tests/aave_live.rs new file mode 100644 index 0000000..153dd91 --- /dev/null +++ b/crates/charon-flashloan/tests/aave_live.rs @@ -0,0 +1,68 @@ +//! Live Aave V3 flash-loan adapter smoke test on BSC. +//! +//! Skipped without `BNB_WS_URL`. 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"; +/// 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] +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(DUMMY_RECEIVER).unwrap(), + ) + .await + .expect("aave connect"); + + assert_eq!(adapter.source(), FlashLoanSource::AaveV3); + assert_eq!(adapter.chain_id(), 56); + assert!( + adapter.fee_rate_bps() > 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_bps, adapter.fee_rate_bps()); +} From 904fffef8061e67a4a7e0926dc9bfcaf92f728a0 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 21 Apr 2026 12:23:46 +0530 Subject: [PATCH 3/4] feat(flashloan): fee-priority router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Picks the cheapest flash-loan source that can cover a requested borrow. Providers are supplied as `Arc`; the router sorts by `fee_rate_bps` at construction so the walk starts with the least expensive option. - `FlashLoanRouter::new(providers)` — sorts once, cheapest first - `route(token, amount)` — walks in order, returns the first quote that fits; per-provider errors or insufficient-liquidity outcomes are logged + skipped rather than aborting - Returns `None` when no source can cover the amount — caller drops the liquidation rather than faking capital from elsewhere - Four unit tests via an in-memory `StubProvider`: cheapest-picked, fallthrough on insufficient liquidity, all-empty → None, empty provider list → None --- crates/charon-flashloan/src/lib.rs | 2 + crates/charon-flashloan/src/router.rs | 214 ++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 crates/charon-flashloan/src/router.rs diff --git a/crates/charon-flashloan/src/lib.rs b/crates/charon-flashloan/src/lib.rs index 4b8df25..b972804 100644 --- a/crates/charon-flashloan/src/lib.rs +++ b/crates/charon-flashloan/src/lib.rs @@ -7,5 +7,7 @@ //! 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..c1553cd --- /dev/null +++ b/crates/charon-flashloan/src/router.rs @@ -0,0 +1,214 @@ +//! Flash-loan router. +//! +//! Walks configured [`FlashLoanProvider`]s in ascending fee-rate order +//! (Balancer 0% → Aave 0.05% → Uniswap pool fee). 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. +//! +//! Single-source-on-BSC today means Aave V3 is the only entry in the +//! provider list; the abstraction is here so adding Balancer / Uniswap +//! on a second chain 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`. +pub struct FlashLoanRouter { + providers: Vec>, +} + +impl FlashLoanRouter { + /// Construct a router, sorting providers by `fee_rate_bps` ascending + /// so the cheapest source is tried first. + pub fn new(mut providers: Vec>) -> Self { + providers.sort_by_key(|p| p.fee_rate_bps()); + Self { providers } + } + + /// 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) 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_bps = provider.fee_rate_bps(); + match provider.quote(token, amount).await { + Ok(Some(quote)) => { + info!( + source = ?source, + fee_bps, + token = %token, + amount = %amount, + "flash-loan source selected" + ); + return Some(quote); + } + Ok(None) => { + debug!( + source = ?source, + fee_bps, + token = %token, + amount = %amount, + "source skipped: insufficient liquidity" + ); + } + Err(err) => { + warn!( + source = ?source, + fee_bps, + 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 anyhow::Result; + use async_trait::async_trait; + use charon_core::FlashLoanSource; + + /// In-memory provider for router tests — skips all RPC. + struct StubProvider { + source: FlashLoanSource, + fee_bps: u16, + 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_bps(&self) -> u16 { + self.fee_bps + } + async fn available_liquidity(&self, _t: Address) -> Result { + Ok(self.liquidity) + } + async fn quote(&self, token: Address, amount: U256) -> Result> { + if self.liquidity < amount { + return Ok(None); + } + let fee = amount * U256::from(self.fee_bps) / U256::from(10_000u64); + Ok(Some(FlashLoanQuote { + source: self.source, + chain_id: self.chain, + token, + amount, + fee, + fee_bps: self.fee_bps, + pool_address: Address::ZERO, + })) + } + fn build_flashloan_calldata(&self, _q: &FlashLoanQuote, _inner: &[u8]) -> Result> { + Ok(Vec::new()) + } + } + + fn token() -> Address { + address!("1111111111111111111111111111111111111111") + } + + #[tokio::test] + async fn picks_cheapest_source_with_sufficient_liquidity() { + let balancer = Arc::new(StubProvider { + source: FlashLoanSource::BalancerV2, + fee_bps: 0, + liquidity: U256::from(1_000u64), + chain: 56, + }); + let aave = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_bps: 5, + liquidity: U256::from(1_000_000u64), + chain: 56, + }); + + // Pass them in reverse order; router should sort internally. + let router = FlashLoanRouter::new(vec![aave, balancer]); + let quote = router + .route(token(), U256::from(500u64)) + .await + .expect("route"); + assert_eq!(quote.source, FlashLoanSource::BalancerV2); + assert_eq!(quote.fee_bps, 0); + } + + #[tokio::test] + async fn falls_through_to_next_source_when_cheaper_has_no_liquidity() { + let balancer = Arc::new(StubProvider { + source: FlashLoanSource::BalancerV2, + fee_bps: 0, + liquidity: U256::from(10u64), // too small + chain: 56, + }); + let aave = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_bps: 5, + liquidity: U256::from(1_000_000u64), + chain: 56, + }); + let router = FlashLoanRouter::new(vec![balancer, aave]); + let quote = router + .route(token(), U256::from(500u64)) + .await + .expect("route"); + assert_eq!(quote.source, FlashLoanSource::AaveV3); + } + + #[tokio::test] + async fn returns_none_when_no_source_has_liquidity() { + let balancer = Arc::new(StubProvider { + source: FlashLoanSource::BalancerV2, + fee_bps: 0, + liquidity: U256::ZERO, + chain: 56, + }); + let aave = Arc::new(StubProvider { + source: FlashLoanSource::AaveV3, + fee_bps: 5, + liquidity: U256::from(100u64), + chain: 56, + }); + let router = FlashLoanRouter::new(vec![balancer, aave]); + 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()); + } +} From b9092e1b2c3db34eab5cadef2890071bd4644833 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 13:53:13 +0530 Subject: [PATCH 4/4] refactor(flashloan): rework provider trait, errors, and aave guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #136: Rename FlashLoanProvider::fee_rate_bps -> fee_rate_millionths (u32, Uniswap 1e6 convention). Aave's 4-decimal-% premium is converted by x100 at connect time (Aave 5 -> 500). FlashLoanQuote.fee_rate_bps is renamed to match. Closes #138: build_flashloan_calldata argument renamed to liquidation_params and guarded against an empty buffer — every real executeOperation ABI-decodes this payload and reverts on 0x. Closes #141: Introduce FlashLoanError (thiserror, non_exhaustive) with InsufficientLiquidity, ReservePaused, ChainIdMismatch, Rpc, Other. Trait methods now return Result; anyhow is kept only on connect() for config-time wiring. Closes #144: #[non_exhaustive] added to FlashLoanSource and FlashLoanError. Closes #137: AaveFlashLoan.available_liquidity calls getReserveConfigurationData and getReserveData on the PoolDataProvider and rejects the borrow with FlashLoanError::ReservePaused when isActive is false, isFrozen is true, or bits 57 (frozen) / 60 (paused) are set in the packed configuration bitmap. Closes #142: AaveFlashLoan::connect calls provider.get_chain_id() and anyhow::ensure!s it equals 56, failing fast on misconfigured RPC. Closes #143: FlashLoanConfig gains an optional data_provider: Address. config/default.toml pins the canonical Aave V3 BSC PoolDataProvider (0x41393e5e337606dc3821075Af65AeE84D7688CBD). connect() takes it as an argument instead of a hardcoded constant; the constant was removed. Closes #145: FlashLoanRouter grows with_liquidity_tiebreaker, an async constructor that probes available_liquidity(token) for each provider and sorts fee_rate_millionths asc, then available_liquidity desc. The plain new() keeps the fee-only ordering for call sites that don't want the probe cost. Closes #139: aave_live integration test is now #[ignore]-gated so cargo test --workspace does not require BNB_WS_URL. Run explicitly with cargo test -p charon-flashloan -- --ignored. Refs #140: charon-flashloan was already in workspace.members; no change required. thiserror is added to the workspace dependency table and pulled into charon-core / charon-flashloan. Gates green: cargo fmt, cargo clippy --all-targets --all-features -D warnings, cargo test --workspace --all-targets, cargo test --workspace --doc. --- Cargo.lock | 2 + Cargo.toml | 1 + config/default.toml | 3 + crates/charon-core/Cargo.toml | 1 + crates/charon-core/src/config.rs | 6 + crates/charon-core/src/flashloan.rs | 124 +++++++++--- crates/charon-core/src/lib.rs | 2 +- crates/charon-core/src/types.rs | 1 + crates/charon-flashloan/Cargo.toml | 1 + crates/charon-flashloan/src/aave.rs | 220 +++++++++++++++++---- crates/charon-flashloan/src/router.rs | 130 +++++++++--- crates/charon-flashloan/tests/aave_live.rs | 12 +- 12 files changed, 401 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 06585ba..a3451e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1145,6 +1145,7 @@ dependencies = [ "anyhow", "async-trait", "serde", + "thiserror 1.0.69", "toml", ] @@ -1157,6 +1158,7 @@ dependencies = [ "async-trait", "charon-core", "dotenvy", + "thiserror 1.0.69", "tokio", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 2fce7a1..3741a7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1" +thiserror = "1" # Async trait objects async-trait = "0.1" diff --git a/config/default.toml b/config/default.toml index 3d9bb3b..e14df42 100644 --- a/config/default.toml +++ b/config/default.toml @@ -32,6 +32,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 ───────────────────────────────────────── [liquidator.bnb] diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index d9b3f67..658eeaf 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -9,5 +9,6 @@ description = "Shared types, traits, and config for Charon" alloy = { workspace = true } serde = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index da9ffe4..9706004 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -83,6 +83,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 index 9eb8bc5..a9619ea 100644 --- a/crates/charon-core/src/flashloan.rs +++ b/crates/charon-core/src/flashloan.rs @@ -1,31 +1,92 @@ //! Flash-loan provider abstraction. //! -//! Every flash-loan source (Balancer V2, Aave V3, Uniswap V3, …) plugs +//! 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` — how expensive is borrowing from this source? +//! * `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 calldata the protocol adapter produced. +//! 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 calldata to +/// builder consumes them alongside the inner liquidation parameters to /// encode the final on-chain call. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FlashLoanQuote { @@ -38,10 +99,11 @@ pub struct FlashLoanQuote { pub amount: U256, /// Absolute fee to repay alongside `amount`, same units as `amount`. pub fee: U256, - /// Fee rate in basis points (e.g. `5` = 0.05%). Balancer is `0`. - pub fee_bps: u16, + /// 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, …). + /// (Aave pool, Balancer vault, Uniswap pool, ...). pub pool_address: Address, } @@ -51,6 +113,8 @@ pub struct FlashLoanQuote { /// 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. @@ -60,25 +124,35 @@ pub trait FlashLoanProvider: Send + Sync { 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. - async fn available_liquidity(&self, token: Address) -> anyhow::Result; - - /// Fee rate in basis points. `5` = 0.05% (Aave V3). `0` = Balancer / - /// Maker flash mint. - fn fee_rate_bps(&self) -> u16; - - /// Roll `available_liquidity` + `fee_rate_bps` 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) -> anyhow::Result>; - - /// Encode the outer flash-loan initiation call. `inner_calldata` is - /// whatever the flash-loan recipient contract (`CharonLiquidator.sol`) - /// needs inside its callback — typically the protocol adapter's - /// liquidation calldata. + /// 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, - inner_calldata: &[u8], - ) -> anyhow::Result>; + liquidation_params: &[u8], + ) -> Result, FlashLoanError>; } diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index 0d7b441..2103667 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -6,7 +6,7 @@ pub mod traits; pub mod types; pub use config::Config; -pub use flashloan::{FlashLoanProvider, FlashLoanQuote}; +pub use flashloan::{FlashLoanError, FlashLoanProvider, FlashLoanQuote}; pub use traits::LendingProtocol; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, diff --git a/crates/charon-core/src/types.rs b/crates/charon-core/src/types.rs index a09840a..d6bb7c6 100644 --- a/crates/charon-core/src/types.rs +++ b/crates/charon-core/src/types.rs @@ -40,6 +40,7 @@ pub struct Position { /// /// Router picks cheapest available: Balancer (0%) → Aave (0.05%) → Uniswap (pool fee). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub enum FlashLoanSource { /// Balancer V2 Vault — 0% fee. BalancerV2, diff --git a/crates/charon-flashloan/Cargo.toml b/crates/charon-flashloan/Cargo.toml index dcc64e5..54e782a 100644 --- a/crates/charon-flashloan/Cargo.toml +++ b/crates/charon-flashloan/Cargo.toml @@ -9,6 +9,7 @@ description = "Flash-loan source adapters and router for Charon" charon-core = { workspace = true } alloy = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } async-trait = { workspace = true } tracing = { workspace = true } diff --git a/crates/charon-flashloan/src/aave.rs b/crates/charon-flashloan/src/aave.rs index 3d64a71..a837ba6 100644 --- a/crates/charon-flashloan/src/aave.rs +++ b/crates/charon-flashloan/src/aave.rs @@ -3,27 +3,47 @@ //! 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` (basis points) at connect time and uses -//! the Aave `PoolDataProvider` to resolve each asset's aToken, whose -//! underlying balance is the liquidity ceiling for a flash loan. +//! `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, address}; +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::{FlashLoanProvider, FlashLoanQuote, FlashLoanSource}; +use charon_core::{FlashLoanError, FlashLoanProvider, FlashLoanQuote, FlashLoanSource}; use tracing::{debug, info}; -/// Aave V3 `PoolDataProvider` on BSC mainnet. -/// -/// Hardcoded because v0.1 targets a single chain; when multi-chain -/// expansion lands this moves into `FlashLoanConfig`. -pub const AAVE_V3_BSC_DATA_PROVIDER: Address = address!("23dF2a19384231aFD114b036C14b6b03324D79BC"); +/// 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. @@ -40,11 +60,12 @@ sol! { uint16 referralCode ) external; - /// Flash-loan premium in basis points (e.g. `5` = 0.05%). + /// 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. + /// Aave V3 PoolDataProvider — resolves asset → aToken / debt tokens + /// and exposes the packed reserve configuration bitmap. #[sol(rpc)] interface IAaveV3DataProvider { function getReserveTokensAddresses(address asset) @@ -53,6 +74,43 @@ sol! { 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). @@ -78,20 +136,37 @@ pub struct AaveFlashLoan { /// callback. receiver: Address, chain_id: u64, - fee_bps: u16, + /// 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. The data provider address defaults to the - /// BSC constant above; other chains will need an explicit override - /// once multi-chain support lands. + /// 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, %receiver, "connecting Aave V3 flash-loan adapter"); + 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 @@ -100,36 +175,38 @@ impl AaveFlashLoan { .await .context("Aave V3: FLASHLOAN_PREMIUM_TOTAL() failed")? ._0; - let fee_bps = u16::try_from(premium) - .context("Aave V3: premium does not fit in u16 bps — unexpected value")?; - - let chain_id = provider - .get_chain_id() - .await - .context("Aave V3: eth_chainId failed")?; + 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, - fee_bps, + aave_premium, + fee_rate_millionths, "Aave V3 flash-loan adapter ready" ); Ok(Self { provider, pool, - data_provider: AAVE_V3_BSC_DATA_PROVIDER, + data_provider, receiver, chain_id, - fee_bps, + 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> { + 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) => { @@ -143,6 +220,40 @@ impl AaveFlashLoan { 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] @@ -155,11 +266,12 @@ impl FlashLoanProvider for AaveFlashLoan { self.chain_id } - fn fee_rate_bps(&self) -> u16 { - self.fee_bps + fn fee_rate_millionths(&self) -> u32 { + self.fee_rate_millionths } - async fn available_liquidity(&self, token: Address) -> Result { + 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); }; @@ -168,28 +280,33 @@ impl FlashLoanProvider for AaveFlashLoan { .balanceOf(atoken) .call() .await - .with_context(|| format!("Aave V3: balanceOf({atoken}) failed"))? + .map_err(|e| FlashLoanError::rpc(format!("balanceOf({atoken}): {e}")))? ._0; Ok(bal) } - async fn quote(&self, token: Address, amount: U256) -> Result> { + 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 * fee_bps / 10_000 + // 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.fee_bps)) - .context("Aave V3: fee multiplication overflow")? - / U256::from(10_000u64); + .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_bps: self.fee_bps, + fee_rate_millionths: self.fee_rate_millionths, pool_address: self.pool, })) } @@ -197,13 +314,19 @@ impl FlashLoanProvider for AaveFlashLoan { fn build_flashloan_calldata( &self, quote: &FlashLoanQuote, - inner_calldata: &[u8], - ) -> Result> { + 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: inner_calldata.to_vec().into(), + params: liquidation_params.to_vec().into(), referralCode: 0, }; Ok(call.abi_encode()) @@ -213,6 +336,7 @@ impl FlashLoanProvider for AaveFlashLoan { #[cfg(test)] mod tests { use super::*; + use alloy::primitives::address; #[test] fn flash_loan_simple_calldata_has_correct_selector() { @@ -222,7 +346,7 @@ mod tests { token: address!("1111111111111111111111111111111111111111"), amount: U256::from(1_000u64), fee: U256::from(5u64), - fee_bps: 5, + fee_rate_millionths: 500, pool_address: address!("2222222222222222222222222222222222222222"), }; // Standalone encoder mirror of `build_flashloan_calldata` so we @@ -242,4 +366,16 @@ mod tests { "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/router.rs b/crates/charon-flashloan/src/router.rs index c1553cd..53b393c 100644 --- a/crates/charon-flashloan/src/router.rs +++ b/crates/charon-flashloan/src/router.rs @@ -1,7 +1,7 @@ //! Flash-loan router. //! //! Walks configured [`FlashLoanProvider`]s in ascending fee-rate order -//! (Balancer 0% → Aave 0.05% → Uniswap pool fee). Returns the first +//! (Balancer 0% -> Aave 0.05% -> Uniswap pool fee). 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. @@ -19,19 +19,57 @@ 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`. +/// 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_bps` ascending - /// so the cheapest source is tried first. + /// 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_bps()); + 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 @@ -39,18 +77,19 @@ impl FlashLoanRouter { /// Pick the cheapest provider that can cover `amount` of `token`. /// - /// Per-provider failures (RPC error, insufficient liquidity) 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. + /// 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_bps = provider.fee_rate_bps(); + let fee_rate_millionths = provider.fee_rate_millionths(); match provider.quote(token, amount).await { Ok(Some(quote)) => { info!( source = ?source, - fee_bps, + fee_rate_millionths, token = %token, amount = %amount, "flash-loan source selected" @@ -60,7 +99,7 @@ impl FlashLoanRouter { Ok(None) => { debug!( source = ?source, - fee_bps, + fee_rate_millionths, token = %token, amount = %amount, "source skipped: insufficient liquidity" @@ -69,7 +108,7 @@ impl FlashLoanRouter { Err(err) => { warn!( source = ?source, - fee_bps, + fee_rate_millionths, token = %token, ?err, "source skipped: quote failed" @@ -91,14 +130,13 @@ impl FlashLoanRouter { mod tests { use super::*; use alloy::primitives::address; - use anyhow::Result; use async_trait::async_trait; - use charon_core::FlashLoanSource; + use charon_core::{FlashLoanError, FlashLoanSource}; /// In-memory provider for router tests — skips all RPC. struct StubProvider { source: FlashLoanSource, - fee_bps: u16, + fee_rate_millionths: u32, liquidity: U256, chain: u64, } @@ -111,29 +149,40 @@ mod tests { fn chain_id(&self) -> u64 { self.chain } - fn fee_rate_bps(&self) -> u16 { - self.fee_bps + fn fee_rate_millionths(&self) -> u32 { + self.fee_rate_millionths } - async fn available_liquidity(&self, _t: Address) -> Result { + async fn available_liquidity(&self, _t: Address) -> Result { Ok(self.liquidity) } - async fn quote(&self, token: Address, amount: U256) -> Result> { + async fn quote( + &self, + token: Address, + amount: U256, + ) -> Result, FlashLoanError> { if self.liquidity < amount { return Ok(None); } - let fee = amount * U256::from(self.fee_bps) / U256::from(10_000u64); + 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_bps: self.fee_bps, + fee_rate_millionths: self.fee_rate_millionths, pool_address: Address::ZERO, })) } - fn build_flashloan_calldata(&self, _q: &FlashLoanQuote, _inner: &[u8]) -> Result> { - Ok(Vec::new()) + 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()) } } @@ -145,13 +194,13 @@ mod tests { async fn picks_cheapest_source_with_sufficient_liquidity() { let balancer = Arc::new(StubProvider { source: FlashLoanSource::BalancerV2, - fee_bps: 0, + fee_rate_millionths: 0, liquidity: U256::from(1_000u64), chain: 56, }); let aave = Arc::new(StubProvider { source: FlashLoanSource::AaveV3, - fee_bps: 5, + fee_rate_millionths: 500, liquidity: U256::from(1_000_000u64), chain: 56, }); @@ -163,20 +212,20 @@ mod tests { .await .expect("route"); assert_eq!(quote.source, FlashLoanSource::BalancerV2); - assert_eq!(quote.fee_bps, 0); + assert_eq!(quote.fee_rate_millionths, 0); } #[tokio::test] async fn falls_through_to_next_source_when_cheaper_has_no_liquidity() { let balancer = Arc::new(StubProvider { source: FlashLoanSource::BalancerV2, - fee_bps: 0, + fee_rate_millionths: 0, liquidity: U256::from(10u64), // too small chain: 56, }); let aave = Arc::new(StubProvider { source: FlashLoanSource::AaveV3, - fee_bps: 5, + fee_rate_millionths: 500, liquidity: U256::from(1_000_000u64), chain: 56, }); @@ -192,13 +241,13 @@ mod tests { async fn returns_none_when_no_source_has_liquidity() { let balancer = Arc::new(StubProvider { source: FlashLoanSource::BalancerV2, - fee_bps: 0, + fee_rate_millionths: 0, liquidity: U256::ZERO, chain: 56, }); let aave = Arc::new(StubProvider { source: FlashLoanSource::AaveV3, - fee_bps: 5, + fee_rate_millionths: 500, liquidity: U256::from(100u64), chain: 56, }); @@ -211,4 +260,25 @@ mod tests { 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::UniswapV3, + 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::UniswapV3); + 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 index 153dd91..c3d3cf9 100644 --- a/crates/charon-flashloan/tests/aave_live.rs +++ b/crates/charon-flashloan/tests/aave_live.rs @@ -1,7 +1,8 @@ //! Live Aave V3 flash-loan adapter smoke test on BSC. //! -//! Skipped without `BNB_WS_URL`. Exercises the full adapter wiring: -//! pool handshake, premium read, data-provider lookup, aToken balance. +//! `#[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; @@ -12,6 +13,7 @@ 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"; @@ -19,6 +21,7 @@ const DUMMY_RECEIVER: &str = "0x000000000000000000000000000000000000dEaD"; 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 { @@ -34,6 +37,7 @@ async fn connects_and_quotes_bsc_usdt() { 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 @@ -42,7 +46,7 @@ async fn connects_and_quotes_bsc_usdt() { assert_eq!(adapter.source(), FlashLoanSource::AaveV3); assert_eq!(adapter.chain_id(), 56); assert!( - adapter.fee_rate_bps() > 0, + adapter.fee_rate_millionths() > 0, "Aave V3 flash premium expected > 0" ); @@ -64,5 +68,5 @@ async fn connects_and_quotes_bsc_usdt() { assert_eq!(quote.source, FlashLoanSource::AaveV3); assert_eq!(quote.token, usdt); assert_eq!(quote.amount, amount); - assert_eq!(quote.fee_bps, adapter.fee_rate_bps()); + assert_eq!(quote.fee_rate_millionths, adapter.fee_rate_millionths()); }