diff --git a/Cargo.lock b/Cargo.lock index 3e28ff094..2f0fc1cbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9648,6 +9648,7 @@ dependencies = [ "serde_with", "sha2 0.10.9", "ssz_types", + "strum 0.27.2", "thiserror 1.0.69", "time", "tree_hash 0.8.0", diff --git a/crates/rbuilder-primitives/Cargo.toml b/crates/rbuilder-primitives/Cargo.toml index 4fe5c906d..b972ec707 100644 --- a/crates/rbuilder-primitives/Cargo.toml +++ b/crates/rbuilder-primitives/Cargo.toml @@ -50,6 +50,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..3f86ea84d --- /dev/null +++ b/crates/rbuilder-primitives/src/ace.rs @@ -0,0 +1,832 @@ +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. +/// +/// Classification logic: +/// - If simulation succeeds and WRITES to ACE slot → `Unlocking` (tx performs unlock) +/// - If simulation succeeds but only READS ACE slot → No ACE interaction (tx just uses unlocked state) +/// - If simulation fails while accessing ACE slot → `NonUnlocking` (tx needs unlock parent) +/// +/// 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 successful unlocking transactions (that WRITE to detection slot) 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 { + // Check if any ACE detection slots were READ + let any_ace_slots_read = config + .to_addresses + .iter() + .flat_map(|address| { + config.detection_slots.iter().map(|slot| SlotKey { + address: *address, + key: *slot, + }) + }) + .any(|key| state_trace.read_slot_values.contains_key(&key)); + + // Check if any ACE detection slots were WRITTEN + let any_ace_slots_written = config + .to_addresses + .iter() + .flat_map(|address| { + config.detection_slots.iter().map(|slot| SlotKey { + address: *address, + key: *slot, + }) + }) + .any(|key| state_trace.written_slot_values.contains_key(&key)); + + let any_ace_slots_accessed = any_ace_slots_read || any_ace_slots_written; + + 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 { + // For successful simulations, only classify as Unlocking if the tx WRITES to detection slot. + // A tx that only READS the slot is just using the unlocked state, not performing an unlock. + // Protocol orders require: direct call + correct signature + whitelisted sender + write to slot + if is_direct_protocol_call && is_force_sig && is_from_whitelisted && any_ace_slots_written { + Some(AceInteraction::Unlocking { + contract_address, + source: AceUnlockSource::ProtocolForce, + }) + } else if is_direct_protocol_call + && is_unlock_sig + && is_from_whitelisted + && any_ace_slots_written + { + Some(AceInteraction::Unlocking { + contract_address, + source: AceUnlockSource::ProtocolOptional, + }) + } else if any_ace_slots_written { + // User tx that writes to detection slot = User unlock + Some(AceInteraction::Unlocking { + contract_address, + source: AceUnlockSource::User, + }) + } else { + // Successful simulation that only READS detection slot = not an unlock, + // just a tx that uses the unlocked state. No ACE interaction to track. + None + } + } else { + // Simulation failed while accessing ACE slot = needs unlock parent + 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 needs_unlock(&self) -> bool { + matches!(self, Self::NonUnlocking { .. }) + } + 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 READ (not written) + fn mock_state_trace_with_slot_read(addr: Address, slot: B256) -> UsedStateTrace { + let mut trace = UsedStateTrace::default(); + trace.read_slot_values.insert( + SlotKey { + address: addr, + key: slot, + }, + Default::default(), + ); + trace + } + + /// Create a mock state trace with the detection slot WRITTEN + fn mock_state_trace_with_slot_written(addr: Address, slot: B256) -> UsedStateTrace { + let mut trace = UsedStateTrace::default(); + trace.written_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 WRITTEN (unlock writes to slot) + let trace = mock_state_trace_with_slot_written(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 WRITTEN (unlock writes to slot) + let trace = mock_state_trace_with_slot_written(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) + // and WRITES to the detection slot = User unlock + 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 write trace since only writes classify as unlock + let trace = mock_state_trace_with_slot_written(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_successful_sim_read_only_returns_none() { + // Transaction that only READS ACE slot and succeeds simulation + // Should return None - tx just uses unlocked state, doesn't perform unlock + 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(); + + // Only READ the slot, not write + let trace = mock_state_trace_with_slot_read(contract, detection_slot); + + // No write to slot = not an unlock, even if successful + let result = classify_ace_interaction( + &trace, + true, + &config, + None, + Some(contract), + Some(whitelisted_from), + ); + + assert_eq!( + result, None, + "Successful simulation that only reads detection slot should return None" + ); + } + + #[test] + fn test_ace_user_unlock_with_slot_write() { + // Transaction that WRITES to ACE slot and succeeds without unlock signature + // Should be classified as User unlock + 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(); + + // WRITE to the slot + let trace = mock_state_trace_with_slot_written(contract, detection_slot); + + // Writes to slot without unlock signature = User unlock + let result = classify_ace_interaction( + &trace, + true, + &config, + None, + Some(contract), + 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_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(); + + // For failed sims, even read-only access triggers NonUnlocking + let trace = mock_state_trace_with_slot_read(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_read(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 WRITTEN (unlocking writes to slot) + let trace = mock_state_trace_with_slot_written(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) that WRITES = 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 + // (use read trace for failed sim - read access is enough to identify ACE dependency) + let read_trace = mock_state_trace_with_slot_read(contract, slot); + let result_failed = classify_ace_interaction( + &read_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" + ); + + // Use write trace since only writes classify as unlock + let trace = mock_state_trace_with_slot_written(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)); + + // Use write trace since only writes classify as unlock + let trace = mock_state_trace_with_slot_written(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(); + + // Use write trace since only writes classify as unlock + let trace = mock_state_trace_with_slot_written(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 4a860e50e..134eb1ea4 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)] @@ -1009,6 +1011,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/backtest_build_block.rs b/crates/rbuilder/src/backtest/build_block/backtest_build_block.rs index 228435c22..12930506a 100644 --- a/crates/rbuilder/src/backtest/build_block/backtest_build_block.rs +++ b/crates/rbuilder/src/backtest/build_block/backtest_build_block.rs @@ -17,7 +17,7 @@ use crate::{ builders::BacktestSimulateBlockInput, BlockBuildingContext, ExecutionResult, NullPartialBlockExecutionTracer, }, - live_builder::cli::LiveBuilderConfig, + live_builder::{cli::LiveBuilderConfig, config::AceConfig}, provider::StateProviderFactory, }; use clap::Parser; @@ -102,10 +102,17 @@ where let provider_factory = orders_source.create_provider_factory()?; orders_source.print_custom_stats(provider_factory.clone())?; + let base_config = config.base_config(); + let ace_configs: Vec = if base_config.ace_enabled { + base_config.ace_protocols.clone() + } else { + Vec::new() + }; let BacktestBlockInput { sim_orders, .. } = backtest_prepare_orders_from_building_context( ctx.clone(), available_orders.clone(), provider_factory.clone(), + ace_configs, )?; if let Some(tx_hash) = build_block_cfg.show_tx_extra_data { @@ -205,6 +212,9 @@ fn print_sim_order(sim_order: &SimulatedOrder) { ); } println!(" * gas_used {:?}", sim_value.gas_used()); + if !sim_order.ace_interactions.is_empty() { + println!(" * ace_interactions: {:?}", sim_order.ace_interactions); + } } fn print_orders_with_tx_hash( diff --git a/crates/rbuilder/src/backtest/build_block/landed_block_from_db.rs b/crates/rbuilder/src/backtest/build_block/landed_block_from_db.rs index 60e02c015..9d8bcda7f 100644 --- a/crates/rbuilder/src/backtest/build_block/landed_block_from_db.rs +++ b/crates/rbuilder/src/backtest/build_block/landed_block_from_db.rs @@ -27,11 +27,12 @@ use crate::{ live_builder::{block_list_provider::BlockList, cli::LiveBuilderConfig}, provider::StateProviderFactory, utils::{ - mevblocker::get_mevblocker_price, timestamp_as_u64, timestamp_ms_to_offset_datetime, - ProviderFactoryReopener, + extract_onchain_block_txs, mevblocker::get_mevblocker_price, timestamp_as_u64, + timestamp_ms_to_offset_datetime, ProviderFactoryReopener, }, }; use clap::Parser; +use rbuilder_primitives::{MempoolTx, Order}; use std::{path::PathBuf, str::FromStr, sync::Arc}; use super::backtest_build_block::{run_backtest_build_block, BuildBlockCfg, OrdersSource}; @@ -73,7 +74,7 @@ struct LandedBlockFromDBOrdersSource { impl LandedBlockFromDBOrdersSource { async fn new(extra_cfg: ExtraCfg, config: ConfigType) -> eyre::Result { - let block_data = read_block_data( + let mut block_data = read_block_data( &config.base_config().backtest_fetch_output_file, extra_cfg.block, extra_cfg @@ -87,6 +88,39 @@ impl LandedBlockFromDBOrdersSource { extra_cfg.show_missing, ) .await?; + + // When sim_landed_block is enabled, inject on-chain txs into available_orders + // This allows ACE detection to work on private txs that landed in the block + if extra_cfg.sim_landed_block { + let landed_txs = extract_onchain_block_txs(&block_data.onchain_block)?; + let block_timestamp_ms = timestamp_as_u64(&block_data.onchain_block) * 1000; + + // Collect existing tx hashes to avoid duplicates + let existing_tx_hashes: ahash::HashSet<_> = block_data + .available_orders + .iter() + .flat_map(|o| o.order.list_txs().into_iter().map(|(tx, _)| tx.hash())) + .collect(); + + let mut injected_count = 0; + for tx in landed_txs { + if !existing_tx_hashes.contains(&tx.hash()) { + block_data.available_orders.push(OrdersWithTimestamp { + order: Order::Tx(MempoolTx::new(tx)), + timestamp_ms: block_timestamp_ms, + }); + injected_count += 1; + } + } + + if injected_count > 0 { + println!( + "Injected {} on-chain txs into available_orders for simulation", + injected_count + ); + } + } + let blocklist = config .base_config() .blocklist_provider(CancellationToken::new()) diff --git a/crates/rbuilder/src/backtest/execute.rs b/crates/rbuilder/src/backtest/execute.rs index e4a63da08..4621722a8 100644 --- a/crates/rbuilder/src/backtest/execute.rs +++ b/crates/rbuilder/src/backtest/execute.rs @@ -4,7 +4,7 @@ use crate::{ builders::BacktestSimulateBlockInput, sim::simulate_all_orders_with_sim_tree, BlockBuildingContext, BundleErr, NullPartialBlockExecutionTracer, OrderErr, TransactionErr, }, - live_builder::{block_list_provider::BlockList, cli::LiveBuilderConfig}, + live_builder::{block_list_provider::BlockList, cli::LiveBuilderConfig, config::AceConfig}, provider::StateProviderFactory, utils::{clean_extradata, mevblocker::get_mevblocker_price, Signer}, }; @@ -91,10 +91,32 @@ pub fn backtest_prepare_orders_from_building_context

( ctx: BlockBuildingContext, available_orders: Vec, provider: P, + ace_configs: Vec, ) -> eyre::Result where P: StateProviderFactory + Clone + 'static, { + // Log ACE config status for debugging + if ace_configs.is_empty() { + tracing::debug!("ACE backtest: no ACE configs provided, ACE detection disabled"); + } else { + tracing::debug!( + ace_config_count = ace_configs.len(), + "ACE backtest: starting simulation with ACE configs" + ); + for config in &ace_configs { + tracing::debug!( + contract_address = ?config.contract_address, + from_addresses_count = config.from_addresses.len(), + to_addresses_count = config.to_addresses.len(), + detection_slots_count = config.detection_slots.len(), + unlock_signatures_count = config.unlock_signatures.len(), + force_signatures_count = config.force_signatures.len(), + "ACE backtest: loaded protocol config" + ); + } + } + let orders = available_orders .iter() .map(|order| order.order.clone()) @@ -103,8 +125,26 @@ where ctx.mempool_tx_detector.add_tx(order); } + tracing::debug!( + order_count = orders.len(), + "ACE backtest: simulating orders" + ); + 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, ace_configs)?; + + // Log simulation results + let ace_orders_count = sim_orders + .iter() + .filter(|o| !o.ace_interactions.is_empty()) + .count(); + tracing::debug!( + simulated_orders = sim_orders.len(), + sim_errors = sim_errors.len(), + ace_interacting_orders = ace_orders_count, + "ACE backtest: simulation complete" + ); + Ok(BacktestBlockInput { sim_orders, sim_errors, @@ -147,6 +187,12 @@ where P: StateProviderFactory + Clone + 'static, ConfigType: LiveBuilderConfig, { + let base_config = config.base_config(); + let ace_configs = if base_config.ace_enabled { + base_config.ace_protocols.clone() + } else { + Vec::new() + }; let BacktestBlockInput { sim_orders, sim_errors, @@ -154,6 +200,7 @@ where ctx.clone(), block_data.available_orders, provider.clone(), + ace_configs, )?; let filtered_orders_blocklist_count = sim_errors diff --git a/crates/rbuilder/src/bin/run-bundle-on-prefix.rs b/crates/rbuilder/src/bin/run-bundle-on-prefix.rs index 57d783b24..399e7959b 100644 --- a/crates/rbuilder/src/bin/run-bundle-on-prefix.rs +++ b/crates/rbuilder/src/bin/run-bundle-on-prefix.rs @@ -19,7 +19,10 @@ use rbuilder::{ BlockBuildingContext, ExecutionError, MockRootHasher, NullPartialBlockExecutionTracer, OrderPriority, ThreadBlockBuildingContext, }, - live_builder::{cli::LiveBuilderConfig, config::Config}, + live_builder::{ + cli::LiveBuilderConfig, + config::{AceConfig, Config}, + }, provider::StateProviderFactory, utils::{extract_onchain_block_txs, find_suggested_fee_recipient}, }; @@ -109,12 +112,17 @@ impl LandedBlockInfo { orders: Vec, use_original_coinbase: bool, ) -> eyre::Result>> { + let base_config = self.config.base_config(); + let ace_configs: Vec = if base_config.ace_enabled { + base_config.ace_protocols.clone() + } else { + Vec::new() + }; let BacktestBlockInput { sim_orders, .. } = backtest_prepare_orders_from_building_context( self.get_context(use_original_coinbase), orders, - self.config - .base_config() - .create_reth_provider_factory(true)?, + base_config.create_reth_provider_factory(true)?, + ace_configs, )?; Ok(sim_orders) } @@ -219,6 +227,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()); @@ -305,6 +314,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/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/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/ordering_builder.rs b/crates/rbuilder/src/building/builders/ordering_builder.rs index ffa69eadd..45b2b6b4c 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..16a4a356f 100644 --- a/crates/rbuilder/src/building/sim.rs +++ b/crates/rbuilder/src/building/sim.rs @@ -15,7 +15,13 @@ use crate::{ }; use ahash::{HashMap, HashSet}; use alloy_primitives::Address; +use alloy_rpc_types::TransactionTrait; +use itertools::Itertools; 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 +33,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_state: AceSimulationState, +} + #[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 +65,153 @@ 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> { + // Because we only expect one or the other, and force tx will always be at the top of the + // block. we want to ensure we always select the correct order for what we expect in the + // block builder. + self.force_unlock_order + .as_ref() + .or_else(|| self.optional_unlock_order.as_ref()) + } +} + +/// Tracks ACE simulation state for an order through iterative re-simulations. +/// +/// Key concepts: +/// - NonUnlocking interactions = need unlock parents (revert without) +/// - Unlocking interactions = ARE the unlock parents (never need parents themselves) +/// +/// Flow: +/// 1) Simulate order + collect possible ACE interactions +/// 2) If NonUnlocking interactions exist, wait for unlock parents +/// 3) Re-simulate with ACE context, compute symmetric difference of +/// new NonUnlocking dependencies vs already-accounted-for interactions +/// 4) If unhandled set is empty -> done, else -> repeat from 3 +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AceSimulationState { + /// ACE interactions detected + /// Includes both Unlocking (parent providers) and NonUnlocking (need parents). + pub detected_interactions: HashSet, + + /// ACE interactions (by contract address) for which we've already provided + /// unlock parents in previous simulation attempts. + pub accounted_for_interactions: HashSet, +} + +impl AceSimulationState { + /// Returns NonUnlocking interactions that still need unlock parents. + /// Filters out interactions where we already have an Unlocking interaction + /// for the same contract in accounted_for_interactions. + pub fn dependencies_to_handle(&self) -> HashSet { + // Get contract addresses for which we have unlocks accounted for + let accounted_contracts: HashSet

