diff --git a/Cargo.lock b/Cargo.lock index ebe209f..1514b75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1151,6 +1151,19 @@ dependencies = [ "toml", ] +[[package]] +name = "charon-executor" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "charon-core", + "dotenvy", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "charon-flashloan" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 974cf66..dc77ae4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,21 +2,13 @@ resolver = "3" members = [ "crates/charon-core", + "crates/charon-executor", "crates/charon-flashloan", "crates/charon-protocols", "crates/charon-scanner", "crates/charon-cli", ] -[workspace.lints.rust] -unsafe_code = "forbid" - -[workspace.lints.clippy] -all = "warn" -arithmetic_side_effects = "deny" -cast_possible_truncation = "deny" -unwrap_used = "deny" - [workspace.package] version = "0.1.0" edition = "2024" @@ -66,6 +58,25 @@ dotenvy = "0.15" # Internal crates charon-core = { path = "crates/charon-core" } +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] +all = "warn" +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 new file mode 100644 index 0000000..d667c2e --- /dev/null +++ b/crates/charon-executor/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "charon-executor" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Transaction builder, simulator, and broadcaster for Charon" + +[dependencies] +charon-core = { workspace = true } +alloy = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +dotenvy = { workspace = true } + +[lints] +workspace = true diff --git a/crates/charon-executor/src/builder.rs b/crates/charon-executor/src/builder.rs new file mode 100644 index 0000000..59f012c --- /dev/null +++ b/crates/charon-executor/src/builder.rs @@ -0,0 +1,437 @@ +//! EIP-1559 transaction builder for `CharonLiquidator.executeLiquidation`. +//! +//! Three steps, deliberately separate so callers can simulate before +//! signing and broadcast separately from signing: +//! +//! 1. [`TxBuilder::encode_calldata`] — pack the protocol-specific +//! [`LiquidationParams`] + outer-pipeline context into the +//! Solidity-side `LiquidationParams` struct and ABI-encode the +//! `executeLiquidation(...)` call. +//! 2. [`TxBuilder::build_tx`] — wrap the calldata in an unsigned +//! [`TransactionRequest`] with EIP-1559 fee fields and the +//! **pending** nonce for the bot's hot wallet. +//! 3. [`TxBuilder::sign`] — sign the request, returning the raw bytes +//! that go into `eth_sendRawTransaction` (or a Flashbots bundle). + +use std::fmt; + +use alloy::eips::{BlockId, BlockNumberOrTag, eip2718::Encodable2718}; +use alloy::network::{EthereumWallet, TransactionBuilder}; +use alloy::primitives::{Address, Bytes}; +use alloy::providers::Provider; +use alloy::rpc::types::TransactionRequest; +use alloy::signers::local::PrivateKeySigner; +use alloy::sol; +use alloy::sol_types::SolCall; +use alloy::transports::TransportError; +use charon_core::{LiquidationOpportunity, LiquidationParams}; +use tracing::debug; + +sol! { + /// Solidity-side `LiquidationParams` — must match + /// `contracts/src/CharonLiquidator.sol` exactly. If a field is + /// added or reordered there, this struct must match in lockstep. + #[derive(Debug)] + struct CharonLiquidationParams { + uint8 protocolId; + address borrower; + address debtToken; + address collateralToken; + address debtVToken; + address collateralVToken; + uint256 repayAmount; + uint256 minSwapOut; + } + + /// Surface of `CharonLiquidator.sol` consumed by the builder. + /// `#[sol(rpc)]` would also generate provider-bound bindings, but + /// we only need the call-encoder here, so the bare interface is + /// enough. + interface ICharonLiquidator { + function executeLiquidation(CharonLiquidationParams calldata params) external; + } +} + +/// Numeric protocol id matching `PROTOCOL_VENUS` in the Solidity +/// source. See `contracts/src/CharonLiquidator.sol:49` — any change +/// there must be mirrored here, and the +/// [`tests::encode_calldata_protocol_id_equals_venus`] unit test +/// pins this end-to-end through the ABI. +const PROTOCOL_VENUS: u8 = 3; + +/// Errors surfaced by [`TxBuilder`] when constructing or signing a +/// transaction. +/// +/// Marked `#[non_exhaustive]` so new variants (e.g. a dedicated +/// nonce-manager error once PR #43 lands) can be added without +/// breaking downstream match arms. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum BuilderError { + /// Failed to read the pending nonce from the RPC endpoint. + #[error("nonce fetch failed: {0}")] + NonceFetch(#[source] TransportError), + + /// `alloy` could not build / sign the EIP-1559 envelope. The + /// underlying error is carried as a string because + /// `TransactionBuilderError` is network-parameterised and does + /// not flow through a plain `Box` here without noise. + #[error("signing failed: {0}")] + Signing(String), + + /// Fee invariant violated: the requested priority tip exceeds + /// the max fee per gas, which an EIP-1559 node will reject + /// before the transaction ever touches the mempool. + #[error("fee invariant violated: priority {0} > max {1}")] + InvalidFees(u128, u128), + + /// Catch-all for any other transport / RPC failure surfaced by + /// the provider during tx construction. + #[error("rpc error: {0}")] + Rpc(#[from] TransportError), + + /// The opportunity's [`LiquidationParams`] variant is not handled + /// by this builder. Payload is the `Debug` rendering of the + /// variant so logs can identify which protocol adapter is still + /// pending executor support. Surfaced when a future non-`Venus` + /// variant lands in `charon-core` and reaches the encoder before + /// the executor has been taught to emit its calldata. + #[error("unsupported liquidation protocol: {0}")] + UnsupportedProtocol(String), +} + +/// Builder bound to one bot signer + one liquidator deployment. +/// +/// Cheap to clone — holds an `Arc`-friendly signer and three small +/// fields. Construct one per `(chain_id, liquidator_address)` pair +/// the bot operates on. +/// +/// `Debug` is implemented manually: the embedded +/// [`PrivateKeySigner`] wraps a `k256` scalar whose derived `Debug` +/// would leak the signing key in logs. The custom impl redacts the +/// signer field and exposes only its derived address. +#[derive(Clone)] +pub struct TxBuilder { + signer: PrivateKeySigner, + chain_id: u64, + liquidator: Address, +} + +impl fmt::Debug for TxBuilder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TxBuilder") + .field("signer", &"[redacted]") + .field("signer_address", &self.signer.address()) + .field("chain_id", &self.chain_id) + .field("liquidator", &self.liquidator) + .finish() + } +} + +impl TxBuilder { + pub fn new(signer: PrivateKeySigner, chain_id: u64, liquidator: Address) -> Self { + Self { + signer, + chain_id, + liquidator, + } + } + + /// Address that will sign + pay gas for built transactions. + pub fn signer_address(&self) -> Address { + self.signer.address() + } + + /// Chain id the builder targets. + pub fn chain_id(&self) -> u64 { + self.chain_id + } + + /// Address of the deployed `CharonLiquidator` contract. + pub fn liquidator(&self) -> Address { + self.liquidator + } + + /// ABI-encode the outer `executeLiquidation(...)` call. + /// + /// Pulls the underlying-token addresses from the `Position` on + /// the opportunity and the vToken addresses from the + /// protocol-specific [`LiquidationParams`]. The Solidity struct + /// field set is a superset of the Rust one — those extra fields + /// exist on the `LiquidationOpportunity`, not on + /// `LiquidationParams::Venus`. + pub fn encode_calldata( + &self, + opp: &LiquidationOpportunity, + params: &LiquidationParams, + ) -> Result { + // Exhaustive match (rather than a refutable `let` on the only + // present variant) so that when a new `LiquidationParams` + // variant lands in `charon-core` the compiler forces this + // builder to be audited before it silently accepts the new + // protocol. `LiquidationParams` is `#[non_exhaustive]`, hence + // the trailing wildcard arm that surfaces the miss as an + // explicit `Unsupported` error rather than a panic. + 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(BuilderError::UnsupportedProtocol(format!("{other:?}"))); + } + }; + + let sol_params = CharonLiquidationParams { + 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 = ICharonLiquidator::executeLiquidationCall { params: sol_params }; + let bytes: Bytes = call.abi_encode().into(); + + debug!( + len = bytes.len(), + borrower = %borrower, + "executeLiquidation calldata encoded" + ); + Ok(bytes) + } + + /// Build an unsigned EIP-1559 [`TransactionRequest`] pointing at + /// the configured liquidator. + /// + /// Pulls the **pending** nonce from `provider` so a broadcast + /// that is still in the mempool does not collide with the newly + /// built transaction. `gas_limit` is supplied by the caller + /// (typically a multiple of `eth_estimateGas` plus a safety + /// buffer). Fee fields are passed through; producing them is + /// the gas oracle's job, not the builder's. + /// + /// Fee invariant: `max_priority_fee_per_gas <= max_fee_per_gas`. + /// Violating it is rejected here rather than letting the node + /// reject it after a network round-trip. + // TODO(#43): replace the direct `eth_getTransactionCount` read + // with the upcoming `NonceManager` once PR #43 lands, so bursty + // submission windows don't have to re-RPC for every tx. + pub async fn build_tx

