diff --git a/.gitignore b/.gitignore index 56ffaa1..868b5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ contracts/out/ contracts/cache/ contracts/broadcast/ +contracts/foundry.lock out/ cache/ broadcast/ diff --git a/contracts/src/CharonLiquidator.sol b/contracts/src/CharonLiquidator.sol index 4730306..adbd192 100644 --- a/contracts/src/CharonLiquidator.sol +++ b/contracts/src/CharonLiquidator.sol @@ -12,8 +12,10 @@ import { IWETH } from "./interfaces/IWETH.sol"; // CharonLiquidator — multi-chain flash-loan liquidation engine, v0.1 // // Scope (v0.1): Venus Protocol on BNB Chain. -// 1. Bot calls executeLiquidation() with repayment parameters. -// 2. Contract requests a flash loan from Aave V3 (flashLoanSimple). +// 1. Bot calls executeLiquidation() — single item — or batchExecute() — up +// to MAX_BATCH_SIZE items — with repayment parameters. +// 2. Contract requests a flash loan from Aave V3 (flashLoanSimple) for each +// item (the batch path loops over _initiateFlashLoan). // 3. Aave calls back executeOperation(); inside we: // a. Approve Venus vToken to spend the debt asset. // b. Call vToken.liquidateBorrow() — repay debt, seize collateral vTokens. @@ -29,10 +31,18 @@ import { IWETH } from "./interfaces/IWETH.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). +// The guard is held for the full duration of the batch loop; Aave re-enters +// executeOperation within the _entered == 2 window, which is the expected +// path. A malicious pool attempting to re-enter batchExecute or +// executeLiquidation mid-loop hits the guard and reverts. // - executeOperation NOT guarded with nonReentrant: it is called by Aave mid- -// flash-loan, re-entering executeLiquidation's guard frame. The msg.sender +// flash-loan, re-entering the entry-point's guard frame. The msg.sender // == AAVE_POOL gate is the equivalent protection for the callback. +// - batchExecute is EVM-atomic: any revert in any item reverts the whole +// batch. No partial state change survives. BatchExecuted is emitted only +// on full-batch success and observers must NOT treat its absence as +// partial progress. // - Lingering approvals zeroed after each consume point (vToken, SwapRouter). // - Profit is swept to the immutable COLD_WALLET, never to the hot wallet. // This enforces the CLAUDE.md safety invariant: "hot wallet holds gas only". @@ -44,8 +54,9 @@ import { IWETH } from "./interfaces/IWETH.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. -/// All profit is routed to the immutable cold wallet set at construction. +/// The bot (hot wallet = owner) is the sole authorized caller of +/// executeLiquidation and batchExecute. All profit is routed to the +/// immutable cold wallet set at construction. contract CharonLiquidator is IFlashLoanSimpleReceiver { // ───────────────────────────────────────────────────────────────────────── // Protocol ID constants — must mirror the Rust `ProtocolId` enum order. @@ -54,6 +65,12 @@ 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 (`Batcher::MAX_BATCH_SIZE`) defaults to 3; 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; + // ───────────────────────────────────────────────────────────────────────── // BNB Chain canonical addresses — hard-coded for the v0.1 BSC-only scope. // ───────────────────────────────────────────────────────────────────────── @@ -83,8 +100,9 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { // Immutable configuration — set once at construction, never changed. // ───────────────────────────────────────────────────────────────────────── - /// @notice The bot's hot wallet. Only address authorised to call executeLiquidation - /// and rescue. By policy it holds gas only — profit is never routed here. + /// @notice The bot's hot wallet. Only address authorised to call + /// executeLiquidation, batchExecute, and rescue. By policy it holds + /// gas only — profit is never routed here. address public immutable owner; /// @notice Aave V3 Pool proxy on BNB Chain. @@ -110,8 +128,9 @@ contract CharonLiquidator is IFlashLoanSimpleReceiver { /// argument so executeOperation can decode them without extra storage. /// Field layout must remain stable — the Rust side abi-encodes this struct. /// NOTE: the companion Rust `LiquidationParams` builder lives in the - /// charon-executor crate (landing in a later PR). When that crate is added - /// it must mirror this layout exactly, including `swapPoolFee`. + /// charon-executor crate. Its `BatchParams` (for `batchExecute`) and the + /// `CharonLiquidationParams` (for `executeLiquidation`) must mirror this + /// layout exactly, including `swapPoolFee`. struct LiquidationParams { /// @dev Protocol identifier. Must equal PROTOCOL_VENUS (3) for v0.1. uint8 protocolId; @@ -166,6 +185,14 @@ 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. + /// @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); + // ───────────────────────────────────────────────────────────────────────── // Modifiers // ───────────────────────────────────────────────────────────────────────── @@ -176,11 +203,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; @@ -212,16 +241,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(). @@ -231,38 +259,41 @@ 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"); - require(params.swapPoolFee > 0, "!swapPoolFee"); - // On the vBNB path the underlying returned by Venus is native BNB, which - // the contract wraps into WBNB before swapping. Enforce that the caller - // declared WBNB as collateralToken so the swap leg routes through a real - // pool and post-swap balance checks read the correct token. - if (params.collateralVToken == VBNB) { - require(params.collateralToken == WBNB, "vBNB requires WBNB"); - } + _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. + /// + /// **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. Profits already swept to + /// COLD_WALLET by earlier items are rolled back together with the rest of + /// the state on revert. 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 + /// 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); } // ───────────────────────────────────────────────────────────────────────── @@ -292,15 +323,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, @@ -475,19 +506,76 @@ 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 eight require guards here (including the vBNB→WBNB pairing check) + /// are the single canonical validation point for any liquidation initiated + /// by this contract. Keep input validation here; do not duplicate it in + /// executeOperation, where the params arrive through abi.decode(data) and + /// are re-checked only for asset/amount consistency with the flash-loan + /// terms. + /// + /// @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"); + require(p.swapPoolFee > 0, "!swapPoolFee"); + // On the vBNB path the underlying returned by Venus is native BNB, which + // the contract wraps into WBNB before swapping. Enforce that the caller + // declared WBNB as collateralToken so the swap leg routes through a real + // pool and post-swap balance checks read the correct token. + if (p.collateralVToken == VBNB) { + require(p.collateralToken == WBNB, "vBNB requires WBNB"); + } + + // ── 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 — native BNB is intentionally rejected // ───────────────────────────────────────────────────────────────────────── // // No `receive()` or `fallback()` is defined. Plain BNB transfers to this // contract revert. Rationale: - // - v0.1 does not liquidate the vBNB native-BNB market; all supported - // Venus markets settle collateral in ERC-20 (WBNB, BUSD, USDT, ...). + // - v0.1 does not liquidate the vBNB native-BNB market at the protocol + // level: the vBNB branch above would only fire if Venus `redeem()` on + // vBNB could credit this contract with native BNB. Venus's current + // vBNB implementation forwards native BNB via `.call{value:...}("")`, + // which requires a `receive()` on the recipient; absent one, the + // redeem reverts and the vBNB branch is unreachable end-to-end. // - An open `receive()` would silently accumulate BNB from any sender, // making misrouted funds hard to notice and providing free storage // for griefers / mixers. - // - If the vBNB market is added later, reintroduce a gated `receive()` - // that requires `msg.sender == vBNB_MARKET` so only the Venus + // - When the vBNB market is activated operationally, reintroduce a gated + // `receive()` that requires `msg.sender == VBNB` so only the Venus // contract can push native BNB into this contract during redeem. // // If BNB is ever trapped here (e.g. as a SELFDESTRUCT beneficiary), the diff --git a/contracts/test/CharonLiquidator.t.sol b/contracts/test/CharonLiquidator.t.sol index 6e5f8e4..1a63e36 100644 --- a/contracts/test/CharonLiquidator.t.sol +++ b/contracts/test/CharonLiquidator.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT pragma solidity 0.8.24; -import { Test } from "forge-std/Test.sol"; +import { Test, Vm } from "forge-std/Test.sol"; import { CharonLiquidator } from "../src/CharonLiquidator.sol"; import { IVToken } from "../src/interfaces/IVToken.sol"; import { IWETH } from "../src/interfaces/IWETH.sol"; @@ -438,4 +438,175 @@ contract CharonLiquidatorForkTest is Test { vm.expectRevert(bytes("!swapPoolFee")); liquidator.executeLiquidation(p); } + + // ───────────────────────────────────────────────────────────────────────── + // F. batchExecute — access control, bounds, and atomicity + // ───────────────────────────────────────────────────────────────────────── + // + // These tests do not require live fork state (onlyOwner / empty-array / + // ceiling / validation all revert before any external call), but the + // `liquidator` instance is only deployed inside setUp when a BSC RPC URL + // is provided. Each test therefore calls `_skipIfNoRpc()` so CI without + // `BNB_RPC_URL` skips cleanly rather than dereferencing the zero address. + + /// @dev Builds a fully-valid `LiquidationParams` tuple used across the + /// batchExecute tests below. All addresses point at the mock + /// sentinels from the top of the file so the struct passes + /// `_initiateFlashLoan`'s eight require guards; individual tests + /// mutate a single field to trigger the specific revert path. + function _validParams() internal pure returns (CharonLiquidator.LiquidationParams memory) { + return CharonLiquidator.LiquidationParams({ + protocolId: 3, + borrower: MOCK_BORROWER, + debtToken: MOCK_DEBT_TOKEN, + collateralToken: MOCK_COLL_TOKEN, + debtVToken: MOCK_DEBT_VTOKEN, + collateralVToken: MOCK_COLL_VTOKEN, + repayAmount: 1 ether, + minSwapOut: 1 ether, + swapPoolFee: 3000 + }); + } + + /// @dev Non-owner calling batchExecute must revert with "!owner". + /// No pool mock needed — onlyOwner fires before any other logic. + function test_batchExecute_revertsWhenNotOwner() public { + _skipIfNoRpc(); + + CharonLiquidator.LiquidationParams[] memory items = + new CharonLiquidator.LiquidationParams[](1); + items[0] = _validParams(); + + vm.prank(address(0xA11CE)); + 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 { + _skipIfNoRpc(); + + 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 { + _skipIfNoRpc(); + + // 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 AAVE_V3_POOL_BSC.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 { + _skipIfNoRpc(); + + 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 the real Aave V3 Pool address (the constructor-bound AAVE_POOL) + // so item[1]'s flashLoanSimple would succeed silently 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(AAVE_V3_POOL_BSC, 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); + } + + /// @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 { + _skipIfNoRpc(); + + 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 AAVE_V3_POOL_BSC.flashLoanSimple so item[0]'s _initiateFlashLoan + // succeeds silently and the loop actually advances to item[1]. Without + // this stub the pool call could revert for an unrelated reason 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(AAVE_V3_POOL_BSC, 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" + ); + } + } + } } diff --git a/crates/charon-executor/src/batcher.rs b/crates/charon-executor/src/batcher.rs new file mode 100644 index 0000000..9eeccc4 --- /dev/null +++ b/crates/charon-executor/src/batcher.rs @@ -0,0 +1,769 @@ +//! Multi-liquidation batcher. +//! +//! 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. +//! +//! 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 and broadcasts it through +//! [`Submitter`](crate::Submitter). +//! +//! 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: +//! - 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 alloy::primitives::{Bytes, U256}; +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 +/// 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 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. +// +// Shape mirrors `CharonLiquidator.sol :: LiquidationParams` including the +// trailing `uint24 swapPoolFee` field added in the cold-wallet / vBNB +// port. The selector test below pins the canonical keccak256 so any +// further drift in field order or count reliably breaks CI before it +// reaches mainnet. +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; + uint24 swapPoolFee; + } + + /// `batchExecute(LiquidationParams[])` entry on `CharonLiquidator`. + interface ICharonBatch { + function batchExecute(BatchParams[] calldata items) external; + } +} + +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)] +#[non_exhaustive] +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, + }, + + /// The corresponding [`LiquidationParams`] variant is not handled + /// by this batcher. Mirrors + /// [`BuilderError::UnsupportedProtocol`](crate::BuilderError::UnsupportedProtocol) + /// — `LiquidationParams` is `#[non_exhaustive]`, so a wildcard arm + /// is required even though v0.1 only surfaces `Venus`. Payload is + /// the `Debug` rendering so logs can identify which protocol + /// adapter is still pending batcher support. + #[error("batcher: unsupported liquidation protocol: {0}")] + UnsupportedProtocol(String), + + /// The swap route attached to an opportunity lacks a `pool_fee`. + /// The on-chain `CharonLiquidator.executeOperation` routes the + /// swap through PancakeSwap V3 at a caller-supplied fee tier and + /// reverts with `"!swapPoolFee"` if the tier is zero or missing; + /// the encoder rejects the calldata earlier rather than burn gas + /// for a guaranteed revert. + #[error( + "batcher: missing pool_fee on swap route for borrower {borrower:#x} \ + (fee-less routes are not supported by CharonLiquidator)" + )] + MissingPoolFee { + /// Borrower address on the opportunity that lacked a pool fee. + borrower: alloy::primitives::Address, + }, + + /// The supplied `pool_fee` does not fit in the on-chain `uint24` + /// slot. The Solidity struct declares `swapPoolFee` as `uint24` + /// (PancakeSwap V3's fee-tier domain maxes at 10_000), so any + /// value greater than 2^24 - 1 is either a programming error in + /// the router or a sign the off-chain type should be tightened. + #[error( + "batcher: pool_fee {got} does not fit in uint24 (on-chain limit {limit}) \ + for borrower {borrower:#x}" + )] + PoolFeeOutOfRange { + /// Borrower address on the offending opportunity. + borrower: alloy::primitives::Address, + /// Fee that overflowed the `uint24` slot. + got: u32, + /// Largest value representable as `uint24` (2^24 - 1). + limit: u32, + }, + + /// `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), + + /// `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. +#[derive(Debug, Clone)] +pub struct LiquidationBatch { + /// 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, + /// Sum of `net_profit_wei` across the batch — used by the caller + /// to rank batches against single-opportunity txs. Kept in wei + /// (the same domain as [`LiquidationOpportunity::net_profit_wei`]) + /// so ranking never crosses a USD-cent boundary where rounding + /// could flip the comparison. + pub total_net_profit_wei: U256, +} + +/// 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 { + // 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: clamped, + } + } + + pub fn with_default_size() -> Self { + Self::new(MAX_BATCH_SIZE) + } + + /// Chunk `opportunities` into BSC-only batches. + /// + /// 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, + ) -> 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 chunk in opportunities.chunks(self.max_batch_size) { + if chunk.len() < 2 { + continue; + } + let total_net_profit_wei = chunk + .iter() + .map(|o| o.net_profit_wei) + .fold(U256::ZERO, U256::saturating_add); + out.push(LiquidationBatch { + chain_id: BSC_CHAIN_ID, + opportunities: chunk.to_vec(), + total_net_profit_wei, + }); + } + debug!(batch_count = out.len(), "batcher planned"); + Ok(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. + /// + /// # Safety + /// + /// 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 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 { + 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, + }); + } + + // Largest value representable as `uint24`: 2^24 - 1. Any + // larger fee lands outside the on-chain slot and would either + // truncate silently (our Rust domain is `u32`) or revert on + // ABI-encode. Keep the constant local so the error message + // and the guard never drift. + const UINT24_MAX: u32 = (1u32 << 24) - 1; + + let mut items = Vec::with_capacity(opps); + for (opp, params) in batch.opportunities.iter().zip(params.iter()) { + // Exhaustive match with a wildcard arm. `LiquidationParams` + // is `#[non_exhaustive]` at the enum level, so a refutable + // `let LiquidationParams::Venus { .. } = params;` outside + // the defining crate would fail to compile. Mirrors the + // same discipline as `TxBuilder::encode_calldata` so the + // two encoders behave identically when a new variant + // (AaveV3, Compound, Morpho…) lands in `charon-core` and + // reaches the batcher before batch support has been + // taught to emit its calldata. + let (borrower, collateral_vtoken, debt_vtoken, repay_amount) = match params { + LiquidationParams::Venus { + borrower, + collateral_vtoken, + debt_vtoken, + repay_amount, + } => (borrower, collateral_vtoken, debt_vtoken, repay_amount), + other => { + return Err(BatcherError::UnsupportedProtocol(format!("{other:?}"))); + } + }; + + // PancakeSwap V3 fee tier. `None` means a fee-less route + // (Curve stable pool, Balancer V2, …) which the on-chain + // `CharonLiquidator` does not support: it calls + // `ISwapRouter.exactInputSingle` unconditionally and + // requires `swapPoolFee > 0` inside `_initiateFlashLoan`. + // Refuse the calldata here rather than emit a tx that + // would revert with `"!swapPoolFee"` on-chain. + let fee_u32 = opp.swap_route.pool_fee.ok_or(BatcherError::MissingPoolFee { + borrower: *borrower, + })?; + if fee_u32 > UINT24_MAX { + return Err(BatcherError::PoolFeeOutOfRange { + borrower: *borrower, + got: fee_u32, + limit: UINT24_MAX, + }); + } + // `alloy::primitives::aliases::U24` is the sol! target + // type; it accepts `u32` via `from` on values that fit. + let swap_pool_fee = alloy::primitives::aliases::U24::from(fee_u32); + + 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, + swapPoolFee: swap_pool_fee, + }); + } + + let call = ICharonBatch::batchExecuteCall { items }; + let bytes: Bytes = call.abi_encode().into(); + debug!( + items = opps, + calldata_len = bytes.len(), + chain_id = batch.chain_id, + "batch calldata encoded" + ); + 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. + /// + /// `gas_limit` must match (or exceed) what the real broadcast + /// will use. Main's [`Simulator::simulate`] takes this explicitly + /// so the simulation cannot under-provision gas relative to the + /// broadcast and pass here only to revert on-chain as + /// out-of-gas. A batch call uses roughly + /// `single_liq_gas * n + calldata_overhead`; the caller is + /// expected to size it using [`GasOracle::estimate_gas_units`] + /// on the same calldata. + pub async fn simulate( + &self, + provider: &P, + simulator: &Simulator, + calldata: UnsimulatedBatchCalldata, + gas_limit: u64, + ) -> Result + where + P: Provider, + T: alloy::transports::Transport + Clone, + { + simulator + .simulate(provider, calldata.as_bytes().clone(), gas_limit) + .await + .map_err(|err| BatcherError::SimulationFailed(format!("{err:#}")))?; + Ok(SimulatedBatchCalldata(calldata.0)) + } +} + +impl Default for Batcher { + fn default() -> Self { + Self::with_default_size() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{Address, U256, address, keccak256}; + use charon_core::{FlashLoanSource, Position, ProtocolId, SwapRoute}; + + fn mk_opp(chain_id: u64, net_wei: 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: Some(3_000), + }, + net_profit_wei: U256::from(net_wei), + } + } + + 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)]) + .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), + ]) + .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_profit_wei, U256::from(600u64)); + } + + /// 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 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] + 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()) + .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 — nine tuple fields including the trailing + /// `uint24 swapPoolFee` that backs the per-opportunity fee-tier + /// routing in `executeOperation`. + #[test] + fn encode_calldata_has_batch_execute_selector() { + const CANONICAL_SIG: &str = "batchExecute((uint8,address,address,address,address,address,uint256,uint256,uint24)[])"; + 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)], + total_net_profit_wei: U256::from(300u64), + }; + let params = vec![mk_params(1), mk_params(2)]; + let wrapped = Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .expect("encode"); + let bytes = wrapped.as_bytes(); + + assert_eq!( + &bytes[..4], + &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] + 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_profit_wei: U256::from(300u64), + }; + let params = vec![mk_params(1)]; // only one + 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_profit_wei: U256::ZERO, + 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:?}"), + } + } + + /// A fee-less swap route is a programming error for the Venus / + /// PancakeSwap V3 pipeline: the on-chain executor requires a + /// non-zero `swapPoolFee`. Reject at encode time. + #[test] + fn encode_calldata_rejects_missing_pool_fee() { + let mut opp1 = mk_opp(56, 100, 1); + let opp2 = mk_opp(56, 200, 2); + opp1.swap_route.pool_fee = None; + let batch = LiquidationBatch { + chain_id: 56, + total_net_profit_wei: U256::from(300u64), + opportunities: vec![opp1, opp2], + }; + let params = vec![mk_params(1), mk_params(2)]; + let err = Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .expect_err("None pool_fee must error"); + match err { + BatcherError::MissingPoolFee { borrower: _ } => {} + other => panic!("wrong error variant: {other:?}"), + } + } + + /// `swapPoolFee` is `uint24` on-chain; a `u32` that overflows + /// that slot must be caught here and not silently truncate. + #[test] + fn encode_calldata_rejects_pool_fee_out_of_range() { + let mut opp1 = mk_opp(56, 100, 1); + let opp2 = mk_opp(56, 200, 2); + opp1.swap_route.pool_fee = Some(1u32 << 24); // 2^24, one past uint24 max + let batch = LiquidationBatch { + chain_id: 56, + total_net_profit_wei: U256::from(300u64), + opportunities: vec![opp1, opp2], + }; + let params = vec![mk_params(1), mk_params(2)]; + let err = Batcher::with_default_size() + .encode_calldata(&batch, ¶ms) + .expect_err("overflow pool_fee must error"); + match err { + BatcherError::PoolFeeOutOfRange { got, limit, .. } => { + assert_eq!(got, 1u32 << 24); + assert_eq!(limit, (1u32 << 24) - 1); + } + other => panic!("wrong error variant: {other:?}"), + } + } +} diff --git a/crates/charon-executor/src/lib.rs b/crates/charon-executor/src/lib.rs index 628d0f0..bbf5c8b 100644 --- a/crates/charon-executor/src/lib.rs +++ b/crates/charon-executor/src/lib.rs @@ -8,13 +8,22 @@ //! `executeLiquidation(...)` call, builds an EIP-1559 transaction, //! signs it with the bot's hot wallet, and runs an `eth_call` //! simulation gate before any broadcast can happen. +//! +//! For multi-opportunity batching, see [`batcher`] — it plans and +//! encodes `batchExecute(LiquidationParams[])` calldata for the +//! on-chain `CharonLiquidator.batchExecute` entrypoint. +pub mod batcher; pub mod builder; pub mod gas; pub mod nonce; pub mod simulation; pub mod submit; +pub use batcher::{ + BSC_CHAIN_ID, Batcher, BatcherError, LiquidationBatch, MAX_BATCH_SIZE, SOLIDITY_MAX_BATCH_SIZE, + SimulatedBatchCalldata, UnsimulatedBatchCalldata, +}; pub use builder::{BuilderError, ICharonLiquidator, TxBuilder}; pub use gas::{ CHAINLINK_DECIMALS, GasDecision, GasError, GasOracle, GasParams, gas_cost_usd_cents, diff --git a/crates/charon-executor/src/nonce.rs b/crates/charon-executor/src/nonce.rs index e2e2998..56ff409 100644 --- a/crates/charon-executor/src/nonce.rs +++ b/crates/charon-executor/src/nonce.rs @@ -212,7 +212,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();