= self + .accounted_for_interactions + .iter() + .filter(|i| i.is_unlocking()) + .map(|i| i.get_contract_address()) + .collect(); + + // Return NonUnlocking interactions whose contracts aren't yet accounted for + self.detected_interactions + .iter() + .filter(|i| i.needs_unlock()) + .filter(|i| !accounted_contracts.contains(&i.get_contract_address())) + .copied() + .collect() + } + + pub fn all_dependencies_accounted(&self) -> bool { + self.dependencies_to_handle().is_empty() + } + + pub fn add_accounted_interactions(&mut self, actions: impl Iterator) { + self.accounted_for_interactions.extend(actions) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct PendingOrder { order: Order, - unsatisfied_nonces: usize, + /// ACE state tracking detected and accounted-for interactions + ace_state: AceSimulationState, } 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_state: AceSimulationState, } -#[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, + simulation_time: Duration, + }, +} + +impl SimulatedResult { + pub fn is_success(&self) -> bool { + matches!(self, Self::Success { .. }) + } +} + +/// Minimal data stored for completed simulations (to avoid Clone on full SimulatedResult) +#[derive(Debug, Clone)] +struct StoredSimulation { + // parents + parent_orders: Vec, + // result + simulated_order: Arc, } // @Feat replaceable orders @@ -77,53 +220,96 @@ 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(); + + if ace_configs.is_empty() { + tracing::debug!("ACE SimTree: initialized with no ACE configs"); + } else { + tracing::debug!( + ace_config_count = ace_configs.len(), + "ACE SimTree: initializing with ACE configs" + ); + } + + for config in ace_configs { + let contract_address = config.contract_address; + tracing::debug!( + contract_address = ?contract_address, + detection_slots = ?config.detection_slots, + "ACE SimTree: registered protocol" + ); + 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) + for dep in pending_deps { + self.pending_dependencies + .entry(dep) .or_default() .push(order.id()); } @@ -131,32 +317,38 @@ impl SimTree { order.id(), PendingOrder { order, - unsatisfied_nonces, + ace_state: AceSimulationState::default(), }, ); } - OrderNonceState::Ready(parents) => { + OrderDependencyState::Ready(parents) => { self.ready_orders.push(SimulationRequest { id: rand::random(), order, parents, + // we don't have a state for it yet. + ace_state: AceSimulationState::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 +361,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,25 +379,136 @@ impl SimTree { address: nonce.address, nonce: nonce.nonce, }; + let dep_key = DependencyKey::Nonce(nonce_key); - if let Some(sim_id) = self.sims_that_update_one_nonce.get(&nonce_key) { + if let Some(sim_id) = self.dependency_providers.get(&dep_key) { // we have something that fills this nonce - let sim = self.sims.get(sim_id).expect("we never delete sims"); - parent_orders.extend_from_slice(&sim.previous_orders); + 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.parent_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)) + } + } + + /// Takes a failed ace dependency order and adds parents / puts into holding for parents to + /// arrive. + pub fn handle_ace_dependencies_for_order( + &mut self, + order: Order, + mut ace_state: AceSimulationState, + ) { + let order_id = order.id(); + let difference = ace_state.dependencies_to_handle(); + // If we have handled all dependencies, this order will not be valid and thus we will + // ignore it. + if difference.is_empty() { + tracing::debug!( + order_id = ?order_id, + "ACE deps: order has no unhandled ACE dependencies, ignoring" + ); + return; + } + + tracing::debug!( + order_id = ?order_id, + dependency_count = difference.len(), + dependencies = ?difference, + "ACE deps: handling ACE dependencies for failed order" + ); + + let mut is_ready = true; + + let keys = difference + .into_iter() + .filter_map(|dep| { + let dep_key = DependencyKey::AceUnlock(dep.get_contract_address()); + + match self.dependency_providers.get(&dep_key) { + Some(key) => { + tracing::debug!( + order_id = ?order_id, + contract_address = ?dep.get_contract_address(), + "ACE deps: found existing unlock provider for dependency" + ); + Some(key) + } + + None => { + tracing::debug!( + order_id = ?order_id, + contract_address = ?dep.get_contract_address(), + "ACE deps: no unlock provider found, order waiting for unlock parent" + ); + is_ready = false; + self.pending_dependencies + .entry(dep_key) + .or_default() + .push(order.id()); + None + } + } + }) + .collect_vec(); + + // Order needs to wait for ACE unlock dependencies + if !is_ready { + tracing::debug!( + order_id = ?order_id, + "ACE deps: order added to pending, waiting for unlock parents" + ); + self.pending_orders + .insert(order.id(), PendingOrder { order, ace_state }); + return; } + + let parents = keys + .into_iter() + .filter_map(|sim_id| { + // for each parent, we want to track it now. + if let Some(sim) = self.sims.get(sim_id) { + ace_state.add_accounted_interactions( + sim.simulated_order.ace_interactions.iter().copied(), + ); + + let mut parents = sim.parent_orders.clone(); + parents.push(sim.simulated_order.order.clone()); + + Some(parents) + } else { + None + } + }) + .flatten() + .collect::>(); + + tracing::debug!( + order_id = ?order_id, + parent_count = parents.len(), + "ACE deps: order ready with unlock parents, queued for re-simulation" + ); + + // Order is ready with all unlock txs as parents + self.ready_orders.push(SimulationRequest { + id: rand::random(), + order, + parents, + ace_state, + }); } pub fn push_orders(&mut self, orders: Vec) -> Result<(), ProviderError> { @@ -223,52 +526,80 @@ 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 { + previous_orders, + simulated_order, + dependencies_satisfied, + id, + .. + } = result + else { + // Only Success variants should be processed here + return Ok(()); + }; + + self.sims.insert( + *id, + StoredSimulation { + parent_orders: previous_orders.clone(), + simulated_order: simulated_order.clone(), + }, + ); + + // Track orders that become ready (all deps satisfied) + let mut orders_ready: Vec = Vec::new(); - match self.sims_that_update_one_nonce.entry(updated_nonce.clone()) { + // Process each dependency this simulation satisfies + for dep_key in dependencies_satisfied.iter().cloned() { + 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); - - if let Some(pending_orders) = self.pending_nonces.remove(&updated_nonce) { - for order in pending_orders { - match self.pending_orders.entry(order) { - 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); - } + // First provider for this dependency + entry.insert(*id); + + // Update orders waiting on this dependency + if let Some(pending_order_ids) = self.pending_dependencies.remove(&dep_key) { + for order_id in pending_order_ids { + if let Entry::Occupied(mut entry) = self.pending_orders.entry(order_id) + { + let pending_order = entry.get_mut(); + + // Add the unlock interactions to the order's accounted_for set + if matches!(dep_key, DependencyKey::AceUnlock(_)) { + pending_order.ace_state.add_accounted_interactions( + simulated_order.ace_interactions.iter().copied(), + ); } - Entry::Vacant(_) => { - error!("SimTree bug order not found"); - // @Metric bug counter + + // Check if all ACE deps are now accounted for + if pending_order.ace_state.all_dependencies_accounted() { + orders_ready.push(entry.remove()); } + // Otherwise order stays pending, waiting for more deps } } } @@ -276,22 +607,38 @@ impl SimTree { } } - for ready_order in orders_ready { - let pending_state = self.get_order_nonce_state(&ready_order)?; + // Process orders that are now fully ready + for 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) => { + let ace_state = ready_pending_order.ace_state; + + // Collect ALL ACE parent orders from dependency_providers + for interaction in ace_state.detected_interactions.iter() { + if interaction.needs_unlock() { + let dep_key = + DependencyKey::AceUnlock(interaction.get_contract_address()); + if let Some(sim_id) = self.dependency_providers.get(&dep_key) { + if let Some(sim) = self.sims.get(sim_id) { + parents.extend(sim.parent_orders.iter().cloned()); + parents.push(sim.simulated_order.order.clone()); + } + } + } + } + self.ready_orders.push(SimulationRequest { id: rand::random(), - order: ready_order, + order: ready_pending_order.order, parents, + ace_state, }); } - OrderNonceState::Invalid => { - // @Metric bug counter + OrderDependencyState::Invalid => { error!("SimTree bug order became invalid"); } - OrderNonceState::PendingNonces(_) => { - // @Metric bug counter + OrderDependencyState::Pending(_) => { error!("SimTree bug order became pending again"); } } @@ -299,14 +646,140 @@ 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)?; + if let SimulatedResult::Success { .. } = result { + 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); + } } - Ok(()) + + Ok((successful_results, cancellations)) } } @@ -318,6 +791,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 +800,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,10 +809,10 @@ 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(); + let sim_errors = Vec::new(); let mut state_for_sim = Arc::::from(provider.history_by_block_hash(ctx.attributes.parent)?); let mut local_ctx = ThreadBlockBuildingContext::default(); @@ -369,36 +843,48 @@ where ctx, &mut local_ctx, &mut block_state, + sim_tree.ace_configs(), + &sim_task.ace_state, )?; 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 we have a failure, we will handle the case were its ace by either putting + // it into pending or requeing if we have the deps. Otherwise, no action is + // taken and flow handles as normal. + sim_tree.handle_ace_dependencies_for_order(sim_task.order, failure.ace_state); } 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 +904,24 @@ pub fn simulate_order( ctx: &BlockBuildingContext, local_ctx: &mut ThreadBlockBuildingContext, state: &mut BlockState, + ace_configs: &HashMap, + // we have parents for these ace addresses. + current_ace_state: &AceSimulationState, ) -> 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, + current_ace_state, + ); fork.rollback(rollback_point); let sim_res = sim_res?; + Ok(OrderSimResultWithGas { result: sim_res, gas_used: tracer.used_gas, @@ -438,8 +934,11 @@ pub fn simulate_order_using_fork( order: Order, fork: &mut PartialBlockFork<'_, '_, '_, '_, Tracer, NullPartialBlockForkExecutionTracer>, mempool_tx_detector: &MempoolTxsDetector, + ace_configs: &HashMap, + current_ace_state: &AceSimulationState, ) -> 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 +952,12 @@ 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, + // Given a parent failure. We will return a empty ace simulation state as this + // signals to treat it as a regular order. + ace_state: AceSimulationState::default(), + })); } } } @@ -461,13 +965,89 @@ 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() + }; + + // Log ACE interactions detected for this order + if !ace_interactions.is_empty() { + for interaction in &ace_interactions { + tracing::debug!( + order_id = ?order.id(), + sim_success = sim_success, + ace_interaction = ?interaction, + "ACE sim: detected interaction for order" + ); + } + } match result { Ok(res) => { let sim_value = create_sim_value(&order, &res, mempool_tx_detector); + + // Check if this is an ACE protocol unlock order (ProtocolForce or ProtocolOptional) + // These orders may have zero profit but are valuable for enabling other transactions + let is_ace_protocol_unlock = ace_interactions.iter().any(|i| i.is_protocol_tx()); + if let Err(err) = order_is_worth_executing(&sim_value) { - return Ok(OrderSimResult::Failed(err)); + if is_ace_protocol_unlock { + // ACE protocol unlocks bypass profit check - their value is enabling other txs + tracing::debug!( + order_id = ?order.id(), + ace_interactions = ?ace_interactions, + "ACE sim: protocol unlock order bypassing profit check" + ); + } else { + // Not an ACE protocol unlock, reject as usual + return Ok(OrderSimResult::Failed(SimulationFailure { + error: err, + ace_state: AceSimulationState::default(), + })); + } } let new_nonces = res.nonces_updated.into_iter().collect::>(); Ok(OrderSimResult::Success( @@ -475,10 +1055,58 @@ 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) => { + // Build ACE state with only NonUnlocking interactions (they need unlock parents) + let non_unlocking: HashSet = ace_interactions + .iter() + .filter(|i| i.needs_unlock()) + .copied() + .collect(); + + // Log failed orders that have ACE dependencies + if !non_unlocking.is_empty() { + tracing::debug!( + order_id = ?order.id(), + error = ?err, + non_unlocking_count = non_unlocking.len(), + non_unlocking_interactions = ?non_unlocking, + "ACE sim: order failed with non-unlocking ACE interactions (needs unlock parent)" + ); + } + + Ok(OrderSimResult::Failed(SimulationFailure { + error: err, + ace_state: AceSimulationState { + detected_interactions: non_unlocking, + accounted_for_interactions: current_ace_state + .accounted_for_interactions + .clone(), + }, + })) + } + } +} + +/// 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..57daebbf8 --- /dev/null +++ b/crates/rbuilder/src/building/testing/ace_tests/mod.rs @@ -0,0 +1,669 @@ +use super::test_chain_state::{BlockArgs, TestChainState}; +use crate::building::sim::{ + AceExchangeState, AceSimulationState, DependencyKey, NonceKey, SimTree, SimulatedResult, + SimulationRequest, +}; +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_force_unlock_always_preferred() { + let contract = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let force_order = create_force_unlock_order(contract, 100_000); + let optional_order = create_optional_unlock_order(contract, 50_000); + + let state = AceExchangeState { + force_unlock_order: Some(force_order.clone()), + optional_unlock_order: Some(optional_order.clone()), + ..Default::default() + }; + + // Force is always preferred over optional, regardless of gas cost + let result = state.get_unlock_order(); + assert_eq!(result, Some(&force_order)); +} + +#[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"), + } +} + +// ============================================================================ +// AceSimulationState Tests +// ============================================================================ + +use ahash::HashSet as AHashSet; + +#[test] +fn test_simulation_request_ace_state_empty_default() { + // New orders should start with empty ace_state + let request = SimulationRequest { + id: rand::random(), + order: create_test_order(), + parents: Vec::new(), + ace_state: AceSimulationState::default(), + }; + + assert!(request.ace_state.all_dependencies_accounted()); +} + +#[test] +fn test_ace_simulation_state_single_dependency() { + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + // Order detected a NonUnlocking interaction (needs unlock) + let mut detected = AHashSet::default(); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_a, + }); + + let ace_state = AceSimulationState { + detected_interactions: detected, + accounted_for_interactions: AHashSet::default(), + }; + + // Should have unhandled dependencies + assert!(!ace_state.all_dependencies_accounted()); + + let deps = ace_state.dependencies_to_handle(); + assert_eq!(deps.len(), 1); +} + +#[test] +fn test_ace_simulation_state_multiple_dependencies() { + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + let contract_c = address!("2222222aa232009084Bd71A5797d089AA4Edfad4"); + + let mut detected = AHashSet::default(); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_a, + }); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_b, + }); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_c, + }); + + let ace_state = AceSimulationState { + detected_interactions: detected, + accounted_for_interactions: AHashSet::default(), + }; + + let deps = ace_state.dependencies_to_handle(); + assert_eq!(deps.len(), 3); +} + +#[test] +fn test_ace_simulation_state_partial_unlock() { + // Test that partial unlocks are tracked correctly + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + let contract_b = address!("1111111aa232009084Bd71A5797d089AA4Edfad4"); + + let mut detected = AHashSet::default(); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_a, + }); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_b, + }); + + // We've accounted for unlock on contract_a + let mut accounted = AHashSet::default(); + accounted.insert(AceInteraction::Unlocking { + contract_address: contract_a, + source: AceUnlockSource::User, + }); + + let ace_state = AceSimulationState { + detected_interactions: detected, + accounted_for_interactions: accounted, + }; + + // Should still have one unhandled dependency (contract_b) + assert!(!ace_state.all_dependencies_accounted()); + let deps = ace_state.dependencies_to_handle(); + assert_eq!(deps.len(), 1); +} + +#[test] +fn test_ace_simulation_state_all_accounted() { + // When all unlocks are accounted for, should be fully resolved + let contract_a = address!("0000000aa232009084Bd71A5797d089AA4Edfad4"); + + let mut detected = AHashSet::default(); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_a, + }); + + let mut accounted = AHashSet::default(); + accounted.insert(AceInteraction::Unlocking { + contract_address: contract_a, + source: AceUnlockSource::User, + }); + + let ace_state = AceSimulationState { + detected_interactions: detected, + accounted_for_interactions: accounted, + }; + + assert!(ace_state.all_dependencies_accounted()); + assert!(ace_state.dependencies_to_handle().is_empty()); +} + +#[test] +fn test_handle_ace_dependencies_with_existing_accounted() -> 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 with ACE state: detected both contracts, accounted for contract_a + let order = create_test_order(); + + let mut detected = AHashSet::default(); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_a, + }); + detected.insert(AceInteraction::NonUnlocking { + contract_address: contract_b, + }); + + let mut accounted = AHashSet::default(); + accounted.insert(AceInteraction::Unlocking { + contract_address: contract_a, + source: AceUnlockSource::User, + }); + + let ace_state = AceSimulationState { + detected_interactions: detected, + accounted_for_interactions: accounted, + }; + + // Handle ACE dependencies - should add order to pending for contract_b + sim_tree.handle_ace_dependencies_for_order(order, ace_state); + + // The order should now be pending for contract_b unlock + // (Function should succeed without error) + + 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 69046ac08..fda2e4f90 100644 --- a/crates/rbuilder/src/building/testing/bundle_tests/setup.rs +++ b/crates/rbuilder/src/building/testing/bundle_tests/setup.rs @@ -203,6 +203,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 34e57ea87..96498a507 100644 --- a/crates/rbuilder/src/live_builder/base_config.rs +++ b/crates/rbuilder/src/live_builder/base_config.rs @@ -165,6 +165,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 { @@ -264,6 +275,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(), }) } @@ -466,6 +479,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 f78e2f27e..b1855da8d 100644 --- a/crates/rbuilder/src/live_builder/building/mod.rs +++ b/crates/rbuilder/src/live_builder/building/mod.rs @@ -42,6 +42,8 @@ pub struct BlockBuildingPool

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