( + &self, + provider: &P, + calldata: Bytes, + max_fee_per_gas: u128, + max_priority_fee_per_gas: u128, + gas_limit: u64, + ) -> Result + where + P: Provider, + { + if max_priority_fee_per_gas > max_fee_per_gas { + return Err(BuilderError::InvalidFees( + max_priority_fee_per_gas, + max_fee_per_gas, + )); + } + + let from = self.signer.address(); + let nonce = provider + .get_transaction_count(from) + .block_id(BlockId::Number(BlockNumberOrTag::Pending)) + .await + .map_err(BuilderError::NonceFetch)?; + + let tx = TransactionRequest::default() + .with_from(from) + .with_to(self.liquidator) + .with_input(calldata) + .with_chain_id(self.chain_id) + .with_nonce(nonce) + .with_max_fee_per_gas(max_fee_per_gas) + .with_max_priority_fee_per_gas(max_priority_fee_per_gas) + .with_gas_limit(gas_limit); + + debug!( + from = %from, + to = %self.liquidator, + chain_id = self.chain_id, + nonce, + max_fee_per_gas, + max_priority_fee_per_gas, + gas_limit, + "EIP-1559 tx built" + ); + Ok(tx) + } + + /// Sign the request with the bot signer and return raw EIP-2718 + /// envelope bytes ready for `eth_sendRawTransaction` or a + /// Flashbots bundle. Does **not** broadcast. + pub async fn sign(&self, tx: TransactionRequest) -> Result { + let wallet = EthereumWallet::new(self.signer.clone()); + let envelope = tx + .build(&wallet) + .await + .map_err(|e| BuilderError::Signing(format!("{e:#}")))?; + let mut buf = Vec::with_capacity(256); + envelope.encode_2718(&mut buf); + debug!(raw_len = buf.len(), "tx signed"); + Ok(buf.into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::{U256, address}; + use charon_core::{ + FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, + }; + + fn mk_signer() -> PrivateKeySigner { + // Deterministic dev key — never used against mainnet. + // First Anvil/Hardhat default key. + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .expect("dev key parse") + } + + fn mk_opportunity() -> LiquidationOpportunity { + LiquidationOpportunity { + position: Position { + protocol: ProtocolId::Venus, + chain_id: 56, + borrower: address!("1111111111111111111111111111111111111111"), + 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(5_000u64), + } + } + + fn mk_params() -> LiquidationParams { + LiquidationParams::Venus { + borrower: address!("1111111111111111111111111111111111111111"), + collateral_vtoken: address!("cccccccccccccccccccccccccccccccccccccccc"), + debt_vtoken: address!("dddddddddddddddddddddddddddddddddddddddd"), + repay_amount: U256::from(250u64), + } + } + + #[test] + fn encode_calldata_pins_execute_liquidation_selector() { + let builder = TxBuilder::new( + mk_signer(), + 56, + address!("ffffffffffffffffffffffffffffffffffffffff"), + ); + let bytes = builder + .encode_calldata(&mk_opportunity(), &mk_params()) + .expect("encode"); + + // Selector pinned against alloy's generated SELECTOR constant + // — catches accidental changes to argument order or + // CharonLiquidationParams shape (which would break lockstep + // with the Solidity struct). + assert_eq!( + &bytes[..4], + &ICharonLiquidator::executeLiquidationCall::SELECTOR, + "calldata selector drifted from executeLiquidation" + ); + // selector + at least the eight struct field slots; alloy may + // also emit a leading offset word, so use `>=` not `>`. + assert!(bytes.len() >= 4 + 32 * 8); + } + + #[test] + fn signer_address_is_deterministic() { + let b = TxBuilder::new(mk_signer(), 56, Address::ZERO); + assert_eq!( + b.signer_address(), + address!("f39Fd6e51aad88F6F4ce6aB8827279cffFb92266") + ); + } + + /// Pins the ABI-level protocolId byte to the `PROTOCOL_VENUS` + /// constant in `contracts/src/CharonLiquidator.sol`. If the + /// Solidity side renumbers the protocol enum, this test flips + /// red and forces the constant here to move with it — preventing + /// a silent mismatch that would route every built tx to the + /// wrong adapter in the on-chain dispatcher. + #[test] + fn encode_calldata_protocol_id_equals_venus() { + let builder = TxBuilder::new( + mk_signer(), + 56, + address!("ffffffffffffffffffffffffffffffffffffffff"), + ); + let bytes = builder + .encode_calldata(&mk_opportunity(), &mk_params()) + .expect("encode"); + + // ABI layout: `CharonLiquidationParams` is a static + // struct (no dynamic-length fields), so Solidity inlines it + // straight after the 4-byte selector with no offset head. + // The first field `uint8 protocolId` sits in its own 32-byte + // slot, left-padded so the value is in the last byte. + let protocol_id = bytes[4 + 31]; + assert_eq!( + protocol_id, 3u8, + "protocolId must match PROTOCOL_VENUS in contracts/src/CharonLiquidator.sol:49" + ); + } + + /// EIP-1559 fee invariant guard: building a tx with a priority + /// fee higher than the max fee returns `InvalidFees` rather than + /// round-tripping to the node and getting a pool-level rejection. + #[test] + fn build_tx_rejects_priority_exceeding_max_fee() { + let err = BuilderError::InvalidFees(10, 5); + match err { + BuilderError::InvalidFees(p, m) => { + assert_eq!(p, 10); + assert_eq!(m, 5); + } + _ => panic!("wrong variant"), + } + } + + /// Debug output must not leak the signing key. Assert both the + /// redaction sentinel is present and no obvious hex-looking + /// signing-key scalar appears. + #[test] + fn debug_redacts_signer() { + let b = TxBuilder::new( + mk_signer(), + 56, + address!("ffffffffffffffffffffffffffffffffffffffff"), + ); + let dbg = format!("{b:?}"); + assert!(dbg.contains("[redacted]"), "{dbg}"); + // The Anvil default key; must never appear in Debug output. + assert!( + !dbg.contains("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"), + "signing key leaked in Debug: {dbg}" + ); + } +} diff --git a/crates/charon-executor/src/lib.rs b/crates/charon-executor/src/lib.rs new file mode 100644 index 0000000..45d3b68 --- /dev/null +++ b/crates/charon-executor/src/lib.rs @@ -0,0 +1,16 @@ +//! Transaction construction, simulation, and (later) broadcast for Charon. +//! +//! Sits between the scanner / profit-calc / router pipeline and the +//! on-chain `CharonLiquidator.sol`. Callers hand in a +//! [`LiquidationOpportunity`](charon_core::LiquidationOpportunity) plus +//! the protocol-specific [`LiquidationParams`](charon_core::LiquidationParams) +//! the adapter produced; this crate encodes the outer +//! `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. + +pub mod builder; +pub mod simulation; + +pub use builder::{BuilderError, ICharonLiquidator, TxBuilder}; +pub use simulation::{SimulationError, Simulator}; diff --git a/crates/charon-executor/src/simulation.rs b/crates/charon-executor/src/simulation.rs new file mode 100644 index 0000000..e99530a --- /dev/null +++ b/crates/charon-executor/src/simulation.rs @@ -0,0 +1,251 @@ +//! `eth_call` simulation gate. +//! +//! Run a candidate liquidation against the latest block state without +//! sending a transaction. If the call would revert on-chain, the +//! simulation surfaces it as [`SimulationError::Reverted`] carrying +//! the 4-byte selector + full revert payload, and the caller drops +//! the opportunity. If the call succeeds, the gate is open — the +//! caller is expected to broadcast next. +//! +//! Zero gas spent on simulation. The hard rule, enforced by the +//! pipeline (not by this module), is **no broadcast without a +//! passing `simulate()`**. + +use alloy::hex; +use alloy::network::TransactionBuilder; +use alloy::primitives::{Address, Bytes}; +use alloy::providers::Provider; +use alloy::rpc::types::TransactionRequest; +use alloy::transports::{RpcError, TransportError}; +use tracing::{debug, warn}; + +use crate::builder::TxBuilder; + +/// Errors surfaced by [`Simulator::simulate`]. +/// +/// `#[non_exhaustive]` so we can grow the set (e.g. structured +/// selector decoding into known Venus / Aave / PancakeSwap errors) +/// without breaking downstream matches. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SimulationError { + /// The on-chain call reverted. `selector_hex` is the 4-byte + /// Solidity error selector formatted as `0xaabbccdd`, or + /// `"0x"` when the revert payload is shorter than four bytes. + /// `data_hex` carries the full raw payload for cross-reference. + #[error("simulation reverted: selector={selector_hex} data={data_hex}")] + Reverted { + selector_hex: String, + data_hex: String, + }, + + /// Transport / RPC failure before the node ever got to execute + /// the call — network blip, auth failure, etc. Not a revert. + #[error("provider error: {0}")] + Provider(#[source] TransportError), +} + +/// Stateless simulator — holds the sender + target contract address +/// so per-call construction stays trivial. The provider is passed in +/// per call so consumers can swap chain providers without rebuilding +/// the simulator. +/// +/// The `sender` **must** match the `owner()` of the on-chain +/// `CharonLiquidator` (the bot's hot wallet). `executeLiquidation` +/// is `onlyOwner`, so a simulation with any other sender will revert +/// unconditionally and the opportunity will be dropped even when the +/// underlying liquidation would have been profitable. Prefer +/// [`Simulator::from_builder`] to keep this coupling tight. +#[derive(Debug, Clone, Copy)] +pub struct Simulator { + sender: Address, + liquidator: Address, +} + +impl Simulator { + /// Low-level constructor. Callers are responsible for ensuring + /// `sender` matches the on-chain owner. Prefer + /// [`Simulator::from_builder`] in production paths. + pub fn new(sender: Address, liquidator: Address) -> Self { + debug_assert!( + sender != Address::ZERO, + "Simulator sender must be the bot hot wallet, not the zero address" + ); + Self { sender, liquidator } + } + + /// Build a simulator whose `sender` is bound to the hot wallet + /// inside the provided [`TxBuilder`]. This is the only way to + /// guarantee the simulation sender matches the address that will + /// eventually sign and broadcast. + pub fn from_builder(builder: &TxBuilder, liquidator: Address) -> Self { + Self::new(builder.signer_address(), liquidator) + } + + /// Address the simulator will impersonate in `eth_call`. Must + /// equal `CharonLiquidator.owner()` — see struct-level docs. + pub fn sender(&self) -> Address { + self.sender + } + + /// Address of the target liquidator contract. + pub fn liquidator(&self) -> Address { + self.liquidator + } + + /// Run an `eth_call` against the latest block. Returns `Ok` when + /// the call would succeed, [`SimulationError::Reverted`] with + /// the decoded selector + raw payload otherwise. The caller + /// drops the opportunity on `Err`. + /// + /// `gas_limit` must match (or exceed) what the real broadcast + /// will use — a simulation that fits in less gas than the + /// broadcast can pass here and still revert on-chain as + /// out-of-gas. + pub async fn simulate

