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());
+}