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-operator/src/flashbots_config.rs b/crates/rbuilder-operator/src/flashbots_config.rs index 4dbb75edd..f48556258 100644 --- a/crates/rbuilder-operator/src/flashbots_config.rs +++ b/crates/rbuilder-operator/src/flashbots_config.rs @@ -36,7 +36,7 @@ use serde::Deserialize; use serde_with::serde_as; use time::OffsetDateTime; use tokio_util::sync::CancellationToken; -use tracing::{error, warn}; +use tracing::warn; use url::Url; use crate::{ diff --git a/crates/rbuilder-operator/src/true_block_value_push/blocks_processor_backend.rs b/crates/rbuilder-operator/src/true_block_value_push/blocks_processor_backend.rs index 6d10761da..76e7132fc 100644 --- a/crates/rbuilder-operator/src/true_block_value_push/blocks_processor_backend.rs +++ b/crates/rbuilder-operator/src/true_block_value_push/blocks_processor_backend.rs @@ -5,7 +5,6 @@ use crate::{ use alloy_signer_local::PrivateKeySigner; use jsonrpsee::core::client::ClientT; use tokio::runtime::Runtime; -use tracing::error; use super::best_true_value_pusher::{Backend, BuiltBlockInfo}; diff --git a/crates/rbuilder-operator/src/true_block_value_push/redis_backend.rs b/crates/rbuilder-operator/src/true_block_value_push/redis_backend.rs index be3e4bf85..6977ff303 100644 --- a/crates/rbuilder-operator/src/true_block_value_push/redis_backend.rs +++ b/crates/rbuilder-operator/src/true_block_value_push/redis_backend.rs @@ -1,5 +1,4 @@ use redis::Commands; -use tracing::error; use super::best_true_value_pusher::{Backend, BuiltBlockInfo}; 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..d25122763 --- /dev/null +++ b/crates/rbuilder-primitives/src/ace.rs @@ -0,0 +1,747 @@ +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 { + /// 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, +} + +/// 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. +/// +/// For `ProtocolForce` and `ProtocolOptional` classification, the transaction must: +/// 1. Be a direct call to the ACE contract (`tx_to` in `config.to_addresses`) +/// 2. Have the appropriate signature (`force_signatures` or `unlock_signatures`) +/// 3. Be from a whitelisted address (`tx_from` in `config.from_addresses`) +/// +/// All other unlocking transactions are classified as `User`. +pub fn classify_ace_interaction( + state_trace: &UsedStateTrace, + sim_success: bool, + config: &AceConfig, + selector: Option, + tx_to: Option
, + tx_from: 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 if transaction is from a whitelisted address (required for protocol orders) + let is_from_whitelisted = tx_from.is_some_and(|from| config.from_addresses.contains(&from)); + + // 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) { + // Protocol orders require: direct call + correct signature + whitelisted sender + let source = if is_direct_protocol_call && is_force_sig && is_from_whitelisted { + AceUnlockSource::ProtocolForce + } else if is_direct_protocol_call && is_unlock_sig && is_from_whitelisted { + AceUnlockSource::ProtocolOptional + } else { + // Any unlock without all three requirements is a User unlock + 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, + } + } +} + +#[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 { + 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(); + let whitelisted_from = *config.from_addresses.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 FROM WHITELISTED ADDRESS should be ProtocolForce + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(force_selector), + Some(contract), + Some(whitelisted_from), + ); + + 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(); + let whitelisted_from = *config.from_addresses.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 FROM WHITELISTED ADDRESS should be ProtocolOptional + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(unlock_selector), + Some(contract), + Some(whitelisted_from), + ); + + 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 + // Even with whitelisted from address, indirect call is User + let whitelisted_from = *config.from_addresses.iter().next().unwrap(); + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(unlock_selector), + None, + Some(whitelisted_from), + ); + + 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 whitelisted_from = *config.from_addresses.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), + Some(whitelisted_from), + ); + + 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 whitelisted_from = *config.from_addresses.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), + Some(whitelisted_from), + ); + + 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(); + let whitelisted_from = *config.from_addresses.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), + Some(whitelisted_from), + ); + + 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 whitelisted_from = *config.from_addresses.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), + Some(whitelisted_from), + ); + + assert_eq!( + result, None, + "Should return None when wrong slot is accessed" + ); + } + + #[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"); + let whitelisted_from = *config.from_addresses.iter().next().unwrap(); + + // 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 FROM WHITELISTED ADDRESS = ProtocolOptional + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(unlock_selector), + Some(contract), + Some(whitelisted_from), + ); + + 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, + Some(whitelisted_from), + ); + + 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), + Some(whitelisted_from), + ); + + 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 whitelisted_from = *config.from_addresses.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), + Some(whitelisted_from), + ); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce + }) + ); + } + + #[test] + fn test_non_whitelisted_from_address_becomes_user() { + // Force call from non-whitelisted address should become User, not ProtocolForce + 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(); + + // Use an address NOT in the whitelist + let non_whitelisted = address!("1111111111111111111111111111111111111111"); + assert!( + !config.from_addresses.contains(&non_whitelisted), + "Address should not be in whitelist for this test" + ); + + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // Direct call with force signature but from non-whitelisted address = User + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(force_selector), + Some(contract), + Some(non_whitelisted), + ); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User // NOT ProtocolForce! + }) + ); + + // Should still be unlocking, but not a protocol tx + assert!(result.unwrap().is_unlocking()); + assert!(!result.unwrap().is_protocol_tx()); + } + + #[test] + fn test_non_whitelisted_optional_unlock_becomes_user() { + // Optional unlock from non-whitelisted address should become User, not ProtocolOptional + 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(); + + // Use an address NOT in the whitelist + let non_whitelisted = address!("2222222222222222222222222222222222222222"); + assert!(!config.from_addresses.contains(&non_whitelisted)); + + let trace = mock_state_trace_with_slot(contract, detection_slot); + + // Direct call with unlock signature but from non-whitelisted address = User + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(unlock_selector), + Some(contract), + Some(non_whitelisted), + ); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User // NOT ProtocolOptional! + }) + ); + + // Should be unlocking but not a protocol tx + assert!(result.unwrap().is_unlocking()); + assert!(!result.unwrap().is_protocol_tx()); + } + + #[test] + fn test_none_from_address_becomes_user() { + // When tx_from is None, should classify as User even with correct signature and direct call + 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 trace = mock_state_trace_with_slot(contract, detection_slot); + + // Direct call with force signature but tx_from = None = User + let result = classify_ace_interaction( + &trace, + true, + &config, + Some(force_selector), + Some(contract), + None, // No from address + ); + + assert_eq!( + result, + Some(AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User + }) + ); + } +} diff --git a/crates/rbuilder-primitives/src/lib.rs b/crates/rbuilder-primitives/src/lib.rs index d403c7c0c..f95678b25 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 interactions - one per ACE contract this order interacts with. + /// Empty if no ACE interactions. + pub ace_interactions: Vec, } impl SimulatedOrder { diff --git a/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs b/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs index 6d3ecdfb4..22cbe4578 100644 --- a/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs +++ b/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs @@ -1,5 +1,5 @@ use alloy_primitives::B256; -use clap::{command, Parser}; +use clap::Parser; use rbuilder_config::load_toml_config; use rbuilder_primitives::{ Bundle, MempoolTx, Metadata, Order, TransactionSignedEcRecoveredWithBlobs, LAST_BUNDLE_VERSION, diff --git a/crates/rbuilder/src/backtest/execute.rs b/crates/rbuilder/src/backtest/execute.rs index 3b677f94d..b2e5d5d9c 100644 --- a/crates/rbuilder/src/backtest/execute.rs +++ b/crates/rbuilder/src/backtest/execute.rs @@ -107,7 +107,7 @@ where } let (sim_orders, sim_errors) = - simulate_all_orders_with_sim_tree(provider, &ctx, &orders, false)?; + simulate_all_orders_with_sim_tree(provider, &ctx, &orders, false, vec![])?; // 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..2c7c99d7a 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_interactions: Vec::new(), }; 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_interactions: Vec::new(), }; 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/multi_share_bundle_merger.rs b/crates/rbuilder/src/building/block_orders/multi_share_bundle_merger.rs index 41ccfa8ed..bf89030f7 100644 --- a/crates/rbuilder/src/building/block_orders/multi_share_bundle_merger.rs +++ b/crates/rbuilder/src/building/block_orders/multi_share_bundle_merger.rs @@ -121,7 +121,7 @@ mod test { // first order generates a megabundle with it context.insert_order(br_hi.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_hi.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_hi)); // for second expect a cancellation and a new megabundle with both context.insert_order(br_low.clone()); @@ -141,17 +141,17 @@ mod test { // first order generates a megabundle with it context.insert_order(br_1.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_1.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_1)); // for second expect a new megabundle with it context.insert_order(br_2.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_2.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_2)); // for an unknown signer expect a new megabundle with it context.insert_order(br_3.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_3.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_3)); } #[test] @@ -163,7 +163,7 @@ mod test { // first order generates a megabundle with it context.insert_order(br_hi.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_hi.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_hi)); // for second expect a cancellation and a new megabundle with both context.insert_order(br_low.clone()); diff --git a/crates/rbuilder/src/building/block_orders/order_priority.rs b/crates/rbuilder/src/building/block_orders/order_priority.rs index 73c882c95..c4f8343c5 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_interactions: Vec::new(), 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..c5515fb60 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_interactions: highest_payback_order.sim_order.ace_interactions.clone(), })) } @@ -471,7 +472,7 @@ mod test { // Insert hi expect an order with only br_hi context.insert_order(br_hi.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_hi.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_hi)); // Insert low expect a cancellation for prev order and hi+low context.insert_order(br_low.clone()); @@ -483,7 +484,7 @@ mod test { context.remove_order(br_hi.id()); assert_eq!(context.pop_remove(), generated_order.id()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_low.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_low)); // Remove low order expect a cancellation for prev order and nothing more (shoudn't insert an empty sbundle!) context.remove_order(br_low.id()); @@ -492,7 +493,7 @@ mod test { // We expect an order with only br_low context.insert_order(br_low.clone()); let generated_order = context.pop_insert(); - context.assert_concatenated_sbundles_ok(&generated_order, &[br_low.clone()]); + context.assert_concatenated_sbundles_ok(&generated_order, std::slice::from_ref(&br_low)); // Insert hi expect a cancellation for prev order and hi+low context.insert_order(br_hi.clone()); diff --git a/crates/rbuilder/src/building/block_orders/test_context.rs b/crates/rbuilder/src/building/block_orders/test_context.rs index 371a5d1a2..0e2ce0ddf 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_interactions: Vec::new(), }) } 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..c3bd4e7f6 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_interactions: Vec::new(), }) } } diff --git a/crates/rbuilder/src/building/builders/block_building_helper.rs b/crates/rbuilder/src/building/builders/block_building_helper.rs index c9dfbe7a8..5bbd70d34 100644 --- a/crates/rbuilder/src/building/builders/block_building_helper.rs +++ b/crates/rbuilder/src/building/builders/block_building_helper.rs @@ -7,7 +7,7 @@ use std::{ }; use time::OffsetDateTime; use tokio_util::sync::CancellationToken; -use tracing::{debug, error, trace, warn}; +use tracing::{debug, trace, warn}; use crate::{ building::{ diff --git a/crates/rbuilder/src/building/builders/ordering_builder.rs b/crates/rbuilder/src/building/builders/ordering_builder.rs index aed6566d2..6baa819cb 100644 --- a/crates/rbuilder/src/building/builders/ordering_builder.rs +++ b/crates/rbuilder/src/building/builders/ordering_builder.rs @@ -289,6 +289,18 @@ 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_interactions.iter().any(|a| a.is_protocol_tx()) { + 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 +313,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..358cb9a35 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,30 @@ 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_interactions.iter().any(|a| a.is_protocol_tx()) { + 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_interactions + .iter() + .any(|a| a.is_protocol_tx()) + }); + } + let mut block_building_helper = BlockBuildingHelperFromProvider::new( self.built_block_id_source.get_new_id(), self.state.clone(), @@ -199,6 +223,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 +297,33 @@ 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_interactions.iter().any(|a| a.is_protocol_tx()) { + 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_interactions + .iter() + .any(|a| a.is_protocol_tx()) + }); + } + let mut block_building_helper = BlockBuildingHelperFromProvider::new( self.built_block_id_source.get_new_id(), self.state.clone(), @@ -275,8 +338,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..07781e94b 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_interactions: Vec::new(), }) } } 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..ab7b2993a 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_interactions: Vec::new(), }) } } diff --git a/crates/rbuilder/src/building/builders/parallel_builder/groups.rs b/crates/rbuilder/src/building/builders/parallel_builder/groups.rs index a0b6d8800..691245664 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_interactions: Vec::new(), }) } } diff --git a/crates/rbuilder/src/building/sim.rs b/crates/rbuilder/src/building/sim.rs index b0eccaf7f..a251097b8 100644 --- a/crates/rbuilder/src/building/sim.rs +++ b/crates/rbuilder/src/building/sim.rs @@ -15,7 +15,12 @@ use crate::{ }; use ahash::{HashMap, HashSet}; use alloy_primitives::Address; +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::{Order, OrderId, SimulatedOrder}; use reth_errors::ProviderError; use reth_provider::StateProvider; @@ -27,11 +32,23 @@ use std::{ }; use tracing::{error, trace}; +/// Information about a simulation failure +#[derive(Debug)] +pub struct SimulationFailure { + /// The error that caused the failure + pub error: OrderErr, + /// If Some, this order needs an ACE unlock from this contract before it can succeed. + /// The order should be queued for re-simulation once the unlock tx is available. + pub ace_dependency: Option
, +} + #[derive(Debug)] #[allow(clippy::large_enum_variant)] pub enum OrderSimResult { + /// Order simulated successfully Success(Arc, Vec<(Address, u64)>), - Failed(OrderErr), + /// Order simulation failed + Failed(SimulationFailure), } #[derive(Debug)] @@ -47,28 +64,101 @@ 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, + /// ACE contracts already provided as unlock parents (for progressive multi-ACE discovery) + ace_unlock_contracts: HashSet
, } pub type SimulationId = u64; -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SimulationRequest { pub id: SimulationId, pub order: Order, pub parents: Vec, + /// ACE contracts for which we've already provided unlock parents. + /// Used to determine if a failure is genuine (contract already unlocked) or needs retry. + /// Supports multiple ACE contracts - order can progressively discover needed unlocks. + pub ace_unlock_contracts: HashSet
, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SimulatedResult { - pub id: SimulationId, - pub simulated_order: Arc, - pub previous_orders: Vec, - pub nonces_after: Vec, - pub simulation_time: Duration, +#[derive(Debug)] +#[allow(clippy::large_enum_variant)] +pub enum SimulatedResult { + /// Successful simulation + Success { + id: SimulationId, + simulated_order: Arc, + previous_orders: Vec, + /// Dependencies this simulation satisfies (nonces updated, ACE unlocks provided) + dependencies_satisfied: Vec, + simulation_time: Duration, + }, + /// Order simulation failed + Failed { + id: SimulationId, + order: Order, + failure: SimulationFailure, + /// ACE contracts that were already provided as unlock parents (preserved for re-queuing) + ace_unlock_contracts: HashSet
, + simulation_time: Duration, + }, +} + +/// Minimal data stored for completed simulations (to avoid Clone on full SimulatedResult) +#[derive(Debug, Clone)] +struct StoredSimulation { + previous_orders: Vec, + simulated_order: Arc, } // @Feat replaceable orders @@ -77,53 +167,83 @@ pub struct SimTree { // fields for nonce management nonces: NonceCache, - sims: HashMap, - sims_that_update_one_nonce: HashMap, + sims: 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,32 +251,38 @@ impl SimTree { order.id(), PendingOrder { order, - unsatisfied_nonces, + unsatisfied_dependencies, + ace_unlock_contracts: HashSet::default(), }, ); } - OrderNonceState::Ready(parents) => { + OrderDependencyState::Ready(parents) => { self.ready_orders.push(SimulationRequest { id: rand::random(), order, parents, + ace_unlock_contracts: HashSet::default(), }); } } 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 +295,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,27 +313,96 @@ 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"); + let Some(sim) = self.sims.get(sim_id) else { + error!("SimTree bug: dependency provider sim not found"); + pending_deps.push(dep_key); + continue; + }; parent_orders.extend_from_slice(&sim.previous_orders); parent_orders.push(sim.simulated_order.order.clone()); 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 simulation when we detect a NonUnlocking ACE interaction. + /// Supports progressive multi-ACE discovery - existing_ace_unlock_contracts contains + /// contracts we've already provided unlocks for in previous sim attempts. + pub fn add_ace_dependency_for_order( + &mut self, + order: Order, + new_contract: Address, + mut existing_ace_unlock_contracts: HashSet
, + ) -> Result<(), ProviderError> { + // Add new contract to the set + existing_ace_unlock_contracts.insert(new_contract); + let dep_key = DependencyKey::AceUnlock(new_contract); + + // Check if we already have an unlock provider for the new contract + if self.dependency_providers.contains_key(&dep_key) { + // Build parents from ALL ACE unlock contracts we need + let mut parents = Vec::new(); + for contract in &existing_ace_unlock_contracts { + let key = DependencyKey::AceUnlock(*contract); + if let Some(sim_id) = self.dependency_providers.get(&key) { + if let Some(sim) = self.sims.get(sim_id) { + parents.extend(sim.previous_orders.clone()); + parents.push(sim.simulated_order.order.clone()); + } + } + } + + // Order is ready with all unlock txs as parents + self.ready_orders.push(SimulationRequest { + id: rand::random(), + order, + parents, + ace_unlock_contracts: existing_ace_unlock_contracts, + }); + return Ok(()); + } + + // New unlock not yet available - add to pending + self.add_order_to_pending_with_ace(order, dep_key, existing_ace_unlock_contracts) + } + + /// Helper to add an order to pending state with ACE unlock tracking + fn add_order_to_pending_with_ace( + &mut self, + order: Order, + dep_key: DependencyKey, + ace_unlock_contracts: HashSet
, + ) -> Result<(), ProviderError> { + self.pending_dependencies + .entry(dep_key) + .or_default() + .push(order.id()); + self.pending_orders.insert( + order.id(), + PendingOrder { + order, + unsatisfied_dependencies: 1, + ace_unlock_contracts, + }, + ); + Ok(()) + } + pub fn push_orders(&mut self, orders: Vec) -> Result<(), ProviderError> { for order in orders { self.push_order(order)?; @@ -223,46 +418,75 @@ impl SimTree { // we don't really need state here because nonces are cached but its smaller if we reuse pending state fn fn process_simulation_task_result( &mut self, - result: SimulatedResult, + result: &SimulatedResult, ) -> 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(); + let SimulatedResult::Success { + id, + simulated_order, + previous_orders, + dependencies_satisfied, + .. + } = result + else { + // Only Success variants should be processed here + return Ok(()); + }; + + self.sims.insert( + *id, + StoredSimulation { + previous_orders: previous_orders.clone(), + simulated_order: simulated_order.clone(), + }, + ); + // Track orders that become ready along with their ACE state + let mut orders_ready: Vec = Vec::new(); + let mut ace_unlock_contract: Option
= None; + + // Process each dependency this simulation satisfies + for dep_key in dependencies_satisfied.iter().cloned() { + // Track if this dependency is an ACE unlock and which contract + if let DependencyKey::AceUnlock(contract) = dep_key { + ace_unlock_contract = Some(contract); + } - match self.sims_that_update_one_nonce.entry(updated_nonce.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 - .get(sim_id) - .expect("we never delete sims") - .simulated_order - .sim_value - .full_profit_info() - .coinbase_profit() + if let Some(existing_sim) = self.sims.get(sim_id) { + existing_sim + .simulated_order + .sim_value + .full_profit_info() + .coinbase_profit() + } else { + continue; + } }; - if result - .simulated_order + if simulated_order .sim_value .full_profit_info() .coinbase_profit() > current_sim_profit { - entry.insert(result.id); + entry.insert(*id); } } Entry::Vacant(entry) => { - entry.insert(result.id); + // First provider for this dependency + entry.insert(*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 { - orders_ready.push(entry.remove().order); + pending_order.unsatisfied_dependencies -= 1; + if pending_order.unsatisfied_dependencies == 0 { + orders_ready.push(entry.remove()); } } Entry::Vacant(_) => { @@ -276,21 +500,29 @@ impl SimTree { } } - for ready_order in orders_ready { - let pending_state = self.get_order_nonce_state(&ready_order)?; + for mut ready_pending_order in orders_ready { + let pending_state = self.get_order_dependency_state(&ready_pending_order.order)?; match pending_state { - OrderNonceState::Ready(parents) => { + OrderDependencyState::Ready(mut parents) => { + // If this order became ready due to ACE unlock, add the unlock tx as parent + // and track the contract in ace_unlock_contracts + if let Some(contract) = ace_unlock_contract { + ready_pending_order.ace_unlock_contracts.insert(contract); + parents.extend(previous_orders.iter().cloned()); + parents.push(simulated_order.order.clone()); + } self.ready_orders.push(SimulationRequest { id: rand::random(), - order: ready_order, + order: ready_pending_order.order, parents, + ace_unlock_contracts: ready_pending_order.ace_unlock_contracts, }); } - 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,14 +531,163 @@ impl SimTree { Ok(()) } + /// Handle ACE unlocking interaction after successful simulation. + /// Returns optional cancellation OrderId if a mempool unlock cancels an optional ACE tx. + /// Note: NonUnlocking ACE interactions are handled at the OrderSimResult level. + pub fn handle_ace_unlock( + &mut self, + result: &SimulatedResult, + ) -> Result, ProviderError> { + let SimulatedResult::Success { + simulated_order, + previous_orders, + .. + } = result + else { + return Ok(Vec::new()); + }; + + // If this order already has parents, it was re-simulated - just pass through + if !previous_orders.is_empty() { + return Ok(Vec::new()); + } + + // Get all unlocking interactions + let unlocking_interactions: Vec<_> = simulated_order + .ace_interactions + .iter() + .filter_map(|i| match i { + AceInteraction::Unlocking { + contract_address, + source, + } => Some((*contract_address, *source)), + AceInteraction::NonUnlocking { .. } => None, + }) + .collect(); + + if unlocking_interactions.is_empty() { + return Ok(Vec::new()); + } + + let mut cancellations = Vec::new(); + + // Process each unlocking interaction + for (contract_address, source) in unlocking_interactions { + match source { + AceUnlockSource::ProtocolForce => { + let state = self.ace_state.entry(contract_address).or_default(); + state.force_unlock_order = Some(simulated_order.clone()); + trace!( + "Added forced ACE protocol unlock order for {:?}", + contract_address + ); + } + AceUnlockSource::ProtocolOptional => { + let state = self.ace_state.entry(contract_address).or_default(); + + // Check if user unlock already available - cancel optional + if state.has_mempool_unlock { + trace!( + "Cancelling optional ACE unlock for {:?} - user unlock exists", + contract_address + ); + cancellations.push(simulated_order.order.id()); + continue; + } + + // Only include optional if there are orders waiting on this unlock + let dep_key = DependencyKey::AceUnlock(contract_address); + if !self.pending_dependencies.contains_key(&dep_key) { + trace!( + "Cancelling optional ACE unlock for {:?} - no pending orders need it", + contract_address + ); + cancellations.push(simulated_order.order.id()); + continue; + } + + // Store optional unlock - there are orders waiting for it + state.optional_unlock_order = Some(simulated_order.clone()); + trace!( + "Added optional ACE protocol unlock order for {:?}", + contract_address + ); + } + AceUnlockSource::User => { + // A user unlocked ACE via mempool - mark it and cancel any optional protocol order + trace!("User mempool unlock detected for {:?}", contract_address); + if let Some(cancelled_id) = self.mark_mempool_unlock(contract_address) { + cancellations.push(cancelled_id); + } + } + } + } + + Ok(cancellations) + } + + /// 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 unlocks and updating dependencies. + /// Returns: + /// - `Vec`: Successful results (to be forwarded to builder) + /// - `Vec`: Order IDs that should be cancelled (e.g., optional ACE unlocks superseded by mempool) pub fn submit_simulation_tasks_results( &mut self, results: Vec, - ) -> Result<(), ProviderError> { + ) -> Result<(Vec, Vec), ProviderError> { + let mut cancellations = Vec::new(); + let mut successful_results = Vec::with_capacity(results.len()); + for result in results { - self.process_simulation_task_result(result)?; + match result { + SimulatedResult::Success { .. } => { + cancellations.extend(self.handle_ace_unlock(&result)?); + // All successful results need to be processed for dependency tracking + self.process_simulation_task_result(&result)?; + successful_results.push(result); + } + SimulatedResult::Failed { + order, + failure: + SimulationFailure { + ace_dependency: Some(contract_address), + .. + }, + ace_unlock_contracts, + .. + } => { + // Order failed but needs ACE unlock - queue for re-simulation + // Pass existing ace_unlock_contracts to support progressive multi-ACE discovery + self.add_ace_dependency_for_order( + order, + contract_address, + ace_unlock_contracts, + )?; + } + SimulatedResult::Failed { .. } => { + // Permanent failure - nothing to do + } + } } - Ok(()) + + Ok((successful_results, cancellations)) } } @@ -318,6 +699,7 @@ pub fn simulate_all_orders_with_sim_tree

( ctx: &BlockBuildingContext, orders: &[Order], randomize_insertion: bool, + ace_config: Vec, ) -> Result<(Vec>, Vec), CriticalCommitOrderError> where P: StateProviderFactory + Clone, @@ -326,7 +708,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); @@ -335,7 +717,7 @@ where // shuffle orders orders.shuffle(&mut rng); } else { - sim_tree.push_orders(orders.clone())?; + sim_tree.push_orders(std::mem::take(&mut orders))?; } let mut sim_errors = Vec::new(); @@ -369,36 +751,61 @@ where ctx, &mut local_ctx, &mut block_state, + sim_tree.ace_configs(), + &sim_task.ace_unlock_contracts, )?; let (_, provider) = block_state.into_parts(); state_for_sim = provider; match sim_result.result { - OrderSimResult::Failed(err) => { - trace!( - order = sim_task.order.id().to_string(), - ?err, - "Order simulation failed" - ); - sim_errors.push(err); - continue; + OrderSimResult::Failed(failure) => { + if let Some(contract_address) = failure.ace_dependency { + // Order failed but needs ACE unlock - queue for re-simulation + // Pass existing ace_unlock_contracts for progressive multi-ACE discovery + sim_tree.add_ace_dependency_for_order( + sim_task.order, + contract_address, + sim_task.ace_unlock_contracts, + )?; + } else { + // Permanent failure + trace!( + order = sim_task.order.id().to_string(), + ?failure, + "Order simulation failed" + ); + sim_errors.push(failure.error); + } } OrderSimResult::Success(sim_order, nonces) => { - let result = SimulatedResult { + let mut dependencies_satisfied: Vec = nonces + .into_iter() + .map(|(address, nonce)| DependencyKey::Nonce(NonceKey { address, nonce })) + .collect(); + + // Add ACE dependencies for all unlocking interactions + for interaction in &sim_order.ace_interactions { + if let AceInteraction::Unlocking { + contract_address, .. + } = interaction + { + dependencies_satisfied + .push(DependencyKey::AceUnlock(*contract_address)); + } + } + + let result = SimulatedResult::Success { 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)?; + // For batch simulation, we ignore cancellations since there's no live processing + let (_, _cancellations) = sim_tree.submit_simulation_tasks_results(sim_results)?; } Ok(( @@ -418,14 +825,23 @@ pub fn simulate_order( ctx: &BlockBuildingContext, local_ctx: &mut ThreadBlockBuildingContext, state: &mut BlockState, + ace_configs: &HashMap, + ace_unlock_contracts: &HashSet

, ) -> 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, + ace_unlock_contracts, + ); fork.rollback(rollback_point); let sim_res = sim_res?; + Ok(OrderSimResultWithGas { result: sim_res, gas_used: tracer.used_gas, @@ -438,8 +854,11 @@ pub fn simulate_order_using_fork( order: Order, fork: &mut PartialBlockFork<'_, '_, '_, '_, Tracer, NullPartialBlockForkExecutionTracer>, mempool_tx_detector: &MempoolTxsDetector, + ace_configs: &HashMap, + ace_unlock_contracts: &HashSet
, ) -> Result { let start = Instant::now(); + // simulate parents let mut space_state = BlockBuildingSpaceState::ZERO; // We use empty combined refunds because the value of the bundle will @@ -453,7 +872,10 @@ pub fn simulate_order_using_fork( } Err(err) => { tracing::trace!(parent_order = ?parent.id(), ?err, "failed to simulate parent order"); - return Ok(OrderSimResult::Failed(err)); + return Ok(OrderSimResult::Failed(SimulationFailure { + error: err, + ace_dependency: None, + })); } } } @@ -461,13 +883,62 @@ 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_mut().and_then(|t| t.take_used_state_trace()); + + // Detect ACE interactions from the state trace using config + // Check ALL transactions in the order and collect ALL ACE interactions (one per contract) + // For each contract, keep the highest priority classification: + // Priority: ProtocolForce > ProtocolOptional > User > NonUnlocking + let ace_interactions: Vec = if let Some(trace) = used_state_trace.as_ref() { + // Use HashMap to track best interaction per contract + let mut per_contract: HashMap = HashMap::default(); + + for (tx, _) in order.list_txs() { + let input = tx.internal_tx_unsecure().input(); + let selector = if input.len() >= 4 { + Some(Selector::from_slice(&input[..4])) + } else { + None + }; + let tx_to = tx.to(); + let tx_from = Some(tx.signer()); + + // Check this transaction against all ACE configs + for (_, config) in ace_configs.iter() { + if let Some(interaction) = + classify_ace_interaction(trace, sim_success, config, selector, tx_to, tx_from) + { + let contract = interaction.get_contract_address(); + // Update if new interaction has higher priority for this contract + per_contract + .entry(contract) + .and_modify(|existing| { + if interaction_priority(&interaction) > interaction_priority(existing) { + *existing = interaction; + } + }) + .or_insert(interaction); + } + } + } + + per_contract.into_values().collect() + } else { + Vec::new() + }; match result { Ok(res) => { let sim_value = create_sim_value(&order, &res, mempool_tx_detector); if let Err(err) = order_is_worth_executing(&sim_value) { - return Ok(OrderSimResult::Failed(err)); + return Ok(OrderSimResult::Failed(SimulationFailure { + error: err, + ace_dependency: None, + })); } let new_nonces = res.nonces_updated.into_iter().collect::>(); Ok(OrderSimResult::Success( @@ -475,10 +946,64 @@ pub fn simulate_order_using_fork( order, sim_value, used_state_trace: res.used_state_trace, + ace_interactions, }), new_nonces, )) } - Err(err) => Ok(OrderSimResult::Failed(err)), + Err(err) => { + // Check if failed order accessed ACE - may need re-simulation with unlock parent + // Find the first NonUnlocking contract we don't already have an unlock for + let ace_dependency = ace_interactions.iter().find_map(|interaction| { + if let AceInteraction::NonUnlocking { contract_address } = interaction { + if ace_unlock_contracts.contains(contract_address) { + // Already had unlock for this contract but still failed - skip + tracing::debug!( + order = ?order.id(), + ?err, + ?contract_address, + "Order failed despite having ACE unlock for this contract" + ); + None + } else { + // Need unlock for this contract + tracing::debug!( + order = ?order.id(), + ?err, + ?contract_address, + existing_unlocks = ?ace_unlock_contracts, + "Order needs additional ACE unlock" + ); + Some(*contract_address) + } + } else { + None + } + }); + + Ok(OrderSimResult::Failed(SimulationFailure { + error: err, + ace_dependency, + })) + } + } +} + +/// Returns priority score for ACE interaction (higher = more important) +fn interaction_priority(interaction: &AceInteraction) -> u8 { + match interaction { + AceInteraction::Unlocking { + source: AceUnlockSource::ProtocolForce, + .. + } => 4, + AceInteraction::Unlocking { + source: AceUnlockSource::ProtocolOptional, + .. + } => 3, + AceInteraction::Unlocking { + source: AceUnlockSource::User, + .. + } => 2, + AceInteraction::NonUnlocking { .. } => 1, } } 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..ea9e6d405 --- /dev/null +++ b/crates/rbuilder/src/building/testing/ace_tests/mod.rs @@ -0,0 +1,629 @@ +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 { + 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_interactions: vec![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_interactions: vec![AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional, + }], + }) +} + +#[test] +fn test_ace_exchange_state_get_unlock_order_force_only() { + let order = create_force_unlock_order( + address!("0000000aa232009084Bd71A5797d089AA4Edfad4"), + 100_000, + ); + let state = AceExchangeState { + force_unlock_order: Some(order.clone()), + ..Default::default() + }; + + 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 state = AceExchangeState { + optional_unlock_order: Some(order.clone()), + ..Default::default() + }; + + 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 state = AceExchangeState { + force_unlock_order: Some(expensive_order.clone()), + optional_unlock_order: Some(cheap_order.clone()), + ..Default::default() + }; + + // 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 state = AceExchangeState { + force_unlock_order: Some(force_order.clone()), + optional_unlock_order: Some(optional_order.clone()), + ..Default::default() + }; + + // 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_unlock_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 result = SimulatedResult::Success { + 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 unlock + let cancellations = sim_tree.handle_ace_unlock(&result)?; + + // Unlocking orders should be cancelled since mempool unlock was already marked + assert!(cancellations.contains(&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_optional_ace_not_stored_without_pending_orders() -> 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]); + + // Create optional unlock order but NO pending orders waiting on it + let optional_order = create_optional_unlock_order(contract_addr, 50_000); + + let result = SimulatedResult::Success { + 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 unlock - should be cancelled because no orders need it + let cancellations = sim_tree.handle_ace_unlock(&result)?; + + // Optional should be cancelled because no orders are waiting + assert!(cancellations.contains(&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"), + } +} + +// ============================================================================ +// Multi-ACE Unlock Tests +// ============================================================================ + +use crate::building::sim::SimulationRequest; +use ahash::HashSet as AHashSet; + +#[test] +fn test_simulation_request_ace_unlock_contracts_empty_default() { + // New orders should start with empty ace_unlock_contracts + let request = SimulationRequest { + id: rand::random(), + order: create_test_order(), + parents: Vec::new(), + ace_unlock_contracts: AHashSet::default(), + }; + + assert!(request.ace_unlock_contracts.is_empty()); +} + +#[test] +fn test_simulation_request_ace_unlock_contracts_single() { + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let mut ace_contracts = AHashSet::default(); + ace_contracts.insert(contract_a); + + let request = SimulationRequest { + id: rand::random(), + order: create_test_order(), + parents: Vec::new(), + ace_unlock_contracts: ace_contracts.clone(), + }; + + assert!(request.ace_unlock_contracts.contains(&contract_a)); + assert_eq!(request.ace_unlock_contracts.len(), 1); +} + +#[test] +fn test_simulation_request_ace_unlock_contracts_multiple() { + // Test that SimulationRequest can track multiple ACE contracts + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + let contract_c = address!("2222222aa232009084Bd71A5797d089AA4Edfad4"); + + let mut ace_contracts = AHashSet::default(); + ace_contracts.insert(contract_a); + ace_contracts.insert(contract_b); + ace_contracts.insert(contract_c); + + let request = SimulationRequest { + id: rand::random(), + order: create_test_order(), + parents: Vec::new(), + ace_unlock_contracts: ace_contracts.clone(), + }; + + assert!(request.ace_unlock_contracts.contains(&contract_a)); + assert!(request.ace_unlock_contracts.contains(&contract_b)); + assert!(request.ace_unlock_contracts.contains(&contract_c)); + assert_eq!(request.ace_unlock_contracts.len(), 3); +} + +#[test] +fn test_ace_unlock_contracts_genuine_failure_detection() { + // When ace_unlock_contracts contains the failing contract, + // it should be treated as genuine failure (not re-queued) + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let mut ace_contracts = AHashSet::default(); + ace_contracts.insert(contract_a); + + // If the order already had unlock for contract_a but still failed, + // checking contains() should return true + assert!(ace_contracts.contains(&contract_a)); + + // A different contract should NOT be in the set + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + assert!(!ace_contracts.contains(&contract_b)); +} + +#[test] +fn test_ace_unlock_contracts_progressive_accumulation() { + // Simulate progressive discovery: start empty, add contracts one by one + let mut ace_contracts = AHashSet::default(); + + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + + // First failure - add contract A + assert!(!ace_contracts.contains(&contract_a)); + ace_contracts.insert(contract_a); + assert!(ace_contracts.contains(&contract_a)); + assert_eq!(ace_contracts.len(), 1); + + // Second failure (different contract) - add contract B + assert!(!ace_contracts.contains(&contract_b)); + ace_contracts.insert(contract_b); + assert!(ace_contracts.contains(&contract_b)); + assert_eq!(ace_contracts.len(), 2); + + // Third failure with contract A - should be genuine failure (already in set) + assert!(ace_contracts.contains(&contract_a)); +} + +#[test] +fn test_add_ace_dependency_preserves_existing_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 contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + + let mut config_a = test_ace_config(); + config_a.contract_address = contract_a; + + let mut config_b = test_ace_config(); + config_b.contract_address = contract_b; + + let mut sim_tree = SimTree::new(nonce_cache, vec![config_a, config_b]); + + // Create an order that already has contract_a in its ace_unlock_contracts + let order = create_test_order(); + let mut existing_contracts = AHashSet::default(); + existing_contracts.insert(contract_a); + + // Add ACE dependency for contract_b (simulating second failure) + sim_tree.add_ace_dependency_for_order(order, contract_b, existing_contracts)?; + + // The order should now be pending for contract_b + // (We can't easily check internal state, but the function should succeed) + + Ok(()) +} + +#[test] +fn test_multi_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 contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + let contract_c = address!("2222222aa232009084Bd71A5797d089AA4Edfad4"); + + let mut config_a = test_ace_config(); + config_a.contract_address = contract_a; + + let mut config_b = test_ace_config(); + config_b.contract_address = contract_b; + + let mut config_c = test_ace_config(); + config_c.contract_address = contract_c; + + let sim_tree = SimTree::new(nonce_cache, vec![config_a, config_b, config_c]); + + // All three contracts should be registered + assert!(sim_tree.ace_configs().contains_key(&contract_a)); + assert!(sim_tree.ace_configs().contains_key(&contract_b)); + assert!(sim_tree.ace_configs().contains_key(&contract_c)); + + // All three should have state + assert!(sim_tree.get_ace_state(&contract_a).is_some()); + assert!(sim_tree.get_ace_state(&contract_b).is_some()); + assert!(sim_tree.get_ace_state(&contract_c).is_some()); + + Ok(()) +} + +// ============================================================================ +// Multi-Transaction Bundle Classification Tests +// ============================================================================ + +use rbuilder_primitives::ace::classify_ace_interaction; + +#[test] +fn test_classify_ace_interaction_priority_force_beats_optional() { + let config = test_ace_config(); + let contract = config.contract_address; + let slot = b256!("0000000000000000000000000000000000000000000000000000000000000003"); + let whitelisted_from = *config.from_addresses.iter().next().unwrap(); + let force_selector = *config.force_signatures.iter().next().unwrap(); + let unlock_selector = *config.unlock_signatures.iter().next().unwrap(); + + let trace = mock_state_trace_with_ace_slot(contract, slot); + + // ProtocolForce classification + let force_result = classify_ace_interaction( + &trace, + true, + &config, + Some(force_selector), + Some(contract), + Some(whitelisted_from), + ); + assert!(matches!( + force_result, + Some(AceInteraction::Unlocking { + source: AceUnlockSource::ProtocolForce, + .. + }) + )); + + // ProtocolOptional classification + let optional_result = classify_ace_interaction( + &trace, + true, + &config, + Some(unlock_selector), + Some(contract), + Some(whitelisted_from), + ); + assert!(matches!( + optional_result, + Some(AceInteraction::Unlocking { + source: AceUnlockSource::ProtocolOptional, + .. + }) + )); + + // User classification (non-whitelisted from) + let non_whitelisted = address!("1111111111111111111111111111111111111111"); + let user_result = classify_ace_interaction( + &trace, + true, + &config, + Some(unlock_selector), + Some(contract), + Some(non_whitelisted), + ); + assert!(matches!( + user_result, + Some(AceInteraction::Unlocking { + source: AceUnlockSource::User, + .. + }) + )); + + // NonUnlocking classification (no unlock signature) + let non_unlocking_result = classify_ace_interaction( + &trace, + true, + &config, + None, // no selector + Some(contract), + Some(whitelisted_from), + ); + assert!(matches!( + non_unlocking_result, + Some(AceInteraction::NonUnlocking { .. }) + )); +} + +#[test] +fn test_ace_interaction_priority_ordering() { + // Test that the priority ordering is correct: + // ProtocolForce > ProtocolOptional > User > NonUnlocking + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let force = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolForce, + }; + let optional = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::ProtocolOptional, + }; + let user = AceInteraction::Unlocking { + contract_address: contract, + source: AceUnlockSource::User, + }; + let non_unlocking = AceInteraction::NonUnlocking { + contract_address: contract, + }; + + // Verify the classification methods work as expected for priority + assert!(force.is_force()); + assert!(force.is_protocol_tx()); + assert!(force.is_unlocking()); + + assert!(!optional.is_force()); + assert!(optional.is_protocol_tx()); + assert!(optional.is_unlocking()); + + assert!(!user.is_force()); + assert!(!user.is_protocol_tx()); + assert!(user.is_unlocking()); + + assert!(!non_unlocking.is_force()); + assert!(!non_unlocking.is_protocol_tx()); + assert!(!non_unlocking.is_unlocking()); +} diff --git a/crates/rbuilder/src/building/testing/bundle_tests/setup.rs b/crates/rbuilder/src/building/testing/bundle_tests/setup.rs index 40395227f..3fcfeb7dd 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_interactions: Vec::new(), }; // 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/building/tracers.rs b/crates/rbuilder/src/building/tracers.rs index cbbf3fb39..44a7211e9 100644 --- a/crates/rbuilder/src/building/tracers.rs +++ b/crates/rbuilder/src/building/tracers.rs @@ -16,6 +16,11 @@ pub trait SimulationTracer { fn get_used_state_tracer(&self) -> Option<&UsedStateTrace> { None } + + /// Take ownership of the used state trace, leaving a default in its place. + fn take_used_state_trace(&mut self) -> Option { + None + } } impl SimulationTracer for () {} @@ -69,4 +74,8 @@ impl SimulationTracer for AccumulatorSimulationTracer { fn get_used_state_tracer(&self) -> Option<&UsedStateTrace> { Some(&self.used_state_trace) } + + fn take_used_state_trace(&mut self) -> Option { + Some(std::mem::take(&mut self.used_state_trace)) + } } diff --git a/crates/rbuilder/src/live_builder/base_config.rs b/crates/rbuilder/src/live_builder/base_config.rs index a82ebd2fd..053d5baf1 100644 --- a/crates/rbuilder/src/live_builder/base_config.rs +++ b/crates/rbuilder/src/live_builder/base_config.rs @@ -171,6 +171,17 @@ pub struct BaseConfig { pub orderflow_tracing_store_path: Option, /// Max number of blocks to keep in disk. pub orderflow_tracing_max_blocks: usize, + + /// Global ACE kill switch - when false, all ACE logic is disabled + #[serde(default = "default_ace_enabled")] + pub ace_enabled: bool, + + /// Ace Configurations + pub ace_protocols: Vec, +} + +fn default_ace_enabled() -> bool { + true } pub fn default_ip() -> Ipv4Addr { @@ -271,6 +282,8 @@ impl BaseConfig { simulation_use_random_coinbase: self.simulation_use_random_coinbase, faster_finalize: self.faster_finalize, order_flow_tracer_manager, + ace_enabled: self.ace_enabled, + ace_config: self.ace_protocols.clone(), }) } @@ -488,6 +501,8 @@ pub const DEFAULT_TIME_TO_KEEP_MEMPOOL_TXS_SECS: u64 = 60; impl Default for BaseConfig { fn default() -> Self { Self { + ace_enabled: true, + 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..a93221acb 100644 --- a/crates/rbuilder/src/live_builder/building/mod.rs +++ b/crates/rbuilder/src/live_builder/building/mod.rs @@ -46,6 +46,8 @@ pub struct BlockBuildingPool

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

BlockBuildingPool

@@ -62,6 +64,8 @@ where run_sparse_trie_prefetcher: bool, sbundle_merger_selected_signers: Arc>, order_flow_tracer_manager: Box, + ace_enabled: bool, + ace_config: Vec, ) -> Self { BlockBuildingPool { provider, @@ -73,6 +77,8 @@ where sbundle_merger_selected_signers, order_flow_tracer_manager, built_block_id_source: Arc::new(BuiltBlockIdSource::new()), + ace_enabled, + ace_config, } } @@ -149,6 +155,8 @@ where orders_for_block, block_cancellation.clone(), sim_tracer, + self.ace_enabled, + 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..fe9f71bf3 100644 --- a/crates/rbuilder/src/live_builder/mod.rs +++ b/crates/rbuilder/src/live_builder/mod.rs @@ -135,6 +135,9 @@ where pub simulation_use_random_coinbase: bool, pub order_flow_tracer_manager: Box, + + pub ace_enabled: bool, + pub ace_config: Vec, } impl

LiveBuilder

@@ -233,6 +236,8 @@ where self.run_sparse_trie_prefetcher, self.sbundle_merger_selected_signers.clone(), self.order_flow_tracer_manager, + self.ace_enabled, + self.ace_config.clone(), ); ready_to_build.store(true, Ordering::Relaxed); diff --git a/crates/rbuilder/src/live_builder/order_flow_tracing/order_flow_tracer.rs b/crates/rbuilder/src/live_builder/order_flow_tracing/order_flow_tracer.rs index b8361aab0..71f058518 100644 --- a/crates/rbuilder/src/live_builder/order_flow_tracing/order_flow_tracer.rs +++ b/crates/rbuilder/src/live_builder/order_flow_tracing/order_flow_tracer.rs @@ -84,24 +84,30 @@ impl OrderFlowTracer { impl SimulationJobTracer for OrderFlowTracer { fn update_simulation_sent(&self, sim_result: &SimulatedResult) { + let SimulatedResult::Success { + simulation_time, + simulated_order, + .. + } = sim_result + else { + // Only Success variants are traced + return; + }; let event = SimulationEvent::SimulatedOrder(SimulatedOrderData { - simulation_time: sim_result.simulation_time, - order_id: sim_result.simulated_order.order.id(), - replacement_key_and_sequence_number: sim_result - .simulated_order + simulation_time: *simulation_time, + order_id: simulated_order.order.id(), + replacement_key_and_sequence_number: simulated_order .order .replacement_key_and_sequence_number(), - full_profit: sim_result - .simulated_order + full_profit: simulated_order .sim_value .full_profit_info() .coinbase_profit(), - non_mempool_profit: sim_result - .simulated_order + non_mempool_profit: simulated_order .sim_value .non_mempool_profit_info() .coinbase_profit(), - gas_used: sim_result.simulated_order.sim_value.gas_used(), + gas_used: simulated_order.sim_value.gas_used(), }); self.sim_events .lock() diff --git a/crates/rbuilder/src/live_builder/simulation/mod.rs b/crates/rbuilder/src/live_builder/simulation/mod.rs index 1dfa36514..df2e86bf7 100644 --- a/crates/rbuilder/src/live_builder/simulation/mod.rs +++ b/crates/rbuilder/src/live_builder/simulation/mod.rs @@ -39,6 +39,10 @@ pub struct SimulationContext { pub requests: flume::Receiver, /// Simulation results go out through this channel. pub results: mpsc::Sender, + /// ACE configuration for this simulation context (empty if ACE is disabled). + pub ace_configs: ahash::HashMap, + /// Whether ACE is enabled globally. + pub ace_enabled: bool, } /// All active SimulationContexts @@ -117,6 +121,8 @@ where input: OrdersForBlock, block_cancellation: CancellationToken, sim_tracer: Arc, + ace_enabled: bool, + ace_config: Vec, ) -> SlotOrderSimResults { let (slot_sim_results_sender, slot_sim_results_receiver) = mpsc::channel(10_000); @@ -153,7 +159,20 @@ where NonceCache::new(state.into()) }; - let sim_tree = SimTree::new(nonces); + // Convert ace_config Vec to HashMap for efficient lookup + // When ace_enabled is false, we pass empty configs to disable all ACE logic + let ace_configs_map: ahash::HashMap<_, _> = if ace_enabled { + ace_config + .iter() + .map(|c| (c.contract_address, c.clone())) + .collect() + } else { + ahash::HashMap::default() + }; + + // Pass empty configs to SimTree when ACE is disabled + let sim_tree_configs = if ace_enabled { ace_config } else { Vec::new() }; + let sim_tree = SimTree::new(nonces, sim_tree_configs); 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 +182,8 @@ where block_ctx: ctx, requests: sim_req_receiver, results: sim_results_sender, + ace_configs: ace_configs_map, + ace_enabled, }; contexts.contexts.insert(block_context, sim_context); } @@ -236,6 +257,8 @@ mod tests { orders_for_block, cancel.clone(), Arc::new(NullSimulationJobTracer {}), + false, // ace_enabled + 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..2f34c0ce6 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, @@ -66,32 +67,69 @@ pub fn run_sim_worker

( let mut block_state = BlockState::new_arc(state_provider.clone()); let sim_result = simulate_order( task.parents.clone(), - task.order, + task.order.clone(), ¤t_sim_context.block_ctx, &mut local_ctx, &mut block_state, + ¤t_sim_context.ace_configs, + &task.ace_unlock_contracts, ); let sim_ok = match sim_result { Ok(sim_result) => { let sim_ok = match sim_result.result { OrderSimResult::Success(simulated_order, nonces_after) => { - let result = SimulatedResult { + let mut dependencies_satisfied: Vec = nonces_after + .into_iter() + .map(|(address, nonce)| { + DependencyKey::Nonce(NonceKey { address, nonce }) + }) + .collect(); + + // Add ACE dependencies for all unlocking interactions + for interaction in &simulated_order.ace_interactions { + if let AceInteraction::Unlocking { + contract_address, .. + } = interaction + { + dependencies_satisfied + .push(DependencyKey::AceUnlock(*contract_address)); + } + } + + let result = SimulatedResult::Success { 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 - .results - .try_send(result) - .unwrap_or_default(); + if current_sim_context.results.try_send(result).is_err() { + error!( + ?order_id, + "Failed to send simulation result - channel full or closed" + ); + } true } - OrderSimResult::Failed(_) => false, + OrderSimResult::Failed(failure) => { + // Only send to SimTree if there's an ACE dependency to handle + if failure.ace_dependency.is_some() { + let result = SimulatedResult::Failed { + id: task.id, + order: task.order, + failure, + ace_unlock_contracts: task.ace_unlock_contracts.clone(), + simulation_time: start_time.elapsed(), + }; + if current_sim_context.results.try_send(result).is_err() { + error!( + ?order_id, + "Failed to send Failed result with ACE dependency" + ); + } + } + false + } }; telemetry::inc_simulated_orders(sim_ok); telemetry::inc_simulation_gas_used(sim_result.gas_used); @@ -99,7 +137,6 @@ pub fn run_sim_worker

( } Err(err) => { error!(?err, ?order_id, "Critical error while simulating order"); - // @Metric break; } }; diff --git a/crates/rbuilder/src/live_builder/simulation/simulation_job.rs b/crates/rbuilder/src/live_builder/simulation/simulation_job.rs index b1bff3c57..616cacbcb 100644 --- a/crates/rbuilder/src/live_builder/simulation/simulation_job.rs +++ b/crates/rbuilder/src/live_builder/simulation/simulation_job.rs @@ -1,7 +1,7 @@ use std::{fmt, sync::Arc}; use crate::{ - building::sim::{SimTree, SimulatedResult, SimulationRequest}, + building::sim::{SimTree, SimulatedResult, SimulationFailure, SimulationRequest}, live_builder::{ order_input::order_sink::OrderPoolCommand, simulation::simulation_job_tracer::SimulationJobTracer, @@ -190,53 +190,80 @@ impl SimulationJob { &mut self, new_sim_results: &mut Vec, ) -> bool { - // send results - let mut valid_simulated_orders = Vec::new(); - for sim_result in new_sim_results { - trace!(order_id=?sim_result.simulated_order.order.id(), - sim_duration_mus = sim_result.simulation_time.as_micros(), - profit = format_ether(sim_result.simulated_order.sim_value.full_profit_info().coinbase_profit()), "Order simulated"); - self.orders_simulated_ok - .accumulate(&sim_result.simulated_order.order); - if let Some(repl_key) = sim_result.simulated_order.order.replacement_key() { - self.unique_replacement_key_bundles_sim_ok.insert(repl_key); - self.orders_with_replacement_key_sim_ok += 1; - } - // Skip cancelled orders and remove from in_flight_orders - if self - .in_flight_orders - .remove(&sim_result.simulated_order.id()) - { - valid_simulated_orders.push(sim_result.clone()); - // Only send if it's the first time. - if self - .not_cancelled_sent_simulated_orders - .insert(sim_result.simulated_order.id()) - { - if self - .slot_sim_results_sender - .send(SimulatedOrderCommand::Simulation( - sim_result.simulated_order.clone(), - )) - .await - .is_err() - { - return false; //receiver closed :( - } else { - self.sim_tracer.update_simulation_sent(sim_result); + // Results to pass to sim_tree: successful sims and failed ones needing ACE re-queue + let mut sim_tree_results = Vec::new(); + for sim_result in new_sim_results.drain(..) { + match &sim_result { + SimulatedResult::Success { + simulated_order, + simulation_time, + .. + } => { + trace!(order_id=?simulated_order.order.id(), + sim_duration_mus = simulation_time.as_micros(), + profit = format_ether(simulated_order.sim_value.full_profit_info().coinbase_profit()), "Order simulated"); + self.orders_simulated_ok.accumulate(&simulated_order.order); + if let Some(repl_key) = simulated_order.order.replacement_key() { + self.unique_replacement_key_bundles_sim_ok.insert(repl_key); + self.orders_with_replacement_key_sim_ok += 1; + } + // Skip cancelled orders and remove from in_flight_orders + if self.in_flight_orders.remove(&simulated_order.id()) { + // Only send if it's the first time. + if self + .not_cancelled_sent_simulated_orders + .insert(simulated_order.id()) + { + if self + .slot_sim_results_sender + .send(SimulatedOrderCommand::Simulation(simulated_order.clone())) + .await + .is_err() + { + return false; //receiver closed :( + } else { + self.sim_tracer.update_simulation_sent(&sim_result); + } + } + sim_tree_results.push(sim_result); } } + SimulatedResult::Failed { + failure: + SimulationFailure { + ace_dependency: Some(_), + .. + }, + .. + } => { + // Failed with ACE dependency - pass to sim_tree for re-queuing with unlock parent + sim_tree_results.push(sim_result); + } + SimulatedResult::Failed { .. } => { + // Permanent failure without ACE dependency - nothing to do + } } } // update simtree - if let Err(err) = self + let (_, cancellations) = match self .sim_tree - .submit_simulation_tasks_results(valid_simulated_orders) + .submit_simulation_tasks_results(sim_tree_results) { - error!(?err, "Failed to push order sim results into the sim tree"); - // @Metric - return false; + Ok(result) => result, + Err(err) => { + error!(?err, "Failed to push order sim results into the sim tree"); + // @Metric + return false; + } + }; + + // Send any cancellations generated by the sim tree (e.g., optional ACE unlocks superseded by mempool) + for cancel_id in cancellations { + if !self.send_cancel(&cancel_id).await { + return false; + } } + true } diff --git a/crates/rbuilder/src/live_builder/wallet_balance_watcher.rs b/crates/rbuilder/src/live_builder/wallet_balance_watcher.rs index c076b240a..016e9f07c 100644 --- a/crates/rbuilder/src/live_builder/wallet_balance_watcher.rs +++ b/crates/rbuilder/src/live_builder/wallet_balance_watcher.rs @@ -3,7 +3,7 @@ use std::time::Duration; use alloy_primitives::{utils::format_ether, Address, BlockNumber, U256}; use reth::providers::ProviderError; use time::{error, OffsetDateTime}; -use tracing::{error, info, warn}; +use tracing::{info, warn}; use crate::{ provider::StateProviderFactory, diff --git a/crates/rbuilder/src/mev_boost/mod.rs b/crates/rbuilder/src/mev_boost/mod.rs index 38fbd0e5d..bc5ccc90d 100644 --- a/crates/rbuilder/src/mev_boost/mod.rs +++ b/crates/rbuilder/src/mev_boost/mod.rs @@ -1,6 +1,7 @@ use crate::telemetry::{add_gzip_compression_time, add_ssz_encoding_time}; use super::utils::u256decimal_serde_helper; + use alloy_primitives::{utils::parse_ether, Address, BlockHash, U256}; use alloy_rpc_types_beacon::BlsPublicKey; use flate2::{write::GzEncoder, Compression}; 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" -