From 32183fb802c912b9ce25f7d96b5215e6df972caf Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 23:56:35 +0530 Subject: [PATCH 1/2] fix(core): reject zero-address liquidator in validate() eth_call to address(0) returns empty bytes (no revert), so a config that ships with liquidator.contract_address = 0x0 let the simulator silently "pass" for any calldata, producing a false-positive gate to live submission. Add ConfigError::ZeroAddressLiquidator, gated on !allow_public_mempool so local anvil / testnet runs before a real deploy still work. Refactor test helper base_config(_, bool) -> base_config(_, Option
) plus a nonzero_liquidator sentinel so existing tests stay focused on the rule they actually exercise. --- crates/charon-core/src/config.rs | 102 ++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 14 deletions(-) diff --git a/crates/charon-core/src/config.rs b/crates/charon-core/src/config.rs index 06b1aab..331a41d 100644 --- a/crates/charon-core/src/config.rs +++ b/crates/charon-core/src/config.rs @@ -159,12 +159,28 @@ pub enum ConfigError { /// start is deliberate: broadcasting liquidation calldata to the /// public mempool reliably loses to front-runners. #[error( - "chain '{chain}' has a deployed liquidator but no private_rpc_url; set one, or set allow_public_mempool = true to opt in (testnet/dev only)" + "chain '{chain}' has a deployed liquidator but no private_rpc_url; set one, or set allow_public_mempool = true to opt in (testnet/dev only)" )] PrivateRpcRequired { /// Chain key (matches a `[chain.]` section). chain: String, }, + /// A chain has a `[liquidator.]` entry whose + /// `contract_address` is the zero address. Starting a live-mempool + /// run with this config would route `eth_call` simulations and + /// every flashloan callback to `address(0)`: the call returns empty + /// bytes (no revert), which the simulator interprets as a pass, + /// producing a silent false-positive gate. Allowed only when the + /// chain explicitly opts in to public-mempool submission + /// (`allow_public_mempool = true`) for local anvil / testnet runs + /// where a real deploy does not yet exist. + #[error( + "chain '{chain}' has liquidator.contract_address = 0x0; deploy CharonLiquidator and set the real address, or set allow_public_mempool = true to opt in (testnet/dev only)" + )] + ZeroAddressLiquidator { + /// Chain key (matches a `[chain.]` section). + chain: String, + }, /// `liquidatable_threshold` must not exceed `near_liq_threshold`. #[error("liquidatable_threshold ({liquidatable}) must be <= near_liq_threshold ({near_liq})")] ThresholdInversion { liquidatable: f64, near_liq: f64 }, @@ -227,6 +243,9 @@ impl Config { /// - Every `[liquidator.]` has a `[chain.]` with a /// `private_rpc_url`, unless that chain set /// `allow_public_mempool = true`. + /// - Every `[liquidator.]` has a non-zero + /// `contract_address`, unless that chain set + /// `allow_public_mempool = true` (dev/testnet escape hatch). /// - `liquidatable_threshold <= near_liq_threshold`. pub fn validate(&self) -> Result<(), ConfigError> { for liq in self.liquidator.values() { @@ -242,6 +261,11 @@ impl Config { chain: liq.chain.clone(), }); } + if liq.contract_address == Address::ZERO && !chain_cfg.allow_public_mempool { + return Err(ConfigError::ZeroAddressLiquidator { + chain: liq.chain.clone(), + }); + } } if self.bot.liquidatable_threshold > self.bot.near_liq_threshold { return Err(ConfigError::ThresholdInversion { @@ -291,16 +315,23 @@ mod tests { } } - fn base_config(chain_cfg: ChainConfig, liquidator_present: bool) -> Config { + /// Non-zero sentinel address used by tests that are not exercising + /// the zero-address rule. Keeps the default `base_config` valid + /// after the `ZeroAddressLiquidator` check landed. + fn nonzero_liquidator() -> Address { + Address::from([0x11; 20]) + } + + fn base_config(chain_cfg: ChainConfig, liquidator: Option
) -> Config { let mut chains = HashMap::new(); chains.insert("bnb".to_string(), chain_cfg); let mut liquidators = HashMap::new(); - if liquidator_present { + if let Some(addr) = liquidator { liquidators.insert( "bnb".to_string(), LiquidatorConfig { chain: "bnb".to_string(), - contract_address: Address::ZERO, + contract_address: addr, }, ); } @@ -322,7 +353,7 @@ mod tests { #[test] fn validate_rejects_liquidator_without_private_rpc() { - let cfg = base_config(chain(None, false), true); + let cfg = base_config(chain(None, false), Some(nonzero_liquidator())); let err = cfg.validate().expect_err("must refuse public mempool"); match err { ConfigError::PrivateRpcRequired { chain } => assert_eq!(chain, "bnb"), @@ -332,13 +363,13 @@ mod tests { #[test] fn validate_allows_public_mempool_opt_in() { - let cfg = base_config(chain(None, true), true); + let cfg = base_config(chain(None, true), Some(nonzero_liquidator())); cfg.validate().expect("opt-in must be honoured"); } #[test] fn validate_passes_with_private_rpc_configured() { - let cfg = base_config(chain(Some("https://private.example"), false), true); + let cfg = base_config(chain(Some("https://private.example"), false), Some(nonzero_liquidator())); cfg.validate().expect("private rpc present -> valid"); } @@ -347,13 +378,13 @@ mod tests { // A chain with no deployed liquidator has nothing to submit, // so the private-rpc requirement does not apply. Validation // must not trip on it. - let cfg = base_config(chain(None, false), false); + let cfg = base_config(chain(None, false), None); cfg.validate().expect("no liquidator -> no private-rpc req"); } #[test] fn validate_rejects_threshold_inversion() { - let mut cfg = base_config(chain(Some("https://p"), false), true); + let mut cfg = base_config(chain(Some("https://p"), false), Some(nonzero_liquidator())); cfg.bot.liquidatable_threshold = 1.1; cfg.bot.near_liq_threshold = 1.0; let err = cfg.validate().expect_err("inverted thresholds rejected"); @@ -364,7 +395,7 @@ mod tests { fn normalize_collapses_empty_private_rpc_auth_to_none() { let mut c = chain(Some("https://private.example"), false); c.private_rpc_auth = Some(SecretString::from(String::new())); - let mut cfg = base_config(c, true); + let mut cfg = base_config(c, Some(nonzero_liquidator())); cfg.normalize_empty_secrets(); let got = cfg.chain.get("bnb").expect("chain present"); assert!( @@ -377,7 +408,7 @@ mod tests { fn normalize_collapses_empty_private_rpc_url_to_none() { let mut c = chain(Some(""), false); c.private_rpc_auth = None; - let mut cfg = base_config(c, true); + let mut cfg = base_config(c, Some(nonzero_liquidator())); cfg.normalize_empty_secrets(); let got = cfg.chain.get("bnb").expect("chain present"); assert!( @@ -390,7 +421,7 @@ mod tests { fn normalize_preserves_non_empty_secrets() { let mut c = chain(Some("https://private.example"), false); c.private_rpc_auth = Some(SecretString::from("token".to_string())); - let mut cfg = base_config(c, true); + let mut cfg = base_config(c, Some(nonzero_liquidator())); cfg.normalize_empty_secrets(); let got = cfg.chain.get("bnb").expect("chain present"); assert!(got.private_rpc_url.is_some(), "url must be preserved"); @@ -401,7 +432,7 @@ mod tests { fn normalize_walks_every_chain_independently() { let empty = chain(Some(""), false); let set = chain(Some("https://private.example"), false); - let mut cfg = base_config(empty, true); + let mut cfg = base_config(empty, Some(nonzero_liquidator())); cfg.chain.insert("l2".to_string(), set); cfg.normalize_empty_secrets(); assert!( @@ -420,7 +451,7 @@ mod tests { // refuse a chain that had an empty `${VAR}` substitution for // its private_rpc_url and did not opt in to public mempool. let c = chain(Some(""), false); - let mut cfg = base_config(c, true); + let mut cfg = base_config(c, Some(nonzero_liquidator())); cfg.normalize_empty_secrets(); let err = cfg .validate() @@ -428,6 +459,49 @@ mod tests { assert!(matches!(err, ConfigError::PrivateRpcRequired { .. })); } + #[test] + fn validate_rejects_zero_address_liquidator_without_opt_in() { + let cfg = base_config( + chain(Some("https://private.example"), false), + Some(Address::ZERO), + ); + let err = cfg + .validate() + .expect_err("zero-address liquidator must be rejected"); + match err { + ConfigError::ZeroAddressLiquidator { chain } => assert_eq!(chain, "bnb"), + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn validate_allows_zero_address_with_public_mempool_opt_in() { + // Dev / testnet runs before a real deploy: zero-address + // liquidator is tolerated when the operator explicitly opts in + // to public-mempool submission. + let cfg = base_config( + chain(Some("https://private.example"), true), + Some(Address::ZERO), + ); + cfg.validate() + .expect("zero-addr + opt-in must pass (dev mode)"); + } + + #[test] + fn validate_private_rpc_check_fires_before_zero_address_check() { + // Both checks gate on !allow_public_mempool; the private-RPC + // rule is listed first in the loop so operators see the more + // actionable error first. Lock that ordering. + let cfg = base_config(chain(None, false), Some(Address::ZERO)); + let err = cfg + .validate() + .expect_err("either check could fire; assert order"); + assert!( + matches!(err, ConfigError::PrivateRpcRequired { .. }), + "expected PrivateRpcRequired first, got {err:?}" + ); + } + #[test] fn debug_redacts_private_rpc_url_and_auth() { let mut c = chain(Some("https://key.example/?auth=SUPER_SECRET_KEY"), false); From 20d0ce73c60a145928cfc7e2246911b44ed0caf0 Mon Sep 17 00:00:00 2001 From: obchain Date: Fri, 24 Apr 2026 00:14:08 +0530 Subject: [PATCH 2/2] feat(cli): wire submitter into process_opportunity behind --execute Before this change, charon-executor carried Submitter, NonceManager, GasOracle, and friends as library code but the CLI listen loop only imported Simulator and TxBuilder, terminating after sim.simulate() with a literal "no broadcast" comment. Local-mainnet validation flagged the branch's headline feature as unreachable at runtime. Changes: - charon-executor builder.build_tx no longer hits the provider for the nonce. It takes nonce: u64 as a parameter and is now a pure sync function. Rationale: calling get_transaction_count inside build_tx would race against NonceManager::next() and hand out duplicate nonces when two opportunities land in the same block. - charon-cli Listen subcommand gains --execute (default off). When set, run_listen builds an ExecHarness (GasOracle + NonceManager + Submitter + signer address) only after checking signer presence, non-zero liquidator, and a present private_rpc_url. Any failed gate aborts startup rather than silently degrading to scan-only. - process_opportunity now returns a ProcessOutcome { Dropped, Queued, Broadcast } trichotomy. The queue push happens before broadcast so a submit failure still leaves a ranked candidate on record. - New broadcast() helper: fetch EIP-1559 fees, eth_estimateGas with a 30% buffer, claim a local nonce, build + sign + submit. Sign errors and every SubmitError::RpcRejected trigger a one-shot NonceManager::resync so an already-consumed nonce does not poison subsequent blocks; ConnectionLost leaves the counter alone because the tx may still land. --execute stays off by default; existing scan-only behavior is unchanged. Fork-level validation (anvil BSC, real underwater borrower, hot-wallet balance delta assertion) follows in a separate mev-sim-auditor run before merge. --- crates/charon-cli/src/main.rs | 259 +++++++++++++++++++++++--- crates/charon-executor/src/builder.rs | 28 +-- 2 files changed, 244 insertions(+), 43 deletions(-) diff --git a/crates/charon-cli/src/main.rs b/crates/charon-cli/src/main.rs index 902043b..ead8990 100644 --- a/crates/charon-cli/src/main.rs +++ b/crates/charon-cli/src/main.rs @@ -9,15 +9,19 @@ use std::path::PathBuf; use std::sync::Arc; +use alloy::network::TransactionBuilder; use alloy::primitives::{Address, U256}; use alloy::providers::{ProviderBuilder, WsConnect}; +use alloy::rpc::types::TransactionRequest; use alloy::signers::local::PrivateKeySigner; -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use charon_core::{ Config, LendingProtocol, LiquidationOpportunity, LiquidationParams, OpportunityQueue, ProfitInputs, SwapRoute, calculate_profit, }; -use charon_executor::{Simulator, TxBuilder}; +use charon_executor::{ + DEFAULT_SUBMIT_TIMEOUT, GasOracle, NonceManager, SubmitError, Submitter, Simulator, TxBuilder, +}; use charon_flashloan::{AaveFlashLoan, FlashLoanRouter}; use charon_protocols::VenusAdapter; use charon_scanner::{ @@ -42,6 +46,15 @@ const DEFAULT_SLIPPAGE_BPS: u16 = 50; /// oracle is wired up. const PLACEHOLDER_GAS_USD_CENTS: u64 = 50; +/// Multiplier applied to `eth_estimateGas` before broadcast. 30 % +/// headroom covers state drift between estimate and inclusion (vToken +/// index update, oracle writes, swap-pool reserve change, Venus +/// reentrancy into the callback) without overpaying on a happy-path +/// liquidation. BSC gas is cheap enough that the extra buffer is worth +/// the reduction in out-of-gas reverts. +const GAS_LIMIT_BUFFER_NUM: u64 = 13; +const GAS_LIMIT_BUFFER_DEN: u64 = 10; + /// Charon — multi-chain flash-loan liquidation bot. #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -66,6 +79,14 @@ enum Command { /// pipeline still spins up so the operator can confirm wiring). #[arg(long = "borrower")] borrowers: Vec
, + + /// Broadcast signed liquidation txs when the simulator passes. + /// Off by default — the pipeline runs scan + simulate only. + /// Requires: `BOT_SIGNER_KEY` set, a non-zero + /// `liquidator.contract_address`, and a `private_rpc_url` (or + /// `allow_public_mempool = true`, dev-only). + #[arg(long = "execute", default_value_t = false)] + execute: bool, }, /// Connect to a configured chain and print its latest block number. @@ -107,7 +128,7 @@ async fn main() -> Result<()> { ); match cli.command { - Command::Listen { borrowers } => run_listen(config, borrowers).await?, + Command::Listen { borrowers, execute } => run_listen(config, borrowers, execute).await?, Command::TestConnection { chain } => { let chain_cfg = config .chain @@ -122,13 +143,25 @@ async fn main() -> Result<()> { Ok(()) } +/// 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. +struct ExecHarness { + gas_oracle: GasOracle, + nonce_manager: Arc, + submitter: Arc, + signer_address: Address, +} + /// Wire the full Venus → scanner → profit → router → builder → sim /// pipeline into the block-event drain loop. /// -/// **Read-only end-to-end:** the simulator's verdict is logged but no -/// transaction is broadcast. Wiring the broadcast step lands with the -/// MEV / private-RPC submission tasks (#18). -async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { +/// With `--execute` the pipeline also signs and broadcasts any +/// opportunity whose simulator gate passes. Without it the pipeline is +/// read-only: simulation results are logged but nothing is signed or +/// sent. +async fn run_listen(config: Config, borrowers: Vec
, execute: bool) -> Result<()> { // ── Adapters + scanner + price cache (existing #8/#9/#10 wiring) ── let bnb = config .chain @@ -228,6 +261,46 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { )) }); + // ── Execution harness (gated on --execute) ── + // Built only when every safety gate passes: signer present, + // non-zero liquidator, private-RPC URL present on the chain. Any + // failure here aborts startup rather than silently degrading to + // scan-only — `--execute` is an explicit operator intent. + let exec_harness: Option> = if execute { + let builder = tx_builder + .as_ref() + .context("--execute requires BOT_SIGNER_KEY to be set and parseable")?; + if liquidator_cfg.contract_address == Address::ZERO { + bail!("--execute refuses to run with zero-address liquidator"); + } + let url = bnb + .private_rpc_url + .as_ref() + .context("--execute requires a private_rpc_url on chain 'bnb' (https:// or wss://)")?; + let submitter = Submitter::connect(url, bnb.private_rpc_auth.as_ref(), DEFAULT_SUBMIT_TIMEOUT) + .await + .context("--execute: failed to connect private-RPC submitter")?; + let signer_address = builder.signer_address(); + 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, + max_gas_gwei = config.bot.max_gas_gwei, + "execute mode ON — bot will sign and broadcast liquidations" + ); + Some(Arc::new(ExecHarness { + gas_oracle, + nonce_manager: Arc::new(nonce_manager), + submitter: Arc::new(submitter), + signer_address, + })) + } else { + None + }; + // ── Profit-ordered queue ── let queue = Arc::new(tokio::sync::Mutex::new(OpportunityQueue::with_default_ttl())); @@ -238,7 +311,8 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { near_liq_threshold = config.bot.near_liq_threshold, flash_sources = router.providers().len(), signer_present = tx_builder.is_some(), - "pipeline ready (scan-only, no broadcast)" + execute = exec_harness.is_some(), + "pipeline ready" ); // ── Block-event drain ── @@ -270,6 +344,7 @@ async fn run_listen(config: Config, borrowers: Vec
) -> Result<()> { router.clone(), tx_builder.clone(), simulator.clone(), + exec_harness.clone(), queue.clone(), provider.clone(), config.bot.min_profit_usd, @@ -299,6 +374,7 @@ async fn process_block( router: Arc, tx_builder: Option>, simulator: Option>, + exec_harness: Option>, queue: Arc>, provider: Arc>, min_profit_usd: f64, @@ -321,6 +397,7 @@ async fn process_block( // 3. Per-liquidatable: route flash loan, calc profit, build, simulate, queue. let liquidatable = scanner.liquidatable(); let mut queued = 0usize; + let mut broadcast = 0usize; for pos in liquidatable { match process_opportunity( &pos, @@ -328,6 +405,7 @@ async fn process_block( router.as_ref(), tx_builder.as_deref(), simulator.as_deref(), + exec_harness.as_deref(), provider.as_ref(), min_profit_usd, block, @@ -335,8 +413,12 @@ async fn process_block( ) .await { - Ok(true) => queued += 1, - Ok(false) => {} + Ok(ProcessOutcome::Queued) => queued += 1, + Ok(ProcessOutcome::Broadcast) => { + queued += 1; + broadcast += 1; + } + Ok(ProcessOutcome::Dropped) => {} Err(err) => debug!(borrower = %pos.borrower, error = ?err, "opportunity dropped"), } } @@ -355,16 +437,27 @@ async fn process_block( near_liq = counts.near_liquidation, liquidatable = counts.liquidatable, queued, + broadcast, queue_len, block_ms = start.elapsed().as_millis() as u64, "pipeline tick" ); } +/// Outcome of a single `process_opportunity` invocation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ProcessOutcome { + /// Dropped at a profit / simulation / gas gate. Not in the queue. + Dropped, + /// Accepted and pushed to the profit-ordered queue. No broadcast. + Queued, + /// Accepted, queued, and broadcast to the private RPC. + Broadcast, +} + /// Run one liquidatable position through the rest of the pipeline. -/// Returns `Ok(true)` if it landed in the queue, `Ok(false)` if it was -/// dropped at a profit / simulation gate, `Err` for unexpected -/// failures. +/// Returns a [`ProcessOutcome`] describing how far it made it, or +/// `Err` for unexpected failures the caller should log at `debug`. #[allow(clippy::too_many_arguments)] async fn process_opportunity( pos: &charon_core::Position, @@ -372,11 +465,12 @@ async fn process_opportunity( router: &FlashLoanRouter, tx_builder: Option<&TxBuilder>, simulator: Option<&Simulator>, + exec_harness: Option<&ExecHarness>, provider: &alloy::providers::RootProvider, min_profit_usd: f64, queued_at_block: u64, queue: Arc>, -) -> Result { +) -> Result { // a. Adapter: build protocol-specific liquidation params (vTokens + repay). let params = adapter.get_liquidation_params(pos)?; let LiquidationParams::Venus { repay_amount, .. } = ¶ms; @@ -384,7 +478,7 @@ async fn process_opportunity( // b. Router: pick cheapest flash-loan source. let Some(quote) = router.route(pos.debt_token, repay).await else { - return Ok(false); + return Ok(ProcessOutcome::Dropped); }; // c. Profit calc — placeholder USD math until precise per-token @@ -404,7 +498,7 @@ async fn process_opportunity( Ok(n) => n, Err(err) => { debug!(borrower = %pos.borrower, error = ?err, "profit gate dropped"); - return Ok(false); + return Ok(ProcessOutcome::Dropped); } }; @@ -430,18 +524,133 @@ async fn process_opportunity( // e. Tx builder + simulator — only if the operator supplied // BOT_SIGNER_KEY. Without it, push to the queue based on profit // alone so dry-runs still surface ranked candidates. - if let (Some(builder), Some(sim)) = (tx_builder, simulator) { - let calldata = builder.encode_calldata(&opp, ¶ms)?; - if let Err(err) = sim.simulate(provider, calldata).await { - debug!(borrower = %pos.borrower, error = ?err, "simulation gate dropped"); - return Ok(false); + let calldata = match (tx_builder, simulator) { + (Some(builder), Some(sim)) => { + let bytes = builder.encode_calldata(&opp, ¶ms)?; + if let Err(err) = sim.simulate(provider, bytes.clone()).await { + debug!(borrower = %pos.borrower, error = ?err, "simulation gate dropped"); + return Ok(ProcessOutcome::Dropped); + } + Some(bytes) } + _ => None, + }; + + // f. Push to the profit-ordered queue before broadcast so a later + // submit failure still leaves a record of the ranked candidate. + let mut outcome = ProcessOutcome::Queued; + { + let mut q = queue.lock().await; + q.push(opp.clone(), queued_at_block); } - // f. Push to the profit-ordered queue. - let mut q = queue.lock().await; - q.push(opp, queued_at_block); - Ok(true) + // g. Broadcast (only when --execute set every gate, and we have + // calldata from the sim step — `exec_harness.is_some()` implies + // `tx_builder.is_some()` and `simulator.is_some()`). + if let (Some(harness), Some(builder), Some(bytes)) = (exec_harness, tx_builder, calldata) { + match broadcast(harness, builder, provider, bytes).await { + Ok(hash) => { + info!( + borrower = %pos.borrower, + net_profit_cents = net.net_usd_cents, + %hash, + "liquidation broadcast" + ); + outcome = ProcessOutcome::Broadcast; + } + Err(err) => { + warn!( + borrower = %pos.borrower, + error = ?err, + "broadcast failed — opportunity left in queue" + ); + } + } + } + + Ok(outcome) +} + +/// Sign and broadcast one opportunity that already passed simulation. +/// +/// Nonce-gap handling: when the node rejects with "nonce too low" / +/// "already known" / similar, force a one-shot `NonceManager::resync` +/// so the next block's broadcast sees the canonical on-chain value. +/// Connection-lost errors leave the nonce where it is — the caller +/// will reconnect and the counter stays locally consistent with what +/// the pipeline actually issued. +async fn broadcast( + harness: &ExecHarness, + builder: &TxBuilder, + provider: &alloy::providers::RootProvider, + calldata: alloy::primitives::Bytes, +) -> Result { + // Fetch EIP-1559 fees; `None` = max-gas ceiling tripped, skip. + let Some(fees) = harness + .gas_oracle + .fetch_params(provider) + .await + .context("broadcast: gas oracle failed")? + else { + bail!("gas ceiling tripped"); + }; + + // Estimate gas on a minimal request — the provider only needs + // to / from / data / fees to simulate. + let est_tx = TransactionRequest::default() + .with_from(harness.signer_address) + .with_to(builder.liquidator()) + .with_input(calldata.clone()) + .with_max_fee_per_gas(fees.max_fee_per_gas) + .with_max_priority_fee_per_gas(fees.max_priority_fee_per_gas); + let gas_units = harness + .gas_oracle + .estimate_gas_units(provider, &est_tx) + .await + .context("broadcast: estimate_gas failed")?; + let gas_limit = gas_units.saturating_mul(GAS_LIMIT_BUFFER_NUM) / GAS_LIMIT_BUFFER_DEN; + + // Claim a nonce locally (atomic — no race with a parallel + // opportunity in the same block) and build the signed tx. + let nonce = harness.nonce_manager.next(); + let tx = builder.build_tx( + calldata, + nonce, + fees.max_fee_per_gas, + fees.max_priority_fee_per_gas, + gas_limit, + ); + let raw = match builder.sign(tx).await { + Ok(bytes) => bytes, + Err(err) => { + // Nonce already consumed by `next()` above. Sign failure + // means no tx hits the wire, so the counter is ahead of + // the chain — force a resync to avoid a permanent gap. + if let Err(resync_err) = harness.nonce_manager.resync(provider).await { + warn!(error = ?resync_err, "nonce resync failed after sign error"); + } + return Err(err.context("broadcast: sign failed")); + } + }; + + match harness.submitter.submit(raw).await { + Ok(hash) => Ok(hash), + Err(err) => { + // Any RpcRejected leaves our local counter ahead of the + // chain — resync unconditionally so we don't poison the + // sequence on non-nonce rejections (insufficient funds, + // gas too low, revert-on-broadcast). ConnectionLost is + // transport-level: the tx may still land, so leave the + // counter alone and let the next nonce-too-high reject + // drive a resync on the following block. + if matches!(err, SubmitError::RpcRejected(_)) + && let Err(resync_err) = harness.nonce_manager.resync(provider).await + { + warn!(error = ?resync_err, "nonce resync failed after rejection"); + } + Err(anyhow::Error::new(err).context("broadcast: submit failed")) + } + } } /// Strip 18 decimals and convert to USD cents (×100), saturating to diff --git a/crates/charon-executor/src/builder.rs b/crates/charon-executor/src/builder.rs index 04f3be5..bcf1f29 100644 --- a/crates/charon-executor/src/builder.rs +++ b/crates/charon-executor/src/builder.rs @@ -16,7 +16,6 @@ use alloy::eips::eip2718::Encodable2718; use alloy::network::{EthereumWallet, TransactionBuilder}; use alloy::primitives::{Address, Bytes}; -use alloy::providers::Provider; use alloy::rpc::types::TransactionRequest; use alloy::signers::local::PrivateKeySigner; use alloy::sol; @@ -133,28 +132,21 @@ impl TxBuilder { /// Build an unsigned EIP-1559 [`TransactionRequest`] pointing at /// the configured liquidator. /// - /// Pulls the next nonce from `provider`. `gas_limit` is supplied - /// by the caller (typically a multiple of `eth_estimateGas` plus a - /// safety buffer). Fee fields are passed through; producing them - /// is the gas oracle's job, not the builder's. - pub async fn build_tx( + /// The caller supplies the nonce (typically from + /// [`crate::NonceManager::next`]) and gas parameters from the gas + /// oracle. This method intentionally does **not** hit the provider + /// — doing so would race against the `NonceManager`'s local counter + /// and hand out duplicate nonces when two opportunities land in the + /// same block. + pub fn build_tx( &self, - provider: &P, calldata: Bytes, + nonce: u64, max_fee_per_gas: u128, max_priority_fee_per_gas: u128, gas_limit: u64, - ) -> Result - where - P: Provider, - T: alloy::transports::Transport + Clone, - { + ) -> TransactionRequest { let from = self.signer.address(); - let nonce = provider - .get_transaction_count(from) - .await - .context("tx builder: failed to fetch nonce")?; - let tx = TransactionRequest::default() .with_from(from) .with_to(self.liquidator) @@ -175,7 +167,7 @@ impl TxBuilder { gas_limit, "EIP-1559 tx built" ); - Ok(tx) + tx } /// Sign the request with the bot signer and return raw EIP-2718