From 3aeab16ee7cf93aa144018f1e1205c78e60ae0b3 Mon Sep 17 00:00:00 2001 From: obchain Date: Tue, 21 Apr 2026 18:48:05 +0530 Subject: [PATCH 1/6] feat(executor+contracts): multi-liquidation batcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds per-chain batching so multiple liquidatable positions land in one transaction instead of N. The base tx cost (21 000 gas + signature + calldata header) amortises across the whole batch; each flash-loan still pays its own 0.05% premium. On-chain (CharonLiquidator.sol): - New `batchExecute(LiquidationParams[] calldata items) external onlyOwner nonReentrant` entrypoint, capped at `MAX_BATCH_SIZE = 10` (uint256 internal constant) - Refactor: the seven input-validation guards + the `flashLoanSimple` kickoff move from `executeLiquidation` into a new internal helper `_initiateFlashLoan(LiquidationParams memory)`. `executeLiquidation` becomes a thin wrapper that delegates to it so both entrypoints share one validated flow - Emits `BatchExecuted(uint256 count)` after the loop completes - `nonReentrant` stays on `batchExecute`; `_initiateFlashLoan` is internal so the guard is held across iterations without deadlock - Four new Foundry tests (access control, empty-array guard, too-large-array guard, first-item-validation atomic revert); total suite now 20/0/1 (skipped = the existing fork scaffold) Rust (charon-executor/batcher.rs): - `Batcher` struct, stateless planner. `plan(opps)` partitions by `chain_id`, chunks into groups of `≤ max_batch_size` (default 3), and omits single-item groups (they belong on the plain path) - `LiquidationBatch` — chain + ordered opps + summed net profit - `encode_calldata(batch, params)` ABI-encodes `batchExecute(LiquidationParams[])` via alloy `sol!` with a `BatchParams` struct pinned to the Solidity shape; selector test asserts lockstep - Six unit tests: single → no batch, same-chain grouping, cross-chain split, size-limit chunking, selector pin, param-length mismatch rejection --- contracts/src/CharonLiquidator.sol | 127 ++++++++--- contracts/test/CharonLiquidator.t.sol | 77 +++++++ crates/charon-executor/src/batcher.rs | 305 ++++++++++++++++++++++++++ crates/charon-executor/src/lib.rs | 2 + 4 files changed, 475 insertions(+), 36 deletions(-) create mode 100644 crates/charon-executor/src/batcher.rs diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 1cb6891..5bf1828 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -26,10 +26,10 @@ import { IERC20 } from "./interfaces/IERC20.sol"; // - executeOperation is only callable by the Aave Pool. // - initiator must equal address(this) — prevents a malicious pool from // invoking our callback with forged parameters. -// - Reentrancy guard on executeLiquidation (nonReentrant). +// - Reentrancy guard on executeLiquidation and batchExecute (nonReentrant). // - executeOperation NOT guarded with nonReentrant: it is called by Aave mid- -// flash-loan, re-entering executeLiquidation's guard frame. The msg.sender -// == AAVE_POOL gate is the equivalent protection for the callback. +// flash-loan, re-entering the guard frame. The msg.sender == AAVE_POOL gate +// is the equivalent protection for the callback. // - Lingering approvals zeroed after each consume point (vToken, SwapRouter). // - No tx.origin usage. No delegatecall. No assembly. No upgradeability. // - No external library imports — all interfaces are inline/local. @@ -39,7 +39,8 @@ import { IERC20 } from "./interfaces/IERC20.sol"; /// @notice On-chain executor for flash-loan-backed liquidations across DeFi protocols. /// v0.1 supports Venus Protocol on BNB Chain. /// @dev Implements IFlashLoanSimpleReceiver for the Aave V3 flash-loan callback. -/// The bot (hot wallet = owner) is the sole authorized caller of executeLiquidation. +/// The bot (hot wallet = owner) is the sole authorized caller of executeLiquidation +/// and batchExecute. contract CharonLiquidator is IFlashLoanSimpleReceiver { // ───────────────────────────────────────────────────────────────────────── // Protocol ID constants — must mirror the Rust `ProtocolId` enum order. @@ -48,6 +49,11 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @dev ProtocolId::Venus = 3 in the Rust enum (0-indexed: Aave=0, Compound=1, ...). uint8 internal constant PROTOCOL_VENUS = 3; + /// @dev Absolute ceiling on the number of liquidations in a single batchExecute call. + /// The Rust batcher uses 3 by default; 10 gives headroom for future tuning. + /// Prevents a compromised owner key from burning unbounded gas in one tx. + uint256 internal constant MAX_BATCH_SIZE = 10; + // ───────────────────────────────────────────────────────────────────────── // Reentrancy guard — simple two-state lock. // Stored as uint256 rather than bool to match the Solidity optimizer's @@ -119,6 +125,10 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// @param amount The amount transferred. event Rescued(address indexed token, address indexed to, uint256 amount); + /// @notice Emitted at the end of a successful batchExecute call. + /// @param count The number of liquidations initiated in the batch. + event BatchExecuted(uint256 count); + // ───────────────────────────────────────────────────────────────────────── // Modifiers // ───────────────────────────────────────────────────────────────────────── @@ -129,11 +139,13 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { _; } - /// @dev Prevents reentrant calls into executeLiquidation. + /// @dev Prevents reentrant calls into executeLiquidation and batchExecute. /// Uses 1/2 rather than 0/1 to avoid cold-write SSTORE costs on every call. /// NOT applied to executeOperation — that function is called by Aave mid- /// flash-loan and is already protected by the msg.sender == AAVE_POOL gate. /// Applying nonReentrant to executeOperation would deadlock the flash loan. + /// NOT applied to _initiateFlashLoan — it is an internal helper called with + /// the guard already held by the outer entry point. modifier nonReentrant() { require(_entered == 1, "reentrant"); _entered = 2; @@ -160,16 +172,15 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { } // ───────────────────────────────────────────────────────────────────────── - // External — owner entry point + // External — owner entry points // ───────────────────────────────────────────────────────────────────────── /// @notice Initiates a flash-loan-backed liquidation of a Venus borrower. - /// @dev Called exclusively by the off-chain bot (owner). Encodes `params` and - /// requests a flash loan from Aave V3; the actual liquidation logic executes - /// atomically inside the executeOperation() callback. + /// @dev Called exclusively by the off-chain bot (owner). Delegates to + /// _initiateFlashLoan after acquiring the reentrancy lock. /// /// Flow: - /// 1. Validate inputs. + /// 1. Validate inputs (inside _initiateFlashLoan). /// 2. ABI-encode params to bytes. /// 3. Call IAaveV3Pool.flashLoanSimple — Aave transfers debtToken to this /// contract then immediately calls executeOperation(). @@ -179,30 +190,32 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// /// @param params All parameters describing the Venus liquidation opportunity. function executeLiquidation(LiquidationParams calldata params) external onlyOwner nonReentrant { - // ── Input validation ────────────────────────────────────────────────── - require(params.protocolId == PROTOCOL_VENUS, "!protocolId"); - require(params.borrower != address(0), "!borrower"); - require(params.debtToken != address(0), "!debtToken"); - require(params.collateralToken != address(0), "!collateralToken"); - require(params.debtVToken != address(0), "!debtVToken"); - require(params.collateralVToken != address(0), "!collateralVToken"); - require(params.repayAmount > 0, "!repayAmount"); + _initiateFlashLoan(params); + } - // ── Encode params and request the flash loan ────────────────────────── - // Aave forwards `encoded` verbatim to executeOperation as the `data` - // argument; we decode it there to recover the liquidation parameters. - bytes memory encoded = abi.encode(params); + /// @notice Initiates multiple flash-loan-backed liquidations in a single transaction. + /// @dev Called exclusively by the off-chain bot (owner). Iterates over `items` and + /// calls _initiateFlashLoan for each. A revert in any iteration reverts the + /// entire batch atomically — there is no partial execution. + /// + /// The nonReentrant guard is held for the full duration of the loop. Each + /// _initiateFlashLoan invocation calls Aave's flashLoanSimple, which re-enters + /// executeOperation within the _entered == 2 window; that is the expected and + /// safe path. A malicious pool attempting to re-enter batchExecute mid-loop + /// would hit the nonReentrant guard and revert. + /// + /// @param items Array of LiquidationParams, one per borrower to liquidate. + /// Must be non-empty and no longer than MAX_BATCH_SIZE. + function batchExecute(LiquidationParams[] calldata items) external onlyOwner nonReentrant { + uint256 n = items.length; + require(n > 0, "!items"); + require(n <= MAX_BATCH_SIZE, "batch too large"); + + for (uint256 i = 0; i < n; i++) { + _initiateFlashLoan(items[i]); + } - IAaveV3Pool(AAVE_POOL) - .flashLoanSimple( - address(this), // receiver — this contract implements the callback - params.debtToken, // asset — the token we need to repay Venus with - params.repayAmount, // amount — exact principal to borrow - encoded, // params — forwarded to executeOperation - 0 // referralCode — no referral - ); - // Aave has pulled amount + premium via the allowance set in executeOperation. - // Nothing further to do in this frame. + emit BatchExecuted(n); } // ───────────────────────────────────────────────────────────────────────── @@ -230,15 +243,15 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// l. Return true. /// /// NOTE: nonReentrant is intentionally NOT applied here. Applying it would - /// deadlock the flash loan because executeLiquidation already holds the lock - /// (_entered == 2) when Aave re-enters this callback within the same tx. - /// The msg.sender == AAVE_POOL gate is the equivalent protection. + /// deadlock the flash loan because executeLiquidation / batchExecute already + /// holds the lock (_entered == 2) when Aave re-enters this callback within + /// the same tx. The msg.sender == AAVE_POOL gate is the equivalent protection. /// /// @param asset The flash-loaned ERC-20 token (must equal p.debtToken). /// @param amount The flash-loan principal (must equal p.repayAmount). /// @param premium The Aave fee owed on top of `amount`. /// @param initiator The address that initiated the flash loan (must be address(this)). - /// @param data ABI-encoded LiquidationParams forwarded from executeLiquidation. + /// @param data ABI-encoded LiquidationParams forwarded from _initiateFlashLoan. /// @return True on success; Aave reverts the entire tx if false is returned. function executeOperation( address asset, @@ -389,6 +402,48 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { emit Rescued(token, to, amount); } + // ───────────────────────────────────────────────────────────────────────── + // Internal — shared flash-loan initiator + // ───────────────────────────────────────────────────────────────────────── + + /// @notice Validates `p`, encodes it, and requests a flashLoanSimple from Aave. + /// @dev Called by both executeLiquidation and batchExecute. Must NOT be decorated + /// with nonReentrant — the caller already holds the lock. Adding nonReentrant + /// here would deadlock the flash loan because Aave re-enters executeOperation + /// (which runs inside _entered == 2) before this function returns. + /// + /// The seven require guards here are identical to those that previously lived + /// in executeLiquidation and are the single canonical validation point for any + /// liquidation initiated by this contract. + /// + /// @param p The fully-populated LiquidationParams for one liquidation. + function _initiateFlashLoan(LiquidationParams memory p) internal { + // ── Input validation ────────────────────────────────────────────────── + require(p.protocolId == PROTOCOL_VENUS, "!protocolId"); + require(p.borrower != address(0), "!borrower"); + require(p.debtToken != address(0), "!debtToken"); + require(p.collateralToken != address(0), "!collateralToken"); + require(p.debtVToken != address(0), "!debtVToken"); + require(p.collateralVToken != address(0), "!collateralVToken"); + require(p.repayAmount > 0, "!repayAmount"); + + // ── Encode params and request the flash loan ────────────────────────── + // Aave forwards `encoded` verbatim to executeOperation as the `data` + // argument; we decode it there to recover the liquidation parameters. + bytes memory encoded = abi.encode(p); + + IAaveV3Pool(AAVE_POOL) + .flashLoanSimple( + address(this), // receiver — this contract implements the callback + p.debtToken, // asset — the token we need to repay Venus with + p.repayAmount, // amount — exact principal to borrow + encoded, // params — forwarded to executeOperation + 0 // referralCode — no referral + ); + // Aave has pulled amount + premium via the allowance set in executeOperation. + // Nothing further to do in this frame. + } + // ───────────────────────────────────────────────────────────────────────── // Receive — accept native BNB // ───────────────────────────────────────────────────────────────────────── diff --git a/contracts/test/CharonLiquidator.t.sol b/contracts/test/CharonLiquidator.t.sol index 1fe6367..c97fc41 100644 --- a/contracts/test/CharonLiquidator.t.sol +++ b/contracts/test/CharonLiquidator.t.sol @@ -320,4 +320,81 @@ contract CharonLiquidatorTest is Test { function test_executeLiquidation_endToEndOnFork() public { vm.skip(true); } + + // ───────────────────────────────────────────────────────────────────────── + // F. batchExecute — access control, bounds, and atomicity (no fork) + // ───────────────────────────────────────────────────────────────────────── + + /// @dev Non-owner calling batchExecute must revert with "!owner". + /// No pool mock needed — onlyOwner fires before any other logic. + function test_batchExecute_revertsWhenNotOwner() public { + CharonLiquidator.LiquidationParams[] memory items = + new CharonLiquidator.LiquidationParams[](1); + items[0] = _validParams(); + + vm.prank(alice); + vm.expectRevert(bytes("!owner")); + liquidator.batchExecute(items); + } + + /// @dev An empty array must revert with "!items". + /// The owner calls with zero-length items; the bound check fires immediately. + function test_batchExecute_revertsOnEmptyItems() public { + CharonLiquidator.LiquidationParams[] memory items = + new CharonLiquidator.LiquidationParams[](0); + + vm.expectRevert(bytes("!items")); + liquidator.batchExecute(items); + } + + /// @dev An array of length 11 (> MAX_BATCH_SIZE = 10) must revert with "batch too large". + /// All items are valid; the ceiling check fires before the loop. + function test_batchExecute_revertsWhenTooLarge() public { + // Build 11 valid items — the batch size ceiling fires before any iteration. + CharonLiquidator.LiquidationParams[] memory items = + new CharonLiquidator.LiquidationParams[](11); + for (uint256 i = 0; i < 11; i++) { + items[i] = _validParams(); + } + + vm.expectRevert(bytes("batch too large")); + liquidator.batchExecute(items); + } + + /// @dev A two-item batch where item[0] has a zero borrower must revert with "!borrower". + /// The entire batch reverts atomically — item[1] is never processed. + /// + /// item[1] is valid and would reach flashLoanSimple if item[0] passed. + /// We mock STUB_POOL.flashLoanSimple to be a no-op so that if the validation + /// logic were ever incorrectly skipped and the call reached the pool, the test + /// would not revert for the wrong reason. The expected revert is "!borrower" + /// from _initiateFlashLoan's validation of item[0]. + function test_batchExecute_revertsOnFirstItemValidation() public { + CharonLiquidator.LiquidationParams[] memory items = + new CharonLiquidator.LiquidationParams[](2); + + // item[0]: invalid — zero borrower triggers "!borrower" inside _initiateFlashLoan. + items[0] = _validParams(); + items[0].borrower = address(0); + + // item[1]: fully valid — would reach flashLoanSimple if iteration 0 were skipped. + items[1] = _validParams(); + + // Stub STUB_POOL.flashLoanSimple to succeed silently for item[1] in case + // validation is incorrectly bypassed. The real assertion is the revert below. + bytes memory flashLoanSig = abi.encodeWithSignature( + "flashLoanSimple(address,address,uint256,bytes,uint16)", + address(liquidator), + items[1].debtToken, + items[1].repayAmount, + abi.encode(items[1]), + uint16(0) + ); + vm.mockCall(STUB_POOL, flashLoanSig, abi.encode()); + + // Expect the batch to revert with "!borrower" from item[0]'s validation. + // No state from item[1] survives — the revert is atomic. + vm.expectRevert(bytes("!borrower")); + liquidator.batchExecute(items); + } } diff --git a/crates/charon-executor/src/batcher.rs b/crates/charon-executor/src/batcher.rs new file mode 100644 index 0000000..9328d9d --- /dev/null +++ b/crates/charon-executor/src/batcher.rs @@ -0,0 +1,305 @@ +//! Multi-liquidation batcher. +//! +//! Groups per-chain [`LiquidationOpportunity`] records that land in the +//! same block window and encodes them into one `batchExecute(...)` call +//! on `CharonLiquidator.sol`. Saves one tx per extra opportunity — the +//! flash-loan fee on each borrow is still paid, but the base tx cost +//! (21000 gas + signature verify + calldata base) amortises across the +//! whole batch. +//! +//! The batcher is a **planner**, not an executor. It returns structured +//! [`LiquidationBatch`] values; a downstream caller (CLI pipeline, in a +//! later PR) builds the tx via [`TxBuilder`](crate::TxBuilder) with the +//! calldata produced here. +//! +//! Current heuristic: +//! - Group strictly by `chain_id` (can't batch across chains) +//! - Preserve profit-desc ordering within a group +//! - Cap at `MAX_BATCH_SIZE = 3` — PRD default; the on-chain cap is 10 +//! - Only emit a batch if it contains ≥ 2 opportunities; a 1-item +//! "batch" is cheaper as a plain `executeLiquidation` call (skips the +//! array length word + loop overhead) + +use std::collections::HashMap; + +use alloy::primitives::Bytes; +use alloy::sol; +use alloy::sol_types::SolCall; +use anyhow::Result; +use charon_core::{LiquidationOpportunity, LiquidationParams}; +use tracing::debug; + +/// Matches `MAX_BATCH_SIZE` in `CharonLiquidator.sol`. The Solidity +/// ceiling is 10; keeping the default smaller (3) keeps gas estimates +/// predictable and mirrors the PRD's suggested batch size. +pub const MAX_BATCH_SIZE: usize = 3; + +// On-chain struct + batch entrypoint. Must stay in lockstep with the +// Solidity source — the selector test on `ICharonLiquidator` catches +// drift on the single-item path; the batch path is pinned here. +sol! { + /// Solidity-side `LiquidationParams` — same shape as in + /// [`crate::builder::CharonLiquidationParams`], redeclared here so + /// the batch encoder is self-contained. + #[derive(Debug)] + struct BatchParams { + uint8 protocolId; + address borrower; + address debtToken; + address collateralToken; + address debtVToken; + address collateralVToken; + uint256 repayAmount; + uint256 minSwapOut; + } + + /// `batchExecute(LiquidationParams[])` entry on `CharonLiquidator`. + interface ICharonBatch { + function batchExecute(BatchParams[] calldata items) external; + } +} + +const PROTOCOL_VENUS: u8 = 3; + +/// One batch ready for `TxBuilder` to wrap into an EIP-1559 transaction. +#[derive(Debug, Clone)] +pub struct LiquidationBatch { + /// Chain the opportunities share. + pub chain_id: u64, + /// Opportunities in profit-desc order, length in `[2, MAX_BATCH_SIZE]`. + pub opportunities: Vec, + /// Sum of `net_profit_usd_cents` across the batch — used by the + /// caller to rank batches against single-opportunity txs. + pub total_net_usd_cents: u64, +} + +/// Stateless planner — construct once per process. +#[derive(Debug, Clone, Copy)] +pub struct Batcher { + max_batch_size: usize, +} + +impl Batcher { + pub fn new(max_batch_size: usize) -> Self { + Self { + max_batch_size: max_batch_size.max(1), + } + } + + pub fn with_default_size() -> Self { + Self::new(MAX_BATCH_SIZE) + } + + /// Partition `opportunities` into per-chain batches. + /// + /// Input order is preserved within each chain group (caller should + /// supply in profit-desc order; the batcher doesn't re-rank). + /// Single-opportunity groups are omitted from the output — they + /// belong on the plain `executeLiquidation` path, not `batchExecute`. + pub fn plan(&self, opportunities: Vec) -> Vec { + let mut by_chain: HashMap> = HashMap::new(); + for opp in opportunities { + by_chain.entry(opp.position.chain_id).or_default().push(opp); + } + + let mut out = Vec::new(); + for (chain_id, group) in by_chain { + for chunk in group.chunks(self.max_batch_size) { + if chunk.len() < 2 { + continue; + } + let total_net_usd_cents = chunk + .iter() + .map(|o| o.net_profit_usd_cents) + .fold(0u64, u64::saturating_add); + out.push(LiquidationBatch { + chain_id, + opportunities: chunk.to_vec(), + total_net_usd_cents, + }); + } + } + debug!(batch_count = out.len(), "batcher planned"); + out + } + + /// ABI-encode a `batchExecute(LiquidationParams[])` call. + /// + /// Each opportunity needs its corresponding + /// [`LiquidationParams`] (produced upstream by + /// `LendingProtocol::get_liquidation_params`). The caller supplies + /// them as a parallel slice so the batcher never has to know how a + /// given protocol derives its vToken addresses. + pub fn encode_calldata( + &self, + batch: &LiquidationBatch, + params: &[LiquidationParams], + ) -> Result { + if params.len() != batch.opportunities.len() { + anyhow::bail!( + "batcher: params/opportunities length mismatch ({} vs {})", + params.len(), + batch.opportunities.len() + ); + } + + let mut items = Vec::with_capacity(batch.opportunities.len()); + for (opp, params) in batch.opportunities.iter().zip(params.iter()) { + let LiquidationParams::Venus { + borrower, + collateral_vtoken, + debt_vtoken, + repay_amount, + } = params; + items.push(BatchParams { + protocolId: PROTOCOL_VENUS, + borrower: *borrower, + debtToken: opp.position.debt_token, + collateralToken: opp.position.collateral_token, + debtVToken: *debt_vtoken, + collateralVToken: *collateral_vtoken, + repayAmount: *repay_amount, + minSwapOut: opp.swap_route.min_amount_out, + }); + } + + let call = ICharonBatch::batchExecuteCall { items }; + let bytes: Bytes = call.abi_encode().into(); + debug!( + items = batch.opportunities.len(), + calldata_len = bytes.len(), + chain_id = batch.chain_id, + "batch calldata encoded" + ); + Ok(bytes) + } +} + +impl Default for Batcher { + fn default() -> Self { + Self::with_default_size() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{Address, U256, address}; + use charon_core::{FlashLoanSource, Position, ProtocolId, SwapRoute}; + + fn mk_opp(chain_id: u64, net_cents: u64, borrower_byte: u8) -> LiquidationOpportunity { + let mut bytes = [0u8; 20]; + bytes[19] = borrower_byte; + LiquidationOpportunity { + position: Position { + protocol: ProtocolId::Venus, + chain_id, + borrower: Address::from(bytes), + collateral_token: address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + debt_token: address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + collateral_amount: U256::from(1_000u64), + debt_amount: U256::from(500u64), + health_factor: U256::ZERO, + liquidation_bonus_bps: 1_000, + }, + debt_to_repay: U256::from(250u64), + expected_collateral_out: U256::from(275u64), + flash_source: FlashLoanSource::AaveV3, + swap_route: SwapRoute { + token_in: address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + token_out: address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + amount_in: U256::from(275u64), + min_amount_out: U256::from(260u64), + pool_fee: 3_000, + }, + net_profit_usd_cents: net_cents, + } + } + + fn mk_params(borrower_byte: u8) -> LiquidationParams { + let mut bytes = [0u8; 20]; + bytes[19] = borrower_byte; + LiquidationParams::Venus { + borrower: Address::from(bytes), + collateral_vtoken: address!("cccccccccccccccccccccccccccccccccccccccc"), + debt_vtoken: address!("dddddddddddddddddddddddddddddddddddddddd"), + repay_amount: U256::from(250u64), + } + } + + #[test] + fn single_opportunity_does_not_become_a_batch() { + let out = Batcher::with_default_size().plan(vec![mk_opp(56, 100, 1)]); + assert!(out.is_empty(), "1-item input should yield no batches"); + } + + #[test] + fn same_chain_groups_into_one_batch() { + let out = Batcher::with_default_size().plan(vec![ + mk_opp(56, 300, 1), + mk_opp(56, 200, 2), + mk_opp(56, 100, 3), + ]); + assert_eq!(out.len(), 1); + assert_eq!(out[0].chain_id, 56); + assert_eq!(out[0].opportunities.len(), 3); + assert_eq!(out[0].total_net_usd_cents, 600); + } + + #[test] + fn different_chains_produce_separate_batches() { + let out = Batcher::with_default_size().plan(vec![ + mk_opp(56, 100, 1), + mk_opp(56, 100, 2), + mk_opp(1, 100, 3), + mk_opp(1, 100, 4), + ]); + assert_eq!(out.len(), 2); + let mut chains: Vec = out.iter().map(|b| b.chain_id).collect(); + chains.sort(); + assert_eq!(chains, vec![1, 56]); + } + + #[test] + fn batches_split_when_group_exceeds_max_size() { + // Size 2 → 5 opps → chunks of [2, 2, 1]; the trailing 1 is + // dropped because it's not a real batch. + let b = Batcher::new(2); + let out = b.plan((1..=5).map(|i| mk_opp(56, 100, i)).collect()); + assert_eq!(out.len(), 2); + assert!(out.iter().all(|b| b.opportunities.len() == 2)); + } + + #[test] + fn encode_calldata_has_batch_execute_selector() { + let batch = LiquidationBatch { + chain_id: 56, + opportunities: vec![mk_opp(56, 100, 1), mk_opp(56, 200, 2)], + total_net_usd_cents: 300, + }; + let params = vec![mk_params(1), mk_params(2)]; + let bytes = Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .expect("encode"); + assert_eq!( + &bytes[..4], + &ICharonBatch::batchExecuteCall::SELECTOR, + "calldata selector drifted from batchExecute" + ); + } + + #[test] + fn encode_calldata_rejects_mismatched_lengths() { + let batch = LiquidationBatch { + chain_id: 56, + opportunities: vec![mk_opp(56, 100, 1), mk_opp(56, 200, 2)], + total_net_usd_cents: 300, + }; + let params = vec![mk_params(1)]; // only one + assert!( + Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .is_err() + ); + } +} diff --git a/crates/charon-executor/src/lib.rs b/crates/charon-executor/src/lib.rs index 9562982..c4b038b 100644 --- a/crates/charon-executor/src/lib.rs +++ b/crates/charon-executor/src/lib.rs @@ -9,12 +9,14 @@ //! signs it with the bot's hot wallet, and runs an `eth_call` //! simulation gate before any broadcast can happen. +pub mod batcher; pub mod builder; pub mod gas; pub mod nonce; pub mod simulation; pub mod submit; +pub use batcher::{Batcher, LiquidationBatch, MAX_BATCH_SIZE}; pub use builder::{ICharonLiquidator, TxBuilder}; pub use gas::{GasOracle, GasParams, gas_cost_usd_cents}; pub use nonce::NonceManager; From f1fbc6bb3e5491a1b6cd76697416382a007a06d2 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 16:35:34 +0530 Subject: [PATCH 2/6] fix(contracts): pin EVM target to paris + lock pragma to 0.8.24 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solc 0.8.24 defaults to Shanghai codegen and emits PUSH0 (0x5f). BSC mainnet runs a pre-Shanghai EVM, so every deploy of CharonLiquidator (batchExecute included) faults at the first PUSH0 site. forge-test runs against revm which accepts PUSH0, so local green does not imply on-chain green — a hidden false signal. Add `evm_version = "paris"` to contracts/foundry.toml and strip the caret from every `pragma solidity ^0.8.24;` so a future solc patch cannot silently shift codegen under the same source tree. Same defect as #113/#114/#118/#119 in prior PRs, now closed at the foundry-config layer across all Solidity files on this branch. Closes #203 Closes #204 --- contracts/foundry.toml | 6 ++++++ contracts/src/CharonLiquidator.sol | 2 +- contracts/src/interfaces/IAaveV3Pool.sol | 2 +- contracts/src/interfaces/IERC20.sol | 2 +- contracts/src/interfaces/IFlashLoanSimpleReceiver.sol | 2 +- contracts/src/interfaces/ISwapRouter.sol | 2 +- contracts/src/interfaces/IVToken.sol | 2 +- contracts/test/CharonLiquidator.t.sol | 2 +- 8 files changed, 13 insertions(+), 7 deletions(-) diff --git a/contracts/foundry.toml b/contracts/foundry.toml index db3ce7f..08c4194 100644 --- a/contracts/foundry.toml +++ b/contracts/foundry.toml @@ -3,6 +3,12 @@ src = "src" out = "out" libs = ["lib"] solc_version = "0.8.24" +# BSC does not support PUSH0 (0x5f). solc 0.8.24 defaults to Shanghai +# and emits PUSH0, so the target EVM must be pinned to Paris or every +# deploy faults on-chain at the first PUSH0 site. The in-process +# revm used by `forge test` accepts PUSH0, which is why a fork-test +# pass is NOT equivalent to a BSC-deploy pass without this pin. +evm_version = "paris" optimizer = true optimizer_runs = 1_000_000 via_ir = false diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 5bf1828..53e255c 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; import { IFlashLoanSimpleReceiver } from "./interfaces/IFlashLoanSimpleReceiver.sol"; import { IAaveV3Pool } from "./interfaces/IAaveV3Pool.sol"; diff --git a/contracts/src/interfaces/IAaveV3Pool.sol b/contracts/src/interfaces/IAaveV3Pool.sol index a9774fa..c61d1fa 100644 --- a/contracts/src/interfaces/IAaveV3Pool.sol +++ b/contracts/src/interfaces/IAaveV3Pool.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IAaveV3Pool /// @notice Stub interface for the Aave V3 Pool contract deployed on BNB Chain. diff --git a/contracts/src/interfaces/IERC20.sol b/contracts/src/interfaces/IERC20.sol index 7c29cd0..aa8f0d6 100644 --- a/contracts/src/interfaces/IERC20.sol +++ b/contracts/src/interfaces/IERC20.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IERC20 /// @notice Minimal ERC-20 interface required by CharonLiquidator. diff --git a/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol b/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol index 1677d77..711676f 100644 --- a/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol +++ b/contracts/src/interfaces/IFlashLoanSimpleReceiver.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IFlashLoanSimpleReceiver /// @notice Aave V3 flash-loan simple receiver callback interface. diff --git a/contracts/src/interfaces/ISwapRouter.sol b/contracts/src/interfaces/ISwapRouter.sol index ff06c6d..d7439ad 100644 --- a/contracts/src/interfaces/ISwapRouter.sol +++ b/contracts/src/interfaces/ISwapRouter.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title ISwapRouter /// @notice Minimal interface for the PancakeSwap V3 SwapRouter on BNB Chain. diff --git a/contracts/src/interfaces/IVToken.sol b/contracts/src/interfaces/IVToken.sol index 637cf1e..874ed8c 100644 --- a/contracts/src/interfaces/IVToken.sol +++ b/contracts/src/interfaces/IVToken.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; /// @title IVToken /// @notice Stub interface for Venus Protocol vToken contracts on BNB Chain. diff --git a/contracts/test/CharonLiquidator.t.sol b/contracts/test/CharonLiquidator.t.sol index c97fc41..bb2df25 100644 --- a/contracts/test/CharonLiquidator.t.sol +++ b/contracts/test/CharonLiquidator.t.sol @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -pragma solidity ^0.8.24; +pragma solidity 0.8.24; import "forge-std/Test.sol"; From 5da4129be1703a93683d0231b6f13084ee604fc1 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 16:59:40 +0530 Subject: [PATCH 3/6] chore(workspace): adopt workspace lints and opt charon-executor in - Hoist unsafe_code=forbid, arithmetic_side_effects=deny, cast_possible_truncation=deny, unwrap_used=deny into [workspace.lints.rust] and [workspace.lints.clippy]. - Add thiserror to [workspace.dependencies] for the batcher error enum landing alongside this change. - Opt charon-executor in via [lints] workspace = true. - Reshape gas_cost_usd_cents to saturating/checked arithmetic so the arithmetic_side_effects lint passes without loosening behaviour. - Replace unwrap() on JoinHandle::join in the nonce concurrency test with expect() carrying a diagnostic message. Closes #211 --- Cargo.lock | 1 + Cargo.toml | 18 ++++++++++++++++++ crates/charon-executor/Cargo.toml | 4 ++++ crates/charon-executor/src/gas.rs | 14 +++++++++++--- crates/charon-executor/src/nonce.rs | 2 +- 5 files changed, 35 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80866a9..54f77a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1157,6 +1157,7 @@ dependencies = [ "alloy", "anyhow", "charon-core", + "thiserror 1.0.69", "tokio", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 39a41d7..019caa6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Error handling anyhow = "1" +thiserror = "1" # Async trait objects async-trait = "0.1" @@ -55,3 +56,20 @@ charon-executor = { path = "crates/charon-executor" } charon-flashloan = { path = "crates/charon-flashloan" } charon-protocols = { path = "crates/charon-protocols" } charon-scanner = { path = "crates/charon-scanner" } + +# Workspace-wide lints. Applied to any crate that opts in with +# `[lints] workspace = true` in its own Cargo.toml. +# +# Safety invariants in CLAUDE.md (onlyOwner execute, sim gate, hot +# wallet = gas only, flash-loan atomicity) assume the bot's off-chain +# code does not silently panic on arithmetic edge cases or swallow +# errors. `unsafe_code` is forbidden outright — nothing the bot does +# needs it, and any new unsafe block should force a conscious +# exception in the owning crate. +[workspace.lints.rust] +unsafe_code = "forbid" + +[workspace.lints.clippy] +arithmetic_side_effects = "deny" +cast_possible_truncation = "deny" +unwrap_used = "deny" diff --git a/crates/charon-executor/Cargo.toml b/crates/charon-executor/Cargo.toml index 229ab57..6173f65 100644 --- a/crates/charon-executor/Cargo.toml +++ b/crates/charon-executor/Cargo.toml @@ -9,5 +9,9 @@ description = "Transaction builder, simulator, and broadcaster for Charon" charon-core = { workspace = true } alloy = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } tracing = { workspace = true } tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/charon-executor/src/gas.rs b/crates/charon-executor/src/gas.rs index 958b015..48f09c3 100644 --- a/crates/charon-executor/src/gas.rs +++ b/crates/charon-executor/src/gas.rs @@ -131,14 +131,22 @@ pub fn gas_cost_usd_cents( native_price: U256, native_decimals: u8, ) -> u64 { - let wei_cost: u128 = (gas_units as u128).saturating_mul(max_fee_per_gas); + let wei_cost: u128 = u128::from(gas_units).saturating_mul(max_fee_per_gas); // wei (1e18 = 1 native) × price (10^decimals = $1) → cents. // Divide by 10^(18 + decimals - 2) to land in cents. - let exponent = 18u32 + u32::from(native_decimals) - 2; + // + // Use saturating / checked arithmetic so the workspace + // `arithmetic_side_effects` lint passes. A pathologically large + // `native_decimals` (> u32::MAX - 16) saturates to u32::MAX, and a + // zero divisor (unreachable while exponent >= 0) falls back to zero + // cents rather than panicking inside the oracle. + let exponent = 18u32 + .saturating_add(u32::from(native_decimals)) + .saturating_sub(2); let divisor = U256::from(10u64).pow(U256::from(exponent)); let numerator = U256::from(wei_cost).saturating_mul(native_price); - let cents = numerator / divisor; + let cents = numerator.checked_div(divisor).unwrap_or(U256::ZERO); u64::try_from(cents).unwrap_or(u64::MAX) } diff --git a/crates/charon-executor/src/nonce.rs b/crates/charon-executor/src/nonce.rs index a920842..93344e4 100644 --- a/crates/charon-executor/src/nonce.rs +++ b/crates/charon-executor/src/nonce.rs @@ -134,7 +134,7 @@ mod tests { let mut all = Vec::with_capacity(THREADS * PER); for h in handles { - all.extend(h.join().unwrap()); + all.extend(h.join().expect("test thread panicked")); } all.sort_unstable(); all.dedup(); From 70802062d8c83c09b3488064b20732823b625682 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 17:00:33 +0530 Subject: [PATCH 4/6] refactor(executor): typed BatcherError + single-chain/size guards - Introduce BatcherError (ParamLengthMismatch, BatchTooLarge, UnsupportedChain, AbiEncodeError) so the public batcher API no longer returns anyhow::Result. - Remove the cross-chain HashMap partitioning from Batcher::plan. v0.1 is BSC-only; the planner now rejects any opportunity whose chain_id is not 56 via BatcherError::UnsupportedChain. - Enforce the on-chain MAX_BATCH_SIZE ceiling (10) inside encode_calldata via a new SOLIDITY_MAX_BATCH_SIZE constant; prevents a misconfigured caller from signing a tx that reverts with "batch too large" on-chain. - Batcher::new now clamps the caller-supplied size to [1, SOLIDITY_MAX_BATCH_SIZE]. - Pin the batchExecute selector to an externally-computed keccak256 digest of the canonical Solidity signature; the earlier test was self-referential and would silently pass even if both the macro and the on-chain signature drifted together. - Document encode_calldata's Safety contract: callers MUST pass the returned calldata through Simulator::simulate before broadcast, per the CLAUDE.md eth_call gate invariant. Batch-path sim wiring is tracked in #298. - Drop the different_chains_produce_separate_batches test; add plan_rejects_non_bsc_chain_id, assert_single_chain, new_clamps_max_batch_size_to_solidity_cap, and encode_calldata_rejects_oversize_batch. Closes #206 Closes #207 Closes #208 Closes #209 Closes #210 --- crates/charon-executor/src/batcher.rs | 326 ++++++++++++++++++++------ crates/charon-executor/src/lib.rs | 4 +- 2 files changed, 256 insertions(+), 74 deletions(-) diff --git a/crates/charon-executor/src/batcher.rs b/crates/charon-executor/src/batcher.rs index 9328d9d..2045dc2 100644 --- a/crates/charon-executor/src/batcher.rs +++ b/crates/charon-executor/src/batcher.rs @@ -1,8 +1,8 @@ //! Multi-liquidation batcher. //! -//! Groups per-chain [`LiquidationOpportunity`] records that land in the -//! same block window and encodes them into one `batchExecute(...)` call -//! on `CharonLiquidator.sol`. Saves one tx per extra opportunity — the +//! Groups [`LiquidationOpportunity`] records that land in the same +//! block window and encodes them into one `batchExecute(...)` call on +//! `CharonLiquidator.sol`. Saves one tx per extra opportunity — the //! flash-loan fee on each borrow is still paid, but the base tx cost //! (21000 gas + signature verify + calldata base) amortises across the //! whole batch. @@ -12,31 +12,49 @@ //! later PR) builds the tx via [`TxBuilder`](crate::TxBuilder) with the //! calldata produced here. //! +//! v0.1 scope: Venus on BNB Chain only. The planner rejects any input +//! whose `chain_id` is not [`BSC_CHAIN_ID`]; cross-chain partitioning is +//! out of scope until the bot grows a second protocol target. +//! //! Current heuristic: -//! - Group strictly by `chain_id` (can't batch across chains) -//! - Preserve profit-desc ordering within a group -//! - Cap at `MAX_BATCH_SIZE = 3` — PRD default; the on-chain cap is 10 +//! - All opportunities must share `chain_id == 56` (BSC) +//! - Preserve profit-desc ordering supplied by the caller +//! - Cap per batch at [`MAX_BATCH_SIZE`] = 3 — PRD default; the on-chain +//! cap is [`SOLIDITY_MAX_BATCH_SIZE`] = 10 and the planner enforces +//! both ceilings //! - Only emit a batch if it contains ≥ 2 opportunities; a 1-item //! "batch" is cheaper as a plain `executeLiquidation` call (skips the //! array length word + loop overhead) -use std::collections::HashMap; - use alloy::primitives::Bytes; use alloy::sol; use alloy::sol_types::SolCall; -use anyhow::Result; use charon_core::{LiquidationOpportunity, LiquidationParams}; +use thiserror::Error; use tracing::debug; /// Matches `MAX_BATCH_SIZE` in `CharonLiquidator.sol`. The Solidity -/// ceiling is 10; keeping the default smaller (3) keeps gas estimates -/// predictable and mirrors the PRD's suggested batch size. +/// ceiling is 10 ([`SOLIDITY_MAX_BATCH_SIZE`]); the Rust default is +/// smaller to keep gas estimates predictable and mirror the PRD's +/// suggested batch size. pub const MAX_BATCH_SIZE: usize = 3; +/// Hard ceiling enforced on the Solidity side (`CharonLiquidator.sol` +/// `MAX_BATCH_SIZE`). Any batch whose length exceeds this constant +/// would be rejected on-chain by `require(n <= MAX_BATCH_SIZE, ...)`; +/// the encoder rejects it earlier so a compromised or misconfigured +/// caller cannot waste a tx or burn gas for guaranteed revert. +pub const SOLIDITY_MAX_BATCH_SIZE: usize = 10; + +/// BNB Chain (v0.1 only). Cross-chain partitioning is deferred until a +/// second protocol target lands; until then any non-BSC opportunity is +/// a programming error and the planner surfaces it as such. +pub const BSC_CHAIN_ID: u64 = 56; + // On-chain struct + batch entrypoint. Must stay in lockstep with the -// Solidity source — the selector test on `ICharonLiquidator` catches -// drift on the single-item path; the batch path is pinned here. +// Solidity source — the selector test pins the canonical keccak256 of +// the function signature, so any drift in the struct shape or the +// function name breaks the test before it reaches mainnet. sol! { /// Solidity-side `LiquidationParams` — same shape as in /// [`crate::builder::CharonLiquidationParams`], redeclared here so @@ -61,10 +79,55 @@ sol! { const PROTOCOL_VENUS: u8 = 3; +/// Typed error surface for the public batcher API. Keeps `anyhow` out +/// of the library boundary so downstream callers can pattern-match on +/// failure modes and surface them as domain errors rather than opaque +/// `Result<_, anyhow::Error>` blobs. +#[derive(Debug, Error)] +pub enum BatcherError { + /// Caller supplied a `params` slice whose length does not equal + /// `batch.opportunities.len()`. Zipping proceeds pairwise, so any + /// mismatch corrupts the mapping between opportunities and their + /// protocol parameters. + #[error("batcher: params/opportunities length mismatch (params={params}, opps={opps})")] + ParamLengthMismatch { + /// Length of the `params` slice supplied to `encode_calldata`. + params: usize, + /// Length of `batch.opportunities`. + opps: usize, + }, + + /// Batch length exceeds the on-chain `MAX_BATCH_SIZE` in + /// `CharonLiquidator.sol`. See [`SOLIDITY_MAX_BATCH_SIZE`]. + #[error("batcher: batch too large ({len} items, on-chain limit {limit})")] + BatchTooLarge { + /// Actual batch length. + len: usize, + /// Hard ceiling on the Solidity side. + limit: usize, + }, + + /// Batch contains an opportunity whose `chain_id` does not match + /// the v0.1 BSC-only scope. Collapses the previous + /// cross-chain `HashMap` into a single explicit guard. + #[error("batcher: unsupported chain_id {got}, only {expected} (BSC) is supported in v0.1")] + UnsupportedChain { + /// Chain id observed on the offending opportunity. + got: u64, + /// The only chain id accepted in v0.1. + expected: u64, + }, + + /// `SolCall::abi_encode` returned an error. Wrapped so the caller + /// does not depend on `alloy`'s internal error types. + #[error("batcher: ABI encoding failed: {0}")] + AbiEncodeError(String), +} + /// One batch ready for `TxBuilder` to wrap into an EIP-1559 transaction. #[derive(Debug, Clone)] pub struct LiquidationBatch { - /// Chain the opportunities share. + /// Chain the opportunities share (always `BSC_CHAIN_ID` in v0.1). pub chain_id: u64, /// Opportunities in profit-desc order, length in `[2, MAX_BATCH_SIZE]`. pub opportunities: Vec, @@ -81,8 +144,12 @@ pub struct Batcher { impl Batcher { pub fn new(max_batch_size: usize) -> Self { + // Clamp to [1, SOLIDITY_MAX_BATCH_SIZE] so a misconfigured + // caller cannot produce a batch that would be rejected on-chain + // after the tx was built and signed. + let clamped = max_batch_size.clamp(1, SOLIDITY_MAX_BATCH_SIZE); Self { - max_batch_size: max_batch_size.max(1), + max_batch_size: clamped, } } @@ -90,37 +157,47 @@ impl Batcher { Self::new(MAX_BATCH_SIZE) } - /// Partition `opportunities` into per-chain batches. + /// Chunk `opportunities` into BSC-only batches. /// - /// Input order is preserved within each chain group (caller should - /// supply in profit-desc order; the batcher doesn't re-rank). - /// Single-opportunity groups are omitted from the output — they + /// Returns [`BatcherError::UnsupportedChain`] if any input is not + /// on BSC — v0.1 does not partition across chains. Input order is + /// preserved (caller should supply profit-desc; the batcher does + /// not re-rank). Single-opportunity chunks are omitted — they /// belong on the plain `executeLiquidation` path, not `batchExecute`. - pub fn plan(&self, opportunities: Vec) -> Vec { - let mut by_chain: HashMap> = HashMap::new(); - for opp in opportunities { - by_chain.entry(opp.position.chain_id).or_default().push(opp); + pub fn plan( + &self, + opportunities: Vec, + ) -> Result, BatcherError> { + // Single-chain guard: v0.1 is BSC-only. Rejecting here rather + // than silently filtering forces the caller to surface the + // misconfiguration before we burn RPC round-trips on + // ineligible opportunities. + for opp in &opportunities { + if opp.position.chain_id != BSC_CHAIN_ID { + return Err(BatcherError::UnsupportedChain { + got: opp.position.chain_id, + expected: BSC_CHAIN_ID, + }); + } } let mut out = Vec::new(); - for (chain_id, group) in by_chain { - for chunk in group.chunks(self.max_batch_size) { - if chunk.len() < 2 { - continue; - } - let total_net_usd_cents = chunk - .iter() - .map(|o| o.net_profit_usd_cents) - .fold(0u64, u64::saturating_add); - out.push(LiquidationBatch { - chain_id, - opportunities: chunk.to_vec(), - total_net_usd_cents, - }); + for chunk in opportunities.chunks(self.max_batch_size) { + if chunk.len() < 2 { + continue; } + let total_net_usd_cents = chunk + .iter() + .map(|o| o.net_profit_usd_cents) + .fold(0u64, u64::saturating_add); + out.push(LiquidationBatch { + chain_id: BSC_CHAIN_ID, + opportunities: chunk.to_vec(), + total_net_usd_cents, + }); } debug!(batch_count = out.len(), "batcher planned"); - out + Ok(out) } /// ABI-encode a `batchExecute(LiquidationParams[])` call. @@ -130,20 +207,41 @@ impl Batcher { /// `LendingProtocol::get_liquidation_params`). The caller supplies /// them as a parallel slice so the batcher never has to know how a /// given protocol derives its vToken addresses. + /// + /// # Safety + /// + /// The returned calldata is **not** self-validating. Callers MUST + /// pass it through + /// [`Simulator::simulate`](crate::Simulator::simulate) before + /// broadcasting, per the CLAUDE.md safety invariant that every + /// liquidation tx passes an `eth_call` gate. The simulator catches + /// protocol-level reverts (insufficient collateral, stale oracle, + /// closed market) that the planner cannot see from off-chain data + /// alone. + /// + /// The batch path of the sim gate is tracked in issue #298; + /// skipping simulation is a bypass of the last line of defense and + /// is never acceptable in production code paths. pub fn encode_calldata( &self, batch: &LiquidationBatch, params: &[LiquidationParams], - ) -> Result { - if params.len() != batch.opportunities.len() { - anyhow::bail!( - "batcher: params/opportunities length mismatch ({} vs {})", - params.len(), - batch.opportunities.len() - ); + ) -> Result { + let opps = batch.opportunities.len(); + if params.len() != opps { + return Err(BatcherError::ParamLengthMismatch { + params: params.len(), + opps, + }); + } + if opps > SOLIDITY_MAX_BATCH_SIZE { + return Err(BatcherError::BatchTooLarge { + len: opps, + limit: SOLIDITY_MAX_BATCH_SIZE, + }); } - let mut items = Vec::with_capacity(batch.opportunities.len()); + let mut items = Vec::with_capacity(opps); for (opp, params) in batch.opportunities.iter().zip(params.iter()) { let LiquidationParams::Venus { borrower, @@ -166,7 +264,7 @@ impl Batcher { let call = ICharonBatch::batchExecuteCall { items }; let bytes: Bytes = call.abi_encode().into(); debug!( - items = batch.opportunities.len(), + items = opps, calldata_len = bytes.len(), chain_id = batch.chain_id, "batch calldata encoded" @@ -184,7 +282,7 @@ impl Default for Batcher { #[cfg(test)] mod tests { use super::*; - use alloy::primitives::{Address, U256, address}; + use alloy::primitives::{Address, U256, address, keccak256}; use charon_core::{FlashLoanSource, Position, ProtocolId, SwapRoute}; fn mk_opp(chain_id: u64, net_cents: u64, borrower_byte: u8) -> LiquidationOpportunity { @@ -229,35 +327,52 @@ mod tests { #[test] fn single_opportunity_does_not_become_a_batch() { - let out = Batcher::with_default_size().plan(vec![mk_opp(56, 100, 1)]); + let out = Batcher::with_default_size() + .plan(vec![mk_opp(56, 100, 1)]) + .expect("plan"); assert!(out.is_empty(), "1-item input should yield no batches"); } #[test] fn same_chain_groups_into_one_batch() { - let out = Batcher::with_default_size().plan(vec![ - mk_opp(56, 300, 1), - mk_opp(56, 200, 2), - mk_opp(56, 100, 3), - ]); + let out = Batcher::with_default_size() + .plan(vec![ + mk_opp(56, 300, 1), + mk_opp(56, 200, 2), + mk_opp(56, 100, 3), + ]) + .expect("plan"); assert_eq!(out.len(), 1); assert_eq!(out[0].chain_id, 56); assert_eq!(out[0].opportunities.len(), 3); assert_eq!(out[0].total_net_usd_cents, 600); } + /// v0.1 is BSC-only. A non-BSC opportunity is a programming error, + /// not a quiet partitioning condition, so the planner rejects it. #[test] - fn different_chains_produce_separate_batches() { - let out = Batcher::with_default_size().plan(vec![ - mk_opp(56, 100, 1), - mk_opp(56, 100, 2), - mk_opp(1, 100, 3), - mk_opp(1, 100, 4), - ]); - assert_eq!(out.len(), 2); - let mut chains: Vec = out.iter().map(|b| b.chain_id).collect(); - chains.sort(); - assert_eq!(chains, vec![1, 56]); + fn plan_rejects_non_bsc_chain_id() { + let err = Batcher::with_default_size() + .plan(vec![mk_opp(56, 100, 1), mk_opp(1, 100, 2)]) + .expect_err("non-BSC must error"); + match err { + BatcherError::UnsupportedChain { got, expected } => { + assert_eq!(got, 1); + assert_eq!(expected, 56); + } + other => panic!("wrong error variant: {other:?}"), + } + } + + /// All-BSC input of length > 1 planned normally — confirms the + /// single-chain guard does not reject the happy path. + #[test] + fn assert_single_chain() { + let out = Batcher::with_default_size() + .plan(vec![mk_opp(56, 100, 1), mk_opp(56, 100, 2)]) + .expect("plan"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].chain_id, 56); } #[test] @@ -265,13 +380,39 @@ mod tests { // Size 2 → 5 opps → chunks of [2, 2, 1]; the trailing 1 is // dropped because it's not a real batch. let b = Batcher::new(2); - let out = b.plan((1..=5).map(|i| mk_opp(56, 100, i)).collect()); + let out = b + .plan((1..=5).map(|i| mk_opp(56, 100, i)).collect()) + .expect("plan"); assert_eq!(out.len(), 2); assert!(out.iter().all(|b| b.opportunities.len() == 2)); } + /// `new()` must clamp a caller-supplied size at the Solidity cap — + /// otherwise `plan` could emit a batch that `batchExecute` rejects + /// with "batch too large" after the tx is already signed. + #[test] + fn new_clamps_max_batch_size_to_solidity_cap() { + let b = Batcher::new(99); + let opps: Vec<_> = (1u8..=12).map(|i| mk_opp(56, 100, i)).collect(); + let out = b.plan(opps).expect("plan"); + // 12 opps chunked at 10 → [10, 2]; both ≥ 2 so both survive. + assert_eq!(out.len(), 2); + assert_eq!(out[0].opportunities.len(), SOLIDITY_MAX_BATCH_SIZE); + assert_eq!(out[1].opportunities.len(), 2); + } + + /// Canonical keccak256 pin: external witness of the batchExecute + /// selector. If either the function name or the struct shape drifts + /// from `CharonLiquidator.sol`, this test fails before any tx is + /// ever built. The signature must exactly mirror the Solidity + /// declaration — v0.1 has eight tuple fields and no `swapPoolFee`. #[test] fn encode_calldata_has_batch_execute_selector() { + const CANONICAL_SIG: &str = + "batchExecute((uint8,address,address,address,address,address,uint256,uint256)[])"; + let digest = keccak256(CANONICAL_SIG.as_bytes()); + let expected: [u8; 4] = [digest[0], digest[1], digest[2], digest[3]]; + let batch = LiquidationBatch { chain_id: 56, opportunities: vec![mk_opp(56, 100, 1), mk_opp(56, 200, 2)], @@ -281,11 +422,15 @@ mod tests { let bytes = Batcher::with_default_size() .encode_calldata(&batch, ¶ms) .expect("encode"); + assert_eq!( &bytes[..4], - &ICharonBatch::batchExecuteCall::SELECTOR, - "calldata selector drifted from batchExecute" + &expected, + "calldata selector drifted from canonical batchExecute signature" ); + // Belt-and-braces: confirm alloy's derived selector agrees with + // the hand-computed keccak256, catching macro-side regressions. + assert_eq!(&bytes[..4], &ICharonBatch::batchExecuteCall::SELECTOR); } #[test] @@ -296,10 +441,45 @@ mod tests { total_net_usd_cents: 300, }; let params = vec![mk_params(1)]; // only one - assert!( - Batcher::with_default_size() - .encode_calldata(&batch, ¶ms) - .is_err() - ); + let err = Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .expect_err("mismatched lengths must error"); + match err { + BatcherError::ParamLengthMismatch { params, opps } => { + assert_eq!(params, 1); + assert_eq!(opps, 2); + } + other => panic!("wrong error variant: {other:?}"), + } + } + + /// Direct guard test: a handcrafted oversize batch is rejected by + /// `encode_calldata` before any abi encoding happens. Bypasses + /// `plan`'s own clamping because the Solidity ceiling is the + /// authoritative invariant. + #[test] + fn encode_calldata_rejects_oversize_batch() { + // SOLIDITY_MAX_BATCH_SIZE is 10; the test needs 11 opps with + // distinct borrower bytes. Use u8::try_from to keep the + // workspace `cast_possible_truncation` lint satisfied. + let limit_u8 = u8::try_from(SOLIDITY_MAX_BATCH_SIZE).expect("limit fits in u8"); + let over = limit_u8.checked_add(1).expect("limit + 1 fits in u8"); + let opps: Vec<_> = (1u8..=over).map(|i| mk_opp(56, 100, i)).collect(); + let params: Vec<_> = (1u8..=over).map(mk_params).collect(); + let batch = LiquidationBatch { + chain_id: 56, + total_net_usd_cents: 0, + opportunities: opps, + }; + let err = Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .expect_err("oversize batch must error"); + match err { + BatcherError::BatchTooLarge { len, limit } => { + assert_eq!(len, SOLIDITY_MAX_BATCH_SIZE + 1); + assert_eq!(limit, SOLIDITY_MAX_BATCH_SIZE); + } + other => panic!("wrong error variant: {other:?}"), + } } } diff --git a/crates/charon-executor/src/lib.rs b/crates/charon-executor/src/lib.rs index c4b038b..4ed7642 100644 --- a/crates/charon-executor/src/lib.rs +++ b/crates/charon-executor/src/lib.rs @@ -16,7 +16,9 @@ pub mod nonce; pub mod simulation; pub mod submit; -pub use batcher::{Batcher, LiquidationBatch, MAX_BATCH_SIZE}; +pub use batcher::{ + BSC_CHAIN_ID, Batcher, BatcherError, LiquidationBatch, MAX_BATCH_SIZE, SOLIDITY_MAX_BATCH_SIZE, +}; pub use builder::{ICharonLiquidator, TxBuilder}; pub use gas::{GasOracle, GasParams, gas_cost_usd_cents}; pub use nonce::NonceManager; From 9c15f6935056d98f953c10353b3dbcb75e32dc65 Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 17:00:54 +0530 Subject: [PATCH 5/6] test(contracts): document batch atomicity and cover mid-batch revert - Add NatSpec on batchExecute stating the EVM-atomicity contract explicitly: any item reverting rolls the full batch back and BatchExecuted is emitted only on full-batch success. - Expand BatchExecuted NatSpec so observers know absence of the event equals no partial progress. - Add test_batchExecute_revertsOnSecondItemValidation: 2-item batch where item[0] is valid (flashLoanSimple mocked to no-op) and item[1].borrower == address(0). Asserts revert with "!borrower" and, via vm.recordLogs, that BatchExecuted was never emitted. Closes #212 --- contracts/src/CharonLiquidator.sol | 12 ++++++ contracts/test/CharonLiquidator.t.sol | 57 +++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 53e255c..9ac8896 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -126,6 +126,10 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { event Rescued(address indexed token, address indexed to, uint256 amount); /// @notice Emitted at the end of a successful batchExecute call. + /// @dev Emitted only on full-batch success. If any item in the batch reverts, + /// the entire transaction reverts atomically and this event is NOT emitted. + /// Observers can therefore treat a BatchExecuted emission as proof that all + /// `count` flash loans initiated by this call completed successfully. /// @param count The number of liquidations initiated in the batch. event BatchExecuted(uint256 count); @@ -198,6 +202,14 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// calls _initiateFlashLoan for each. A revert in any iteration reverts the /// entire batch atomically — there is no partial execution. /// + /// **Atomicity contract.** Execution is EVM-atomic. If any item reverts — on + /// input validation inside _initiateFlashLoan, on the Aave flashLoanSimple + /// call, inside executeOperation's Venus / PancakeSwap path, or on the final + /// Aave repayment pull — all prior items in the same batch are also reverted + /// and no state change from this call survives. BatchExecuted is emitted only + /// on full-batch success; observers must NOT treat the absence of a revert + /// event as partial progress. + /// /// The nonReentrant guard is held for the full duration of the loop. Each /// _initiateFlashLoan invocation calls Aave's flashLoanSimple, which re-enters /// executeOperation within the _entered == 2 window; that is the expected and diff --git a/contracts/test/CharonLiquidator.t.sol b/contracts/test/CharonLiquidator.t.sol index bb2df25..00527da 100644 --- a/contracts/test/CharonLiquidator.t.sol +++ b/contracts/test/CharonLiquidator.t.sol @@ -397,4 +397,61 @@ contract CharonLiquidatorTest is Test { vm.expectRevert(bytes("!borrower")); liquidator.batchExecute(items); } + + /// @dev Mid-batch atomicity: item[0] is fully valid (flashLoanSimple stubbed to + /// no-op so the loop can advance), item[1].borrower == address(0). The + /// inner require on item[1] must revert with "!borrower" and, because the + /// revert is atomic, no state from item[0] — including a BatchExecuted + /// emission — must survive. + /// + /// This locks in the NatSpec guarantee that BatchExecuted is emitted only + /// on full-batch success: a 2-item batch that reverts on item[1] must not + /// emit it. `vm.recordLogs` captures every event emitted during the call; + /// after the revert the VM keeps the recorder state, and a scan over the + /// captured topics confirms the BatchExecuted signature never appeared. + function test_batchExecute_revertsOnSecondItemValidation() public { + CharonLiquidator.LiquidationParams[] memory items = + new CharonLiquidator.LiquidationParams[](2); + + // item[0]: fully valid — would reach flashLoanSimple if the loop runs. + items[0] = _validParams(); + + // item[1]: invalid — zero borrower triggers "!borrower" on iteration 1. + items[1] = _validParams(); + items[1].borrower = address(0); + + // Stub STUB_POOL.flashLoanSimple so item[0]'s _initiateFlashLoan succeeds + // silently and the loop actually advances to item[1]. Without this stub + // the pool call would revert on EmptyCode and we could not distinguish + // "loop never advanced" from "validation on item[1] caught it". + bytes memory flashLoanSig = abi.encodeWithSignature( + "flashLoanSimple(address,address,uint256,bytes,uint16)", + address(liquidator), + items[0].debtToken, + items[0].repayAmount, + abi.encode(items[0]), + uint16(0) + ); + vm.mockCall(STUB_POOL, flashLoanSig, abi.encode()); + + // Start event recording before the call. vm.recordLogs captures all logs + // emitted during the tx even if it ultimately reverts; combined with the + // expectRevert this lets us assert both "reverted with the right reason" + // and "no BatchExecuted snuck out before the revert point". + vm.recordLogs(); + + vm.expectRevert(bytes("!borrower")); + liquidator.batchExecute(items); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + bytes32 batchExecutedSig = keccak256("BatchExecuted(uint256)"); + for (uint256 i = 0; i < entries.length; i++) { + if (entries[i].topics.length > 0) { + assertTrue( + entries[i].topics[0] != batchExecutedSig, + "BatchExecuted must NOT be emitted on mid-batch revert" + ); + } + } + } } From 225a68b9ad8168062e216eba8b986e19da7aa27c Mon Sep 17 00:00:00 2001 From: obchain Date: Thu, 23 Apr 2026 22:23:39 +0530 Subject: [PATCH 6/6] feat(executor): type-level simulate gate on batch calldata Wraps Batcher::encode_calldata's output in an opaque UnsimulatedBatchCalldata newtype. The only promotion path is Batcher::simulate, which runs the buffer through Simulator::simulate and returns SimulatedBatchCalldata on success. A broadcaster written against this crate that accepts only SimulatedBatchCalldata cannot be handed raw encoder output by mistake, so the CLAUDE.md invariant "no broadcast without a passing eth_call" becomes a compile-time guarantee for the batch path (mirroring the UnverifiedPreSigned guard on the mempool pre-sign path). BatcherError gains a SimulationFailed variant carrying the node's revert string. Foundry-side batchExecute fork test stays on feat/25 / PR #53. Closes #298 --- crates/charon-executor/src/batcher.rs | 154 +++++++++++++++++++++++--- crates/charon-executor/src/lib.rs | 1 + 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/crates/charon-executor/src/batcher.rs b/crates/charon-executor/src/batcher.rs index 2045dc2..2c3a420 100644 --- a/crates/charon-executor/src/batcher.rs +++ b/crates/charon-executor/src/batcher.rs @@ -27,12 +27,15 @@ //! array length word + loop overhead) use alloy::primitives::Bytes; +use alloy::providers::Provider; use alloy::sol; use alloy::sol_types::SolCall; use charon_core::{LiquidationOpportunity, LiquidationParams}; use thiserror::Error; use tracing::debug; +use crate::simulation::Simulator; + /// Matches `MAX_BATCH_SIZE` in `CharonLiquidator.sol`. The Solidity /// ceiling is 10 ([`SOLIDITY_MAX_BATCH_SIZE`]); the Rust default is /// smaller to keep gas estimates predictable and mirror the PRD's @@ -122,6 +125,91 @@ pub enum BatcherError { /// does not depend on `alloy`'s internal error types. #[error("batcher: ABI encoding failed: {0}")] AbiEncodeError(String), + + /// `eth_call` simulation of the batch reverted. Carries the + /// underlying revert string from the node so the caller can + /// log it and drop the batch. This is the failure path of the + /// type-level simulate gate — see [`UnsimulatedBatchCalldata`] + /// and [`Batcher::simulate`]. + #[error("batcher: batch simulation reverted: {0}")] + SimulationFailed(String), +} + +/// Calldata returned by [`Batcher::encode_calldata`]. +/// +/// Wraps the raw ABI-encoded bytes so they cannot reach a submitter +/// without first being promoted to [`SimulatedBatchCalldata`] via +/// [`Batcher::simulate`]. The wrapper is deliberately opaque: no +/// `Deref`, no `AsRef`, no public `.0`. The only paths into +/// this type are the encoder and its tests; the only path out is the +/// simulate gate. This makes the CLAUDE.md invariant "no broadcast +/// without a passing `eth_call`" a compile-time guarantee for the +/// batch path, mirroring the `UnverifiedPreSigned` guard on the +/// mempool pre-sign path (see `charon-scanner::mempool`). +#[derive(Debug, Clone)] +pub struct UnsimulatedBatchCalldata(Bytes); + +impl UnsimulatedBatchCalldata { + /// Borrow the inner bytes for simulation purposes **only**. The + /// simulate gate inside the batcher is the one caller — external + /// code must go through [`Batcher::simulate`]. + /// + /// This is `pub(crate)` instead of `pub` so a broadcaster written + /// against `charon-executor` cannot reach the raw calldata without + /// passing through `Batcher::simulate` first. `#[cfg(test)]` + /// tests in this module access it through the same accessor. + pub(crate) fn as_bytes(&self) -> &Bytes { + &self.0 + } + + /// Length of the inner calldata in bytes. Useful for telemetry + /// (fee estimation, calldata-budget checks) at sites that do not + /// need to read the bytes themselves. + pub fn len(&self) -> usize { + self.0.len() + } + + /// True if the inner calldata is empty. Paired with + /// [`Self::len`] so the opaque wrapper can still satisfy the + /// standard length/empty contract without exposing the buffer. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// Calldata that has passed the batcher's `eth_call` simulation gate. +/// +/// Produced exclusively by [`Batcher::simulate`]. A downstream +/// submitter that accepts only `SimulatedBatchCalldata` cannot be +/// handed raw encoder output by mistake — the type system refuses. +/// Consumes the inner bytes on request via [`Self::into_bytes`] so +/// the broadcaster gets an owned `Bytes` for the final +/// `eth_sendRawTransaction` without paying a copy. +#[derive(Debug, Clone)] +pub struct SimulatedBatchCalldata(Bytes); + +impl SimulatedBatchCalldata { + /// Consume the wrapper and return the inner calldata. Intended + /// for the broadcaster call site once batch submission is wired + /// into the CLI pipeline. + pub fn into_bytes(self) -> Bytes { + self.0 + } + + /// Borrow the inner bytes without consuming the wrapper. + pub fn as_bytes(&self) -> &Bytes { + &self.0 + } + + /// Length of the inner calldata in bytes. + pub fn len(&self) -> usize { + self.0.len() + } + + /// True if the inner calldata is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } } /// One batch ready for `TxBuilder` to wrap into an EIP-1559 transaction. @@ -210,23 +298,27 @@ impl Batcher { /// /// # Safety /// - /// The returned calldata is **not** self-validating. Callers MUST - /// pass it through - /// [`Simulator::simulate`](crate::Simulator::simulate) before - /// broadcasting, per the CLAUDE.md safety invariant that every - /// liquidation tx passes an `eth_call` gate. The simulator catches - /// protocol-level reverts (insufficient collateral, stale oracle, - /// closed market) that the planner cannot see from off-chain data - /// alone. + /// The returned [`UnsimulatedBatchCalldata`] is a compile-time + /// guard enforcing the CLAUDE.md invariant that every + /// liquidation tx passes an `eth_call` gate before broadcast. + /// The wrapper cannot be unpacked into raw bytes by external + /// code; the only promotion path is [`Batcher::simulate`], which + /// runs the calldata through [`Simulator::simulate`] and returns + /// [`SimulatedBatchCalldata`] on success. A broadcaster written + /// against this crate that accepts only `SimulatedBatchCalldata` + /// therefore cannot be handed raw encoder output by mistake. /// - /// The batch path of the sim gate is tracked in issue #298; - /// skipping simulation is a bypass of the last line of defense and - /// is never acceptable in production code paths. + /// The simulator catches protocol-level reverts (insufficient + /// collateral, stale oracle, closed market) that the planner + /// cannot see from off-chain data alone. Skipping simulation is + /// a bypass of the last line of defense and is never acceptable + /// in production code paths — the type system now makes that + /// bypass a compile error rather than a doc-comment aspiration. pub fn encode_calldata( &self, batch: &LiquidationBatch, params: &[LiquidationParams], - ) -> Result { + ) -> Result { let opps = batch.opportunities.len(); if params.len() != opps { return Err(BatcherError::ParamLengthMismatch { @@ -269,7 +361,40 @@ impl Batcher { chain_id = batch.chain_id, "batch calldata encoded" ); - Ok(bytes) + Ok(UnsimulatedBatchCalldata(bytes)) + } + + /// Run a batch calldata through the `eth_call` simulation gate + /// and promote it to [`SimulatedBatchCalldata`]. + /// + /// Consumes the [`UnsimulatedBatchCalldata`] so the same buffer + /// cannot be simulated twice and reused without going through + /// the gate again (a resubmission of stale calldata after + /// intervening block state change is a silent profit regression + /// the gate would otherwise miss). On simulation failure the + /// revert string is surfaced via [`BatcherError::SimulationFailed`] + /// and the caller drops the batch. + /// + /// The `simulator` argument carries the sender and liquidator + /// addresses; pass a freshly constructed [`Simulator`] or one + /// already built by the submitter wiring. The `provider` is the + /// same alloy `Provider` used by the scanner/executor — no + /// bespoke transport plumbing. + pub async fn simulate( + &self, + provider: &P, + simulator: &Simulator, + calldata: UnsimulatedBatchCalldata, + ) -> Result + where + P: Provider, + T: alloy::transports::Transport + Clone, + { + simulator + .simulate(provider, calldata.as_bytes().clone()) + .await + .map_err(|err| BatcherError::SimulationFailed(format!("{err:#}")))?; + Ok(SimulatedBatchCalldata(calldata.0)) } } @@ -419,9 +544,10 @@ mod tests { total_net_usd_cents: 300, }; let params = vec![mk_params(1), mk_params(2)]; - let bytes = Batcher::with_default_size() + let wrapped = Batcher::with_default_size() .encode_calldata(&batch, ¶ms) .expect("encode"); + let bytes = wrapped.as_bytes(); assert_eq!( &bytes[..4], diff --git a/crates/charon-executor/src/lib.rs b/crates/charon-executor/src/lib.rs index 4ed7642..6ae5ada 100644 --- a/crates/charon-executor/src/lib.rs +++ b/crates/charon-executor/src/lib.rs @@ -18,6 +18,7 @@ pub mod submit; pub use batcher::{ BSC_CHAIN_ID, Batcher, BatcherError, LiquidationBatch, MAX_BATCH_SIZE, SOLIDITY_MAX_BATCH_SIZE, + SimulatedBatchCalldata, UnsimulatedBatchCalldata, }; pub use builder::{ICharonLiquidator, TxBuilder}; pub use gas::{GasOracle, GasParams, gas_cost_usd_cents};