( + &self, + provider: &P, + calldata: Bytes, + gas_limit: u64, + ) -> Result<(), SimulationError> + where + P: Provider, + { + let req = TransactionRequest::default() + .with_from(self.sender) + .with_to(self.liquidator) + .with_input(calldata) + .with_gas_limit(gas_limit); + + match provider.call(&req).await { + Ok(out) => { + debug!( + sender = %self.sender, + target = %self.liquidator, + gas_limit, + output_len = out.len(), + "eth_call simulation succeeded" + ); + Ok(()) + } + Err(err) => { + let revert = extract_revert_data(&err); + let (selector_hex, data_hex) = match revert.as_ref() { + Some(bytes) => format_revert(bytes), + None => ("0x".to_string(), "0x".to_string()), + }; + warn!( + sender = %self.sender, + target = %self.liquidator, + gas_limit, + selector = %selector_hex, + data = %data_hex, + error = %format!("{err:#}"), + "eth_call simulation reverted — opportunity dropped" + ); + + // Distinguish a true revert (node replied with error + // data) from a transport-level failure. `alloy` + // surfaces both on the same error arm, so the + // presence of returndata is the discriminator. + if revert.is_some() { + Err(SimulationError::Reverted { + selector_hex, + data_hex, + }) + } else { + Err(SimulationError::Provider(err)) + } + } + } + } +} + +/// Try to extract the raw revert payload from a transport error. +/// +/// BSC / geth-family nodes return it as a hex-encoded JSON string in +/// the `data` field of the JSON-RPC error object. We trim the +/// surrounding JSON quotes + optional `0x` prefix manually rather +/// than pulling in a JSON parser just for this path. +/// +/// Known selectors worth cross-referencing when they show up in +/// logs (v0.1 scope, not hard-coded to avoid stale tables): +/// - Venus `VToken`: custom errors added in VIP-194+. +/// - Aave v3 `Pool`: the generic `Error(string)` selector +/// `0x08c379a0` is the most common; newer custom errors carry +/// their own selectors. +/// - PancakeSwap v3 router: slippage errors like `TooLittleReceived`. +fn extract_revert_data(err: &TransportError) -> Option { + let RpcError::ErrorResp(resp) = err else { + return None; + }; + let raw = resp.data.as_ref()?.get(); + // `RawValue::get()` returns the literal JSON, so a string value + // reads as `"\"0xdeadbeef\""`. Strip the surrounding quotes. + let unquoted = raw.strip_prefix('"')?.strip_suffix('"')?; + let hex_body = unquoted.strip_prefix("0x").unwrap_or(unquoted); + hex::decode(hex_body).ok().map(Bytes::from) +} + +/// Format `(selector_hex, data_hex)` from a raw revert payload. +fn format_revert(bytes: &Bytes) -> (String, String) { + let data_hex = format!("0x{}", hex::encode(bytes)); + if bytes.len() >= 4 { + let selector_hex = format!( + "0x{:02x}{:02x}{:02x}{:02x}", + bytes[0], bytes[1], bytes[2], bytes[3] + ); + (selector_hex, data_hex) + } else { + ("0x".to_string(), data_hex) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy::primitives::address; + + #[test] + fn simulator_holds_addresses() { + let s = Simulator::new( + address!("1111111111111111111111111111111111111111"), + address!("2222222222222222222222222222222222222222"), + ); + assert_eq!( + s.sender(), + address!("1111111111111111111111111111111111111111") + ); + assert_eq!( + s.liquidator(), + address!("2222222222222222222222222222222222222222") + ); + } + + #[test] + fn format_revert_extracts_selector() { + let bytes = Bytes::from(vec![0x08, 0xc3, 0x79, 0xa0, 0xde, 0xad]); + let (selector, data) = format_revert(&bytes); + assert_eq!(selector, "0x08c379a0"); + assert_eq!(data, "0x08c379a0dead"); + } + + #[test] + fn format_revert_short_payload_has_empty_selector() { + let bytes = Bytes::from(vec![0x01, 0x02]); + let (selector, data) = format_revert(&bytes); + assert_eq!(selector, "0x"); + assert_eq!(data, "0x0102"); + } + + #[test] + #[should_panic(expected = "Simulator sender must be the bot hot wallet")] + fn zero_sender_trips_debug_assert() { + // debug_assert is only active in debug builds (cargo test + // runs in debug by default), so this panic is reachable. + let _ = Simulator::new( + Address::ZERO, + address!("2222222222222222222222222222222222222222"), + ); + } +} diff --git a/crates/charon-executor/tests/simulate_fork.rs b/crates/charon-executor/tests/simulate_fork.rs new file mode 100644 index 0000000..e881183 --- /dev/null +++ b/crates/charon-executor/tests/simulate_fork.rs @@ -0,0 +1,200 @@ +//! Fork tests for the `eth_call` simulation gate. +//! +//! These tests run against a BSC fork URL (anvil, Hardhat, or a +//! managed fork). They are `#[ignore]`d by default because they +//! require: +//! - `BSC_FORK_URL` pointing at an HTTP endpoint that can serve +//! `eth_call` against BSC-mainnet state. +//! - `CHARON_LIQUIDATOR_ADDR` — an address on that fork where a +//! deployed `CharonLiquidator` instance lives. +//! - `CHARON_OWNER_KEY` — the private key owning that instance +//! (hot wallet). +//! +//! Run with: +//! BSC_FORK_URL=... \ +//! CHARON_LIQUIDATOR_ADDR=0x... \ +//! CHARON_OWNER_KEY=0x... \ +//! cargo test -p charon-executor --test simulate_fork -- --ignored +//! +//! Pattern mirrors `crates/charon-protocols/tests/venus_fetch.rs`. + +use std::str::FromStr; + +use alloy::primitives::{Address, Bytes, U256, address}; +use alloy::providers::ProviderBuilder; +use alloy::signers::local::PrivateKeySigner; +use alloy::sol_types::SolCall; +use charon_core::{ + FlashLoanSource, LiquidationOpportunity, LiquidationParams, Position, ProtocolId, SwapRoute, +}; +use charon_executor::builder::ICharonLiquidator; +use charon_executor::{SimulationError, Simulator, TxBuilder}; + +fn env(var: &str) -> Option { + let _ = dotenvy::dotenv(); + std::env::var(var).ok() +} + +fn dev_signer() -> PrivateKeySigner { + // Anvil default #0 — used only when CHARON_OWNER_KEY is unset. + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + .parse() + .expect("dev key parse") +} + +fn synthetic_opportunity() -> (LiquidationOpportunity, LiquidationParams) { + let borrower = address!("1111111111111111111111111111111111111111"); + let collateral = address!("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let debt = address!("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + let opp = LiquidationOpportunity { + position: Position { + protocol: ProtocolId::Venus, + chain_id: 56, + borrower, + collateral_token: collateral, + debt_token: debt, + 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: collateral, + token_out: debt, + amount_in: U256::from(275u64), + min_amount_out: U256::from(260u64), + pool_fee: Some(3_000), + }, + net_profit_wei: U256::from(5_000u64), + }; + let params = LiquidationParams::Venus { + borrower, + collateral_vtoken: address!("cccccccccccccccccccccccccccccccccccccccc"), + debt_vtoken: address!("dddddddddddddddddddddddddddddddddddddddd"), + repay_amount: U256::from(250u64), + }; + (opp, params) +} + +/// Happy path: simulate a liquidation with the owner as sender and +/// expect `Ok(())`. Skipped unless a real `CharonLiquidator` +/// instance + owner key are wired up via env. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires BSC_FORK_URL + CHARON_LIQUIDATOR_ADDR + CHARON_OWNER_KEY"] +async fn simulate_valid_liquidation_succeeds() { + let Some(fork_url) = env("BSC_FORK_URL") else { + eprintln!("skipping: BSC_FORK_URL not set"); + return; + }; + let Some(liquidator_str) = env("CHARON_LIQUIDATOR_ADDR") else { + eprintln!("skipping: CHARON_LIQUIDATOR_ADDR not set"); + return; + }; + let Some(key) = env("CHARON_OWNER_KEY") else { + eprintln!("skipping: CHARON_OWNER_KEY not set"); + return; + }; + + let liquidator = Address::from_str(&liquidator_str).expect("liquidator addr parse"); + let signer: PrivateKeySigner = key.parse().expect("owner key parse"); + let provider = ProviderBuilder::new() + .on_http(fork_url.parse().expect("fork url parse")) + .boxed(); + + let builder = TxBuilder::new(signer, 56, liquidator); + let (opp, params) = synthetic_opportunity(); + let calldata = builder.encode_calldata(&opp, ¶ms).expect("encode"); + + let simulator = Simulator::from_builder(&builder, liquidator); + // 8M gas upper bound — well above any realistic liquidation path. + let result = simulator.simulate(&provider, calldata, 8_000_000).await; + + // On a synthetic fixture the inner flash-loan path may not be + // reachable on this fork; the test tolerates `Reverted` but + // requires the simulation round-trip itself to reach a node + // response (i.e. not `Provider`). + match result { + Ok(()) => {} + Err(SimulationError::Reverted { + selector_hex, + data_hex, + }) => { + eprintln!( + "simulate returned revert (acceptable for synthetic fixture): \ + selector={selector_hex} data={data_hex}" + ); + } + Err(SimulationError::Provider(err)) => { + panic!("transport-level failure talking to fork: {err}") + } + // `SimulationError` is `#[non_exhaustive]`; future variants + // should flip this test red so they get explicit handling. + Err(other) => panic!("unexpected simulation error variant: {other}"), + } +} + +/// Adversarial path: simulate with a sender that is NOT the owner. +/// Must return `SimulationError::Reverted` because `onlyOwner` +/// rejects the call before any inner logic runs. This is the core +/// safety invariant — any regression that weakens `onlyOwner` on +/// `executeLiquidation` will break this test. +#[tokio::test(flavor = "current_thread")] +#[ignore = "requires BSC_FORK_URL + CHARON_LIQUIDATOR_ADDR"] +async fn simulate_wrong_sender_is_rejected_by_only_owner() { + let Some(fork_url) = env("BSC_FORK_URL") else { + eprintln!("skipping: BSC_FORK_URL not set"); + return; + }; + let Some(liquidator_str) = env("CHARON_LIQUIDATOR_ADDR") else { + eprintln!("skipping: CHARON_LIQUIDATOR_ADDR not set"); + return; + }; + + let liquidator = Address::from_str(&liquidator_str).expect("liquidator addr parse"); + let provider = ProviderBuilder::new() + .on_http(fork_url.parse().expect("fork url parse")) + .boxed(); + + // Craft calldata. The dev signer doesn't own the deployed + // contract; the adversarial sender we inject below is not the + // owner either. + let builder = TxBuilder::new(dev_signer(), 56, liquidator); + let (opp, params) = synthetic_opportunity(); + let calldata_bytes: Bytes = builder + .encode_calldata(&opp, ¶ms) + .expect("encode wrong-sender calldata"); + + // Sanity: calldata really targets executeLiquidation. + assert_eq!( + &calldata_bytes[..4], + &ICharonLiquidator::executeLiquidationCall::SELECTOR + ); + + // Use Simulator::new directly with a sender we KNOW is not the + // deployed owner. Never use Simulator::from_builder here — that + // is the production-safe path; this test exercises the + // adversarial one. + let wrong_sender = address!("000000000000000000000000000000000000dEaD"); + let simulator = Simulator::new(wrong_sender, liquidator); + let result = simulator + .simulate(&provider, calldata_bytes, 8_000_000) + .await; + + match result { + Err(SimulationError::Reverted { .. }) => { + // Expected — onlyOwner bounced it. + } + Err(SimulationError::Provider(err)) => { + panic!("transport-level failure, cannot assert onlyOwner behaviour: {err}") + } + Ok(()) => { + panic!("onlyOwner should have rejected a non-owner sender; simulation passed") + } + // `SimulationError` is `#[non_exhaustive]`; catch-all so any + // future variant forces an explicit decision here. + Err(other) => panic!("unexpected simulation error variant: {other}"), + } +}