From af1353d58fc52fa2314f845239ee1187c6e90667 Mon Sep 17 00:00:00 2001 From: obchain Date: Fri, 24 Apr 2026 00:32:21 +0530 Subject: [PATCH] feat(cli): real per-token USD pricing + real gas cost in profit gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two placeholders mev-sim-auditor flagged as a P1 econ blocker on PR #305: `repay_to_usd_cents_placeholder` (treated every token as 18-dec $1) and `PLACEHOLDER_GAS_USD_CENTS = 50` (flat 50c). Before this change, for BTCB / ETH / BNB debt the profit gate underpriced repayments by ~10^4 and overpriced-by-omission gas — so the gate would pass unprofitable txs and the signer would bleed gas under --execute. Changes: - New `charon-scanner::token_meta::TokenMetaCache`. Populated once at startup with `(symbol, decimals)` for every Venus underlying via `ERC20::symbol()` + `decimals()`. Tokens whose metadata calls fail log at `error!` and are skipped — the profit gate treats "no meta" the same as "no price" and drops. Bot refuses to start if the cache ends up empty (RPC or adapter wiring is broken). - `run_listen` always constructs a `GasOracle` (no longer --execute-only) and also bails if the BNB/USD Chainlink feed is missing from the price cache — a missing native feed means gas cost cannot be priced in USD. - `process_block` fetches one `gas_snapshot` (Option) per tick via the gas oracle, shared across every opportunity in that block so there is no fan-out of `get_block` calls. - `process_opportunity` now takes `&PriceCache`, `&TokenMetaCache`, `Option`. Drops the opportunity if any of: unknown debt token meta, missing-or-stale Chainlink price for the debt token, missing-or-stale BNB/USD price, or gas snapshot unavailable. - New `amount_to_usd_cents(amount, token_dec, price, price_dec) -> u64` using saturating U256 math, with 4 unit tests (USDT@$1, BTCB@$60k, zero price, overflow saturation). - New constants `PROFIT_GATE_ROUGH_GAS_UNITS = 1_500_000` (Venus+Aave path empirical upper bound) and `NATIVE_FEED_SYMBOL = "BNB"`. --execute remains OFF by default. With this PR plus #305 the profit gate is now trustworthy for non-stablecoin debt; the next step before enabling --execute on mainnet is a fork-level replay (deferred in PR #305 pending Submitter scheme-gate decision and the cold-wallet port from the sibling branch). --- crates/charon-cli/src/main.rs | 228 +++++++++++++++++++++--- crates/charon-scanner/src/lib.rs | 2 + crates/charon-scanner/src/token_meta.rs | 136 ++++++++++++++ 3 files changed, 343 insertions(+), 23 deletions(-) create mode 100644 crates/charon-scanner/src/token_meta.rs diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index ead8990..cb6f6a1 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -20,12 +20,14 @@ use charon_core::{ ProfitInputs, SwapRoute, calculate_profit, }; use charon_executor::{ - DEFAULT_SUBMIT_TIMEOUT, GasOracle, NonceManager, SubmitError, Submitter, Simulator, TxBuilder, + DEFAULT_SUBMIT_TIMEOUT, GasOracle, GasParams, NonceManager, Simulator, SubmitError, Submitter, + TxBuilder, gas_cost_usd_cents, }; use charon_flashloan::{AaveFlashLoan, FlashLoanRouter}; use charon_protocols::VenusAdapter; use charon_scanner::{ BlockListener, ChainEvent, ChainProvider, DEFAULT_MAX_AGE, HealthScanner, PriceCache, + TokenMetaCache, }; use clap::{Parser, Subcommand}; use tokio::sync::mpsc; @@ -41,10 +43,19 @@ const CHAIN_EVENT_CHANNEL: usize = 1024; /// 0.5% — conservative default for PancakeSwap V3 hot-pair swaps. const DEFAULT_SLIPPAGE_BPS: u16 = 50; -/// Placeholder gas estimate per liquidation tx (USD cents). Real -/// `eth_estimateGas × gas_price × native_price` lands once a gas -/// oracle is wired up. -const PLACEHOLDER_GAS_USD_CENTS: u64 = 50; +/// Pre-broadcast gas-units estimate used by the profit gate. Venus +/// liquidation path through the Aave flashloan callback empirically +/// lands in ~1.1-1.6M gas; we use 1.5M to avoid gating out profitable +/// txs that would comfortably fit under the real `eth_estimateGas` +/// result fetched inside [`broadcast`]. The actual gas limit sent on +/// the wire is still `estimate_gas × 1.3` at broadcast time. +const PROFIT_GATE_ROUGH_GAS_UNITS: u64 = 1_500_000; + +/// Native-asset Chainlink feed symbol on BSC. Used to price the gas +/// cost estimate in USD cents. If this feed is missing from the +/// config's `[chainlink.bnb]` table the bot refuses to start — a +/// missing BNB feed means we cannot compute gas cost at all. +const NATIVE_FEED_SYMBOL: &str = "BNB"; /// Multiplier applied to `eth_estimateGas` before broadcast. 30 % /// headroom covers state drift between estimate and inclusion (vToken @@ -146,7 +157,10 @@ async fn main() -> Result<()> { /// Bundle of executor components needed to broadcast a simulated /// opportunity. Present only when the operator ran `listen --execute` /// and every safety gate passed; `None` means the pipeline is in -/// scan-only or scan-plus-simulate mode. +/// scan-only or scan-plus-simulate mode. Holds its own `GasOracle` +/// clone — the oracle is also used by the profit gate outside +/// `--execute` mode, so it lives at run-listen scope and the clone +/// here is just convenience. struct ExecHarness { gas_oracle: GasOracle, nonce_manager: Arc, @@ -209,6 +223,37 @@ async fn run_listen(config: Config, borrowers: Vec
, execute: bool) -> R info!(symbol = %sym, price = %p.price, decimals = p.decimals, "chainlink feed"); } } + if prices.get(NATIVE_FEED_SYMBOL).is_none() { + bail!( + "chainlink feed for '{NATIVE_FEED_SYMBOL}' missing or stale — gas cost cannot be priced" + ); + } + + // Token metadata (symbol + decimals) for every Venus underlying. + // Queried once at startup; the profit gate needs both fields to + // convert a raw repay amount into USD cents via the price cache. + let token_meta = Arc::new( + TokenMetaCache::build( + provider.as_ref(), + adapter.underlying_to_vtoken.keys().copied(), + ) + .await, + ); + info!( + tokens_cached = token_meta.len(), + "token metadata cache built" + ); + if token_meta.is_empty() { + bail!( + "token metadata cache is empty — no Venus underlying resolved its symbol/decimals; \ + profit gate would drop every opportunity. Check RPC and adapter wiring." + ); + } + + // Gas oracle is needed by both the profit gate (every block) and + // the broadcast path (under --execute). Build once, share by + // value — `GasOracle` is `Copy`. + let gas_oracle = GasOracle::new(config.bot.max_gas_gwei, bnb.priority_fee_gwei); // ── Flash-loan router (#13) ── // Liquidator address may be the placeholder zero — adapter still @@ -284,7 +329,6 @@ async fn run_listen(config: Config, borrowers: Vec
, execute: bool) -> R let nonce_manager = NonceManager::init(provider.as_ref(), signer_address) .await .context("--execute: failed to initialise nonce manager")?; - let gas_oracle = GasOracle::new(config.bot.max_gas_gwei, bnb.priority_fee_gwei); warn!( signer = %signer_address, liquidator = %liquidator_cfg.contract_address, @@ -345,6 +389,9 @@ async fn run_listen(config: Config, borrowers: Vec
, execute: bool) -> R tx_builder.clone(), simulator.clone(), exec_harness.clone(), + prices.clone(), + token_meta.clone(), + gas_oracle, queue.clone(), provider.clone(), config.bot.min_profit_usd, @@ -375,6 +422,9 @@ async fn process_block( tx_builder: Option>, simulator: Option>, exec_harness: Option>, + prices: Arc, + token_meta: Arc, + gas_oracle: GasOracle, queue: Arc>, provider: Arc>, min_profit_usd: f64, @@ -394,7 +444,21 @@ async fn process_block( scanner.upsert(positions); let counts = scanner.bucket_counts(); - // 3. Per-liquidatable: route flash loan, calc profit, build, simulate, queue. + // 3a. One-shot gas snapshot for this block. Shared across every + // opportunity in this tick so we make a single `get_block` + // call no matter how many liquidatable positions fan out. + // `None` means the gas oracle refused to emit (ceiling tripped + // or RPC error); the profit gate treats that as "too expensive + // this block" and drops every candidate. + let gas_snapshot = match gas_oracle.fetch_params(provider.as_ref()).await { + Ok(params) => params, + Err(err) => { + warn!(chain = %chain, block, error = ?err, "gas oracle tick failed"); + None + } + }; + + // 3b. Per-liquidatable: route flash loan, calc profit, build, simulate, queue. let liquidatable = scanner.liquidatable(); let mut queued = 0usize; let mut broadcast = 0usize; @@ -406,6 +470,9 @@ async fn process_block( tx_builder.as_deref(), simulator.as_deref(), exec_harness.as_deref(), + prices.as_ref(), + token_meta.as_ref(), + gas_snapshot, provider.as_ref(), min_profit_usd, block, @@ -466,6 +533,9 @@ async fn process_opportunity( tx_builder: Option<&TxBuilder>, simulator: Option<&Simulator>, exec_harness: Option<&ExecHarness>, + prices: &PriceCache, + token_meta: &TokenMetaCache, + gas_snapshot: Option, provider: &alloy::providers::RootProvider, min_profit_usd: f64, queued_at_block: u64, @@ -481,17 +551,65 @@ async fn process_opportunity( return Ok(ProcessOutcome::Dropped); }; - // c. Profit calc — placeholder USD math until precise per-token - // pricing lands. Treat repay_amount as 1:1 USD cents-equivalent - // after stripping decimals (works for stablecoin debt; underprices - // BNB/BTC/ETH debt — flagged as a follow-up). - let repay_usd_cents = repay_to_usd_cents_placeholder(repay); - let flash_fee_usd_cents = repay_to_usd_cents_placeholder(quote.fee); + // c. Real profit calc. Any missing piece of price/meta/gas data + // deliberately drops the opportunity rather than falling back + // to an optimistic default — the profit gate is the last line + // of defence against broadcasting an unprofitable tx. + let Some(debt_meta) = token_meta.get(&pos.debt_token) else { + debug!( + borrower = %pos.borrower, + debt_token = %pos.debt_token, + "no token metadata — dropped" + ); + return Ok(ProcessOutcome::Dropped); + }; + let Some(debt_price) = prices.get(&debt_meta.symbol) else { + debug!( + borrower = %pos.borrower, + symbol = %debt_meta.symbol, + "no chainlink price (or stale) — dropped" + ); + return Ok(ProcessOutcome::Dropped); + }; + let Some(native_price) = prices.get(NATIVE_FEED_SYMBOL) else { + debug!( + borrower = %pos.borrower, + "no BNB/USD price (or stale) — dropped" + ); + return Ok(ProcessOutcome::Dropped); + }; + let Some(gas_params) = gas_snapshot else { + debug!( + borrower = %pos.borrower, + "gas snapshot unavailable this block — dropped" + ); + return Ok(ProcessOutcome::Dropped); + }; + + let repay_usd_cents = amount_to_usd_cents( + repay, + debt_meta.decimals, + debt_price.price, + debt_price.decimals, + ); + let flash_fee_usd_cents = amount_to_usd_cents( + quote.fee, + debt_meta.decimals, + debt_price.price, + debt_price.decimals, + ); + let gas_cost_usd = gas_cost_usd_cents( + PROFIT_GATE_ROUGH_GAS_UNITS, + gas_params.max_fee_per_gas, + native_price.price, + native_price.decimals, + ); + let profit_inputs = ProfitInputs { repay_amount_usd_cents: repay_usd_cents, liquidation_bonus_bps: pos.liquidation_bonus_bps, flash_fee_usd_cents, - gas_cost_usd_cents: PLACEHOLDER_GAS_USD_CENTS, + gas_cost_usd_cents: gas_cost_usd, slippage_bps: DEFAULT_SLIPPAGE_BPS, }; let net = match calculate_profit(&profit_inputs, min_profit_usd) { @@ -653,13 +771,77 @@ async fn broadcast( } } -/// Strip 18 decimals and convert to USD cents (×100), saturating to -/// `u64`. Treats every token as 1 USD per unit — fine for stablecoin -/// debt, wildly off for BNB/BTC/ETH. Real per-token pricing replaces -/// this once a token-decimals + symbol-resolution layer lands. -fn repay_to_usd_cents_placeholder(amount: U256) -> u64 { - // 1 token (18 decimals) ≈ $1 → 100 cents. Divide by 1e16. - let scale = U256::from(10u64).pow(U256::from(16u64)); - let cents = amount / scale; +/// Convert a token amount into USD cents using a Chainlink price. +/// +/// Inputs: +/// - `amount` — raw units of the ERC-20 (`repay_amount`, `flash_fee`, …). +/// - `token_decimals` — decimals of the ERC-20 itself (`USDT` = 6, `BTCB` = 18). +/// - `price` — raw Chainlink aggregator answer (non-negative). +/// - `price_decimals` — feed's `decimals()` (typically 8 on BSC). +/// +/// Math: +/// ```text +/// usd_cents = amount × price × 100 / 10^(token_decimals + price_decimals) +/// ``` +/// Every step is on `U256` with `saturating_mul`; the final cast +/// clamps to `u64::MAX` so a pathological amount never panics. +fn amount_to_usd_cents( + amount: U256, + token_decimals: u8, + price: U256, + price_decimals: u8, +) -> u64 { + let numerator = amount + .saturating_mul(price) + .saturating_mul(U256::from(100u64)); + let exponent = u64::from(token_decimals) + u64::from(price_decimals); + let divisor = U256::from(10u64).pow(U256::from(exponent)); + if divisor.is_zero() { + return 0; + } + let cents = numerator / divisor; u64::try_from(cents).unwrap_or(u64::MAX) } + +#[cfg(test)] +mod amount_to_usd_cents_tests { + use super::*; + + #[test] + fn usdt_6_decimals_at_1_dollar_gives_cents_scaled_by_100() { + // 1_000_000 raw USDT = 1 USDT @ 6 decimals × $1.00 = 100 cents + let cents = amount_to_usd_cents(U256::from(1_000_000u64), 6, U256::from(100_000_000u64), 8); + assert_eq!(cents, 100); + } + + #[test] + fn btcb_18_decimals_at_60k_dollars_gives_six_million_cents() { + // 1 BTCB (1e18 raw) @ price 60_000 × 1e8 → 6_000_000 cents + let cents = amount_to_usd_cents( + U256::from(10u64).pow(U256::from(18u64)), + 18, + U256::from(60_000u64) * U256::from(100_000_000u64), + 8, + ); + assert_eq!(cents, 6_000_000); + } + + #[test] + fn zero_price_returns_zero_cents() { + let cents = amount_to_usd_cents(U256::from(1u64), 18, U256::ZERO, 8); + assert_eq!(cents, 0); + } + + #[test] + fn saturates_on_extreme_inputs() { + // price ~= 10^30 × amount ~= 10^30 → numerator ~10^62 / + // divisor 10^26 = 10^36, overflows u64 → saturates. + let cents = amount_to_usd_cents( + U256::from(10u64).pow(U256::from(30u64)), + 18, + U256::from(10u64).pow(U256::from(30u64)), + 8, + ); + assert_eq!(cents, u64::MAX); + } +} diff --git a/crates/charon-scanner/src/lib.rs b/crates/charon-scanner/src/lib.rs index 92f32c6..c698b14 100644 --- a/crates/charon-scanner/src/lib.rs +++ b/crates/charon-scanner/src/lib.rs @@ -4,8 +4,10 @@ pub mod listener; pub mod oracle; pub mod provider; pub mod scanner; +pub mod token_meta; pub use listener::{BlockListener, ChainEvent}; pub use oracle::{CachedPrice, DEFAULT_MAX_AGE, PriceCache}; pub use provider::ChainProvider; pub use scanner::{BucketCounts, BucketedPosition, HealthScanner, PositionBucket}; +pub use token_meta::{TokenMeta, TokenMetaCache}; diff --git a/crates/charon-scanner/src/token_meta.rs b/crates/charon-scanner/src/token_meta.rs new file mode 100644 index 0000000..97dede2 --- /dev/null +++ b/crates/charon-scanner/src/token_meta.rs @@ -0,0 +1,136 @@ +//! Cached `(symbol, decimals)` for every ERC-20 the bot cares about. +//! +//! The profit gate needs to convert a raw `repay_amount` (in token +//! units) into USD cents, which means knowing two things per token: +//! +//! 1. How many decimals the ERC-20 uses (`USDT` = 6 on BSC; `BTCB` = 18). +//! 2. Which Chainlink feed to look up in [`crate::PriceCache`] — that +//! cache is keyed by symbol string, not address. +//! +//! Both are static after deployment, so we query each underlying once +//! at startup and stash the result. A missing or failing token is +//! skipped (logged at warn) rather than panicking — the profit gate +//! treats "no meta" the same as "no price" and drops the opportunity. + +use std::collections::HashMap; + +use alloy::primitives::Address; +use alloy::providers::RootProvider; +use alloy::pubsub::PubSubFrontend; +use alloy::sol; +use tracing::{debug, error}; + +sol! { + /// ERC-20 metadata-only surface: `symbol()` + `decimals()`. + #[sol(rpc)] + interface IERC20Meta { + function symbol() external view returns (string); + function decimals() external view returns (uint8); + } +} + +/// Metadata for one ERC-20: what to call it and how to scale it. +#[derive(Debug, Clone)] +pub struct TokenMeta { + pub symbol: String, + pub decimals: u8, +} + +/// Address-keyed cache populated once at startup from the list of +/// underlying tokens the adapter discovered. +#[derive(Debug, Default)] +pub struct TokenMetaCache { + inner: HashMap, +} + +impl TokenMetaCache { + /// Query `symbol()` and `decimals()` for every address in `tokens` + /// and return a populated cache. Tokens whose calls fail or whose + /// `symbol()` returns something unprintable are dropped from the + /// cache; callers see them as unknown and skip the opportunity. + pub async fn build( + provider: &RootProvider, + tokens: impl IntoIterator, + ) -> Self { + let mut inner = HashMap::new(); + for addr in tokens { + let contract = IERC20Meta::new(addr, provider); + let symbol = match contract.symbol().call().await { + Ok(r) => r._0, + Err(err) => { + // Legacy tokens (MKR-style bytes32 symbol, non-standard + // ERC-20s) and RPC failures both land here. Either way + // the profit gate cannot price this market — log loud + // so the operator notices, and skip. + error!( + token = %addr, + error = ?err, + "symbol() failed — market is now UNREACHABLE by the profit gate", + ); + continue; + } + }; + let decimals = match contract.decimals().call().await { + Ok(r) => r._0, + Err(err) => { + error!( + token = %addr, + error = ?err, + "decimals() failed — market is now UNREACHABLE by the profit gate", + ); + continue; + } + }; + debug!(token = %addr, %symbol, decimals, "token meta cached"); + inner.insert(addr, TokenMeta { symbol, decimals }); + } + Self { inner } + } + + /// Look up meta by underlying address. `None` if the token was + /// never queried or its metadata calls failed at startup. + pub fn get(&self, addr: &Address) -> Option<&TokenMeta> { + self.inner.get(addr) + } + + /// Count of successfully cached tokens. + pub fn len(&self) -> usize { + self.inner.len() + } + + /// `true` when no tokens cached — useful for startup sanity checks. + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_cache_returns_none_on_lookup() { + let c = TokenMetaCache::default(); + assert!(c.is_empty()); + assert_eq!(c.len(), 0); + assert!(c.get(&Address::ZERO).is_none()); + } + + #[test] + fn populated_cache_reports_len_and_hit() { + let mut c = TokenMetaCache::default(); + let addr = Address::from([0x11; 20]); + c.inner.insert( + addr, + TokenMeta { + symbol: "USDT".into(), + decimals: 18, + }, + ); + assert_eq!(c.len(), 1); + assert!(!c.is_empty()); + let meta = c.get(&addr).expect("hit"); + assert_eq!(meta.symbol, "USDT"); + assert_eq!(meta.decimals, 18); + } +}