diff --git a/Cargo.lock b/Cargo.lock index 6c1f2ca..ebe209f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1147,6 +1147,7 @@ dependencies = [ "async-trait", "serde", "thiserror 2.0.18", + "tokio", "toml", ] diff --git a/crates/charon-core/Cargo.toml b/crates/charon-core/Cargo.toml index c44928a..bec15c7 100644 --- a/crates/charon-core/Cargo.toml +++ b/crates/charon-core/Cargo.toml @@ -15,3 +15,7 @@ anyhow = { workspace = true } thiserror = { workspace = true } async-trait = { workspace = true } toml = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/charon-core/src/lib.rs b/crates/charon-core/src/lib.rs index f63763a..39332e4 100644 --- a/crates/charon-core/src/lib.rs +++ b/crates/charon-core/src/lib.rs @@ -2,11 +2,15 @@ pub mod config; pub mod flashloan; +pub mod profit; +pub mod queue; pub mod traits; pub mod types; pub use config::{Config, ConfigError}; pub use flashloan::{FlashLoanError, FlashLoanProvider, FlashLoanQuote}; +pub use profit::{NetProfit, Price, ProfitError, ProfitInputs, calculate_profit}; +pub use queue::{DEFAULT_TTL_BLOCKS, OpportunityQueue, QueueEntry}; pub use traits::{LendingProtocol, LendingProtocolError, Result as LendingResult}; pub use types::{ FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, diff --git a/crates/charon-core/src/profit.rs b/crates/charon-core/src/profit.rs new file mode 100644 index 0000000..87786f3 --- /dev/null +++ b/crates/charon-core/src/profit.rs @@ -0,0 +1,591 @@ +//! Gas-aware profit calculator. +//! +//! Sits between the scanner (surfaces a liquidatable [`Position`]) and +//! the router (picks a flash-loan source): given a candidate liquidation, +//! decide whether it clears the configured `min_profit_usd_1e6` +//! threshold. +//! +//! # Unit discipline +//! +//! The calculator is **native-wei first**: every cost is denominated in +//! the **debt token's base units (wei)**, matching +//! [`LiquidationOpportunity::net_profit_wei`]. Wei is the canonical +//! storage unit — it avoids f64 drift, survives chain-native precision, +//! and never depends on a USD oracle to express a profit figure. +//! +//! USD is a reporting / gating concern only. The final [`NetProfit`] +//! carries both the authoritative `net_profit_wei: U256` and a +//! derived `net_profit_usd_1e6: u64` convenience field for logging and +//! the `min_profit_usd_1e6` threshold compare. +//! +//! # Profit formula (all amounts in **debt-token wei**) +//! +//! ```text +//! gross_debt_wei = expected_swap_output_wei +//! slippage_wei = expected_swap_output_wei * slippage_bps / 10_000 +//! total_cost_wei = flash_fee_wei + gas_cost_debt_wei + slippage_wei +//! net_profit_wei = gross_debt_wei - total_cost_wei (saturating) +//! ``` +//! +//! Slippage is charged against the DEX swap output (collateral -> +//! debt-token) because that is the trade whose execution price the bot +//! is exposed to — losing 0.5% on a $10 000 swap is $50, not 0.5% of +//! the $1 000 bonus. +//! +//! Gas is passed in already converted to debt-token wei +//! (`gas_cost_debt_wei`). The conversion `gas_units * +//! effective_gas_price * native_price / debt_price` is the caller's +//! responsibility — typically it goes through a PriceCache lookup +//! against Chainlink feeds for the native asset (BNB) and the debt +//! token. + +use alloy::primitives::U256; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::types::LiquidationOpportunity; + +/// Chainlink-style 1e8 price of one whole token, in USD. +/// +/// BSC-native Chainlink aggregators report `int256` answers with 8 +/// decimals. We normalise to `u64` here — any feed that returns a +/// negative answer is a feed fault and must be rejected upstream before +/// this type is constructed. +/// +/// `usd_1e8 = 6 * 10^10` means 1 token = $600. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Price { + /// USD value of 1 whole token, scaled by `1e8`. + pub usd_1e8: u64, +} + +impl Price { + /// Construct a price; rejects zero/malformed feeds. + pub fn new(usd_1e8: u64) -> Result { + if usd_1e8 == 0 { + return Err(ProfitError::InvalidPrice); + } + Ok(Self { usd_1e8 }) + } +} + +/// Hard-typed errors from the profit calculator. +/// +/// Every negative outcome the executor can plausibly react to is a +/// distinct variant — no `anyhow` `String` matching in the hot path. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +#[non_exhaustive] +pub enum ProfitError { + /// A basis-points input exceeds `10_000` (100%). + #[error("basis-points value {0} exceeds 10_000 (100%)")] + InvalidBps(u16), + /// Price feed produced zero or malformed output. + #[error("price feed reported a zero or invalid value")] + InvalidPrice, + /// Unsigned arithmetic would have wrapped. + #[error("arithmetic overflow while computing profit")] + Overflow, + /// Token decimals exceed the supported range (0..=18). + #[error("unsupported token decimals {0} (must be <= 18)")] + UnsupportedDecimals(u8), + /// Total cost swallows the gross swap output — liquidation is unprofitable. + #[error( + "unprofitable: gross_wei={gross_wei} <= total_cost_wei={total_cost_wei} \ + (flash_fee_wei={flash_fee_wei}, gas_cost_wei={gas_cost_wei}, slippage_wei={slippage_wei})" + )] + Unprofitable { + gross_wei: U256, + total_cost_wei: U256, + flash_fee_wei: U256, + gas_cost_wei: U256, + slippage_wei: U256, + }, + /// Net profit is positive but below the configured threshold. + #[error("below threshold: net_usd_1e6={net_usd_1e6} < min_usd_1e6={min_usd_1e6}")] + BelowMinThreshold { net_usd_1e6: u64, min_usd_1e6: u64 }, +} + +/// Everything the calculator needs, already expressed in debt-token wei. +/// +/// Construct via [`ProfitInputs::from_opportunity`] whenever possible; +/// the direct literal form is kept only for tests and callers who have +/// already priced the opportunity themselves. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ProfitInputs { + /// Expected DEX output (seized collateral -> debt token) in + /// debt-token wei. This is the bot's realised revenue before fees + /// and slippage, and is the value against which slippage is + /// applied. + pub expected_swap_output_wei: U256, + /// Absolute flash-loan fee in debt-token wei (matches + /// `FlashLoanQuote::fee` for an Aave-style loan denominated in the + /// debt asset). + pub flash_fee_wei: U256, + /// Expected gas cost for the full liquidation tx, converted to + /// **debt-token wei** by the caller (typical conversion: + /// `gas_units * effective_gas_price * native_price / debt_price`). + pub gas_cost_debt_wei: U256, + /// DEX swap slippage to budget for, in basis points applied to + /// `expected_swap_output_wei`. `50` = 0.5%. + pub slippage_bps: u16, + /// USD price of the debt token, Chainlink 1e8 scaled. Used to + /// convert `net_profit_wei` into `net_profit_usd_1e6` for the + /// threshold compare and for logging — never used inside the + /// arithmetic path. + pub debt_price: Price, + /// Debt-token decimals (0..=18). Drives the final wei->USD_1e6 + /// conversion. + pub debt_decimals: u8, +} + +/// Itemised profit breakdown returned on success. +/// +/// `net_profit_wei` is authoritative and is the value copied into +/// [`LiquidationOpportunity::net_profit_wei`] via +/// [`LiquidationOpportunity::with_profit`]. All `*_wei` fields are +/// denominated in **debt-token wei**. `net_profit_usd_1e6` is a +/// derived convenience figure for the threshold compare and logs; do +/// **not** feed it back into downstream wei arithmetic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub struct NetProfit { + pub gross_wei: U256, + pub flash_fee_wei: U256, + pub gas_cost_wei: U256, + pub slippage_wei: U256, + pub net_profit_wei: U256, + /// Derived USD value of `net_profit_wei`, scaled by 1e6. + /// Convenience only — for logs and threshold display. + pub net_profit_usd_1e6: u64, +} + +/// 10^n for n in 0..=18 — pre-computed so the decimals path never +/// allocates and never panics on overflow. +const POW10: [u128; 19] = [ + 1, + 10, + 100, + 1_000, + 10_000, + 100_000, + 1_000_000, + 10_000_000, + 100_000_000, + 1_000_000_000, + 10_000_000_000, + 100_000_000_000, + 1_000_000_000_000, + 10_000_000_000_000, + 100_000_000_000_000, + 1_000_000_000_000_000, + 10_000_000_000_000_000, + 100_000_000_000_000_000, + 1_000_000_000_000_000_000, +]; + +/// Convert `amount_wei` of a token with `decimals` at `price` into USD +/// micro-units (1e6). +/// +/// ```text +/// usd_1e6 = amount_wei * usd_1e8 / 10^decimals / 10^2 +/// ``` +/// +/// Performed in `U256` so an 18-decimal BEP-20 at trillion-dollar scale +/// still cannot overflow. The final USD_1e6 value is range-checked to +/// fit `u64` — anything larger is a faulty input and returns +/// [`ProfitError::Overflow`]. +fn wei_to_usd_1e6(amount_wei: U256, price: Price, decimals: u8) -> Result { + if (decimals as usize) >= POW10.len() { + return Err(ProfitError::UnsupportedDecimals(decimals)); + } + // scale = 10^decimals * 10^2 + // (divide by 10^decimals to get whole tokens; + // divide by 10^2 to move from 1e8-priced USD down to 1e6.) + let pow_dec = U256::from(POW10[decimals as usize]); + let pow_2 = U256::from(100u64); + let scale = pow_dec.checked_mul(pow_2).ok_or(ProfitError::Overflow)?; + let numerator = amount_wei + .checked_mul(U256::from(price.usd_1e8)) + .ok_or(ProfitError::Overflow)?; + // scale >= 100 (non-zero) so division cannot panic. + let usd_u256 = numerator / scale; + let usd: u64 = usd_u256.try_into().map_err(|_| ProfitError::Overflow)?; + Ok(usd) +} + +impl ProfitInputs { + /// Construct [`ProfitInputs`] from a [`LiquidationOpportunity`] + /// plus live gas / fee quotes. + /// + /// # Inputs + /// + /// - `opportunity` — the candidate, in native wei-scale units. + /// - `expected_swap_output_wei` — DEX router quote for the seized + /// collateral -> debt-token swap, in debt-token wei. Slippage is + /// applied to this. + /// - `flash_fee_wei` — absolute flash-loan fee in debt-token wei. + /// - `gas_cost_debt_wei` — gas budget converted to debt-token wei. + /// - `slippage_bps` — DEX slippage budget. + /// - `debt_price` / `debt_decimals` — debt-token Chainlink price + /// and BEP-20 decimals (must be `<= 18`), used downstream to + /// derive `net_profit_usd_1e6`. + pub fn from_opportunity( + opportunity: &LiquidationOpportunity, + expected_swap_output_wei: U256, + flash_fee_wei: U256, + gas_cost_debt_wei: U256, + slippage_bps: u16, + debt_price: Price, + debt_decimals: u8, + ) -> Result { + if slippage_bps > 10_000 { + return Err(ProfitError::InvalidBps(slippage_bps)); + } + if opportunity.position.liquidation_bonus_bps > 10_000 { + return Err(ProfitError::InvalidBps( + opportunity.position.liquidation_bonus_bps, + )); + } + if (debt_decimals as usize) >= POW10.len() { + return Err(ProfitError::UnsupportedDecimals(debt_decimals)); + } + + Ok(Self { + expected_swap_output_wei, + flash_fee_wei, + gas_cost_debt_wei, + slippage_bps, + debt_price, + debt_decimals, + }) + } +} + +/// Compute net profit for a candidate liquidation. +/// +/// Returns `Err` whenever the liquidation is unprofitable — either the +/// total cost (flash fee + gas + slippage) swallows the gross swap +/// output, or the net (converted to USD_1e6 via `inputs.debt_price`) +/// falls below `min_profit_usd_1e6`. The caller is expected to drop +/// the opportunity on `Err`; no partial state is ever emitted. +pub fn calculate_profit( + inputs: &ProfitInputs, + min_profit_usd_1e6: u64, +) -> Result { + if inputs.slippage_bps > 10_000 { + return Err(ProfitError::InvalidBps(inputs.slippage_bps)); + } + + // Slippage is charged on the DEX swap output — the bot only pays + // slippage on the swap it performs. + let slippage_mul = inputs + .expected_swap_output_wei + .checked_mul(U256::from(inputs.slippage_bps)) + .ok_or(ProfitError::Overflow)?; + // 10_000 is a non-zero constant so the division is infallible. + let slippage_wei = slippage_mul / U256::from(10_000u64); + + let total_cost_wei = inputs + .flash_fee_wei + .checked_add(inputs.gas_cost_debt_wei) + .and_then(|v| v.checked_add(slippage_wei)) + .ok_or(ProfitError::Overflow)?; + + let gross_wei = inputs.expected_swap_output_wei; + + if gross_wei <= total_cost_wei { + return Err(ProfitError::Unprofitable { + gross_wei, + total_cost_wei, + flash_fee_wei: inputs.flash_fee_wei, + gas_cost_wei: inputs.gas_cost_debt_wei, + slippage_wei, + }); + } + + // gross > total_cost (checked above). + let net_profit_wei = gross_wei + .checked_sub(total_cost_wei) + .ok_or(ProfitError::Overflow)?; + + // Convert net_profit_wei to USD_1e6 for threshold compare + logs. + let net_profit_usd_1e6 = + wei_to_usd_1e6(net_profit_wei, inputs.debt_price, inputs.debt_decimals)?; + + if net_profit_usd_1e6 < min_profit_usd_1e6 { + return Err(ProfitError::BelowMinThreshold { + net_usd_1e6: net_profit_usd_1e6, + min_usd_1e6: min_profit_usd_1e6, + }); + } + + Ok(NetProfit { + gross_wei, + flash_fee_wei: inputs.flash_fee_wei, + gas_cost_wei: inputs.gas_cost_debt_wei, + slippage_wei, + net_profit_wei, + net_profit_usd_1e6, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{FlashLoanSource, LiquidationOpportunity, Position, ProtocolId, SwapRoute}; + use alloy::primitives::{Address, U256, address}; + + /// 1 BNB = 1e18 wei. + const ONE_BNB: u128 = 1_000_000_000_000_000_000; + + fn typical_inputs() -> ProfitInputs { + // $1 000 debt at $600/BNB ~= 1.667 BNB. Keep everything in BNB + // wei for readability of the arithmetic. + // + // Swap output = 1.1 BNB, flash fee = 0.05% of 1 BNB = 0.0005 BNB, + // gas = 0.001 BNB, 0.5% slippage budget. + ProfitInputs { + expected_swap_output_wei: U256::from(ONE_BNB) + .checked_mul(U256::from(11u64)) + .expect("const mul") + / U256::from(10u64), + flash_fee_wei: U256::from(ONE_BNB / 2_000), + gas_cost_debt_wei: U256::from(ONE_BNB / 1_000), + slippage_bps: 50, + debt_price: Price::new(60_000_000_000).expect("valid"), // $600 + debt_decimals: 18, + } + } + + #[test] + fn healthy_liquidation_is_profitable() { + let inputs = typical_inputs(); + // min = $5.00 (1e6 scale) + let np = calculate_profit(&inputs, 5_000_000).expect("profitable"); + + // slippage = 1.1 BNB * 50 / 10_000 = 0.0055 BNB + let expected_slippage = inputs.expected_swap_output_wei * U256::from(50u64) + / U256::from(10_000u64); + assert_eq!(np.slippage_wei, expected_slippage); + + // net = 1.1 - 0.0005 - 0.001 - 0.0055 = 1.0930 BNB + let expected_net = inputs.expected_swap_output_wei + - inputs.flash_fee_wei + - inputs.gas_cost_debt_wei + - expected_slippage; + assert_eq!(np.net_profit_wei, expected_net); + + // 1.0930 BNB * $600 ~= $655.80 -> 655_800_000 in 1e6 scale. + // Allow last-digit rounding (floor division). + assert!(np.net_profit_usd_1e6 >= 655_000_000); + assert!(np.net_profit_usd_1e6 <= 656_000_000); + } + + #[test] + fn below_threshold_is_rejected() { + // Threshold of $10 000 -> $1 000 000 000 in 1e6 scale. Typical + // inputs yield ~$650, nowhere near the bar. + let err = calculate_profit(&typical_inputs(), 1_000_000_000_000) + .expect_err("should reject below threshold"); + assert!(matches!(err, ProfitError::BelowMinThreshold { .. })); + } + + #[test] + fn cost_greater_than_gross_is_rejected() { + let inputs = ProfitInputs { + expected_swap_output_wei: U256::from(ONE_BNB / 1_000), // tiny trade + flash_fee_wei: U256::from(ONE_BNB / 500), + gas_cost_debt_wei: U256::from(ONE_BNB / 500), + slippage_bps: 50, + debt_price: Price::new(60_000_000_000).expect("valid"), + debt_decimals: 18, + }; + let err = calculate_profit(&inputs, 0).expect_err("unprofitable"); + assert!(matches!(err, ProfitError::Unprofitable { .. })); + } + + #[test] + fn bogus_slippage_bps_is_rejected() { + let mut inputs = typical_inputs(); + inputs.slippage_bps = 20_000; + assert!(matches!( + calculate_profit(&inputs, 0), + Err(ProfitError::InvalidBps(20_000)) + )); + } + + #[test] + fn slippage_bps_boundary_10_000_is_accepted_and_10_001_rejected() { + let mut inputs = typical_inputs(); + // 100% slippage consumes the full swap output -> unprofitable, + // but the bps check itself must *pass*. + inputs.slippage_bps = 10_000; + let err = calculate_profit(&inputs, 0) + .expect_err("100% slippage eats the whole swap"); + assert!(matches!(err, ProfitError::Unprofitable { .. })); + + inputs.slippage_bps = 10_001; + assert!(matches!( + calculate_profit(&inputs, 0), + Err(ProfitError::InvalidBps(10_001)) + )); + } + + #[test] + fn min_profit_zero_accepts_any_positive_net() { + let inputs = ProfitInputs { + expected_swap_output_wei: U256::from(ONE_BNB / 100), + flash_fee_wei: U256::from(ONE_BNB / 100_000), + gas_cost_debt_wei: U256::from(ONE_BNB / 100_000), + slippage_bps: 50, + debt_price: Price::new(60_000_000_000).expect("valid"), + debt_decimals: 18, + }; + let np = calculate_profit(&inputs, 0).expect("profitable"); + assert!(np.net_profit_wei > U256::ZERO); + } + + #[test] + fn total_cost_addition_overflow_is_reported() { + // Force checked_add(flash_fee_wei, gas_cost_debt_wei) to wrap. + let inputs = ProfitInputs { + expected_swap_output_wei: U256::ZERO, + flash_fee_wei: U256::MAX, + gas_cost_debt_wei: U256::from(1u64), + slippage_bps: 0, + debt_price: Price::new(60_000_000_000).expect("valid"), + debt_decimals: 18, + }; + assert!(matches!( + calculate_profit(&inputs, 0), + Err(ProfitError::Overflow) + )); + } + + // ── from_opportunity / wei->usd_1e6 path ──────────────────────── + + fn mk_opp( + collateral_amount: U256, + debt_amount: U256, + bonus_bps: u16, + ) -> LiquidationOpportunity { + LiquidationOpportunity { + position: Position { + protocol: ProtocolId::Venus, + chain_id: 56, + borrower: address!("1111111111111111111111111111111111111111"), + collateral_token: Address::ZERO, + debt_token: Address::ZERO, + collateral_amount, + debt_amount, + health_factor: U256::ZERO, + liquidation_bonus_bps: bonus_bps, + }, + debt_to_repay: debt_amount, + expected_collateral_out: collateral_amount, + flash_source: FlashLoanSource::AaveV3, + swap_route: SwapRoute { + token_in: Address::ZERO, + token_out: Address::ZERO, + amount_in: collateral_amount, + min_amount_out: debt_amount, + pool_fee: None, + }, + net_profit_wei: U256::ZERO, + } + } + + #[test] + fn bsc_bnb_one_token_at_600_usd_prices_to_600_dollars() { + // 1 BNB repay, matching-asset collateral, 10% bonus, $600 price + let one_bnb = U256::from(ONE_BNB); + let one_point_one_bnb = one_bnb * U256::from(11u64) / U256::from(10u64); + let opp = mk_opp(one_point_one_bnb, one_bnb, 1_000); + let price = Price::new(60_000_000_000).expect("valid"); // $600 + + // Swap output ~= 1.1 BNB; flash fee = 0.05% of 1 BNB = 0.0005 BNB. + let flash_fee_wei = one_bnb / U256::from(2_000u64); + let gas_cost_debt_wei = one_bnb / U256::from(1_000u64); + + let inputs = ProfitInputs::from_opportunity( + &opp, + one_point_one_bnb, + flash_fee_wei, + gas_cost_debt_wei, + 50, + price, + 18, + ) + .expect("valid"); + + assert_eq!(inputs.expected_swap_output_wei, one_point_one_bnb); + assert_eq!(inputs.flash_fee_wei, flash_fee_wei); + + let np = calculate_profit(&inputs, 0).expect("profitable"); + // net = 1.1 - 0.0005 - 0.001 - (1.1*0.005) = 1.0930 BNB ~= $655.80 + assert!(np.net_profit_usd_1e6 >= 655_000_000); + assert!(np.net_profit_usd_1e6 <= 656_000_000); + } + + #[test] + fn zero_price_is_rejected() { + assert!(matches!(Price::new(0), Err(ProfitError::InvalidPrice))); + } + + #[test] + fn decimals_above_18_are_rejected() { + let opp = mk_opp(U256::from(1u64), U256::from(1u64), 1_000); + let price = Price::new(60_000_000_000).expect("valid"); + assert!(matches!( + ProfitInputs::from_opportunity( + &opp, + U256::from(1u64), + U256::from(0u64), + U256::from(0u64), + 0, + price, + 19, // invalid + ), + Err(ProfitError::UnsupportedDecimals(19)) + )); + } + + #[test] + fn position_bonus_bps_above_10_000_is_rejected_in_constructor() { + let opp = mk_opp(U256::from(1u64), U256::from(1u64), 10_001); + let price = Price::new(60_000_000_000).expect("valid"); + assert!(matches!( + ProfitInputs::from_opportunity( + &opp, + U256::from(1u64), + U256::from(0u64), + U256::from(0u64), + 0, + price, + 18, + ), + Err(ProfitError::InvalidBps(10_001)) + )); + } + + /// `LiquidationOpportunity::net_profit_wei` must equal + /// `NetProfit::net_profit_wei` — this is the invariant that + /// [`LiquidationOpportunity::with_profit`] enforces. + #[test] + fn with_profit_copies_net_profit_wei_into_opportunity() { + let inputs = typical_inputs(); + let np = calculate_profit(&inputs, 0).expect("profitable"); + let opp = mk_opp(U256::from(ONE_BNB), U256::from(ONE_BNB), 1_000); + let out = LiquidationOpportunity::with_profit( + opp.position.clone(), + opp.debt_to_repay, + opp.expected_collateral_out, + opp.flash_source, + opp.swap_route.clone(), + np, + ); + assert_eq!(out.net_profit_wei, np.net_profit_wei); + } +} diff --git a/crates/charon-core/src/queue.rs b/crates/charon-core/src/queue.rs new file mode 100644 index 0000000..9c01a1e --- /dev/null +++ b/crates/charon-core/src/queue.rs @@ -0,0 +1,348 @@ +//! Profit-ordered opportunity queue. +//! +//! After the router prices a liquidation, the resulting +//! [`LiquidationOpportunity`] lands in this queue. The executor drains +//! entries highest-net-profit first, dropping anything older than +//! `ttl_blocks` (default 2) — stale quotes are priced against stale +//! balances and usually revert on `eth_call` anyway. +//! +//! The queue is `Send + Sync` and cloneable: it wraps a +//! `std::collections::BinaryHeap` inside a [`tokio::sync::Mutex`] inside +//! an `Arc` so a single `OpportunityQueue` handle can be shared across +//! the block listener, scanner, and executor tasks. +//! +//! # Ordering +//! +//! The heap is keyed on a private [`QueueEntry`] wrapper so we do not +//! leak `Ord` semantics out of the type. Ordering is lexicographic: +//! +//! 1. **net profit** — delegated to +//! [`LiquidationOpportunity`]'s own `Ord` impl, which compares by +//! `net_profit_wei` ascending so the max-heap pops the highest wei +//! first. +//! 2. **inserted_at_block, descending** — on a tie, the fresher entry +//! wins. This matters around reorgs, where two identically-priced +//! entries may land on either side of a re-seen block; the fresher +//! one is strictly better because its balance / price snapshot is +//! younger. +//! +//! Manual `PartialEq` / `Eq` mirror `Ord` exactly so the heap's +//! invariants hold even after `retain`-style surgery. + +use std::cmp::Ordering; +use std::collections::BinaryHeap; +use std::sync::Arc; + +use tokio::sync::Mutex; + +use crate::types::LiquidationOpportunity; + +/// Default TTL, in blocks. Two blocks ~= 6 s on BSC — long enough to +/// survive one routing round-trip but short enough that stale quotes +/// don't pile up. +pub const DEFAULT_TTL_BLOCKS: u64 = 2; + +/// Heap wrapper — compares by the opportunity's own `Ord` (net profit +/// wei ascending) first, then by `inserted_at_block` (fresher wins). +/// See the module docs. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct QueueEntry { + pub opportunity: LiquidationOpportunity, + /// Block height at which this entry was enqueued — drives both TTL + /// expiry and the Ord tie-break. + pub inserted_at_block: u64, +} + +impl PartialEq for QueueEntry { + fn eq(&self, other: &Self) -> bool { + self.cmp(other) == Ordering::Equal + } +} +impl Eq for QueueEntry {} +impl PartialOrd for QueueEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for QueueEntry { + fn cmp(&self, other: &Self) -> Ordering { + // BinaryHeap is a max-heap. LiquidationOpportunity::Ord ranks + // by net_profit_wei ascending — largest profit pops first. + // Tie-break on inserted_at_block ascending so that the larger + // (= fresher) block pops first. + self.opportunity + .cmp(&other.opportunity) + .then_with(|| self.inserted_at_block.cmp(&other.inserted_at_block)) + } +} + +/// Thread-safe priority queue of ready-to-execute liquidations. +/// +/// Clone to hand a new handle to another task — all handles share the +/// same underlying heap. +#[derive(Clone, Debug)] +pub struct OpportunityQueue { + inner: Arc>>, + ttl_blocks: u64, +} + +impl OpportunityQueue { + /// Create a new queue with an explicit TTL, in blocks. + pub fn new(ttl_blocks: u64) -> Self { + Self { + inner: Arc::new(Mutex::new(BinaryHeap::new())), + ttl_blocks, + } + } + + /// Create a new queue with [`DEFAULT_TTL_BLOCKS`]. + pub fn with_default_ttl() -> Self { + Self::new(DEFAULT_TTL_BLOCKS) + } + + /// TTL this queue was constructed with. + pub fn ttl_blocks(&self) -> u64 { + self.ttl_blocks + } + + /// Current number of entries (stale entries included — run + /// [`prune_stale`](Self::prune_stale) first to exclude them). + pub async fn len(&self) -> usize { + self.inner.lock().await.len() + } + + /// `true` when the heap is empty. + pub async fn is_empty(&self) -> bool { + self.inner.lock().await.is_empty() + } + + /// Enqueue a freshly-priced opportunity, tagged with the block it + /// was queued at (for TTL accounting). + pub async fn push(&self, opportunity: LiquidationOpportunity, inserted_at_block: u64) { + self.inner.lock().await.push(QueueEntry { + opportunity, + inserted_at_block, + }); + } + + /// Pop the highest-profit *fresh* opportunity, silently discarding + /// any stale entries popped along the way. Returns `None` when the + /// queue has no fresh entries left. + pub async fn pop(&self, current_block: u64) -> Option { + let mut guard = self.inner.lock().await; + while let Some(entry) = guard.pop() { + if !is_stale(&entry, current_block, self.ttl_blocks) { + return Some(entry.opportunity); + } + } + None + } + + /// Remove every stale entry, returning the number dropped. Cheap + /// to run once per block so stale opportunities don't balloon the + /// heap between bursts. + pub async fn prune_stale(&self, current_block: u64) -> usize { + let mut guard = self.inner.lock().await; + let before = guard.len(); + let ttl = self.ttl_blocks; + let fresh: Vec = std::mem::take(&mut *guard) + .into_iter() + .filter(|e| !is_stale(e, current_block, ttl)) + .collect(); + *guard = BinaryHeap::from(fresh); + // before >= guard.len() by construction. + before.saturating_sub(guard.len()) + } +} + +impl Default for OpportunityQueue { + fn default() -> Self { + Self::with_default_ttl() + } +} + +/// Age-based staleness. `current_block - inserted_at_block > ttl`. Uses +/// `saturating_sub` so a reorg that momentarily *rewinds* the block +/// pointer (current_block < inserted_at_block) treats the entry as +/// fresh rather than wrapping to a near-`u64::MAX` age. +fn is_stale(entry: &QueueEntry, current_block: u64, ttl: u64) -> bool { + current_block.saturating_sub(entry.inserted_at_block) > ttl +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{FlashLoanSource, Position, ProtocolId, SwapRoute}; + use alloy::primitives::{Address, U256, address}; + + fn mk_opp(net_wei: U256) -> LiquidationOpportunity { + LiquidationOpportunity { + position: Position { + protocol: ProtocolId::Venus, + chain_id: 56, + borrower: address!("1111111111111111111111111111111111111111"), + collateral_token: Address::ZERO, + debt_token: Address::ZERO, + collateral_amount: U256::ZERO, + debt_amount: U256::ZERO, + health_factor: U256::ZERO, + liquidation_bonus_bps: 1_000, + }, + debt_to_repay: U256::ZERO, + expected_collateral_out: U256::ZERO, + flash_source: FlashLoanSource::AaveV3, + swap_route: SwapRoute { + token_in: Address::ZERO, + token_out: Address::ZERO, + amount_in: U256::ZERO, + min_amount_out: U256::ZERO, + pool_fee: None, + }, + net_profit_wei: net_wei, + } + } + + #[tokio::test] + async fn pop_returns_highest_profit_first() { + let q = OpportunityQueue::new(5); + q.push(mk_opp(U256::from(100u64)), 1).await; + q.push(mk_opp(U256::from(500u64)), 1).await; + q.push(mk_opp(U256::from(250u64)), 1).await; + assert_eq!(q.pop(1).await.expect("fresh").net_profit_wei, U256::from(500u64)); + assert_eq!(q.pop(1).await.expect("fresh").net_profit_wei, U256::from(250u64)); + assert_eq!(q.pop(1).await.expect("fresh").net_profit_wei, U256::from(100u64)); + assert!(q.pop(1).await.is_none()); + } + + #[tokio::test] + async fn stale_entries_are_dropped_on_pop() { + let q = OpportunityQueue::new(2); + q.push(mk_opp(U256::from(999u64)), 10).await; // queued at block 10 + // Current block 13 -> age 3 > ttl 2 -> stale + assert!(q.pop(13).await.is_none()); + } + + #[tokio::test] + async fn fresh_survives_ttl_boundary() { + let q = OpportunityQueue::new(2); + q.push(mk_opp(U256::from(42u64)), 10).await; + // age 2 == ttl 2 -> still fresh (ttl is inclusive) + assert_eq!( + q.pop(12).await.expect("fresh").net_profit_wei, + U256::from(42u64) + ); + } + + #[tokio::test] + async fn prune_stale_drops_old_entries_and_reports_count() { + let q = OpportunityQueue::new(2); + q.push(mk_opp(U256::from(100u64)), 5).await; + q.push(mk_opp(U256::from(200u64)), 10).await; + q.push(mk_opp(U256::from(300u64)), 11).await; + assert_eq!(q.len().await, 3); + // At block 12: block-5 is 7 (stale), block-10 is 2 (fresh), + // block-11 is 1 (fresh). One dropped. + let dropped = q.prune_stale(12).await; + assert_eq!(dropped, 1); + assert_eq!(q.len().await, 2); + } + + #[tokio::test] + async fn default_ttl_is_two_blocks() { + let q = OpportunityQueue::with_default_ttl(); + assert_eq!(q.ttl_blocks(), DEFAULT_TTL_BLOCKS); + } + + /// Ord tie-break: two entries with the same net profit should pop + /// in fresher-first order. With LiquidationOpportunity::PartialEq + /// keying on (net_profit_wei, borrower, chain_id), equal-profit + /// entries are Ordering::Equal on the primary key — the queue's + /// `inserted_at_block` tie-break must take over. + #[tokio::test] + async fn tie_break_favours_fresher_entry() { + let q = OpportunityQueue::new(10); + q.push(mk_opp(U256::from(500u64)), 100).await; // older + q.push(mk_opp(U256::from(500u64)), 105).await; // fresher + q.push(mk_opp(U256::from(500u64)), 102).await; // middle + let first = q.pop(110).await.expect("fresh").net_profit_wei; + assert_eq!(first, U256::from(500u64)); + // All three share net_profit; tie-break by inserted_at_block + // desc should have popped the 105 entry first. Remaining two + // must still drain without violating heap invariant. + assert_eq!(q.len().await, 2); + let _ = q.pop(110).await.expect("second"); + let _ = q.pop(110).await.expect("third"); + assert!(q.pop(110).await.is_none()); + } + + /// Reorg scenario: entry enqueued at block 105, chain reorgs and + /// the current block pointer rewinds to 104. `saturating_sub` keeps + /// the entry alive (treated as age 0) rather than wrapping to a + /// massive age and being pruned. + #[tokio::test] + async fn reorg_rewind_does_not_drop_entry() { + let q = OpportunityQueue::new(2); + q.push(mk_opp(U256::from(777u64)), 105).await; + // Reorg: head rewinds to block 104. + assert_eq!(q.prune_stale(104).await, 0); + assert_eq!(q.len().await, 1); + // Entry must still be poppable at the rewound head. + let out = q.pop(104).await.expect("survives reorg rewind"); + assert_eq!(out.net_profit_wei, U256::from(777u64)); + } + + /// Prunable entry at block 105 stays dropped across a rewind to + /// 104: once removed from the heap, it does not resurrect. + #[tokio::test] + async fn pruned_entry_stays_dropped_after_reorg() { + let q = OpportunityQueue::new(2); + q.push(mk_opp(U256::from(100u64)), 95).await; // age at block 105 = 10 -> stale + q.push(mk_opp(U256::from(200u64)), 103).await; // fresh + assert_eq!(q.prune_stale(105).await, 1); + assert_eq!(q.len().await, 1); + + // Reorg rewinds the head to 104. The pruned block-95 entry is + // already gone from the heap; it must not reappear. + let out = q.pop(104).await.expect("survivor"); + assert_eq!(out.net_profit_wei, U256::from(200u64)); + assert!(q.pop(104).await.is_none()); + } + + /// Spawn 16 producer tasks concurrently pushing spread profit + /// values and one consumer task draining the queue. The drained + /// sequence must be weakly decreasing by net profit wei. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_producers_maintain_heap_order() { + let q = OpportunityQueue::new(1_000); + let mut producers = Vec::new(); + for i in 0..16u64 { + let q_clone = q.clone(); + producers.push(tokio::spawn(async move { + // Deterministic spread of profit values so the test is + // reproducible but still exercises interleaving. + for j in 0..8u64 { + let net = i.saturating_mul(8).saturating_add(j).saturating_mul(10); + q_clone.push(mk_opp(U256::from(net)), 1).await; + } + })); + } + for p in producers { + p.await.expect("producer joined"); + } + assert_eq!(q.len().await, 16 * 8); + + let mut last = U256::MAX; + let mut drained = 0usize; + while let Some(opp) = q.pop(1).await { + assert!( + opp.net_profit_wei <= last, + "ordering violated: {} > previous {last}", + opp.net_profit_wei + ); + last = opp.net_profit_wei; + drained += 1; + } + assert_eq!(drained, 16 * 8); + } +} diff --git a/crates/charon-core/src/types.rs b/crates/charon-core/src/types.rs index e58a246..a05c923 100644 --- a/crates/charon-core/src/types.rs +++ b/crates/charon-core/src/types.rs @@ -132,6 +132,35 @@ pub struct LiquidationOpportunity { pub net_profit_wei: U256, } +impl LiquidationOpportunity { + /// Build an opportunity and copy the authoritative + /// `net_profit_wei` out of a [`crate::profit::NetProfit`] + /// breakdown. + /// + /// The breakdown itself (gross, fee, gas, slippage) stays local to + /// the caller for logging / tracing — only the final + /// `net_profit_wei` is stored on the opportunity. This is the only + /// sanctioned constructor that ties a stored profit figure back to + /// the calculator that produced it. + pub fn with_profit( + position: Position, + debt_to_repay: U256, + expected_collateral_out: U256, + flash_source: FlashLoanSource, + swap_route: SwapRoute, + net_profit: crate::profit::NetProfit, + ) -> Self { + Self { + position, + debt_to_repay, + expected_collateral_out, + flash_source, + swap_route, + net_profit_wei: net_profit.net_profit_wei, + } + } +} + impl Ord for LiquidationOpportunity { /// Ranks opportunities by `net_profit_wei` ascending so that a /// `BinaryHeap` pops the highest-profit entry first.