BlockBuildingPool

@@ -57,6 +59,8 @@ where order_simulation_pool: OrderSimulationPool

, run_sparse_trie_prefetcher: bool, order_flow_tracer_manager: Box, + ace_enabled: bool, + ace_config: Vec, ) -> Self { BlockBuildingPool { provider, @@ -67,6 +71,8 @@ where run_sparse_trie_prefetcher, order_flow_tracer_manager, built_block_id_source: Arc::new(BuiltBlockIdSource::new()), + ace_enabled, + ace_config, } } @@ -143,6 +149,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 b35be25d6..060de8131 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 bdcdd38f5..65c126999 100644 --- a/crates/rbuilder/src/live_builder/mod.rs +++ b/crates/rbuilder/src/live_builder/mod.rs @@ -134,6 +134,9 @@ where pub simulation_use_random_coinbase: bool, pub order_flow_tracer_manager: Box, + + pub ace_enabled: bool, + pub ace_config: Vec, } impl

LiveBuilder

@@ -231,6 +234,8 @@ where order_simulation_pool, self.run_sparse_trie_prefetcher, 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 77cfdc68d..00be23712 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 @@ -79,24 +79,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..246cee07e 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,68 @@ 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_state, ); 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_state.all_dependencies_accounted() { + let result = SimulatedResult::Failed { + id: task.id, + order: task.order, + failure, + 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 +136,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 2fc950272..d67e38ded 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,77 @@ 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 { ref ace_state, .. }, + .. + } => { + // Check if there are unhandled ACE dependencies + if !ace_state.all_dependencies_accounted() { + // Failed with ACE dependency - pass to sim_tree for re-queuing with unlock parent + sim_tree_results.push(sim_result); } + // 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/mev_boost/mod.rs b/crates/rbuilder/src/mev_boost/mod.rs index 1168f5f12..e02c5619e 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-backtest-example.toml b/examples/config/rbuilder/config-backtest-example.toml index 9cb531735..f67589471 100644 --- a/examples/config/rbuilder/config-backtest-example.toml +++ b/examples/config/rbuilder/config-backtest-example.toml @@ -31,4 +31,20 @@ name = "parallel" algo = "parallel-builder" discard_txs = true num_threads = 25 -safe_sorting_only = false \ No newline at end of file +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"] \ No newline at end of file diff --git a/examples/config/rbuilder/config-live-example.toml b/examples/config/rbuilder/config-live-example.toml index ba5f6770d..c6dd29202 100644 --- a/examples/config/rbuilder/config-live-example.toml +++ b/examples/config/rbuilder/config-live-example.toml @@ -37,7 +37,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 @@ -56,7 +56,6 @@ mode = "full" max_bid_eth = "0.05" - [[builders]] name = "mgp-ordering" algo = "ordering-builder" @@ -80,6 +79,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" @@ -91,4 +106,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" - diff --git a/examples/config/rbuilder/config-playground.toml b/examples/config/rbuilder/config-playground.toml index 9520e3f31..10c963177 100644 --- a/examples/config/rbuilder/config-playground.toml +++ b/examples/config/rbuilder/config-playground.toml @@ -10,3 +10,19 @@ coinbase_secret_key = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf root_hash_use_sparse_trie=true root_hash_compare_sparse_trie=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"]