Skip to content

feat: multi-liquidation batcher (contracts + executor)#45

Merged
obchain merged 7 commits intomainfrom
feat/20-multi-liq-batcher
Apr 24, 2026
Merged

feat: multi-liquidation batcher (contracts + executor)#45
obchain merged 7 commits intomainfrom
feat/20-multi-liq-batcher

Conversation

@obchain
Copy link
Copy Markdown
Owner

@obchain obchain commented Apr 21, 2026

Closes #18

Adds per-chain batching so multiple liquidatable positions land in one transaction instead of N. Base tx cost (21 000 gas + signature + calldata header) amortises across the whole batch; each flash-loan still pays its own 0.05% premium individually.

On-chain — CharonLiquidator.sol:

  • New batchExecute(LiquidationParams[] calldata items) entrypoint — onlyOwner + nonReentrant, capped at MAX_BATCH_SIZE = 10
  • Refactor: seven input-validation guards + flashLoanSimple kickoff moved from executeLiquidation into internal _initiateFlashLoan. Both entrypoints now share one validated flow
  • Emits BatchExecuted(uint256 count) after the loop
  • 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. Suite: 20/0/1 (skipped = existing fork scaffold)

Rust — charon-executor/batcher.rs:

  • Batcher stateless planner. plan(opps) partitions by chain_id, chunks into groups of ≤ max_batch_size (default 3), omits single-item groups
  • LiquidationBatch — chain + ordered opps + summed net profit
  • encode_calldata(batch, params) ABI-encodes batchExecute(LiquidationParams[]) via alloy::sol!; selector test asserts lockstep with Solidity struct
  • Six unit tests: single → no batch, same-chain grouping, cross-chain split, size-limit chunking, selector pin, param-length mismatch rejection

Depends on feat/19-private-rpc-submit.

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
obchain added 5 commits April 23, 2026 16:35
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
- 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
- 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
- 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
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
@obchain obchain changed the base branch from feat/19-private-rpc-submit to main April 24, 2026 12:22
…cher

# Conflicts:
#	Cargo.lock
#	Cargo.toml
#	contracts/foundry.toml
#	contracts/src/CharonLiquidator.sol
#	crates/charon-executor/Cargo.toml
#	crates/charon-executor/src/gas.rs
#	crates/charon-executor/src/lib.rs
@obchain obchain merged commit f5c010c into main Apr 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[executor] Multi-liquidation batcher: group same-chain opportunities into a single tx

1 participant