use std::{
sync::{Arc, Mutex},
time::Duration,
};
use alloy::{
eips::BlockNumberOrTag,
network::Ethereum,
primitives::{address, map::B256HashMap, uint, Address, Bytes, TxKind, B256, U256},
providers::{ext::DebugApi, Provider, ProviderBuilder, RootProvider},
rpc::types::{
simulate::{SimBlock, SimulatePayload},
state::{AccountOverride, StateOverride},
trace::geth::{
CallConfig, GethDebugBuiltInTracerType, GethDebugTracerType,
GethDebugTracingCallOptions, GethDebugTracingOptions,
},
BlockOverrides, TransactionRequest,
},
};
use metrics::{counter, histogram};
use num_bigint::BigUint;
use num_traits::ToPrimitive;
use rustc_hash::FxHashMap;
use tokio::{sync::OnceCell, time::timeout};
use tracing::debug;
use tycho_simulation::tycho_common::models::Chain;
use crate::{
encoding::encoder::PERMIT2_ADDRESS,
simulation::{
deviation::deviation_bps,
revert,
token_layout::{discover_layout, DiscoveryError, TokenLayout},
},
solver::defaults::SIMULATION_LAYOUT_DISCOVERY_TIMEOUT,
OrderQuote, SimulationResult,
};
const SIMULATION_FUNDING_VALUE: U256 =
uint!(1_000_000_000_000_000_000_000_000_000_000_000_000_U256);
const SIMULATION_GAS_LIMIT_MULTIPLIER: u64 = 4;
const SIMULATION_MIN_GAS_LIMIT: u64 = 500_000;
const SIMULATION_MAX_GAS_LIMIT: u64 = 30_000_000;
const SIMULATION_BLOCK_GAS_LIMIT: u64 = 45_000_000;
const SIMULATION_FALLBACK_GAS_PRICE: u128 = 1_000_000_000;
const SIMULATION_COINBASE: Address = address!("0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5");
const SIMULATION_SENDER_NONCE: u64 = 1;
const SIMULATION_TRACE_TIMEOUT: Duration = Duration::from_millis(500);
type LayoutCell = Arc<OnceCell<Result<TokenLayout, String>>>;
#[derive(Clone, Copy)]
pub(crate) struct SimulatedCall<'a> {
pub(crate) sender: Address,
pub(crate) router: Address,
pub(crate) value: U256,
pub(crate) data: &'a [u8],
}
pub struct QuoteSimulator {
provider: RootProvider<Ethereum>,
layout_cache: Mutex<FxHashMap<Address, LayoutCell>>,
native_token: Address,
request_timeout: std::time::Duration,
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct SimulationEnvelope {
gas_limit: u64,
gas_price: u128,
}
impl SimulationEnvelope {
fn for_quote(quote: &OrderQuote) -> Self {
Self::new(
quote.gas_estimate().to_u64(),
quote
.gas_price()
.and_then(ToPrimitive::to_u128),
)
}
fn new(gas_estimate: Option<u64>, gas_price: Option<u128>) -> Self {
let gas_limit = gas_estimate
.map_or(SIMULATION_MIN_GAS_LIMIT, |estimate| {
estimate.saturating_mul(SIMULATION_GAS_LIMIT_MULTIPLIER)
})
.clamp(SIMULATION_MIN_GAS_LIMIT, SIMULATION_MAX_GAS_LIMIT);
Self { gas_limit, gas_price: gas_price.unwrap_or(SIMULATION_FALLBACK_GAS_PRICE) }
}
}
pub(crate) enum SimulationAttempt {
Success { amount_out: BigUint, gas_used: u64 },
Reverted { reason: String },
Failure { reason: String },
}
impl QuoteSimulator {
pub fn new(
rpc_url: &str,
chain: Chain,
request_timeout: std::time::Duration,
) -> Result<Self, String> {
let url = rpc_url
.parse()
.map_err(|error| format!("invalid RPC URL {rpc_url:?}: {error}"))?;
let native_token = chain
.try_native_token()
.map_err(|error| format!("native token for {chain:?}: {error}"))?;
Ok(Self::with_provider(
ProviderBuilder::default().connect_http(url),
Address::from_slice(native_token.address.as_ref()),
request_timeout,
))
}
pub(crate) async fn simulate_attempt(&self, quote: &OrderQuote) -> SimulationAttempt {
let attempt = self.attempt(quote).await;
record_outcome(quote, &attempt);
attempt
}
async fn attempt(&self, quote: &OrderQuote) -> SimulationAttempt {
let transaction = match quote.transaction() {
Some(value) => value,
None => return failure("simulation setup failed: quote has no encoded transaction"),
};
let route = match quote.route() {
Some(value) => value,
None => return failure("simulation setup failed: quote has no route"),
};
let first_swap = match route.swaps().first() {
Some(value) => value,
None => return failure("simulation setup failed: route has no swaps"),
};
let sender = match crate::rpc::to_address(quote.sender(), "quote sender") {
Ok(value) => value,
Err(error) => return failure_with(format!("simulation setup failed: {error}")),
};
let router = match crate::rpc::to_address(transaction.to(), "transaction destination") {
Ok(value) => value,
Err(error) => return failure_with(format!("simulation setup failed: {error}")),
};
let token_in = match crate::rpc::to_address(first_swap.token_in(), "route token_in") {
Ok(value) => value,
Err(error) => return failure_with(format!("simulation setup failed: {error}")),
};
let overrides = match self
.overrides(sender, token_in, router)
.await
{
Ok(value) => value,
Err(reason) => return failure_with(reason),
};
let value = U256::from_be_slice(
transaction
.value()
.to_bytes_be()
.as_slice(),
);
self.simulate_within_timeout(
SimulatedCall { sender, router, value, data: transaction.data() },
overrides,
SimulationEnvelope::for_quote(quote),
)
.await
}
pub(crate) async fn simulate_within_timeout(
&self,
call: SimulatedCall<'_>,
overrides: StateOverride,
envelope: SimulationEnvelope,
) -> SimulationAttempt {
match simulate_with_overrides(
&self.provider,
call,
overrides,
envelope,
self.request_timeout,
)
.await
{
CallOutcome::Success { amount_out, gas_used } => {
SimulationAttempt::Success { amount_out, gas_used }
}
CallOutcome::Reverted { reason } => {
SimulationAttempt::Reverted { reason: format!("simulation reverted: {reason}") }
}
CallOutcome::Failure(reason) => SimulationAttempt::Failure { reason },
}
}
pub(crate) fn with_provider(
provider: RootProvider<Ethereum>,
native_token: Address,
request_timeout: std::time::Duration,
) -> Self {
Self {
provider,
layout_cache: Mutex::new(FxHashMap::default()),
native_token,
request_timeout,
}
}
async fn overrides(
&self,
sender: Address,
token: Address,
router: Address,
) -> Result<StateOverride, String> {
if token == self.native_token {
return Ok(native_balance_override(sender));
}
let permit2: Address = PERMIT2_ADDRESS
.parse()
.map_err(|error| format!("invalid Permit2 address: {error}"))?;
let layout = self
.cached_layout(token, sender, router)
.await?;
Ok(token_overrides(sender, router, permit2, layout))
}
async fn cached_layout(
&self,
token: Address,
holder: Address,
spender: Address,
) -> Result<TokenLayout, String> {
let cell = Arc::clone(
self.layout_cache
.lock()
.map_err(|_| "simulation layout cache lock poisoned".to_string())?
.entry(token)
.or_default(),
);
cell.get_or_try_init(|| self.discover_once(token, holder, spender))
.await?
.clone()
.map_err(|reason| format!("simulation token layout discovery failed: {reason}"))
}
async fn discover_once(
&self,
token: Address,
holder: Address,
spender: Address,
) -> Result<Result<TokenLayout, String>, String> {
let discovered = timeout(
SIMULATION_LAYOUT_DISCOVERY_TIMEOUT,
discover_layout(&self.provider, token, holder, spender),
)
.await
.map_err(|_| {
format!("simulation token layout discovery failed: timed out after {SIMULATION_LAYOUT_DISCOVERY_TIMEOUT:?}")
})?;
match discovered {
Ok(layout) => Ok(Ok(layout)),
Err(DiscoveryError::Unsupported(reason)) => Ok(Err(reason)),
Err(DiscoveryError::Rpc(reason)) => {
Err(format!("simulation token layout discovery failed: {reason}"))
}
}
}
}
impl SimulationAttempt {
pub(crate) fn into_result(self) -> SimulationResult {
match self {
Self::Success { amount_out, gas_used } => {
SimulationResult::Success { amount_out, gas_used }
}
Self::Reverted { reason } | Self::Failure { reason } => {
SimulationResult::Failure { reason }
}
}
}
}
const SIMULATION_OUTCOME_TARGET: &str = "fynd::simulation_outcome";
fn log_outcome(quote: &OrderQuote, outcome: &'static str, reason: &str) -> &'static str {
debug!(
target: SIMULATION_OUTCOME_TARGET,
order_id = quote.order_id(),
pool = quote.worker_pool(),
algorithm = quote.algorithm(),
outcome,
"{reason}"
);
outcome
}
fn record_outcome(quote: &OrderQuote, attempt: &SimulationAttempt) {
let pool = quote.worker_pool().to_string();
let algorithm = quote.algorithm().to_string();
let outcome = match attempt {
SimulationAttempt::Success { amount_out, .. } => {
if let Some(deviation) = deviation_bps(quote, amount_out) {
histogram!(
"quote_simulation_deviation_bps",
"pool" => pool.clone(),
"algorithm" => algorithm.clone()
)
.record(deviation);
}
"success"
}
SimulationAttempt::Reverted { reason } => log_outcome(quote, "reverted", reason),
SimulationAttempt::Failure { reason } => log_outcome(quote, "failed", reason),
};
counter!(
"quote_simulations_total",
"outcome" => outcome,
"pool" => pool,
"algorithm" => algorithm
)
.increment(1);
}
fn failure(reason: &str) -> SimulationAttempt {
failure_with(reason.to_string())
}
fn failure_with(reason: String) -> SimulationAttempt {
SimulationAttempt::Failure { reason }
}
fn block_overrides() -> BlockOverrides {
BlockOverrides {
coinbase: Some(SIMULATION_COINBASE),
random: Some(B256::from(rand::random::<[u8; 32]>())),
gas_limit: Some(SIMULATION_BLOCK_GAS_LIMIT),
..Default::default()
}
}
fn executed_in(environment: BlockOverrides, number: u64, timestamp: u64) -> BlockOverrides {
BlockOverrides { number: Some(U256::from(number)), time: Some(timestamp), ..environment }
}
async fn simulate_with_overrides(
provider: &RootProvider<Ethereum>,
simulated: SimulatedCall<'_>,
overrides: StateOverride,
envelope: SimulationEnvelope,
request_timeout: Duration,
) -> CallOutcome {
let call = TransactionRequest {
from: Some(simulated.sender),
to: Some(TxKind::Call(simulated.router)),
value: Some(simulated.value),
input: Bytes::copy_from_slice(simulated.data).into(),
gas: Some(envelope.gas_limit),
gas_price: Some(envelope.gas_price),
..Default::default()
};
let environment = block_overrides();
let payload = SimulatePayload::default().extend(
SimBlock::default()
.with_state_overrides(overrides.clone())
.with_block_overrides(environment.clone())
.call(call.clone()),
);
let response = match timeout(request_timeout, provider.simulate(&payload)).await {
Ok(Ok(value)) => value,
Ok(Err(error)) => {
return CallOutcome::Failure(format!(
"simulation eth_simulateV1 failed: {}",
rpc_error_reason(&error)
))
}
Err(_) => {
return CallOutcome::Failure(format!(
"simulation request timed out after {request_timeout:?}"
))
}
};
let Some(block) = response.first() else {
return CallOutcome::Failure("simulation eth_simulateV1 returned no blocks".to_string());
};
let Some(result) = block.calls.first() else {
return CallOutcome::Failure(
"simulation eth_simulateV1 returned no call results".to_string(),
);
};
if !result.status {
let message = result
.error
.as_ref()
.map_or("execution reverted", |error| error.message.as_str())
.to_string();
if let Some(decoded) = revert::decode_error(&result.return_data) {
return CallOutcome::Reverted { reason: decoded };
}
let traced = timeout(
SIMULATION_TRACE_TIMEOUT,
traced_revert_reason(
provider,
call,
overrides,
executed_in(environment, block.inner.header.number, block.inner.header.timestamp),
),
)
.await
.ok()
.flatten();
return CallOutcome::Reverted { reason: traced.unwrap_or(message) };
}
if result.return_data.len() != 32 {
return CallOutcome::Failure(format!(
"simulation eth_simulateV1 returned {} bytes; expected exactly 32 bytes for uint256",
result.return_data.len()
));
}
let amount = U256::from_be_slice(&result.return_data);
CallOutcome::Success {
amount_out: BigUint::from_bytes_be(&amount.to_be_bytes::<32>()),
gas_used: result.gas_used,
}
}
enum CallOutcome {
Success {
amount_out: BigUint,
gas_used: u64,
},
Reverted {
reason: String,
},
Failure(String),
}
fn rpc_error_reason(
error: &alloy::transports::RpcError<alloy::transports::TransportErrorKind>,
) -> String {
let response = error.as_error_resp();
let message =
response.map_or_else(|| error.to_string(), |response| response.message.to_string());
response
.and_then(|response| response.as_revert_data())
.and_then(|data| revert::decode_error(data.as_ref()))
.unwrap_or(message)
}
fn sender_override() -> AccountOverride {
AccountOverride {
balance: Some(SIMULATION_FUNDING_VALUE),
nonce: Some(SIMULATION_SENDER_NONCE),
..Default::default()
}
}
fn native_balance_override(sender: Address) -> StateOverride {
StateOverride::from_iter([(sender, sender_override())])
}
fn token_overrides(
sender: Address,
router: Address,
permit2: Address,
layout: TokenLayout,
) -> StateOverride {
let funding = B256::from(SIMULATION_FUNDING_VALUE);
let mut state_diff = B256HashMap::default();
for holder in [sender, router] {
state_diff.insert(layout.balance_slot(holder), funding);
}
for spender in [router, permit2] {
state_diff.insert(layout.allowance_slot(sender, spender), funding);
}
StateOverride::from_iter([
(sender, sender_override()),
(
layout.storage_contract(),
AccountOverride { state_diff: Some(state_diff), ..Default::default() },
),
])
}
async fn traced_revert_reason(
provider: &RootProvider<Ethereum>,
call: TransactionRequest,
overrides: StateOverride,
environment: BlockOverrides,
) -> Option<String> {
let options = GethDebugTracingCallOptions::default()
.with_tracing_options(
GethDebugTracingOptions::default()
.with_tracer(GethDebugTracerType::BuiltInTracer(
GethDebugBuiltInTracerType::CallTracer,
))
.with_call_config(CallConfig { only_top_call: Some(false), with_log: Some(false) }),
)
.with_state_overrides(overrides)
.with_block_overrides(environment);
match provider
.debug_trace_call_callframe(call, BlockNumberOrTag::Latest.into(), options)
.await
{
Ok(frame) => revert::reason_from_frame(&frame),
Err(error) => {
tracing::debug!(%error, "tracing a reverted simulation failed");
None
}
}
}
#[cfg(test)]
#[path = "../tests/simulation/simulator.rs"]
mod tests;