diff --git a/Cargo.lock b/Cargo.lock index 66ac2aa3c5..3a239536ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7704,6 +7704,7 @@ dependencies = [ "serde_json", "serde_with", "signature-validator", + "simulator", "strum", "testlib", "thiserror 1.0.69", diff --git a/crates/configs/src/orderbook/mod.rs b/crates/configs/src/orderbook/mod.rs index ef3b6fdb01..84205ed112 100644 --- a/crates/configs/src/orderbook/mod.rs +++ b/crates/configs/src/orderbook/mod.rs @@ -20,6 +20,7 @@ use { std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, path::Path, + time::Duration, }, }; @@ -59,6 +60,14 @@ pub struct OrderSimulationConfig { /// URL. #[serde(default)] pub tenderly: Option, + + /// Per-call timeout for the order creation simulation. + #[serde(default = "default_simulation_timeout", with = "humantime_serde")] + pub timeout: Duration, +} + +fn default_simulation_timeout() -> Duration { + Duration::from_secs(2) } /// Top-level orderbook service configuration. @@ -171,6 +180,16 @@ pub mod test_util { std::path::Path, }; + impl TestDefault for OrderSimulationConfig { + fn test_default() -> Self { + Self { + gas_limit: U256::try_from(16777215).expect("u64 can be converted to U256"), + tenderly: None, + timeout: std::time::Duration::from_secs(2), + } + } + } + impl Configuration { pub async fn to_path>(&self, path: P) -> anyhow::Result<()> { Ok(tokio::fs::write(path, toml::to_string_pretty(self)?).await?) @@ -225,10 +244,7 @@ pub mod test_util { ..TestDefault::test_default() }, // Enable order simulation for testing - order_simulation: Some(OrderSimulationConfig { - gas_limit: U256::try_from(16777215).expect("u64 can be converted to U256"), - tenderly: None, - }), + order_simulation: Some(TestDefault::test_default()), hide_competition_before_deadline: false, } } @@ -438,4 +454,22 @@ mod tests { ); assert_eq!(config.http_client.timeout, deserialized.http_client.timeout) } + + #[test] + fn parses_order_simulation_defaults() { + let toml = r#"gas-limit = "16777216""#; + let cfg: OrderSimulationConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.timeout, Duration::from_secs(2)); + assert!(cfg.tenderly.is_none()); + } + + #[test] + fn parses_order_simulation_full() { + let toml = r#" + gas-limit = "16777216" + timeout = "5s" + "#; + let cfg: OrderSimulationConfig = toml::from_str(toml).unwrap(); + assert_eq!(cfg.timeout, Duration::from_secs(5)); + } } diff --git a/crates/cow-amm/src/amm.rs b/crates/cow-amm/src/amm.rs index 738ada502c..4e39f36867 100644 --- a/crates/cow-amm/src/amm.rs +++ b/crates/cow-amm/src/amm.rs @@ -67,13 +67,13 @@ impl Amm { // To avoid issues caused by that we check the validity of the signature. let hash = hashed_eip712_message(domain_separator, &template.order.hash_struct()); validator - .validate_signature_and_get_additional_gas(SignatureCheck { - signer: self.address, - hash: hash.0, - signature: template.signature.to_bytes(), - interactions: template.pre_interactions.clone(), - balance_override: None, - }) + .validate_signature_and_get_additional_gas(SignatureCheck::new( + self.address, + hash.0, + template.signature.to_bytes(), + template.pre_interactions.clone(), + None, + )) .await .context("invalid signature")?; diff --git a/crates/e2e/tests/e2e/eip1271_creation_simulation.rs b/crates/e2e/tests/e2e/eip1271_creation_simulation.rs new file mode 100644 index 0000000000..7feff4f72c --- /dev/null +++ b/crates/e2e/tests/e2e/eip1271_creation_simulation.rs @@ -0,0 +1,100 @@ +//! Local-node smoke test for the EIP-1271 creation-time simulation wiring. +//! +//! A Safe-signed order with empty `app_data` is accepted, proving that +//! `OrderSimulator` runs alongside the cheap signature check without +//! disrupting the happy path. The simulation runs in shadow mode (logs +//! disagreements, never rejects). An enforce-mode rejection test will be +//! added together with the enforce-mode follow-up PR. + +use { + configs::{ + orderbook::{Configuration, OrderSimulationConfig}, + test_util::TestDefault, + }, + e2e::setup::{MintableToken, OnchainComponents, Services, run_test, safe::Safe}, + model::order::{OrderCreation, OrderCreationAppData, OrderKind}, + number::units::EthUnit, + shared::web3::Web3, +}; + +#[tokio::test] +#[ignore] +async fn local_node_eip1271_creation_simulation_accepts_valid_order() { + run_test(accepts_valid_order).await; +} + +async fn accepts_valid_order(web3: Web3) { + let mut onchain = OnchainComponents::deploy(web3.clone()).await; + let [solver] = onchain.make_solvers(1u64.eth()).await; + let [trader] = onchain.make_accounts(1u64.eth()).await; + + let safe = Safe::deploy(trader, web3.provider.clone()).await; + + let [token] = onchain + .deploy_tokens_with_weth_uni_v2_pools(100_000u64.eth(), 100_000u64.eth()) + .await; + fund_safe(&safe, &token, &onchain).await; + + let services = start_services_with_simulation(&onchain, solver).await; + + let order = sign_order(&safe, &onchain, &token); + + let uid = services + .create_order(&order) + .await + .expect("expected order to be accepted"); + let stored = services.get_order(&uid).await.unwrap(); + assert_eq!(stored.metadata.uid, uid); +} + +async fn fund_safe(safe: &Safe, token: &MintableToken, onchain: &OnchainComponents) { + token.mint(safe.address(), 10u64.eth()).await; + safe.exec_alloy_call( + token + .approve(onchain.contracts().allowance, 10u64.eth()) + .into_transaction_request(), + ) + .await; +} + +async fn start_services_with_simulation<'a>( + onchain: &'a OnchainComponents, + solver: e2e::setup::onchain_components::TestAccount, +) -> Services<'a> { + let orderbook_config = Configuration { + order_simulation: Some(OrderSimulationConfig::test_default()), + ..Configuration::test_default() + }; + + let services = Services::new(onchain).await; + services + .start_protocol_with_args( + configs::autopilot::Configuration::test("test_solver", solver.address()), + orderbook_config, + solver, + ) + .await; + services +} + +fn sign_order( + safe: &Safe, + onchain: &OnchainComponents, + sell_token: &MintableToken, +) -> OrderCreation { + let mut order = OrderCreation { + kind: OrderKind::Sell, + sell_token: *sell_token.address(), + sell_amount: 5u64.eth(), + buy_token: *onchain.contracts().weth.address(), + buy_amount: 1u64.eth(), + valid_to: model::time::now_in_epoch_seconds() + 300, + from: Some(safe.address()), + app_data: OrderCreationAppData::Full { + full: "{}".to_string(), + }, + ..Default::default() + }; + safe.sign_order(&mut order, onchain); + order +} diff --git a/crates/e2e/tests/e2e/main.rs b/crates/e2e/tests/e2e/main.rs index 9711aee7e6..bf28f1c5fc 100644 --- a/crates/e2e/tests/e2e/main.rs +++ b/crates/e2e/tests/e2e/main.rs @@ -16,6 +16,7 @@ mod cow_amm; mod database; mod debug_order; mod deprecated_endpoints; +mod eip1271_creation_simulation; mod eip4626; mod eth_integration; mod eth_safe; diff --git a/crates/e2e/tests/e2e/malformed_requests.rs b/crates/e2e/tests/e2e/malformed_requests.rs index dff0c2ddb8..5a8e5baf44 100644 --- a/crates/e2e/tests/e2e/malformed_requests.rs +++ b/crates/e2e/tests/e2e/malformed_requests.rs @@ -460,7 +460,7 @@ async fn http_validation(web3: Web3) { "zero sellAmount should return 422" ); - // some fields missing → 422 + // Invalid kind enum value → 422 let response = client .post(format!("{API_HOST}/restricted/api/v1/debug/simulation")) .json(&json!({ @@ -510,15 +510,10 @@ async fn http_validation(web3: Web3) { ); let body: Error = response.json().await.unwrap(); assert!( - body.description.contains("app_data"), + body.description.contains("app data"), "error description should name the failing field. Got: {}", body.description ); - assert!( - body.description.contains(bad_app_data), - "error description should include the bad value. Got: {}", - body.description - ); } #[tokio::test] diff --git a/crates/orderbook/src/run.rs b/crates/orderbook/src/run.rs index 71e08ca00e..bc18f1c064 100644 --- a/crates/orderbook/src/run.rs +++ b/crates/orderbook/src/run.rs @@ -41,7 +41,7 @@ use { }, shared::{ order_quoting::{self, OrderQuoter}, - order_validation::{OrderValidPeriodConfiguration, OrderValidator}, + order_validation::{OrderSimulator, OrderValidPeriodConfiguration, OrderValidator}, }, std::{future::Future, net::SocketAddr, sync::Arc, time::Duration}, token_info::{CachedTokenInfoFetcher, TokenInfoFetcher}, @@ -385,6 +385,49 @@ pub async fn run(config: Configuration) { let chainalysis_oracle = ChainalysisOracle::Instance::deployed(&web3.provider) .await .ok(); + + let order_simulator = match &config.order_simulation { + Some(sim_config) => { + let tenderly: Option> = + sim_config.tenderly.as_ref().map(|tenderly_config| { + Arc::new(simulator::tenderly::TenderlyApi::new( + tenderly_config, + &http_factory, + chain.id().to_string(), + )) as _ + }); + Some( + simulator::simulation_builder::SettlementSimulator::new( + settlement_contract.clone(), + flashloan_router_address, + hooks_trampoline_address, + *native_token.address(), + sim_config.gas_limit.saturating_to(), + balance_overrider.clone(), + current_block_stream.clone(), + tenderly, + ) + .await + .expect("failed to create SettlementSimulator"), + ) + } + None => None, + }; + + let validator_simulator = config + .order_simulation + .as_ref() + .zip(order_simulator.clone()) + .map(|(sim_config, settlement_simulator)| { + let simulator: Arc = Arc::new( + simulator::order_simulation::OrderSimulatorAdapter::new(settlement_simulator), + ); + OrderSimulator { + simulator, + timeout: sim_config.timeout, + } + }); + let order_validator = Arc::new(OrderValidator::new( native_token.clone(), Arc::new(order_validation::banned::Users::new( @@ -399,6 +442,7 @@ pub async fn run(config: Configuration) { optimal_quoter.clone(), balance_fetcher, signature_validator, + validator_simulator, Arc::new(postgres_write.clone()), config.order_validation.max_limit_orders_per_user, code_fetcher, @@ -421,33 +465,6 @@ pub async fn run(config: Configuration) { ipfs, )); - let order_simulator = if let Some(config) = config.order_simulation { - let tenderly: Option> = - config.tenderly.as_ref().map(|tenderly_config| { - Arc::new(simulator::tenderly::TenderlyApi::new( - tenderly_config, - &http_factory, - chain.id().to_string(), - )) as _ - }); - Some( - simulator::simulation_builder::SettlementSimulator::new( - settlement_contract.clone(), - flashloan_router_address, - hooks_trampoline_address, - *native_token.address(), - config.gas_limit.saturating_to(), - balance_overrider.clone(), - current_block_stream.clone(), - tenderly, - ) - .await - .expect("failed to initialize SettlementSimulator"), - ) - } else { - None - }; - let orderbook = Arc::new(Orderbook::new( domain_separator, *settlement_contract.address(), diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index ab8b09f759..549a4d9a60 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -56,6 +56,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_with = { workspace = true } signature-validator = { workspace = true } +simulator = { workspace = true } strum = { workspace = true } thiserror = { workspace = true } token-info = { workspace = true } @@ -75,6 +76,7 @@ mockall = { workspace = true } price-estimation = { workspace = true, features = ["test-util"] } regex = { workspace = true } signature-validator = { workspace = true, features = ["test-util"] } +simulator = { workspace = true, features = ["test-util"] } testlib = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread"] } toml = { workspace = true } diff --git a/crates/shared/src/order_validation.rs b/crates/shared/src/order_validation.rs index 5d6c5978d8..aa11fb58b9 100644 --- a/crates/shared/src/order_validation.rs +++ b/crates/shared/src/order_validation.rs @@ -29,6 +29,7 @@ use { OrderData, OrderKind, OrderMetadata, + OrderUid, SellTokenSource, VerificationError, }, @@ -43,10 +44,73 @@ use { trade_verifier::code_fetching::CodeFetching, }, signature_validator::{SignatureCheck, SignatureValidating, SignatureValidationError}, + simulator::order_simulation::{OrderSimulating, OrderSimulationError}, std::{sync::Arc, time::Duration}, tracing::instrument, }; +/// Runs the full order simulation alongside the cheap EIP-1271 signature +/// check. Disagreements are logged. The simulation never rejects the order +/// (shadow-mode behaviour). An enforce-mode follow-up will use the same +/// simulator output to reject orders whose simulation reverts. +#[derive(Clone)] +pub struct OrderSimulator { + pub simulator: Arc, + pub timeout: Duration, +} + +/// Runs the simulation under the configured timeout, folding the timeout +/// case into [`OrderSimulationError::Infra`]. +async fn timed_simulation( + config: &OrderSimulator, + order: &Order, + full_app_data: String, +) -> Result<(), OrderSimulationError> { + match tokio::time::timeout( + config.timeout, + config.simulator.simulate(order, full_app_data), + ) + .await + { + Err(_) => Err(OrderSimulationError::Infra(anyhow!( + "order simulation timeout" + ))), + Ok(res) => res, + } +} + +/// Logs the simulation result alongside the signature outcome. Disagreements +/// (signature pass + simulation revert, or vice versa) and infra errors are +/// surfaced as warnings; agreement is silent. +fn log_simulation_outcome( + signature: &Result, + simulation: &Result<(), OrderSimulationError>, + order_uid: OrderUid, +) { + match (signature, simulation) { + ( + Ok(_), + Err(OrderSimulationError::Reverted { + reason, + tenderly_url, + }), + ) => tracing::warn!( + ?order_uid, + ?reason, + ?tenderly_url, + "order simulation disagreement: signature passed, simulation reverted", + ), + (Err(SignatureValidationError::Invalid), Ok(())) => tracing::warn!( + ?order_uid, + "order simulation disagreement: signature invalid, simulation passed", + ), + (_, Err(OrderSimulationError::Infra(err))) => { + tracing::warn!(?order_uid, ?err, "order simulation infra error") + } + _ => {} + } +} + #[cfg_attr(any(test, feature = "test-util"), mockall::automock)] #[async_trait::async_trait] pub trait OrderValidating: Send + Sync { @@ -246,6 +310,7 @@ pub struct OrderValidator { quoter: Arc, balance_fetcher: Arc, signature_validator: Arc, + order_simulator: Option, limit_order_counter: Arc, max_limit_orders_per_user: u64, pub code_fetcher: Arc, @@ -316,6 +381,7 @@ impl OrderValidator { quoter: Arc, balance_fetcher: Arc, signature_validator: Arc, + order_simulator: Option, limit_order_counter: Arc, max_limit_orders_per_user: u64, code_fetcher: Arc, @@ -333,6 +399,7 @@ impl OrderValidator { quoter, balance_fetcher, signature_validator, + order_simulator, limit_order_counter, max_limit_orders_per_user, code_fetcher, @@ -342,6 +409,122 @@ impl OrderValidator { } } + /// Computes the `verification_gas_limit` for an order. Returns `0` for + /// non-EIP-1271 signatures, otherwise delegates to `run_eip1271_checks`. + async fn calculate_verification_gas_limit( + &self, + order: &OrderCreation, + data: &OrderData, + app_data: &OrderAppData, + domain_separator: &DomainSeparator, + owner: Address, + uid: OrderUid, + ) -> Result { + let Signature::Eip1271(signature) = &order.signature else { + return Ok(0u64); + }; + + let hash = hashed_eip712_message(domain_separator, &data.hash_struct()); + let check = SignatureCheck::new( + owner, + hash.0, + signature.to_owned(), + app_data.interactions.pre.clone(), + app_data + .inner + .protocol + .flashloan + .as_ref() + .map(|loan| BalanceOverrideRequest { + token: loan.token, + holder: loan.receiver, + amount: loan.amount, + }), + ); + let preview_order = Order { + metadata: OrderMetadata { + owner, + uid, + ..Default::default() + }, + data: *data, + signature: order.signature.clone(), + interactions: app_data.interactions.clone(), + }; + let full_app_data = app_data.inner.document.clone(); + self.run_eip1271_checks(check, &preview_order, full_app_data, hash) + .await + } + + /// Entry point for the EIP-1271 block of `validate_and_construct_order`. + /// + /// When the [`OrderValidator::eip1271_skip_creation_validation`] flag is: + /// + /// - `true`: the cheap `isValidSignature` check is bypassed by the + /// operator, and we return a `verification_gas_limit` of `0` (no gas was + /// spent on-chain verifying the signature). If the optional + /// [`OrderSimulator`] is configured, the full simulation still runs for + /// observability only and can never reject. + /// - `false`: delegates to + /// [`OrderValidator::run_eip1271_with_signature_check`]. + async fn run_eip1271_checks( + &self, + check: SignatureCheck, + preview_order: &Order, + full_app_data: String, + hash: B256, + ) -> Result { + if self.eip1271_skip_creation_validation { + if let Some(config) = &self.order_simulator { + let simulation = timed_simulation(config, preview_order, full_app_data).await; + // No signature outcome to compare against, so synthesize a + // signature-pass: only simulation reverts and infra errors log. + log_simulation_outcome(&Ok(0), &simulation, preview_order.metadata.uid); + } + return Ok(0u64); + } + self.run_eip1271_with_signature_check(check, preview_order, full_app_data, hash) + .await + } + + /// Runs the cheap `isValidSignature` check and, when a simulator is + /// configured, the full order simulation concurrently. The signature + /// result decides whether the order is accepted. The simulation result + /// is logged for observability (shadow mode), it never rejects. + /// Simulation infra errors (RPC / Tenderly / timeout) are logged. + async fn run_eip1271_with_signature_check( + &self, + check: SignatureCheck, + preview_order: &Order, + full_app_data: String, + hash: B256, + ) -> Result { + let signature_fut = self + .signature_validator + .validate_signature_and_get_additional_gas(check); + + let Some(config) = &self.order_simulator else { + return signature_fut.await.map_err(|err| match err { + SignatureValidationError::Invalid => ValidationError::InvalidEip1271Signature(hash), + SignatureValidationError::Other(err) => ValidationError::Other(err), + }); + }; + + // Shadow mode: the simulation runs for observability only. Disagreements + // are logged below and never affect the return value. The enforce-mode + // follow-up will consume `simulation` here to reject orders whose + // simulation reverts. + let simulation_fut = timed_simulation(config, preview_order, full_app_data); + let (signature_res, simulation) = tokio::join!(signature_fut, simulation_fut); + + log_simulation_outcome(&signature_res, &simulation, preview_order.metadata.uid); + + signature_res.map_err(|err| match err { + SignatureValidationError::Invalid => ValidationError::InvalidEip1271Signature(hash), + SignatureValidationError::Other(err) => ValidationError::Other(err), + }) + } + async fn check_max_limit_orders(&self, owner: Address) -> Result<(), ValidationError> { let num_limit_orders = self .limit_order_counter @@ -637,39 +820,16 @@ impl OrderValidating for OrderValidator { }; let uid = data.uid(domain_separator, owner); - let verification_gas_limit = if let Signature::Eip1271(signature) = &order.signature { - if self.eip1271_skip_creation_validation { - tracing::debug!(?signature, "skipping EIP-1271 signature validation"); - // We don't care! Because we are skipping validation anyway - 0u64 - } else { - let hash = hashed_eip712_message(domain_separator, &data.hash_struct()); - self.signature_validator - .validate_signature_and_get_additional_gas(SignatureCheck { - signer: owner, - hash: hash.0, - signature: signature.to_owned(), - interactions: app_data.interactions.pre.clone(), - balance_override: app_data.inner.protocol.flashloan.as_ref().map(|loan| { - BalanceOverrideRequest { - token: loan.token, - holder: loan.receiver, - amount: loan.amount, - } - }), - }) - .await - .map_err(|err| match err { - SignatureValidationError::Invalid => { - ValidationError::InvalidEip1271Signature(hash) - } - SignatureValidationError::Other(err) => ValidationError::Other(err), - })? - } - } else { - // in any other case, just apply 0 - 0u64 - }; + let verification_gas_limit = self + .calculate_verification_gas_limit( + &order, + &data, + &app_data, + domain_separator, + owner, + uid, + ) + .await?; if data.buy_amount.is_zero() || data.sell_amount.is_zero() { return Err(ValidationError::ZeroAmount); @@ -1057,7 +1217,7 @@ mod tests { crate::order_quoting::{FindQuoteError, MockOrderQuoting}, account_balances::MockBalanceFetching, alloy::{ - primitives::{Address, U160, address, b256}, + primitives::{Address, U160, U256, address, b256}, providers::{Provider, ProviderBuilder, mock::Asserter}, signers::local::PrivateKeySigner, }, @@ -1072,8 +1232,11 @@ mod tests { price_estimation::trade_verifier::code_fetching::MockCodeFetching, serde_json::json, signature_validator::MockSignatureValidating, + simulator::order_simulation::MockOrderSimulating, }; + const DEFAULT_ORDER_SIM_TIMEOUT: Duration = Duration::from_secs(2); + #[tokio::test] async fn pre_validate_err() { let native_token = WETH9::Instance::new([0xef; 20].into(), ethrpc::mock::web3().provider); @@ -1095,7 +1258,7 @@ mod tests { false, DenyListedTokens::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1103,6 +1266,7 @@ mod tests { Arc::new(MockOrderQuoting::new()), Arc::new(MockBalanceFetching::new()), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -1251,7 +1415,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1259,6 +1423,7 @@ mod tests { Arc::new(MockOrderQuoting::new()), Arc::new(MockBalanceFetching::new()), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -1332,7 +1497,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1340,6 +1505,7 @@ mod tests { Arc::new(MockOrderQuoting::new()), Arc::new(MockBalanceFetching::new()), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -1454,6 +1620,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), signature_validating, + None, Arc::new(limit_order_counter), max_limit_orders_per_user, Arc::new(MockCodeFetching::new()), @@ -1534,13 +1701,13 @@ mod tests { let mut signature_validator = MockSignatureValidating::new(); signature_validator .expect_validate_signature_and_get_additional_gas() - .with(eq(SignatureCheck { - signer: creation.from.unwrap(), - hash: order_hash.0, - signature: vec![1, 2, 3], - interactions: pre_interactions.clone(), - balance_override: None, - })) + .with(eq(SignatureCheck::new( + creation.from.unwrap(), + order_hash.0, + vec![1, 2, 3], + pre_interactions.clone(), + None, + ))) .returning(|_| Ok(0u64)); let validator = OrderValidator { @@ -1563,13 +1730,13 @@ mod tests { let mut signature_validator = MockSignatureValidating::new(); signature_validator .expect_validate_signature_and_get_additional_gas() - .with(eq(SignatureCheck { - signer: creation.from.unwrap(), - hash: order_hash.0, - signature: vec![1, 2, 3], - interactions: pre_interactions.clone(), - balance_override: None, - })) + .with(eq(SignatureCheck::new( + creation.from.unwrap(), + order_hash.0, + vec![1, 2, 3], + pre_interactions.clone(), + None, + ))) .returning(|_| Err(SignatureValidationError::Invalid)); let validator = OrderValidator { @@ -1659,7 +1826,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1667,6 +1834,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), signature_validating, + None, Arc::new(limit_order_counter), MAX_LIMIT_ORDERS_PER_USER, Arc::new(MockCodeFetching::new()), @@ -1732,7 +1900,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1740,6 +1908,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), signature_validating, + None, Arc::new(limit_order_counter), MAX_LIMIT_ORDERS_PER_USER, Arc::new(MockCodeFetching::new()), @@ -1793,7 +1962,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1801,6 +1970,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -1847,7 +2017,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1855,6 +2025,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -1905,7 +2076,7 @@ mod tests { false, deny_listed_tokens, HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1913,6 +2084,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -1966,7 +2138,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -1974,6 +2146,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -2026,7 +2199,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -2034,6 +2207,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(signature_validator), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -2101,6 +2275,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -2184,7 +2359,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -2192,6 +2367,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(MockSignatureValidating::new()), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -2596,7 +2772,7 @@ mod tests { false, Default::default(), HooksTrampoline::Instance::new( - Address::from([0xcf; 20]), + Address::repeat_byte(0xcf), ProviderBuilder::new() .connect_mocked_client(Asserter::new()) .erased(), @@ -2604,6 +2780,7 @@ mod tests { Arc::new(order_quoter), Arc::new(balance_fetcher), Arc::new(signature_validating), + None, Arc::new(limit_order_counter), 0, Arc::new(MockCodeFetching::new()), @@ -2640,4 +2817,240 @@ mod tests { assert_eq!(quote_id, returned_quote_id.and_then(|quote| quote.id)); } + + fn make_1271_order_creation() -> OrderCreation { + OrderCreation { + valid_to: time::now_in_epoch_seconds() + 2, + sell_token: Address::with_last_byte(1), + buy_token: Address::with_last_byte(2), + buy_amount: U256::ONE, + sell_amount: U256::ONE, + fee_amount: U256::ZERO, + from: Some(Address::repeat_byte(1)), + signature: Signature::Eip1271(vec![1, 2, 3]), + app_data: OrderCreationAppData::Full { + full: "{}".to_string(), + }, + ..Default::default() + } + } + + fn order_simulator(sim: MockOrderSimulating) -> OrderSimulator { + OrderSimulator { + simulator: Arc::new(sim), + timeout: DEFAULT_ORDER_SIM_TIMEOUT, + } + } + + fn build_1271_validator( + signature_validator: MockSignatureValidating, + order_simulator: Option, + eip1271_skip_creation_validation: bool, + ) -> OrderValidator { + // The quote lookup, balance fetch, and limit-order count are off the + // path under test here — stub them to always succeed so every test + // reaches the EIP-1271 block without tripping earlier validation. + let mut order_quoter = MockOrderQuoting::new(); + order_quoter + .expect_find_quote() + .returning(|_, _| Ok(Default::default())); + let mut balance_fetcher = MockBalanceFetching::new(); + balance_fetcher + .expect_can_transfer() + .returning(|_, _| Ok(())); + let mut limit_order_counter = MockLimitOrderCounting::new(); + limit_order_counter.expect_count().returning(|_| Ok(0u64)); + let native_token = + WETH9::Instance::new(Address::repeat_byte(0xef), ethrpc::mock::web3().provider); + OrderValidator::new( + native_token, + Arc::new(order_validation::banned::Users::none()), + OrderValidPeriodConfiguration::any(), + eip1271_skip_creation_validation, + Default::default(), + HooksTrampoline::Instance::new( + Address::repeat_byte(0xcf), + ProviderBuilder::new() + .connect_mocked_client(Asserter::new()) + .erased(), + ), + Arc::new(order_quoter), + Arc::new(balance_fetcher), + Arc::new(signature_validator), + order_simulator, + Arc::new(limit_order_counter), + 0, + Arc::new(MockCodeFetching::new()), + Default::default(), + u64::MAX, + SameTokensPolicy::Disallow, + ) + } + + /// Verifies the (signature × simulation) outcome matrix in shadow mode. + /// The signature result alone decides acceptance; the simulation result + /// is observed only. + #[tokio::test] + async fn signature_and_simulation_outcome_matrix() { + #[derive(Copy, Clone, Debug)] + enum Sig { + Pass, + Invalid, + } + #[derive(Copy, Clone, Debug)] + enum Sim { + Pass, + Reverted, + } + #[derive(Copy, Clone, Debug)] + enum Expected { + Accepted, + InvalidSignature, + } + + let cases: &[(Sig, Sim, Expected)] = &[ + (Sig::Pass, Sim::Pass, Expected::Accepted), + (Sig::Pass, Sim::Reverted, Expected::Accepted), + (Sig::Invalid, Sim::Pass, Expected::InvalidSignature), + (Sig::Invalid, Sim::Reverted, Expected::InvalidSignature), + ]; + + for &(sig, simulation, expected) in cases { + let label = format!("sig={sig:?} sim={simulation:?}"); + let mut signature_validator = MockSignatureValidating::new(); + signature_validator + .expect_validate_signature_and_get_additional_gas() + .returning(move |_| match sig { + Sig::Pass => Ok(0u64), + Sig::Invalid => Err(SignatureValidationError::Invalid), + }); + let mut sim = MockOrderSimulating::new(); + sim.expect_simulate() + .returning(move |_, _| match simulation { + Sim::Pass => Ok(()), + Sim::Reverted => Err(OrderSimulationError::Reverted { + reason: "hook reverted".into(), + tenderly_url: None, + }), + }); + let validator = + build_1271_validator(signature_validator, Some(order_simulator(sim)), false); + let result = validator + .validate_and_construct_order( + make_1271_order_creation(), + &DomainSeparator::default(), + Default::default(), + None, + ) + .await; + match expected { + Expected::Accepted => assert!(result.is_ok(), "{label}: got {result:?}"), + Expected::InvalidSignature => assert!( + matches!(result, Err(ValidationError::InvalidEip1271Signature(_))), + "{label}: got {result:?}" + ), + } + } + } + + #[tokio::test] + async fn simulation_infra_error_is_fail_open() { + let mut signature_validator = MockSignatureValidating::new(); + signature_validator + .expect_validate_signature_and_get_additional_gas() + .returning(|_| Ok(0u64)); + let mut sim = MockOrderSimulating::new(); + sim.expect_simulate() + .returning(|_, _| Err(OrderSimulationError::Infra(anyhow!("RPC down")))); + let validator = + build_1271_validator(signature_validator, Some(order_simulator(sim)), false); + let result = validator + .validate_and_construct_order( + make_1271_order_creation(), + &DomainSeparator::default(), + Default::default(), + None, + ) + .await; + assert!(result.is_ok(), "expected Ok, got {result:?}"); + } + + #[tokio::test] + async fn skip_flag_runs_simulation_only_and_never_rejects() { + let mut signature_validator = MockSignatureValidating::new(); + // With `eip1271_skip_creation_validation = true`, the signature + // validator must not be called. + signature_validator + .expect_validate_signature_and_get_additional_gas() + .times(0); + let mut sim = MockOrderSimulating::new(); + sim.expect_simulate().returning(|_, _| { + Err(OrderSimulationError::Reverted { + reason: "x".into(), + tenderly_url: None, + }) + }); + let validator = build_1271_validator(signature_validator, Some(order_simulator(sim)), true); + let result = validator + .validate_and_construct_order( + make_1271_order_creation(), + &DomainSeparator::default(), + Default::default(), + None, + ) + .await; + assert!(result.is_ok(), "got {result:?}"); + } + + #[tokio::test] + async fn simulator_is_not_invoked_for_non_eip1271_orders() { + // Only EIP-1271 orders go through the simulation path; verify that an + // EOA order does not invoke the simulator. + let mut signature_validator = MockSignatureValidating::new(); + signature_validator + .expect_validate_signature_and_get_additional_gas() + .times(0); + let mut sim = MockOrderSimulating::new(); + sim.expect_simulate().times(0); + let validator = + build_1271_validator(signature_validator, Some(order_simulator(sim)), false); + + let eoa_order = OrderCreation { + signature: Signature::Eip712(EcdsaSignature::non_zero()), + ..make_1271_order_creation() + }; + // Ignore the final result (it will fail WrongOwner/etc. later in the + // pipeline - we only care that the sim was not invoked). + let _ = validator + .validate_and_construct_order( + eoa_order, + &DomainSeparator::default(), + Default::default(), + None, + ) + .await; + // `sim.expect_simulate().times(0)` asserts on drop. + } + + #[tokio::test] + async fn no_simulator_configured_returns_invalid_eip1271_signature_on_invalid_signature() { + let mut signature_validator = MockSignatureValidating::new(); + signature_validator + .expect_validate_signature_and_get_additional_gas() + .returning(|_| Err(SignatureValidationError::Invalid)); + let validator = build_1271_validator(signature_validator, None, false); + let err = validator + .validate_and_construct_order( + make_1271_order_creation(), + &DomainSeparator::default(), + Default::default(), + None, + ) + .await + .unwrap_err(); + assert!( + matches!(err, ValidationError::InvalidEip1271Signature(_)), + "got {err:?}" + ); + } } diff --git a/crates/signature-validator/src/lib.rs b/crates/signature-validator/src/lib.rs index 8f1a20cfb6..819adbf7cc 100644 --- a/crates/signature-validator/src/lib.rs +++ b/crates/signature-validator/src/lib.rs @@ -22,6 +22,22 @@ pub struct SignatureCheck { } impl SignatureCheck { + pub fn new( + signer: Address, + hash: [u8; 32], + signature: Vec, + interactions: Vec, + balance_override: Option, + ) -> Self { + Self { + signer, + hash, + signature, + interactions, + balance_override, + } + } + /// A signature check requires setup when there are interactions to be taken /// into account or when the balance override is set. /// diff --git a/crates/simulator/src/lib.rs b/crates/simulator/src/lib.rs index 78856d3031..c32edb38e5 100644 --- a/crates/simulator/src/lib.rs +++ b/crates/simulator/src/lib.rs @@ -1,5 +1,6 @@ pub mod encoding; pub mod ethereum; +pub mod order_simulation; pub mod simulation_builder; pub mod tenderly; mod utils; diff --git a/crates/simulator/src/order_simulation.rs b/crates/simulator/src/order_simulation.rs new file mode 100644 index 0000000000..a49c6ea159 --- /dev/null +++ b/crates/simulator/src/order_simulation.rs @@ -0,0 +1,95 @@ +use { + crate::simulation_builder::{ + self, + Block, + ExecutionAmount, + PriceEncoding, + SettlementSimulator, + Solver, + }, + anyhow::anyhow, + async_trait::async_trait, + model::order::Order, +}; + +/// Outcome of the order creation simulation. +#[derive(Debug)] +pub enum OrderSimulationError { + /// The simulation ran and the transaction reverted. `reason` is the + /// revert string returned by the EVM (or a Tenderly reason string). + Reverted { + reason: String, + tenderly_url: Option, + }, + /// The simulation could not run (RPC failure, Tenderly error, malformed + /// input, timeout). Treated as fail-open. + Infra(anyhow::Error), +} + +/// Simulates an order's pre-hooks, swap, and post-hooks against the chain. +#[cfg_attr(any(test, feature = "test-util"), mockall::automock)] +#[async_trait] +pub trait OrderSimulating: Send + Sync { + async fn simulate( + &self, + order: &Order, + full_app_data: String, + ) -> Result<(), OrderSimulationError>; +} + +/// Drives [`SettlementSimulator`] to run a full-order simulation at order +/// creation time, including pre/post hooks, swap, and any wrapper chain. +pub struct OrderSimulatorAdapter { + inner: SettlementSimulator, +} + +impl OrderSimulatorAdapter { + pub fn new(inner: SettlementSimulator) -> Self { + Self { inner } + } +} + +#[async_trait] +impl OrderSimulating for OrderSimulatorAdapter { + async fn simulate( + &self, + order: &Order, + full_app_data: String, + ) -> Result<(), OrderSimulationError> { + let inputs = self + .inner + .new_simulation_builder() + .with_orders([simulation_builder::Order::new(order.data) + .with_signature(order.metadata.owner, order.signature.clone()) + .fill_at(ExecutionAmount::Full, PriceEncoding::LimitPrice)]) + .parameters_from_app_data(&full_app_data) + .map_err(|err| OrderSimulationError::Infra(anyhow!(err).context("parse app data")))? + .from_solver(Solver::Fake(None)) + .provide_sufficient_buy_tokens() + .at_block(Block::Latest) + .build() + .await + .map_err(|err| OrderSimulationError::Infra(anyhow!(err).context("build")))?; + + // Capture the Tenderly handle and the diagnostic request before + // consuming `inputs` with `simulate()`. The Tenderly call is only + // dispatched on revert, since the URL is only useful for diagnostics + // and most simulations succeed. + let tenderly = inputs.simulator.tenderly(); + let tenderly_request = inputs.to_tenderly_request().ok(); + + match inputs.simulate().await { + Ok(_) => Ok(()), + Err(err) => { + let tenderly_url = match (tenderly, tenderly_request) { + (Some(api), Some(req)) => api.simulate_and_share(req).await.ok(), + _ => None, + }; + Err(OrderSimulationError::Reverted { + reason: err.to_string(), + tenderly_url, + }) + } + } + } +} diff --git a/crates/simulator/src/simulation_builder.rs b/crates/simulator/src/simulation_builder.rs index 202ef7f79b..e97ae53e99 100644 --- a/crates/simulator/src/simulation_builder.rs +++ b/crates/simulator/src/simulation_builder.rs @@ -93,6 +93,10 @@ impl SettlementSimulator { self.0.authenticator } + pub fn tenderly(&self) -> Option> { + self.0.tenderly.clone() + } + pub fn new_simulation_builder(&self) -> SimulationBuilder { SimulationBuilder { simulator: self.clone(), diff --git a/crates/simulator/tests/aave_replay.rs b/crates/simulator/tests/aave_replay.rs new file mode 100644 index 0000000000..a5308d980f --- /dev/null +++ b/crates/simulator/tests/aave_replay.rs @@ -0,0 +1,314 @@ +use { + alloy_primitives::{U256, address, hex}, + app_data::{AppDataHash, hash_full_app_data}, + model::{ + order::{BuyTokenDestination, OrderData, OrderKind, SellTokenSource}, + signature::Signature, + }, + simulator::simulation_builder::{ + self, + Block, + EthCallInputs, + ExecutionAmount, + PriceEncoding, + SettlementSimulator, + Solver, + }, + std::{str::FromStr, sync::Arc}, +}; + +/// Full `app_data` JSON the trader signed for the replayed Aave v3 debt-swap +/// order. Source-level whitespace is for readability only - run through +/// `canonicalise_app_data` before hashing or passing downstream. +const APP_DATA: &str = r#"{ + "appCode": "aave-v3-interface-debt-swap", + "metadata": { + "flashloan": { + "amount": "4475596734006878742", + "liquidityProvider": "0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2", + "protocolAdapter": "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", + "receiver": "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192", + "token": "0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2" + }, + "hooks": { + "post": [{ + "callData": "0xad3da559000000000000000000000000000000000000000000000000444d51cbc68377680000000000000000000000000000000000000000000000000000000069f323f8000000000000000000000000000000000000000000000000000000000000001c445675473b3e0941842eb5405ec1d9cb93c7d64b513b30d928d7ea42067440cb00fbafa80085d754964d5d70f616d899049f4e75c240fda7c2f108a99c882e8b", + "dappId": "cow-sdk://flashloans/aave/v3/debt-swap", + "gasLimit": "1000000", + "target": "0xe58aCB86761699c1cBC665e6b7E0271503f6336C" + }], + "pre": [{ + "callData": "0xb1b6308b00000000000000000000000073e7af13ef172f13d8fefebfd90c7a65300963440000000000000000000000006276ac03090f2bb8be680178343ac368f713b4e8000000000000000000000000e58acb86761699c1cbc665e6b7e0271503f6336c000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000040d16fc0246ad3160ccc09b8d0d3a2cd28ae6c2f0000000000000000000000000000000000000000000000003e14904047a25ee600000000000000000000000000000000000000000000021e4382edd5a86c00006ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc0000000000000000000000000000000000000000000000000000000069f323f80000000000000000000000000000000000000000000000003e1c83845060e2160000000000000000000000000000000000000000000000000007f34408be83300000000000000000000000000000000000000000000000003e1c83845060e21600000000000000000000000000000000000000000000021e4382edd5a86c0000", + "dappId": "cow-sdk://flashloans/aave/v3/debt-swap", + "gasLimit": "300000", + "target": "0xdeCC46a4b09162F5369c5C80383AAa9159bCf192" + }] + }, + "orderClass": {"orderClass": "market"}, + "partnerFee": {"recipient": "0x464C71f6c2F760DdA6093dCB91C24c39e5d6e18c", "volumeBps": 0}, + "quote": {"slippageBips": 140, "smartSlippage": true}, + "utm": { + "utmCampaign": "developer-cohort", + "utmContent": "", + "utmMedium": "cow-sdk@7.3.4", + "utmSource": "cowmunity", + "utmTerm": "js" + } + }, + "version": "1.14.0" +}"#; + +/// Minifies `app_data` with keys sorted alphabetically. +fn canonicalise_app_data(app_data: &str) -> String { + let value: serde_json::Value = + serde_json::from_str(app_data).expect("APP_DATA must be valid JSON"); + serde_json::to_string(&value).expect("re-serialising must succeed") +} + +/// Production EIP-1271 signature blob for the replayed order. +const SIGNATURE_HEX: &str = "0x000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000040d16fc0246ad3160ccc09b8d0d3a2cd28ae6c2f000000000000000000000000e58acb86761699c1cbc665e6b7e0271503f6336c0000000000000000000000000000000000000000000000003e14904047a25ee600000000000000000000000000000000000000000000021e4382edd5a86c00000000000000000000000000000000000000000000000000000000000069f323f8a1435054976e030f531f620f051bbabe34ef387901808b8677cf7c9304c21f3c00000000000000000000000000000000000000000000000000000000000000006ed88e868af0a1983e3886d5f3e95a2fafbd6c3450bc229e27342283dc429ccc00000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000041bb5488854dd5149f8843514851b7e25499917ca742af77061d2355681f3b608157bb34a59f9632e2228ea869c6d571f822295ee2eb03904dd8dd874245478f3b1b00000000000000000000000000000000000000000000000000000000000000"; + +/// AAVE v3 debt-swap order +/// `0x7f5df255b55f5eba3034f74acb8e91a04aaf61a755b88c61ad7c61068856f3b2e58acb86761699c1cbc665e6b7e0271503f6336c69f323f8`, +/// owner is an EIP-1167 minimal proxy the pre-hook deploys JIT. +/// +/// We exercise `SettlementSimulator` directly instead of going through +/// `OrderValidator::validate_and_construct_order`, which bounds `valid_to` +/// by `SystemTime::now()` and would make this test rot. +#[tokio::test] +#[ignore] +async fn aave_debt_swap_replay() { + let Ok(rpc_url) = std::env::var("MAINNET_RPC_URL") else { + eprintln!("MAINNET_RPC_URL not set - skipping replay test"); + return; + }; + + let canonical_app_data = canonicalise_app_data(APP_DATA); + let inputs = build_replay_simulation(&rpc_url, &canonical_app_data).await; + + inputs + .simulate() + .await + .expect("simulation must not revert for a healthy production order"); +} + +/// Same order, but the `flashloan.amount` in `app_data` is rewritten to a +/// value Aave's WETH pool cannot satisfy. The wrapper call to the Aave Pool +/// must revert, and the simulation must propagate that revert. +/// +/// This proves the prototype actually executes the flashloan path: if the +/// wrapper call were a silent no-op (e.g. wrong router address), the +/// simulation would not depend on Aave's liquidity at all and would not +/// fail here. +#[tokio::test] +#[ignore] +async fn aave_debt_swap_replay_fails_when_flashloan_oversubscribed() { + let Ok(rpc_url) = std::env::var("MAINNET_RPC_URL") else { + eprintln!("MAINNET_RPC_URL not set - skipping replay test"); + return; + }; + + let mut value: serde_json::Value = + serde_json::from_str(APP_DATA).expect("APP_DATA must be valid JSON"); + // Way more WETH than Aave can lend. Aave reverts with insufficient + // liquidity (or similar) before any settlement runs. + value["metadata"]["flashloan"]["amount"] = serde_json::Value::String(U256::MAX.to_string()); + let tampered_app_data = serde_json::to_string(&value).unwrap(); + + let inputs = build_replay_simulation(&rpc_url, &tampered_app_data).await; + + let err = inputs + .simulate() + .await + .expect_err("simulation must revert when the flashloan exceeds Aave liquidity"); + let msg = format!("{err:?}"); + assert!( + msg.contains("execution reverted"), + "expected an EVM revert, got: {msg}", + ); +} + +/// Shared builder for the positive replay (`APP_DATA`) and the +/// flashloan-tampered negative replay. +async fn build_replay_simulation(rpc_url: &str, full_app_data: &str) -> EthCallInputs { + // Pinned one block before the on-chain settlement: pre-hook hasn't + // yet deployed the owner contract, Aave has WETH liquidity. + let fork_block_mainnet = 24_992_051u64; + let order_owner = address!("e58aCB86761699c1cBC665e6b7E0271503f6336C"); + let sell_token_weth = address!("c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2"); + let buy_token_gho = address!("40d16fc0246ad3160ccc09b8d0d3a2cd28ae6c2f"); + let sell_amount = U256::from_str("4473358935639875302").unwrap(); + let buy_amount = U256::from_str("10003000000000000000000").unwrap(); + let valid_to = 1_777_542_136u32; // 2026-04-30 09:42:16 UTC + + let web3 = ethrpc::Web3::new_from_url(rpc_url); + let provider = web3.provider.clone(); + let chain_id = 1u64; + + let settlement = contracts::GPv2Settlement::Instance::deployed(&provider) + .await + .expect("settlement contract not deployed on mainnet?"); + + let flash_loan_router = contracts::FlashLoanRouter::deployment_address(&chain_id) + .expect("FlashLoanRouter deployment address"); + let hooks_trampoline = contracts::HooksTrampoline::deployment_address(&chain_id) + .expect("HooksTrampoline deployment address"); + + let balance_overrider = Arc::new(balance_overrides::BalanceOverrides::new(web3)); + let block_stream = ethrpc::block_stream::mock_single_block(Default::default()); + + let simulator = SettlementSimulator::new( + settlement, + flash_loan_router, + hooks_trampoline, + sell_token_weth, + 30_000_000u64, + balance_overrider, + block_stream, + None, + ) + .await + .expect("failed to create SettlementSimulator"); + + let signature_bytes = hex::decode(SIGNATURE_HEX.trim_start_matches("0x")) + .expect("SIGNATURE_HEX must be valid hex"); + let app_data_hash = AppDataHash(hash_full_app_data(full_app_data.as_bytes())); + + let order_data = OrderData { + sell_token: sell_token_weth, + buy_token: buy_token_gho, + receiver: Some(order_owner), + sell_amount, + buy_amount, + valid_to, + app_data: app_data_hash, + fee_amount: U256::ZERO, + kind: OrderKind::Buy, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }; + + simulator + .new_simulation_builder() + .with_orders([simulation_builder::Order::new(order_data) + .with_signature(order_owner, Signature::Eip1271(signature_bytes)) + .fill_at(ExecutionAmount::Full, PriceEncoding::LimitPrice)]) + .parameters_from_app_data(full_app_data) + .expect("parameters_from_app_data should parse the app data") + .from_solver(Solver::Fake(None)) + .provide_sufficient_buy_tokens() + .at_block(Block::Number(fork_block_mainnet)) + .build() + .await + .expect("failed to build simulation") +} + +/// AAVE v3 collateral-swap order +/// `0x441ad034a3c8cd9ad0fc9a9d143c8201bc92d62851a8428997c36a89a03ee2ad4caea9074f2897a3a4ac173c4e5b5bd8b7e3dc976b5f9c6f` +/// which reverts onchain due to corrupted hooks +const NATURALLY_FAILING_APP_DATA: &str = r#"{"appCode":"aave-v3-interface-collateral-swap","metadata":{"flashloan":{"amount":"6136714","liquidityProvider":"0x87870Bca3F3fD6335C3F4ce8392D69350B4fA4E2","protocolAdapter":"0xdeCC46a4b09162F5369c5C80383AAa9159bCf192","receiver":"0xdeCC46a4b09162F5369c5C80383AAa9159bCf192","token":"0x2260fac5e5542a773aa44fbcfedf7c193bc2c599"},"hooks":{"post":[{"callData":"0x398925de00000000000000000000000000000000000000000000000000000000006700b10000000000000000000000000000000000000000000000000000000069850ebf000000000000000000000000000000000000000000000000000000000000001bb32da7ed8403b8369956ffc15f4c1295953004866fa786c0162d28bea3d18b3b08466a876f9a0cb5d1175ef44838426558b19d64b48002231a8c1c90821e08a4","dappId":"cow-sdk://flashloans/aave/v3/collateral-swap","gasLimit":"700000","target":"0x4CAea9074f2897a3A4ac173C4E5b5Bd8b7E3Dc97"}],"pre":[{"callData":"0xb1b6308b000000000000000000000000029d584e847373b6373b01dfad1a0c9bfb9163820000000000000000000000004ec7efb8c873f54c5f62830ec5ecc2362580bdfe0000000000000000000000004caea9074f2897a3a4ac173c4e5b5bd8b7e3dc970000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c599000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec700000000000000000000000000000000000000000000000000000000005d978d00000000000000000000000000000000000000000000000000000000c9f769e0f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee346775000000000000000000000000000000000000000000000000000000006b5f9c6f00000000000000000000000000000000000000000000000000000000005da38a0000000000000000000000000000000000000000000000000000000000000bfd00000000000000000000000000000000000000000000000000000000005da38a00000000000000000000000000000000000000000000000000000000c9f769e0","dappId":"cow-sdk://flashloans/aave/v3/collateral-swap","gasLimit":"300000","target":"0xdeCC46a4b09162F5369c5C80383AAa9159bCf192"}]},"orderClass":{"orderClass":"limit"},"partnerFee":{"recipient":"0xC542C2F197c4939154017c802B0583C596438380","volumeBps":25},"quote":{"slippageBips":0,"smartSlippage":true},"utm":{"utmCampaign":"developer-cohort","utmContent":"","utmMedium":"cow-sdk@7.3.4","utmSource":"cowmunity","utmTerm":"js"}},"version":"1.14.0"}"#; + +const NATURALLY_FAILING_SIGNATURE_HEX: &str = "0x0000000000000000000000002260fac5e5542a773aa44fbcfedf7c193bc2c599000000000000000000000000dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000004caea9074f2897a3a4ac173c4e5b5bd8b7e3dc9700000000000000000000000000000000000000000000000000000000005d978d00000000000000000000000000000000000000000000000000000000c9f769e0000000000000000000000000000000000000000000000000000000006b5f9c6f4223823feadc36c4373c9d88ac1e9875d067e3df5ced70c0a1fedcec396f34d10000000000000000000000000000000000000000000000000000000000000000f3b277728b3fee749481eb3e0b3b48980dbbab78658fc419025cb16eee34677500000000000000000000000000000000000000000000000000000000000000005a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc95a28e9363bb942b639270062aa6bb295f434bcdfc42c97267bf003f272060dc900000000000000000000000000000000000000000000000000000000000001a00000000000000000000000000000000000000000000000000000000000000041dd46f4ee5ab3d65846780582c116a93cf37d99be5c87ed1638e3f52bc39861b91595d66c1afd17d585d6ccdbe7b643a2d44b46d1a919f479224e49934c293f231c00000000000000000000000000000000000000000000000000000000000000"; + +/// Replay of a real production order that the mainnet driver was repeatedly +/// dropping with `Simulation(Revert(_))`. The pre-hook reverts because the +/// trader's aToken balance is insufficient for the collateral swap. +#[tokio::test] +#[ignore] +async fn aave_collateral_swap_replay_fails_naturally() { + let Ok(rpc_url) = std::env::var("MAINNET_RPC_URL") else { + eprintln!("MAINNET_RPC_URL not set - skipping replay test"); + return; + }; + + let canonical_app_data = canonicalise_app_data(NATURALLY_FAILING_APP_DATA); + let inputs = build_naturally_failing_replay_simulation(&rpc_url, &canonical_app_data).await; + + let err = inputs + .simulate() + .await + .expect_err("simulation must revert: pre-hook moves aToken the trader no longer holds"); + let msg = format!("{err:?}"); + assert!( + msg.contains("execution reverted"), + "expected an EVM revert, got: {msg}", + ); +} + +async fn build_naturally_failing_replay_simulation( + rpc_url: &str, + full_app_data: &str, +) -> EthCallInputs { + // Block taken from a `BlockNo(...)` embedded in one of the dropped- + // solution events for this order on 2026-05-05. + let fork_block_mainnet = 25_028_258u64; + let chain_id = 1u64; + let order_owner = address!("4caea9074f2897a3a4ac173c4e5b5bd8b7e3dc97"); + let sell_token_a_wbtc = address!("2260fac5e5542a773aa44fbcfedf7c193bc2c599"); + let buy_token_usdt = address!("dac17f958d2ee523a2206206994597c13d831ec7"); + let sell_amount = U256::from_str("6133645").unwrap(); + let buy_amount = U256::from_str("3388434912").unwrap(); + let valid_to = 1_801_428_079u32; // 2027-01-31 ish + + let web3 = ethrpc::Web3::new_from_url(rpc_url); + let provider = web3.provider.clone(); + + let settlement = contracts::GPv2Settlement::Instance::deployed(&provider) + .await + .expect("settlement contract not deployed on mainnet?"); + + let flash_loan_router = contracts::FlashLoanRouter::deployment_address(&chain_id) + .expect("FlashLoanRouter deployment address"); + let hooks_trampoline = contracts::HooksTrampoline::deployment_address(&chain_id) + .expect("HooksTrampoline deployment address"); + + let balance_overrider = Arc::new(balance_overrides::BalanceOverrides::new(web3)); + let block_stream = ethrpc::block_stream::mock_single_block(Default::default()); + + let simulator = SettlementSimulator::new( + settlement, + flash_loan_router, + hooks_trampoline, + sell_token_a_wbtc, + 30_000_000u64, + balance_overrider, + block_stream, + None, + ) + .await + .expect("failed to create SettlementSimulator"); + + let signature_bytes = hex::decode(NATURALLY_FAILING_SIGNATURE_HEX.trim_start_matches("0x")) + .expect("NATURALLY_FAILING_SIGNATURE_HEX must be valid hex"); + let app_data_hash = AppDataHash(hash_full_app_data(full_app_data.as_bytes())); + + let order_data = OrderData { + sell_token: sell_token_a_wbtc, + buy_token: buy_token_usdt, + receiver: Some(order_owner), + sell_amount, + buy_amount, + valid_to, + app_data: app_data_hash, + fee_amount: U256::ZERO, + kind: OrderKind::Sell, + partially_fillable: false, + sell_token_balance: SellTokenSource::Erc20, + buy_token_balance: BuyTokenDestination::Erc20, + }; + + simulator + .new_simulation_builder() + .with_orders([simulation_builder::Order::new(order_data) + .with_signature(order_owner, Signature::Eip1271(signature_bytes)) + .fill_at(ExecutionAmount::Full, PriceEncoding::LimitPrice)]) + .parameters_from_app_data(full_app_data) + .expect("parameters_from_app_data should parse the app data") + .from_solver(Solver::Fake(None)) + .provide_sufficient_buy_tokens() + .at_block(Block::Number(fork_block_mainnet)) + .build() + .await + .expect("failed to build simulation") +}