diff --git a/Cargo.lock b/Cargo.lock index eb6bdd516..a39f4e751 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9645,6 +9645,7 @@ dependencies = [ "serde_with", "sha2 0.10.9", "ssz_types", + "strum 0.27.2", "thiserror 1.0.69", "time", "tracing", diff --git a/crates/rbuilder-primitives/Cargo.toml b/crates/rbuilder-primitives/Cargo.toml index 10e3b4ee4..ca96f5f82 100644 --- a/crates/rbuilder-primitives/Cargo.toml +++ b/crates/rbuilder-primitives/Cargo.toml @@ -51,6 +51,7 @@ eyre.workspace = true serde.workspace = true derive_more.workspace = true serde_json.workspace = true +strum = { version = "0.27.2", features = ["derive"] } [dev-dependencies] alloy-primitives = { workspace = true, features = ["arbitrary"] } diff --git a/crates/rbuilder-primitives/src/ace.rs b/crates/rbuilder-primitives/src/ace.rs new file mode 100644 index 000000000..7308b21ed --- /dev/null +++ b/crates/rbuilder-primitives/src/ace.rs @@ -0,0 +1,611 @@ +use crate::evm_inspector::{SlotKey, UsedStateTrace}; +use alloy_primitives::{Address, FixedBytes, B256}; +use serde::Deserialize; +use std::collections::HashSet; + +/// 4-byte function selector +pub type Selector = FixedBytes<4>; + +/// Configuration for an ACE (Application Controlled Execution) protocol +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct AceConfig { + /// Whether this ACE config is enabled + #[serde(default = "default_enabled")] + pub enabled: bool, + /// The primary contract address for this ACE protocol (used as unique identifier) + pub contract_address: Address, + /// Addresses that send ACE orders (used to identify force unlocks) + pub from_addresses: HashSet
, + /// Addresses that receive ACE orders (the ACE contract addresses) + pub to_addresses: HashSet
, + /// Storage slots that must be read to detect ACE interaction (e.g., _lastBlockUpdated at slot 3) + pub detection_slots: HashSet, + /// Function selectors (4 bytes) that indicate an unlock operation + pub unlock_signatures: HashSet, + /// Function selectors (4 bytes) that indicate a forced unlock operation + pub force_signatures: HashSet, +} + +fn default_enabled() -> bool { + true +} + +/// Classify an ACE order interaction type based on state trace, simulation success, and config. +/// Uses both state trace (address access) AND function signatures to determine interaction type. +pub fn classify_ace_interaction( + state_trace: &UsedStateTrace, + sim_success: bool, + config: &AceConfig, + selector: Option, + tx_to: Option
, +) -> Option { + let any_ace_slots_accessed = config + .to_addresses + .iter() + .flat_map(|address| { + config.detection_slots.iter().map(|slot| SlotKey { + address: *address, + key: *slot, + }) + }) + .flat_map(|key| { + [ + state_trace.read_slot_values.contains_key(&key), + state_trace.written_slot_values.contains_key(&key), + ] + }) + .any(|read_slot_of_interest| read_slot_of_interest); + + if !any_ace_slots_accessed { + return None; + } + + // Check if this is a direct call to the protocol + let is_direct_protocol_call = tx_to.is_some_and(|to| config.to_addresses.contains(&to)); + + // Check function selectors with direct HashSet lookup + let is_force_sig = selector.is_some_and(|sel| config.force_signatures.contains(&sel)); + let is_unlock_sig = selector.is_some_and(|sel| config.unlock_signatures.contains(&sel)); + + let contract_address = config.contract_address; + + if sim_success && (is_force_sig || is_unlock_sig) { + let source = if is_direct_protocol_call && is_force_sig { + AceUnlockSource::ProtocolForce + } else if is_direct_protocol_call && is_unlock_sig { + AceUnlockSource::ProtocolOptional + } else { + AceUnlockSource::User + }; + Some(AceInteraction::Unlocking { + contract_address, + source, + }) + } else { + Some(AceInteraction::NonUnlocking { contract_address }) + } +} + +/// Source of an ACE unlock order +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AceUnlockSource { + /// Direct call to protocol with force signature - must always be included + ProtocolForce, + /// Direct call to protocol with optional unlock signature + ProtocolOptional, + /// Indirect interaction (user tx that interacts with ACE contract) + User, +} + +/// Type of ACE interaction for orders +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AceInteraction { + /// Unlocking ACE order - doesn't revert without an ACE order, must be placed with ACE bundle. + Unlocking { + contract_address: Address, + source: AceUnlockSource, + }, + /// Requires an unlocking ACE order, will revert otherwise + NonUnlocking { contract_address: Address }, +} + +impl AceInteraction { + pub fn is_unlocking(&self) -> bool { + matches!(self, Self::Unlocking { .. }) + } + + pub fn is_protocol_tx(&self) -> bool { + matches!( + self, + Self::Unlocking { + source: AceUnlockSource::ProtocolForce | AceUnlockSource::ProtocolOptional, + .. + } + ) + } + + pub fn is_force(&self) -> bool { + matches!( + self, + Self::Unlocking { + source: AceUnlockSource::ProtocolForce, + .. + } + ) + } + + pub fn get_contract_address(&self) -> Address { + match self { + AceInteraction::Unlocking { + contract_address, .. + } + | AceInteraction::NonUnlocking { contract_address } => *contract_address, + } + } +} + +/// Type of unlock for ACE protocol transactions (Order::Ace) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum AceUnlockType { + /// Must unlock, transaction will fail if unlock conditions aren't met + Force, + /// Optional unlock, transaction can proceed with or without unlock + Optional, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::evm_inspector::{SlotKey, UsedStateTrace}; + use alloy_primitives::hex; + use alloy_primitives::{address, b256}; + + /// Create the real ACE config from the provided TOML configuration + fn real_ace_config() -> AceConfig { + AceConfig { + enabled: true, + contract_address: address!("0000000aa232009084Bd71A5797d089AA4Edfad4"), + from_addresses: HashSet::from([ + address!("c41ae140ca9b281d8a1dc254c50e446019517d04"), + address!("d437f3372f3add2c2bc3245e6bd6f9c202e61bb3"), + address!("693ca5c6852a7d212dabc98b28e15257465c11f3"), + ]), + to_addresses: HashSet::from([address!("0000000aa232009084Bd71A5797d089AA4Edfad4")]), + // _lastBlockUpdated storage slot (slot 3) + detection_slots: HashSet::from([b256!( + "0000000000000000000000000000000000000000000000000000000000000003" + )]), + // unlockWithEmptyAttestation(address,bytes) nonpayable - 0x1828e0e7 + unlock_signatures: HashSet::from([Selector::from_slice(&[0x18, 0x28, 0xe0, 0xe7])]), + // execute(bytes) nonpayable - 0x09c5eabe + force_signatures: HashSet::from([Selector::from_slice(&[0x09, 0xc5, 0xea, 0xbe])]), + } + } + + /// Create a mock state trace with the detection slot accessed + fn mock_state_trace_with_slot(addr: Address, slot: B256) -> UsedStateTrace { + let mut trace = UsedStateTrace::default(); + trace.read_slot_values.insert( + SlotKey { + address: addr, + key: slot, + }, + Default::default(), + ); + trace + } + + #[test] + fn test_real_ace_force_order_classification() { + // Test with real force order calldata + let config = real_ace_config(); + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + let force_selector = *config.force_signatures.iter().next().unwrap(); + + // Mock state trace with detection slot accessed + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // Direct call to ACE contract with force signature should be ProtocolForce + let result = + classify_ace_interaction(&trace, true, &config, Some(force_selector), Some(contract)); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce + }) + ); + + // Verify it's detected as force + assert!(result.unwrap().is_force()); + assert!(result.unwrap().is_protocol_tx()); + } + + #[test] + fn test_real_ace_unlock_order_classification() { + // Test with real unlock signature from config + let config = real_ace_config(); + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + let unlock_selector = *config.unlock_signatures.iter().next().unwrap(); + + // Mock state trace with detection slot accessed + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // Direct call to ACE contract with unlock signature should be ProtocolOptional + let result = + classify_ace_interaction(&trace, true, &config, Some(unlock_selector), Some(contract)); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional + }) + ); + + // Verify it's protocol tx but not force + assert!(result.unwrap().is_protocol_tx()); + assert!(!result.unwrap().is_force()); + } + + #[test] + fn test_ace_user_unlock_indirect_call() { + // User transaction that calls ACE contract indirectly (not tx.to = contract) + let config = real_ace_config(); + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + let unlock_selector = *config.unlock_signatures.iter().next().unwrap(); + + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // tx.to is NOT the ACE contract (indirect call via user tx) = User unlock + let result = classify_ace_interaction(&trace, true, &config, Some(unlock_selector), None); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User + }) + ); + + // Verify it's unlocking but not a protocol tx + assert!(result.unwrap().is_unlocking()); + assert!(!result.unwrap().is_protocol_tx()); + } + + #[test] + fn test_ace_non_unlocking_interaction() { + // Transaction that accesses ACE slot but doesn't have unlock signature + let config = real_ace_config(); + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // No unlock/force signature = NonUnlocking + let result = classify_ace_interaction(&trace, true, &config, None, Some(contract)); + + assert_eq!( + result, + Some(AceInteraction::NonUnlocking { + contract_address: contract + }) + ); + + // Verify it's not an unlocking interaction + assert!(!result.unwrap().is_unlocking()); + } + + #[test] + fn test_ace_failed_sim_becomes_non_unlocking() { + // Even with unlock signature, failed simulation = NonUnlocking + let config = real_ace_config(); + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + let unlock_selector = *config.unlock_signatures.iter().next().unwrap(); + + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // sim_success = false turns unlock into NonUnlocking + let result = classify_ace_interaction( + &trace, + false, + &config, + Some(unlock_selector), + Some(contract), + ); + + assert_eq!( + result, + Some(AceInteraction::NonUnlocking { + contract_address: contract + }) + ); + + // Failed sim should not be considered unlocking + assert!(!result.unwrap().is_unlocking()); + } + + #[test] + fn test_ace_no_slot_access_returns_none() { + // If detection slot is not accessed, no ACE interaction detected + let config = real_ace_config(); + let empty_trace = UsedStateTrace::default(); + let force_selector = *config.force_signatures.iter().next().unwrap(); + + // Even with valid force signature, no slot access = None + let result = classify_ace_interaction( + &empty_trace, + true, + &config, + Some(force_selector), + Some(config.contract_address), + ); + + assert_eq!( + result, None, + "Should return None when detection slot is not accessed" + ); + } + + #[test] + fn test_ace_wrong_slot_returns_none() { + // Accessing wrong slot should return None + let config = real_ace_config(); + let wrong_slot = b256!("0000000000000000000000000000000000000000000000000000000000000099"); + let force_selector = *config.force_signatures.iter().next().unwrap(); + + let trace = mock_state_trace_with_slot(config.contract_address, wrong_slot); + + // Wrong slot accessed = None (even with valid signature) + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(force_selector), + Some(config.contract_address), + ); + + assert_eq!( + result, None, + "Should return None when wrong slot is accessed" + ); + } + + #[test] + fn test_ace_disabled_config() { + let mut config = real_ace_config(); + config.enabled = false; + + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + let force_selector = *config.force_signatures.iter().next().unwrap(); + + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // Classification still works even if disabled - filtering happens at higher level + let result = + classify_ace_interaction(&trace, true, &config, Some(force_selector), Some(contract)); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce + }) + ); + } + + #[test] + fn test_ace_interaction_is_unlocking() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let force = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce, + }; + assert!(force.is_unlocking()); + + let optional = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional, + }; + assert!(optional.is_unlocking()); + + let user = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User, + }; + assert!(user.is_unlocking()); + + let non_unlocking = AceInteraction::NonUnlocking { + contract_address: contract, + }; + assert!(!non_unlocking.is_unlocking()); + } + + #[test] + fn test_ace_interaction_is_protocol_tx() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let force = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce, + }; + assert!(force.is_protocol_tx()); + + let optional = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional, + }; + assert!(optional.is_protocol_tx()); + + let user = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User, + }; + assert!(!user.is_protocol_tx()); + } + + #[test] + fn test_ace_interaction_is_force() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let force = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce, + }; + assert!(force.is_force()); + + let optional = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional, + }; + assert!(!optional.is_force()); + + let user = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User, + }; + assert!(!user.is_force()); + } + + #[test] + fn test_ace_interaction_get_contract_address() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let unlocking = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce, + }; + assert_eq!(unlocking.get_contract_address(), contract); + + let non_unlocking = AceInteraction::NonUnlocking { + contract_address: contract, + }; + assert_eq!(non_unlocking.get_contract_address(), contract); + } + + #[test] + fn test_force_signature_from_real_calldata() { + // The provided calldata starts with 0x09c5eabe (execute function) + let calldata = hex::decode("09c5eabe000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000002950000cca0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000000000003e6d1e500000000000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc200000000000000000000000003675af200000000000000000000008cff47e70a0000000000000000006f8f0c22bbdf00dac17f958d2ee523a2206206994597c13d831ec70000000000000000000000000000000000000000000000000000000001f548eb0000000000000000000000000000000000004c00000001000000000000000000000000000000000000003d82768a1dd582a9887587911fe9180001000200010000000000000000000000000000000000000000000000002b708452feb67dac0000660200000000000000000000004a458c968ab800000000000000000000000000000003ba0000000000000000010e8724bdb79c980300010000000000000000002548f28ce93ff600000000000000000000008cff47e70a000000000000000000225d82a810177e000108090000000000000000004a458c968ab80000000000000000000000000003e6ce2b000000000000000000000000000000010000000000000000000000000000000000001c2514149461c050689a85a1a293766501a00feab18c79d5b3cacb8c4052c9c0ea432416a6b9b672896d6596a3fa25fb765ab8a0245e2ebacfde1ed5a42786a6d60b00000000000000000025497f8c31270000000000000000000000000001f548eb00000000000000000000000003675af200000000000000000000000003675af200011c833917577a24b35aca558dcee9b4ab547c419f53a6b8b4e353e23ba811b956c35ef19655e4695da96e6e85f36c84db41d860eb7c267466cd1c0ebe581196086a0000000000000000000000000000").unwrap(); + + // Extract first 4 bytes + let selector = Selector::from_slice(&calldata[..4]); + + // Should match the force signature + let expected_force_sig = Selector::from_slice(&[0x09, 0xc5, 0xea, 0xbe]); + assert_eq!(selector, expected_force_sig); + + // Verify it's in the config + let config = real_ace_config(); + assert!(config.force_signatures.contains(&selector)); + } + + #[test] + fn test_optional_unlock_signature_from_real_transaction() { + let calldata = hex::decode("1828e0e7000000000000000000000000c41ae140ca9b281d8a1dc254c50e446019517d0400000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000041c28cfd9fd7ffdce92022dcd0116088a1a0b1a9fb2124f55dce50ec39a10b9ad819f4ca93c677b0952c90389a4e1af98f9770fe4f3cdfa7b2fa30ecbd2c01a9bf1c00000000000000000000000000000000000000000000000000000000000000").unwrap(); + + // Extract first 4 bytes (function selector) + let selector = Selector::from_slice(&calldata[..4]); + + // Should match the optional unlock signature: unlockWithEmptyAttestation(address,bytes) + let expected_unlock_sig = Selector::from_slice(&[0x18, 0x28, 0xe0, 0xe7]); + assert_eq!(selector, expected_unlock_sig); + + // Verify it's in the config as an unlock signature + let config = real_ace_config(); + assert!(config.unlock_signatures.contains(&selector)); + assert!(!config.force_signatures.contains(&selector)); // Should NOT be in force + } + + #[test] + fn test_optional_unlock_with_real_config() { + // Test complete optional unlock classification with real config + let config = real_ace_config(); + let contract = config.contract_address; + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + + // Optional unlock signature from real transaction + let unlock_selector = Selector::from_slice(&[0x18, 0x28, 0xe0, 0xe7]); + + // Mock state trace showing slot 3 was accessed + let trace = mock_state_trace_with_slot(contract, slot); + + // Test 1: Direct call to ACE contract = ProtocolOptional + let result = + classify_ace_interaction(&trace, true, &config, Some(unlock_selector), Some(contract)); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional + }) + ); + + // Test 2: Indirect call (user tx) = User unlock + let result_indirect = + classify_ace_interaction(&trace, true, &config, Some(unlock_selector), None); + + assert_eq!( + result_indirect, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User + }) + ); + + // Test 3: Failed simulation with unlock signature = NonUnlocking + let result_failed = classify_ace_interaction( + &trace, + false, + &config, + Some(unlock_selector), + Some(contract), + ); + + assert_eq!( + result_failed, + Some(AceInteraction::NonUnlocking { + contract_address: contract + }) + ); + } + + #[test] + fn test_slot_written_also_detected() { + // Test that writing to the detection slot is also detected (not just reading) + let config = real_ace_config(); + let contract = config.contract_address; + let detection_slot = *config.detection_slots.iter().next().unwrap(); + let force_selector = *config.force_signatures.iter().next().unwrap(); + + let mut trace = UsedStateTrace::default(); + // Write to slot instead of reading + trace.written_slot_values.insert( + SlotKey { + address: contract, + key: detection_slot, + }, + Default::default(), + ); + + // Writing to detection slot should still trigger classification + let result = + classify_ace_interaction(&trace, true, &config, Some(force_selector), Some(contract)); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce + }) + ); + } +} diff --git a/crates/rbuilder-primitives/src/lib.rs b/crates/rbuilder-primitives/src/lib.rs index d403c7c0c..bddc1a057 100644 --- a/crates/rbuilder-primitives/src/lib.rs +++ b/crates/rbuilder-primitives/src/lib.rs @@ -1,5 +1,6 @@ //! Order types used as elements for block building. +pub mod ace; pub mod built_block; pub mod evm_inspector; pub mod fmt; @@ -40,7 +41,8 @@ pub use test_data_generator::TestDataGenerator; use thiserror::Error; use uuid::Uuid; -use crate::serialize::TxEncoding; +use crate::{ace::AceInteraction, serialize::TxEncoding}; +pub use ace::AceConfig; /// Extra metadata for an order. #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -1116,8 +1118,7 @@ impl Order { /// Non virtual orders should return self pub fn original_orders(&self) -> Vec<&Order> { match self { - Order::Bundle(_) => vec![self], - Order::Tx(_) => vec![self], + Order::Bundle(_) | Order::Tx(_) => vec![self], Order::ShareBundle(sb) => { let res = sb.original_orders(); if res.is_empty() { @@ -1362,6 +1363,9 @@ pub struct SimulatedOrder { pub sim_value: SimValue, /// Info about read/write slots during the simulation to help figure out what the Order is doing. pub used_state_trace: Option, + /// ACE interaction classification - None if not an ACE interaction. + /// Use `ace_interaction.map(|a| a.is_force()).unwrap_or(false)` to check if force unlock. + pub ace_interaction: Option, } impl SimulatedOrder { diff --git a/crates/rbuilder/src/backtest/execute.rs b/crates/rbuilder/src/backtest/execute.rs index 3b677f94d..eb8cf8379 100644 --- a/crates/rbuilder/src/backtest/execute.rs +++ b/crates/rbuilder/src/backtest/execute.rs @@ -6,7 +6,9 @@ use crate::{ NullPartialBlockExecutionTracer, OrderErr, SimulatedOrderSink, SimulatedOrderStore, TransactionErr, }, - live_builder::{block_list_provider::BlockList, cli::LiveBuilderConfig}, + live_builder::{ + block_list_provider::BlockList, cli::LiveBuilderConfig, simulation::SimulatedOrderCommand, + }, provider::StateProviderFactory, utils::{clean_extradata, mevblocker::get_mevblocker_price, Signer}, }; @@ -16,6 +18,7 @@ use rbuilder_primitives::{OrderId, SimulatedOrder}; use reth_chainspec::ChainSpec; use serde::{Deserialize, Serialize}; use std::{cell::RefCell, rc::Rc, sync::Arc}; +use tokio::sync::mpsc; use super::OrdersWithTimestamp; @@ -106,8 +109,15 @@ where ctx.mempool_tx_detector.add_tx(order); } - let (sim_orders, sim_errors) = - simulate_all_orders_with_sim_tree(provider, &ctx, &orders, false)?; + let (cancellation_sender, _) = mpsc::channel::(1); + let (sim_orders, sim_errors) = simulate_all_orders_with_sim_tree( + provider, + &ctx, + &orders, + false, + vec![], + &cancellation_sender, + )?; // Apply bundle merging as in live building. let order_store = Rc::new(RefCell::new(SimulatedOrderStore::new())); diff --git a/crates/rbuilder/src/bin/run-bundle-on-prefix.rs b/crates/rbuilder/src/bin/run-bundle-on-prefix.rs index 01eb99b31..2679647fe 100644 --- a/crates/rbuilder/src/bin/run-bundle-on-prefix.rs +++ b/crates/rbuilder/src/bin/run-bundle-on-prefix.rs @@ -220,6 +220,7 @@ async fn main() -> eyre::Result<()> { order, sim_value: Default::default(), used_state_trace: Default::default(), + ace_interaction: None, }; let res = builder.commit_order(&mut block_info.local_ctx, &sim_order, &|_| Ok(()))?; println!("{:?} {:?}", tx.hash(), res.is_ok()); @@ -315,6 +316,7 @@ fn execute_orders_on_tob( order: order_ts.order.clone(), sim_value: Default::default(), used_state_trace: Default::default(), + ace_interaction: None, }; let res = builder.commit_order(&mut block_info.local_ctx, &sim_order, &|_| Ok(()))?; let profit = res diff --git a/crates/rbuilder/src/building/block_orders/order_priority.rs b/crates/rbuilder/src/building/block_orders/order_priority.rs index 73c882c95..9c7ddeaf5 100644 --- a/crates/rbuilder/src/building/block_orders/order_priority.rs +++ b/crates/rbuilder/src/building/block_orders/order_priority.rs @@ -332,6 +332,7 @@ mod test { U256::from(non_mempool_profit), gas, ), + ace_interaction: None, used_state_trace: None, }) } diff --git a/crates/rbuilder/src/building/block_orders/share_bundle_merger.rs b/crates/rbuilder/src/building/block_orders/share_bundle_merger.rs index 2c37e5e9c..ef37ec1c5 100644 --- a/crates/rbuilder/src/building/block_orders/share_bundle_merger.rs +++ b/crates/rbuilder/src/building/block_orders/share_bundle_merger.rs @@ -148,6 +148,7 @@ impl MultiBackrunManager { order: Order::ShareBundle(sbundle), sim_value: highest_payback_order.sim_order.sim_value.clone(), used_state_trace: highest_payback_order.sim_order.used_state_trace.clone(), + ace_interaction: highest_payback_order.sim_order.ace_interaction, })) } diff --git a/crates/rbuilder/src/building/block_orders/test_context.rs b/crates/rbuilder/src/building/block_orders/test_context.rs index 371a5d1a2..3199cb3b2 100644 --- a/crates/rbuilder/src/building/block_orders/test_context.rs +++ b/crates/rbuilder/src/building/block_orders/test_context.rs @@ -169,6 +169,7 @@ impl TestContext { order, sim_value, used_state_trace: None, + ace_interaction: None, }) } diff --git a/crates/rbuilder/src/building/block_orders/test_data_generator.rs b/crates/rbuilder/src/building/block_orders/test_data_generator.rs index c792a439d..f2a8fd4a7 100644 --- a/crates/rbuilder/src/building/block_orders/test_data_generator.rs +++ b/crates/rbuilder/src/building/block_orders/test_data_generator.rs @@ -31,6 +31,7 @@ impl TestDataGenerator { order, sim_value, used_state_trace: None, + ace_interaction: None, }) } } diff --git a/crates/rbuilder/src/building/builders/ordering_builder.rs b/crates/rbuilder/src/building/builders/ordering_builder.rs index aed6566d2..0876072ee 100644 --- a/crates/rbuilder/src/building/builders/ordering_builder.rs +++ b/crates/rbuilder/src/building/builders/ordering_builder.rs @@ -289,6 +289,22 @@ impl OrderingBuilderContext { self.failed_orders.clear(); self.order_attempts.clear(); + // Extract ACE protocol orders (direct calls to protocol) from block_orders + // These will be pre-committed at the top of the block + let all_orders = block_orders.get_all_orders(); + let mut ace_orders = Vec::new(); + for order in all_orders { + if order + .ace_interaction + .map(|a| a.is_protocol_tx()) + .unwrap_or(false) + { + ace_orders.push(order.clone()); + // Remove from block_orders so they don't get processed in fill_orders + block_orders.remove_order(order.id()); + } + } + let mut block_building_helper = BlockBuildingHelperFromProvider::new_with_execution_tracer( built_block_id, self.state.clone(), @@ -301,6 +317,18 @@ impl OrderingBuilderContext { partial_block_execution_tracer, self.max_order_execution_duration_warning, )?; + + // Pre-commit ACE protocol orders at the top of the block + for ace_order in &ace_orders { + trace!(order_id = ?ace_order.id(), "Pre-committing ACE protocol order"); + if let Err(err) = block_building_helper.commit_order( + &mut self.local_ctx, + ace_order, + &|_| Ok(()), // ACE protocol orders bypass profit validation + ) { + trace!(order_id = ?ace_order.id(), ?err, "Failed to pre-commit ACE protocol order"); + } + } self.fill_orders( &mut block_building_helper, &mut block_orders, diff --git a/crates/rbuilder/src/building/builders/parallel_builder/block_building_result_assembler.rs b/crates/rbuilder/src/building/builders/parallel_builder/block_building_result_assembler.rs index 78c5e2f78..d4da950ec 100644 --- a/crates/rbuilder/src/building/builders/parallel_builder/block_building_result_assembler.rs +++ b/crates/rbuilder/src/building/builders/parallel_builder/block_building_result_assembler.rs @@ -186,6 +186,34 @@ impl BlockBuildingResultAssembler { ) -> eyre::Result> { let build_start = Instant::now(); + // Extract ACE protocol orders (direct calls to protocol) from all groups + // These will be pre-committed at the top of the block + let mut ace_orders = Vec::new(); + for (_, group) in best_orderings_per_group.iter() { + for order in group.orders.iter() { + if order + .ace_interaction + .map(|a| a.is_protocol_tx()) + .unwrap_or(false) + { + ace_orders.push(order.clone()); + } + } + } + + // Remove ACE orders from groups so they don't get processed twice + for (resolution_result, group) in best_orderings_per_group.iter_mut() { + // Filter out ACE orders from the sequence + resolution_result + .sequence_of_orders + .retain(|(order_idx, _)| { + !group.orders[*order_idx] + .ace_interaction + .map(|a| a.is_protocol_tx()) + .unwrap_or(false) + }); + } + let mut block_building_helper = BlockBuildingHelperFromProvider::new( self.built_block_id_source.get_new_id(), self.state.clone(), @@ -199,6 +227,18 @@ impl BlockBuildingResultAssembler { )?; block_building_helper.set_trace_orders_closed_at(orders_closed_at); + // Pre-commit ACE protocol orders at the top of the block + for ace_order in &ace_orders { + trace!(order_id = ?ace_order.id(), "Pre-committing ACE protocol order"); + if let Err(err) = block_building_helper.commit_order( + &mut self.local_ctx, + ace_order, + &|_| Ok(()), // ACE protocol orders bypass profit validation + ) { + trace!(order_id = ?ace_order.id(), ?err, "Failed to pre-commit ACE protocol order"); + } + } + // Sort groups by total profit in descending order best_orderings_per_group.sort_by(|(a_ordering, _), (b_ordering, _)| { b_ordering.total_profit.cmp(&a_ordering.total_profit) @@ -261,6 +301,37 @@ impl BlockBuildingResultAssembler { best_results: HashMap, orders_closed_at: OffsetDateTime, ) -> eyre::Result> { + let mut best_orderings_per_group: Vec<(ResolutionResult, ConflictGroup)> = + best_results.into_values().collect(); + + // Extract ACE protocol orders (direct calls to protocol) from all groups + // These will be pre-committed at the top of the block + let mut ace_orders = Vec::new(); + for (_, group) in best_orderings_per_group.iter() { + for order in group.orders.iter() { + if order + .ace_interaction + .map(|a| a.is_protocol_tx()) + .unwrap_or(false) + { + ace_orders.push(order.clone()); + } + } + } + + // Remove ACE orders from groups so they don't get processed twice + for (resolution_result, group) in best_orderings_per_group.iter_mut() { + // Filter out ACE orders from the sequence + resolution_result + .sequence_of_orders + .retain(|(order_idx, _)| { + !group.orders[*order_idx] + .ace_interaction + .map(|a| a.is_protocol_tx()) + .unwrap_or(false) + }); + } + let mut block_building_helper = BlockBuildingHelperFromProvider::new( self.built_block_id_source.get_new_id(), self.state.clone(), @@ -275,8 +346,17 @@ impl BlockBuildingResultAssembler { block_building_helper.set_trace_orders_closed_at(orders_closed_at); - let mut best_orderings_per_group: Vec<(ResolutionResult, ConflictGroup)> = - best_results.into_values().collect(); + // Pre-commit ACE protocol orders at the top of the block + for ace_order in &ace_orders { + trace!(order_id = ?ace_order.id(), "Pre-committing ACE protocol order in backtest"); + if let Err(err) = block_building_helper.commit_order( + &mut self.local_ctx, + ace_order, + &|_| Ok(()), // ACE protocol orders bypass profit validation + ) { + trace!(order_id = ?ace_order.id(), ?err, "Failed to pre-commit ACE protocol order in backtest"); + } + } // Sort groups by total profit in descending order best_orderings_per_group.sort_by(|(a_ordering, _), (b_ordering, _)| { diff --git a/crates/rbuilder/src/building/builders/parallel_builder/conflict_resolvers.rs b/crates/rbuilder/src/building/builders/parallel_builder/conflict_resolvers.rs index 8e06e596b..996d1a6c0 100644 --- a/crates/rbuilder/src/building/builders/parallel_builder/conflict_resolvers.rs +++ b/crates/rbuilder/src/building/builders/parallel_builder/conflict_resolvers.rs @@ -533,6 +533,7 @@ mod tests { order: Order::Bundle(bundle), used_state_trace: None, sim_value, + ace_interaction: None, }) } } diff --git a/crates/rbuilder/src/building/builders/parallel_builder/conflict_task_generator.rs b/crates/rbuilder/src/building/builders/parallel_builder/conflict_task_generator.rs index dfc6f62ed..381b634d9 100644 --- a/crates/rbuilder/src/building/builders/parallel_builder/conflict_task_generator.rs +++ b/crates/rbuilder/src/building/builders/parallel_builder/conflict_task_generator.rs @@ -496,6 +496,7 @@ mod tests { }), sim_value, used_state_trace: Some(trace), + ace_interaction: None, }) } } diff --git a/crates/rbuilder/src/building/builders/parallel_builder/groups.rs b/crates/rbuilder/src/building/builders/parallel_builder/groups.rs index a0b6d8800..be892f41a 100644 --- a/crates/rbuilder/src/building/builders/parallel_builder/groups.rs +++ b/crates/rbuilder/src/building/builders/parallel_builder/groups.rs @@ -479,6 +479,7 @@ mod tests { }), used_state_trace: Some(trace), sim_value: SimValue::default(), + ace_interaction: None, }) } } diff --git a/crates/rbuilder/src/building/sim.rs b/crates/rbuilder/src/building/sim.rs index b0eccaf7f..bd3ea64f6 100644 --- a/crates/rbuilder/src/building/sim.rs +++ b/crates/rbuilder/src/building/sim.rs @@ -8,14 +8,24 @@ use crate::{ order_is_worth_executing, BlockBuildingContext, BlockBuildingSpaceState, BlockState, CriticalCommitOrderError, NullPartialBlockForkExecutionTracer, }, - live_builder::order_input::mempool_txs_detector::MempoolTxsDetector, + live_builder::{ + order_input::mempool_txs_detector::MempoolTxsDetector, simulation::SimulatedOrderCommand, + }, provider::StateProviderFactory, telemetry::{add_order_simulation_time, mark_order_pending_nonce}, utils::NonceCache, }; use ahash::{HashMap, HashSet}; use alloy_primitives::Address; +use alloy_primitives::U256; +use alloy_rpc_types::TransactionTrait; use rand::seq::SliceRandom; +use rbuilder_primitives::ace::{ + classify_ace_interaction, AceInteraction, AceUnlockSource, Selector, +}; +use rbuilder_primitives::AceConfig; +use rbuilder_primitives::BlockSpace; +use rbuilder_primitives::SimValue; use rbuilder_primitives::{Order, OrderId, SimulatedOrder}; use reth_errors::ProviderError; use reth_provider::StateProvider; @@ -25,6 +35,7 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; +use tokio::sync::mpsc; use tracing::{error, trace}; #[derive(Debug)] @@ -47,10 +58,56 @@ pub struct NonceKey { pub nonce: u64, } +/// Generic dependency key - represents something an order needs before it can execute +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DependencyKey { + /// Order needs a specific nonce to be filled + Nonce(NonceKey), + /// Order needs an ACE unlock transaction for the given contract address + AceUnlock(Address), +} + +impl From for DependencyKey { + fn from(nonce: NonceKey) -> Self { + DependencyKey::Nonce(nonce) + } +} + +/// State for a specific ACE exchange +#[derive(Debug, Clone, Default)] +pub struct AceExchangeState { + /// Force ACE protocol order - always included + pub force_unlock_order: Option>, + /// Optional ACE protocol order - can be cancelled if mempool unlock arrives + pub optional_unlock_order: Option>, + /// Whether we've seen a mempool unlocking order (cancels optional) + pub has_mempool_unlock: bool, +} + +impl AceExchangeState { + /// Get the best available unlock order. + /// Selects the cheapest (lowest gas) for frontrunning when both are available. + pub fn get_unlock_order(&self) -> Option<&Arc> { + match (&self.force_unlock_order, &self.optional_unlock_order) { + (Some(force), Some(optional)) => { + // Select cheapest (lowest gas) for frontrunning + if force.sim_value.gas_used() <= optional.sim_value.gas_used() { + Some(force) + } else { + Some(optional) + } + } + (Some(force), None) => Some(force), + (None, Some(optional)) => Some(optional), + (None, None) => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PendingOrder { order: Order, - unsatisfied_nonces: usize, + unsatisfied_dependencies: usize, } pub type SimulationId = u64; @@ -67,7 +124,8 @@ pub struct SimulatedResult { pub id: SimulationId, pub simulated_order: Arc, pub previous_orders: Vec, - pub nonces_after: Vec, + /// Dependencies this simulation satisfies (nonces updated, ACE unlocks provided) + pub dependencies_satisfied: Vec, pub simulation_time: Duration, } @@ -78,52 +136,82 @@ pub struct SimTree { nonces: NonceCache, sims: HashMap, - sims_that_update_one_nonce: HashMap, + /// Maps a dependency to the simulation that provides it (for single-dependency sims) + dependency_providers: HashMap, pending_orders: HashMap, - pending_nonces: HashMap>, + + /// Orders waiting on each dependency + pending_dependencies: HashMap>, ready_orders: Vec, + + // ACE state management + /// ACE configuration lookup by contract address + ace_config: HashMap, + /// ACE state (force/optional unlocks, mempool unlock tracking) by contract address + ace_state: HashMap, } #[derive(Debug)] -enum OrderNonceState { +enum OrderDependencyState { Invalid, - PendingNonces(Vec), + Pending(Vec), Ready(Vec), } impl SimTree { - pub fn new(nonce_cache_ref: NonceCache) -> Self { + pub fn new(nonce_cache_ref: NonceCache, ace_configs: Vec) -> Self { + let mut ace_config = HashMap::default(); + let mut ace_state = HashMap::default(); + + for config in ace_configs { + let contract_address = config.contract_address; + ace_config.insert(contract_address, config); + ace_state.insert(contract_address, AceExchangeState::default()); + } + Self { nonces: nonce_cache_ref, sims: HashMap::default(), - sims_that_update_one_nonce: HashMap::default(), + dependency_providers: HashMap::default(), pending_orders: HashMap::default(), - pending_nonces: HashMap::default(), + pending_dependencies: HashMap::default(), ready_orders: Vec::default(), + ace_config, + ace_state, } } + /// Get the ACE configs + pub fn ace_configs(&self) -> &HashMap { + &self.ace_config + } + + /// Get the ACE state for a given contract address + pub fn get_ace_state(&self, contract_address: &Address) -> Option<&AceExchangeState> { + self.ace_state.get(contract_address) + } + fn push_order(&mut self, order: Order) -> Result<(), ProviderError> { if self.pending_orders.contains_key(&order.id()) { return Ok(()); } - let order_nonce_state = self.get_order_nonce_state(&order)?; + let order_dep_state = self.get_order_dependency_state(&order)?; let order_id = order.id(); - match order_nonce_state { - OrderNonceState::Invalid => { + match order_dep_state { + OrderDependencyState::Invalid => { return Ok(()); } - OrderNonceState::PendingNonces(pending_nonces) => { + OrderDependencyState::Pending(pending_deps) => { mark_order_pending_nonce(order_id); - let unsatisfied_nonces = pending_nonces.len(); - for nonce in pending_nonces { - self.pending_nonces - .entry(nonce) + let unsatisfied_dependencies = pending_deps.len(); + for dep in pending_deps { + self.pending_dependencies + .entry(dep) .or_default() .push(order.id()); } @@ -131,11 +219,11 @@ impl SimTree { order.id(), PendingOrder { order, - unsatisfied_nonces, + unsatisfied_dependencies, }, ); } - OrderNonceState::Ready(parents) => { + OrderDependencyState::Ready(parents) => { self.ready_orders.push(SimulationRequest { id: rand::random(), order, @@ -146,17 +234,21 @@ impl SimTree { Ok(()) } - fn get_order_nonce_state(&mut self, order: &Order) -> Result { + fn get_order_dependency_state( + &mut self, + order: &Order, + ) -> Result { let mut onchain_nonces_incremented = HashSet::default(); - let mut pending_nonces = Vec::new(); + let mut pending_deps = Vec::new(); let mut parent_orders = Vec::new(); + // Check nonce dependencies for nonce in order.nonces() { let onchain_nonce = self.nonces.nonce(nonce.address)?; match onchain_nonce.cmp(&nonce.nonce) { Ordering::Equal => { - // nonce, valid + // nonce valid onchain_nonces_incremented.insert(nonce.address); continue; } @@ -169,7 +261,7 @@ impl SimTree { ?nonce, "Dropping order because of nonce" ); - return Ok(OrderNonceState::Invalid); + return Ok(OrderDependencyState::Invalid); } else { // we can ignore this tx continue; @@ -187,8 +279,9 @@ impl SimTree { address: nonce.address, nonce: nonce.nonce, }; + let dep_key = DependencyKey::Nonce(nonce_key); - if let Some(sim_id) = self.sims_that_update_one_nonce.get(&nonce_key) { + if let Some(sim_id) = self.dependency_providers.get(&dep_key) { // we have something that fills this nonce let sim = self.sims.get(sim_id).expect("we never delete sims"); parent_orders.extend_from_slice(&sim.previous_orders); @@ -196,18 +289,56 @@ impl SimTree { continue; } - pending_nonces.push(nonce_key); + pending_deps.push(dep_key); } } } - if pending_nonces.is_empty() { - Ok(OrderNonceState::Ready(parent_orders)) + if pending_deps.is_empty() { + Ok(OrderDependencyState::Ready(parent_orders)) } else { - Ok(OrderNonceState::PendingNonces(pending_nonces)) + Ok(OrderDependencyState::Pending(pending_deps)) } } + /// Check if an order needs ACE unlock and add that dependency. + /// Called after initial simulation when we detect a NonUnlocking ACE interaction. + fn add_ace_dependency_for_order( + &mut self, + order: Order, + contract_address: Address, + ) -> Result<(), ProviderError> { + let dep_key = DependencyKey::AceUnlock(contract_address); + + // Check if we already have an unlock provider + if let Some(sim_id) = self.dependency_providers.get(&dep_key) { + let sim = self.sims.get(sim_id).expect("we never delete sims"); + let mut parents = sim.previous_orders.clone(); + parents.push(sim.simulated_order.order.clone()); + + // Order is ready with the unlock tx as parent + self.ready_orders.push(SimulationRequest { + id: rand::random(), + order, + parents, + }); + } else { + // No unlock yet - add to pending + self.pending_dependencies + .entry(dep_key) + .or_default() + .push(order.id()); + self.pending_orders.insert( + order.id(), + PendingOrder { + order, + unsatisfied_dependencies: 1, + }, + ); + } + Ok(()) + } + pub fn push_orders(&mut self, orders: Vec) -> Result<(), ProviderError> { for order in orders { self.push_order(order)?; @@ -227,11 +358,18 @@ impl SimTree { ) -> Result<(), ProviderError> { self.sims.insert(result.id, result.clone()); let mut orders_ready = Vec::new(); - if result.nonces_after.len() == 1 { - let updated_nonce = result.nonces_after.first().unwrap().clone(); - match self.sims_that_update_one_nonce.entry(updated_nonce.clone()) { + // Process each dependency this simulation satisfies + if result.dependencies_satisfied.len() == 1 { + let dep_key = result + .dependencies_satisfied + .first() + .expect("checked len == 1") + .clone(); + + match self.dependency_providers.entry(dep_key.clone()) { Entry::Occupied(mut entry) => { + // Already have a provider - check if this one is more profitable let current_sim_profit = { let sim_id = entry.get_mut(); self.sims @@ -253,15 +391,17 @@ impl SimTree { } } Entry::Vacant(entry) => { + // First provider for this dependency entry.insert(result.id); - if let Some(pending_orders) = self.pending_nonces.remove(&updated_nonce) { - for order in pending_orders { - match self.pending_orders.entry(order) { + // Unblock orders waiting on this dependency + if let Some(pending_order_ids) = self.pending_dependencies.remove(&dep_key) { + for order_id in pending_order_ids { + match self.pending_orders.entry(order_id) { Entry::Occupied(mut entry) => { let pending_order = entry.get_mut(); - pending_order.unsatisfied_nonces -= 1; - if pending_order.unsatisfied_nonces == 0 { + pending_order.unsatisfied_dependencies -= 1; + if pending_order.unsatisfied_dependencies == 0 { orders_ready.push(entry.remove().order); } } @@ -277,20 +417,20 @@ impl SimTree { } for ready_order in orders_ready { - let pending_state = self.get_order_nonce_state(&ready_order)?; + let pending_state = self.get_order_dependency_state(&ready_order)?; match pending_state { - OrderNonceState::Ready(parents) => { + OrderDependencyState::Ready(parents) => { self.ready_orders.push(SimulationRequest { id: rand::random(), order: ready_order, parents, }); } - OrderNonceState::Invalid => { + OrderDependencyState::Invalid => { // @Metric bug counter error!("SimTree bug order became invalid"); } - OrderNonceState::PendingNonces(_) => { + OrderDependencyState::Pending(_) => { // @Metric bug counter error!("SimTree bug order became pending again"); } @@ -299,13 +439,135 @@ impl SimTree { Ok(()) } + /// Handle ACE interaction after simulation. + /// Returns (skip_forwarding, optional_cancellation_order_id) + /// - For Unlocking interactions: registers as force or optional unlock provider, skip_forwarding=false + /// - For NonUnlocking interactions: adds order as pending on ACE unlock dependency, skip_forwarding=true + /// - Returns cancellation OrderId if a mempool unlock cancels an optional ACE tx + pub fn handle_ace_interaction( + &mut self, + result: &mut SimulatedResult, + ) -> Result<(bool, Option), ProviderError> { + let Some(interaction) = result.simulated_order.ace_interaction else { + return Ok((false, None)); + }; + + // If this order already has parents, it was re-simulated with unlock - just pass through + if !result.previous_orders.is_empty() { + return Ok((false, None)); + } + + let mut cancellation = None; + + match interaction { + AceInteraction::Unlocking { + contract_address, + source, + } => { + // Register the unlock in ACE state + let state = self.ace_state.entry(contract_address).or_default(); + + if source == AceUnlockSource::ProtocolForce { + state.force_unlock_order = Some(result.simulated_order.clone()); + trace!( + "Added forced ACE protocol unlock order for {:?}", + contract_address + ); + } else { + state.optional_unlock_order = Some(result.simulated_order.clone()); + trace!( + "Added optional ACE protocol unlock order for {:?}", + contract_address + ); + } + + // Check if we should cancel the optional ACE order (mempool unlock arrived first) + if state.has_mempool_unlock { + if let Some(optional) = state.optional_unlock_order.take() { + cancellation = Some(optional.order.id()); + } + } + + // Make sure the ACE unlock dependency is in dependencies_satisfied + let dep_key = DependencyKey::AceUnlock(contract_address); + if !result.dependencies_satisfied.contains(&dep_key) { + result.dependencies_satisfied.push(dep_key); + } + + // Process this result to unblock pending orders + self.process_simulation_task_result(result.clone())?; + } + AceInteraction::NonUnlocking { contract_address } => { + // This is a mempool order that needs ACE unlock + let state = self.ace_state.entry(contract_address).or_default(); + + // Check if we have an unlock order to use as parent + if let Some(unlock_order) = state.get_unlock_order().cloned() { + // Re-queue with the unlock as parent + self.ready_orders.push(SimulationRequest { + id: rand::random(), + order: result.simulated_order.order.clone(), + parents: vec![unlock_order.order.clone()], + }); + } else { + // No unlock yet - add as pending on ACE dependency + self.add_ace_dependency_for_order( + result.simulated_order.order.clone(), + contract_address, + )?; + } + return Ok((true, None)); + } + } + + Ok((false, cancellation)) + } + + /// Mark that a mempool unlocking order has been seen for a contract address. + /// Returns the OrderId of the optional ACE order to cancel, if any. + pub fn mark_mempool_unlock(&mut self, contract_address: Address) -> Option { + let state = self.ace_state.entry(contract_address).or_default(); + + // Only cancel once + if state.has_mempool_unlock { + return None; + } + state.has_mempool_unlock = true; + + // Cancel the optional ACE order if present + state + .optional_unlock_order + .take() + .map(|order| order.order.id()) + } + + /// Process simulation results, handling ACE interactions and updating dependencies. + /// Filters out orders that should not be forwarded (e.g., ACE orders queued for re-sim). + /// Sends cancellation commands via the provided sender. pub fn submit_simulation_tasks_results( &mut self, - results: Vec, + results: &mut Vec, + cancellation_sender: &mpsc::Sender, ) -> Result<(), ProviderError> { - for result in results { - self.process_simulation_task_result(result)?; - } + results.retain_mut(|result| { + let (skip, cancel) = self.handle_ace_interaction(result).unwrap_or_else(|err| { + error!(?err, "Failed to handle ACE interaction"); + (false, None) + }); + + if let Some(id) = cancel { + let _ = cancellation_sender.try_send(SimulatedOrderCommand::Cancellation(id)); + } + + if !skip { + if let Err(err) = self.process_simulation_task_result(result.clone()) { + error!(?err, "Failed to process simulation result"); + } + } + + !skip + }); + Ok(()) } } @@ -318,6 +580,8 @@ pub fn simulate_all_orders_with_sim_tree

( ctx: &BlockBuildingContext, orders: &[Order], randomize_insertion: bool, + ace_config: Vec, + cancellation_sender: &mpsc::Sender, ) -> Result<(Vec>, Vec), CriticalCommitOrderError> where P: StateProviderFactory + Clone, @@ -326,7 +590,7 @@ where let state = provider.history_by_block_hash(ctx.attributes.parent)?; NonceCache::new(state.into()) }; - let mut sim_tree = SimTree::new(nonces); + let mut sim_tree = SimTree::new(nonces, ace_config); let mut orders = orders.to_vec(); let random_insert_size = max(orders.len() / 20, 1); @@ -369,6 +633,7 @@ where ctx, &mut local_ctx, &mut block_state, + sim_tree.ace_configs(), )?; let (_, provider) = block_state.into_parts(); state_for_sim = provider; @@ -383,22 +648,31 @@ where continue; } OrderSimResult::Success(sim_order, nonces) => { + let mut dependencies_satisfied: Vec = nonces + .into_iter() + .map(|(address, nonce)| DependencyKey::Nonce(NonceKey { address, nonce })) + .collect(); + + // If this is an unlocking ACE order, add the ACE dependency + if let Some(AceInteraction::Unlocking { + contract_address, .. + }) = sim_order.ace_interaction + { + dependencies_satisfied.push(DependencyKey::AceUnlock(contract_address)); + } + let result = SimulatedResult { id: sim_task.id, simulated_order: sim_order, previous_orders: sim_task.parents, - nonces_after: nonces - .into_iter() - .map(|(address, nonce)| NonceKey { address, nonce }) - .collect(), - + dependencies_satisfied, simulation_time: start_time.elapsed(), }; sim_results.push(result); } } } - sim_tree.submit_simulation_tasks_results(sim_results)?; + sim_tree.submit_simulation_tasks_results(&mut sim_results, cancellation_sender)?; } Ok(( @@ -418,14 +692,21 @@ pub fn simulate_order( ctx: &BlockBuildingContext, local_ctx: &mut ThreadBlockBuildingContext, state: &mut BlockState, + ace_configs: &HashMap, ) -> Result { let mut tracer = AccumulatorSimulationTracer::new(); let mut fork = PartialBlockFork::new(state, ctx, local_ctx).with_tracer(&mut tracer); let rollback_point = fork.rollback_point(); - let sim_res = - simulate_order_using_fork(parent_orders, order, &mut fork, &ctx.mempool_tx_detector); + let sim_res = simulate_order_using_fork( + parent_orders, + order, + &mut fork, + &ctx.mempool_tx_detector, + ace_configs, + ); fork.rollback(rollback_point); let sim_res = sim_res?; + Ok(OrderSimResultWithGas { result: sim_res, gas_used: tracer.used_gas, @@ -438,8 +719,11 @@ pub fn simulate_order_using_fork( order: Order, fork: &mut PartialBlockFork<'_, '_, '_, '_, Tracer, NullPartialBlockForkExecutionTracer>, mempool_tx_detector: &MempoolTxsDetector, + ace_configs: &HashMap, ) -> Result { let start = Instant::now(); + let has_parents = !parent_orders.is_empty(); + // simulate parents let mut space_state = BlockBuildingSpaceState::ZERO; // We use empty combined refunds because the value of the bundle will @@ -461,7 +745,37 @@ pub fn simulate_order_using_fork( // simulate let result = fork.commit_order(&order, space_state, true, &combined_refunds)?; let sim_time = start.elapsed(); - add_order_simulation_time(sim_time, "sim", result.is_ok()); // we count parent sim time + order sim time time here + let sim_success = result.is_ok(); + add_order_simulation_time(sim_time, "sim", sim_success); // we count parent sim time + order sim time time here + + // Get the used_state_trace from tracer (available regardless of success/failure) + let used_state_trace = fork + .tracer + .as_ref() + .and_then(|t| t.get_used_state_tracer()) + .cloned(); + + // Detect ACE interaction from the state trace using config + // Get function selector and tx.to from order's first transaction + let (selector, tx_to): (Option, Option

) = + order.list_txs().first().map_or((None, None), |(tx, _)| { + let input = tx.internal_tx_unsecure().input(); + let sel = if input.len() >= 4 { + Some(Selector::from_slice(&input[..4])) + } else { + None + }; + (sel, tx.to()) + }); + + let ace_interaction = used_state_trace.as_ref().and_then(|trace| { + ace_configs.iter().find_map(|(_, config)| { + if !config.enabled { + return None; + } + classify_ace_interaction(trace, sim_success, config, selector, tx_to) + }) + }); match result { Ok(res) => { @@ -475,10 +789,44 @@ pub fn simulate_order_using_fork( order, sim_value, used_state_trace: res.used_state_trace, + ace_interaction, }), new_nonces, )) } - Err(err) => Ok(OrderSimResult::Failed(err)), + Err(err) => { + // Check if failed order accessed ACE - if so, treat as successful with zero profit + if let Some(interaction @ AceInteraction::NonUnlocking { contract_address }) = + ace_interaction + { + // ACE can inject parent orders, we want to ignore these. + if !has_parents { + tracing::debug!( + order = ?order.id(), + ?err, + ?contract_address, + "Failed order accessed ACE - treating as successful non-unlocking ACE order" + ); + // For failed-but-ACE orders, we use 0 gas since the order + // didn't actually succeed - it's just marked as a non-unlocking ACE interaction + let gas_used = 0; + return Ok(OrderSimResult::Success( + Arc::new(SimulatedOrder { + order, + sim_value: SimValue::new( + U256::ZERO, + U256::ZERO, + BlockSpace::new(gas_used, 0, 0), + Vec::new(), + ), + used_state_trace, + ace_interaction: Some(interaction), + }), + Vec::new(), + )); + } + } + Ok(OrderSimResult::Failed(err)) + } } } diff --git a/crates/rbuilder/src/building/testing/ace_tests/mod.rs b/crates/rbuilder/src/building/testing/ace_tests/mod.rs new file mode 100644 index 000000000..de4d45018 --- /dev/null +++ b/crates/rbuilder/src/building/testing/ace_tests/mod.rs @@ -0,0 +1,313 @@ +use super::test_chain_state::{BlockArgs, TestChainState}; +use crate::building::sim::{AceExchangeState, DependencyKey, NonceKey, SimTree, SimulatedResult}; +use crate::utils::NonceCache; +use alloy_primitives::{address, b256, Address, B256, U256}; +use rbuilder_primitives::ace::{AceConfig, AceInteraction, AceUnlockSource, Selector}; +use rbuilder_primitives::evm_inspector::{SlotKey, UsedStateTrace}; +use rbuilder_primitives::{BlockSpace, Bundle, BundleVersion, Order, SimValue, SimulatedOrder}; +use std::collections::HashSet; +use std::sync::Arc; +use uuid::Uuid; + +/// Create a minimal order for testing (empty bundle with unique ID) +fn create_test_order() -> Order { + Order::Bundle(Bundle { + version: BundleVersion::V1, + block: None, + min_timestamp: None, + max_timestamp: None, + txs: Vec::new(), + reverting_tx_hashes: Vec::new(), + dropping_tx_hashes: Vec::new(), + hash: B256::ZERO, + uuid: Uuid::new_v4(), + replacement_data: None, + signer: None, + refund_identity: None, + metadata: Default::default(), + refund: None, + external_hash: None, + }) +} + +/// Create the real ACE config for testing +fn test_ace_config() -> AceConfig { + AceConfig { + enabled: true, + contract_address: address!("0000000aa232009084Bd71A5797d089AA4Edfad4"), + from_addresses: HashSet::from([address!("c41ae140ca9b281d8a1dc254c50e446019517d04")]), + to_addresses: HashSet::from([address!("0000000aa232009084Bd71A5797d089AA4Edfad4")]), + detection_slots: HashSet::from([b256!( + "0000000000000000000000000000000000000000000000000000000000000003" + )]), + unlock_signatures: HashSet::from([Selector::from_slice(&[0x18, 0x28, 0xe0, 0xe7])]), + force_signatures: HashSet::from([Selector::from_slice(&[0x09, 0xc5, 0xea, 0xbe])]), + } +} + +/// Create a mock state trace with ACE detection slot accessed +fn mock_state_trace_with_ace_slot(contract: Address, slot: B256) -> UsedStateTrace { + let mut trace = UsedStateTrace::default(); + trace.read_slot_values.insert( + SlotKey { + address: contract, + key: slot, + }, + Default::default(), + ); + trace +} + +/// Create a mock force unlock order +fn create_force_unlock_order(contract: Address, gas_used: u64) -> Arc { + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + Arc::new(SimulatedOrder { + order: create_test_order(), + sim_value: SimValue::new( + U256::from(10), + U256::from(10), + BlockSpace::new(gas_used, 0, 0), + Vec::new(), + ), + used_state_trace: Some(mock_state_trace_with_ace_slot(contract, slot)), + ace_interaction: Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce, + }), + }) +} + +/// Create a mock optional unlock order +fn create_optional_unlock_order(contract: Address, gas_used: u64) -> Arc { + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + Arc::new(SimulatedOrder { + order: create_test_order(), + sim_value: SimValue::new( + U256::from(5), + U256::from(5), + BlockSpace::new(gas_used, 0, 0), + Vec::new(), + ), + used_state_trace: Some(mock_state_trace_with_ace_slot(contract, slot)), + ace_interaction: Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional, + }), + }) +} + +/// Create a mock non-unlocking order (accesses ACE slot but doesn't unlock) +fn create_non_unlocking_order(contract: Address, gas_used: u64) -> Arc { + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + Arc::new(SimulatedOrder { + order: create_test_order(), + sim_value: SimValue::new( + U256::from(15), + U256::from(15), + BlockSpace::new(gas_used, 0, 0), + Vec::new(), + ), + used_state_trace: Some(mock_state_trace_with_ace_slot(contract, slot)), + ace_interaction: Some(AceInteraction::NonUnlocking { + contract_address: contract, + }), + }) +} + +#[test] +fn test_ace_exchange_state_get_unlock_order_force_only() { + let order = create_force_unlock_order( + address!("0000000aa232009084Bd71A5797d089AA4Edfad4"), + 100_000, + ); + let mut state = AceExchangeState::default(); + state.force_unlock_order = Some(order.clone()); + + let result = state.get_unlock_order(); + assert_eq!(result, Some(&order)); +} + +#[test] +fn test_ace_exchange_state_get_unlock_order_optional_only() { + let order = + create_optional_unlock_order(address!("0000000aa232009084Bd71A5797d089AA4Edfad4"), 50_000); + let mut state = AceExchangeState::default(); + state.optional_unlock_order = Some(order.clone()); + + let result = state.get_unlock_order(); + assert_eq!(result, Some(&order)); +} + +#[test] +fn test_cheapest_unlock_selected() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let expensive_order = create_force_unlock_order(contract, 100_000); + let cheap_order = create_optional_unlock_order(contract, 50_000); + + let mut state = AceExchangeState::default(); + state.force_unlock_order = Some(expensive_order.clone()); + state.optional_unlock_order = Some(cheap_order.clone()); + + // Should select the cheaper one (50k < 100k) + let result = state.get_unlock_order(); + assert_eq!(result.unwrap().sim_value.gas_used(), 50_000); +} + +#[test] +fn test_equal_gas_prefers_force() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let force_order = create_force_unlock_order(contract, 100_000); + let optional_order = create_optional_unlock_order(contract, 100_000); + + let mut state = AceExchangeState::default(); + state.force_unlock_order = Some(force_order.clone()); + state.optional_unlock_order = Some(optional_order.clone()); + + // When equal gas, should prefer force (d comparison) + let result = state.get_unlock_order(); + assert_eq!(result, Some(&force_order)); +} + +#[test] +fn test_ace_exchange_state_get_unlock_order_none() { + let state = AceExchangeState::default(); + assert_eq!(state.get_unlock_order(), None); +} + +#[test] +fn test_sim_tree_ace_config_registration() -> eyre::Result<()> { + let test_chain = TestChainState::new(BlockArgs::default())?; + let state = test_chain.provider_factory().latest()?; + let nonce_cache = NonceCache::new(state.into()); + + let ace_config = test_ace_config(); + let contract_addr = ace_config.contract_address; + + let sim_tree = SimTree::new(nonce_cache, vec![ace_config.clone()]); + + // Verify config is registered + assert!(sim_tree.ace_configs().contains_key(&contract_addr)); + + // Verify state is initialized + assert!(sim_tree.get_ace_state(&contract_addr).is_some()); + + Ok(()) +} + +#[test] +fn test_multiple_ace_contracts() -> eyre::Result<()> { + let test_chain = TestChainState::new(BlockArgs::default())?; + let state = test_chain.provider_factory().latest()?; + let nonce_cache = NonceCache::new(state.into()); + + let contract1 = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract2 = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + + let mut config1 = test_ace_config(); + config1.contract_address = contract1; + + let mut config2 = test_ace_config(); + config2.contract_address = contract2; + + let sim_tree = SimTree::new(nonce_cache, vec![config1, config2]); + + // Both contracts should be registered + assert!(sim_tree.ace_configs().contains_key(&contract1)); + assert!(sim_tree.ace_configs().contains_key(&contract2)); + + // Both should have state + assert!(sim_tree.get_ace_state(&contract1).is_some()); + assert!(sim_tree.get_ace_state(&contract2).is_some()); + + Ok(()) +} + +#[test] +fn test_mark_mempool_unlock_basic() -> eyre::Result<()> { + let test_chain = TestChainState::new(BlockArgs::default())?; + let state = test_chain.provider_factory().latest()?; + let nonce_cache = NonceCache::new(state.into()); + + let ace_config = test_ace_config(); + let contract_addr = ace_config.contract_address; + + let mut sim_tree = SimTree::new(nonce_cache, vec![ace_config]); + + // Mark mempool unlock (first time) + let cancelled1 = sim_tree.mark_mempool_unlock(contract_addr); + // No optional order yet, so nothing to cancel + assert_eq!(cancelled1, None); + + // Mark again (should be idempotent) + let cancelled2 = sim_tree.mark_mempool_unlock(contract_addr); + assert_eq!(cancelled2, None); + + // Verify state shows mempool unlock was marked + let ace_state = sim_tree.get_ace_state(&contract_addr).unwrap(); + assert!(ace_state.has_mempool_unlock); + + Ok(()) +} + +// Note: More detailed cancellation tests require access to SimTree internals +// or integration with handle_ace_interaction which we'll test separately + +#[test] +fn test_handle_ace_interaction_with_mempool_unlock() -> eyre::Result<()> { + let test_chain = TestChainState::new(BlockArgs::default())?; + let state = test_chain.provider_factory().latest()?; + let nonce_cache = NonceCache::new(state.into()); + + let ace_config = test_ace_config(); + let contract_addr = ace_config.contract_address; + + let mut sim_tree = SimTree::new(nonce_cache, vec![ace_config]); + + let cancelled = sim_tree.mark_mempool_unlock(contract_addr); + assert_eq!(cancelled, None); // Nothing to cancel yet + + let optional_order = create_optional_unlock_order(contract_addr, 50_000); + + let mut result = SimulatedResult { + id: rand::random(), + simulated_order: optional_order.clone(), + previous_orders: Vec::new(), + dependencies_satisfied: Vec::new(), + simulation_time: std::time::Duration::from_millis(10), + }; + + // Handle the ACE interaction + let (skip_forwarding, cancellation) = sim_tree.handle_ace_interaction(&mut result)?; + + // Unlocking orders are not skipped (they go downstream), but should be cancelled + assert!(!skip_forwarding); + assert_eq!(cancellation, Some(optional_order.order.id())); + + // Optional order should NOT be stored + let ace_state = sim_tree.get_ace_state(&contract_addr).unwrap(); + assert!(ace_state.optional_unlock_order.is_none()); + + Ok(()) +} + +#[test] +fn test_dependency_key_from_nonce() { + let nonce_key = NonceKey { + address: address!("0000000000000000000000000000000000000001"), + nonce: 5, + }; + let nonce_key_clone = nonce_key.clone(); + let dep_key: DependencyKey = nonce_key.into(); + assert_eq!(dep_key, DependencyKey::Nonce(nonce_key_clone)); +} + +#[test] +fn test_dependency_key_ace_unlock() { + let contract_addr = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let dep_key = DependencyKey::AceUnlock(contract_addr); + + match dep_key { + DependencyKey::AceUnlock(addr) => assert_eq!(addr, contract_addr), + _ => panic!("Expected AceUnlock dependency"), + } +} diff --git a/crates/rbuilder/src/building/testing/bundle_tests/setup.rs b/crates/rbuilder/src/building/testing/bundle_tests/setup.rs index 40395227f..c0338f2e9 100644 --- a/crates/rbuilder/src/building/testing/bundle_tests/setup.rs +++ b/crates/rbuilder/src/building/testing/bundle_tests/setup.rs @@ -233,6 +233,7 @@ impl TestSetup { order: self.order_builder.build_order(), sim_value: Default::default(), used_state_trace: Default::default(), + ace_interaction: None, }; // we commit order twice to test evm caching diff --git a/crates/rbuilder/src/building/testing/mod.rs b/crates/rbuilder/src/building/testing/mod.rs index 38c649890..0bde21593 100644 --- a/crates/rbuilder/src/building/testing/mod.rs +++ b/crates/rbuilder/src/building/testing/mod.rs @@ -1,4 +1,6 @@ #[cfg(test)] +pub mod ace_tests; +#[cfg(test)] pub mod bundle_tests; #[cfg(test)] pub mod evm_inspector_tests; diff --git a/crates/rbuilder/src/live_builder/base_config.rs b/crates/rbuilder/src/live_builder/base_config.rs index a82ebd2fd..892e0ff24 100644 --- a/crates/rbuilder/src/live_builder/base_config.rs +++ b/crates/rbuilder/src/live_builder/base_config.rs @@ -171,6 +171,9 @@ pub struct BaseConfig { pub orderflow_tracing_store_path: Option, /// Max number of blocks to keep in disk. pub orderflow_tracing_max_blocks: usize, + + /// Ace Configurations + pub ace_protocols: Vec, } pub fn default_ip() -> Ipv4Addr { @@ -271,6 +274,7 @@ impl BaseConfig { simulation_use_random_coinbase: self.simulation_use_random_coinbase, faster_finalize: self.faster_finalize, order_flow_tracer_manager, + ace_config: self.ace_protocols.clone(), }) } @@ -488,6 +492,7 @@ pub const DEFAULT_TIME_TO_KEEP_MEMPOOL_TXS_SECS: u64 = 60; impl Default for BaseConfig { fn default() -> Self { Self { + ace_protocols: vec![], full_telemetry_server_port: 6069, full_telemetry_server_ip: default_ip(), redacted_telemetry_server_port: 6070, diff --git a/crates/rbuilder/src/live_builder/building/mod.rs b/crates/rbuilder/src/live_builder/building/mod.rs index a650776d1..a46f7b4cf 100644 --- a/crates/rbuilder/src/live_builder/building/mod.rs +++ b/crates/rbuilder/src/live_builder/building/mod.rs @@ -46,6 +46,7 @@ pub struct BlockBuildingPool

{ sbundle_merger_selected_signers: Arc>, order_flow_tracer_manager: Box, built_block_id_source: Arc, + ace_config: Vec, } impl

BlockBuildingPool

@@ -62,6 +63,7 @@ where run_sparse_trie_prefetcher: bool, sbundle_merger_selected_signers: Arc>, order_flow_tracer_manager: Box, + ace_config: Vec, ) -> Self { BlockBuildingPool { provider, @@ -73,6 +75,7 @@ where sbundle_merger_selected_signers, order_flow_tracer_manager, built_block_id_source: Arc::new(BuiltBlockIdSource::new()), + ace_config, } } @@ -149,6 +152,7 @@ where orders_for_block, block_cancellation.clone(), sim_tracer, + self.ace_config.clone(), ); self.start_building_job( block_ctx, diff --git a/crates/rbuilder/src/live_builder/config.rs b/crates/rbuilder/src/live_builder/config.rs index cd55f4b17..235410dc5 100644 --- a/crates/rbuilder/src/live_builder/config.rs +++ b/crates/rbuilder/src/live_builder/config.rs @@ -61,6 +61,7 @@ use eyre::Context; use lazy_static::lazy_static; use rbuilder_config::EnvOrValue; use rbuilder_primitives::mev_boost::{MevBoostRelayID, RelayMode}; +pub use rbuilder_primitives::AceConfig; use reth_chainspec::{Chain, ChainSpec, NamedChain}; use reth_db::DatabaseEnv; use reth_node_api::NodeTypesWithDBAdapter; @@ -69,8 +70,9 @@ use reth_primitives::StaticFileSegment; use reth_provider::StaticFileProviderFactory; use serde::Deserialize; use serde_with::{serde_as, OneOrMany}; +use std::collections::HashSet; use std::{ - collections::{HashMap, HashSet}, + collections::HashMap, fmt::Debug, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, path::{Path, PathBuf}, diff --git a/crates/rbuilder/src/live_builder/mod.rs b/crates/rbuilder/src/live_builder/mod.rs index 48555cafc..85c0e02d6 100644 --- a/crates/rbuilder/src/live_builder/mod.rs +++ b/crates/rbuilder/src/live_builder/mod.rs @@ -135,6 +135,8 @@ where pub simulation_use_random_coinbase: bool, pub order_flow_tracer_manager: Box, + + pub ace_config: Vec, } impl

LiveBuilder

@@ -233,6 +235,7 @@ where self.run_sparse_trie_prefetcher, self.sbundle_merger_selected_signers.clone(), self.order_flow_tracer_manager, + self.ace_config.clone(), ); ready_to_build.store(true, Ordering::Relaxed); diff --git a/crates/rbuilder/src/live_builder/simulation/mod.rs b/crates/rbuilder/src/live_builder/simulation/mod.rs index 1dfa36514..d06991408 100644 --- a/crates/rbuilder/src/live_builder/simulation/mod.rs +++ b/crates/rbuilder/src/live_builder/simulation/mod.rs @@ -39,6 +39,8 @@ pub struct SimulationContext { pub requests: flume::Receiver, /// Simulation results go out through this channel. pub results: mpsc::Sender, + /// ACE configuration for this simulation context. + pub ace_configs: ahash::HashMap, } /// All active SimulationContexts @@ -117,6 +119,7 @@ where input: OrdersForBlock, block_cancellation: CancellationToken, sim_tracer: Arc, + ace_config: Vec, ) -> SlotOrderSimResults { let (slot_sim_results_sender, slot_sim_results_receiver) = mpsc::channel(10_000); @@ -153,7 +156,14 @@ where NonceCache::new(state.into()) }; - let sim_tree = SimTree::new(nonces); + // Convert ace_config Vec to HashMap for efficient lookup + let ace_configs_map: ahash::HashMap<_, _> = ace_config + .iter() + .filter(|c| c.enabled) + .map(|c| (c.contract_address, c.clone())) + .collect(); + + let sim_tree = SimTree::new(nonces, ace_config); let new_order_sub = input.new_order_sub; let (sim_req_sender, sim_req_receiver) = flume::unbounded(); let (sim_results_sender, sim_results_receiver) = mpsc::channel(1024); @@ -163,6 +173,7 @@ where block_ctx: ctx, requests: sim_req_receiver, results: sim_results_sender, + ace_configs: ace_configs_map, }; contexts.contexts.insert(block_context, sim_context); } @@ -236,6 +247,7 @@ mod tests { orders_for_block, cancel.clone(), Arc::new(NullSimulationJobTracer {}), + vec![], ); // Create a simple tx that sends to coinbase 5 wei. let coinbase_profit = 5; diff --git a/crates/rbuilder/src/live_builder/simulation/sim_worker.rs b/crates/rbuilder/src/live_builder/simulation/sim_worker.rs index 838ad3c6c..c96931c8a 100644 --- a/crates/rbuilder/src/live_builder/simulation/sim_worker.rs +++ b/crates/rbuilder/src/live_builder/simulation/sim_worker.rs @@ -1,6 +1,6 @@ use crate::{ building::{ - sim::{NonceKey, OrderSimResult, SimulatedResult}, + sim::{DependencyKey, NonceKey, OrderSimResult, SimulatedResult}, simulate_order, BlockState, ThreadBlockBuildingContext, }, live_builder::simulation::CurrentSimulationContexts, @@ -8,6 +8,7 @@ use crate::{ telemetry::{self, add_sim_thread_utilisation_timings, mark_order_simulation_end}, }; use parking_lot::Mutex; +use rbuilder_primitives::ace::AceInteraction; use std::{ sync::Arc, thread::sleep, @@ -70,19 +71,33 @@ pub fn run_sim_worker

( ¤t_sim_context.block_ctx, &mut local_ctx, &mut block_state, + ¤t_sim_context.ace_configs, ); let sim_ok = match sim_result { Ok(sim_result) => { let sim_ok = match sim_result.result { OrderSimResult::Success(simulated_order, nonces_after) => { + let mut dependencies_satisfied: Vec = nonces_after + .into_iter() + .map(|(address, nonce)| { + DependencyKey::Nonce(NonceKey { address, nonce }) + }) + .collect(); + + // If this is an unlocking ACE order, add the ACE dependency + if let Some(AceInteraction::Unlocking { + contract_address, .. + }) = simulated_order.ace_interaction + { + dependencies_satisfied + .push(DependencyKey::AceUnlock(contract_address)); + } + let result = SimulatedResult { id: task.id, simulated_order, previous_orders: task.parents, - nonces_after: nonces_after - .into_iter() - .map(|(address, nonce)| NonceKey { address, nonce }) - .collect(), + dependencies_satisfied, simulation_time: start_time.elapsed(), }; current_sim_context diff --git a/crates/rbuilder/src/live_builder/simulation/simulation_job.rs b/crates/rbuilder/src/live_builder/simulation/simulation_job.rs index b1bff3c57..7ce0541e2 100644 --- a/crates/rbuilder/src/live_builder/simulation/simulation_job.rs +++ b/crates/rbuilder/src/live_builder/simulation/simulation_job.rs @@ -229,10 +229,10 @@ impl SimulationJob { } } // update simtree - if let Err(err) = self - .sim_tree - .submit_simulation_tasks_results(valid_simulated_orders) - { + if let Err(err) = self.sim_tree.submit_simulation_tasks_results( + &mut valid_simulated_orders, + &self.slot_sim_results_sender, + ) { error!(?err, "Failed to push order sim results into the sim tree"); // @Metric return false; diff --git a/crates/rbuilder/src/mev_boost/mod.rs b/crates/rbuilder/src/mev_boost/mod.rs index 38fbd0e5d..970d9de78 100644 --- a/crates/rbuilder/src/mev_boost/mod.rs +++ b/crates/rbuilder/src/mev_boost/mod.rs @@ -1,6 +1,8 @@ use crate::telemetry::{add_gzip_compression_time, add_ssz_encoding_time}; use super::utils::u256decimal_serde_helper; +use itertools::Itertools; + use alloy_primitives::{utils::parse_ether, Address, BlockHash, U256}; use alloy_rpc_types_beacon::BlsPublicKey; use flate2::{write::GzEncoder, Compression}; @@ -794,6 +796,29 @@ impl RelayClient { if let Some(top_competitor_bid) = metadata.value.top_competitor_bid { builder = builder.header(TOP_BID_HEADER, top_competitor_bid.to_string()); } + if !metadata.order_ids.is_empty() { + const MAX_BUNDLE_IDS: usize = 150; + let bundle_ids: Vec<_> = metadata + .order_ids + .iter() + .filter_map(|order| match order { + rbuilder_primitives::OrderId::Tx(_fixed_bytes) => None, + rbuilder_primitives::OrderId::Bundle(uuid) => Some(uuid), + rbuilder_primitives::OrderId::ShareBundle(_fixed_bytes) => None, + }) + .collect(); + let total_bundles = bundle_ids.len(); + let mut bundle_ids = bundle_ids + .iter() + .take(MAX_BUNDLE_IDS) + .map(|uuid| format!("{uuid:?}")); + let bundle_ids = if total_bundles > MAX_BUNDLE_IDS { + bundle_ids.join(",") + ",CAPPED" + } else { + bundle_ids.join(",") + }; + builder = builder.header(BUNDLE_HASHES_HEADER, bundle_ids); + } const MAX_BUNDLE_HASHES: usize = 150; if !metadata.bundle_hashes.is_empty() { diff --git a/examples/config/rbuilder/config-live-example.toml b/examples/config/rbuilder/config-live-example.toml index 790a6af4d..4d03c5081 100644 --- a/examples/config/rbuilder/config-live-example.toml +++ b/examples/config/rbuilder/config-live-example.toml @@ -38,7 +38,7 @@ enabled_relays = ["flashbots"] subsidy = "0.01" [[subsidy_overrides]] -relay = "flashbots_test2" +relay = "flashbots_test2" value = "0.05" # This can be used with test-relay @@ -57,7 +57,6 @@ mode = "full" max_bid_eth = "0.05" - [[builders]] name = "mgp-ordering" algo = "ordering-builder" @@ -81,6 +80,22 @@ discard_txs = true num_threads = 25 safe_sorting_only = false +[[ace_protocols]] +# Contract address serves as unique identifier for this ACE protocol +contract_address = "0x0000000aa232009084Bd71A5797d089AA4Edfad4" +from_addresses = [ + "0xc41ae140ca9b281d8a1dc254c50e446019517d04", + "0xd437f3372f3add2c2bc3245e6bd6f9c202e61bb3", + "0x693ca5c6852a7d212dabc98b28e15257465c11f3", +] +to_addresses = ["0x0000000aa232009084Bd71A5797d089AA4Edfad4"] +# _lastBlockUpdated storage slot (slot 3) +detection_slots = ["0x0000000000000000000000000000000000000000000000000000000000000003"] +# unlockWithEmptyAttestation(address,bytes) nonpayable +unlock_signatures = ["0x1828e0e7"] +# execute(bytes) nonpayable +force_signatures = ["0x09c5eabe"] + [[relay_bid_scrapers]] type = "ultrasound-ws" name = "ultrasound-ws-eu" @@ -92,4 +107,3 @@ type = "ultrasound-ws" name = "ultrasound-ws-us" ultrasound_url = "ws://relay-builders-us.ultrasound.money/ws/v1/top_bid" relay_name = "ultrasound-money-us" -