diff --git a/crates/rbuilder-primitives/src/lib.rs b/crates/rbuilder-primitives/src/lib.rs index d80b6b826..ba66bb60a 100644 --- a/crates/rbuilder-primitives/src/lib.rs +++ b/crates/rbuilder-primitives/src/lib.rs @@ -22,7 +22,7 @@ use alloy_eips::{ use alloy_primitives::{keccak256, Address, Bytes, TxHash, B256, U256}; use alloy_rlp::Encodable as _; use derivative::Derivative; -use evm_inspector::UsedStateTrace; +use evm_inspector::{SlotKey, UsedStateTrace}; use integer_encoding::VarInt; use reth_ethereum_primitives::{PooledTransactionVariant, Transaction, TransactionSigned}; use reth_primitives_traits::{InMemorySize, Recovered, SignedTransaction as _, SignerRecoverable}; @@ -221,6 +221,14 @@ pub struct Bundle { /// Unique identifier for a bundle that was set by the sender of the bundle pub external_hash: Option, + + /// Storage slots (contract address + slot key) this bundle wants to be attempted after. + /// The builder only tries to include the bundle once an order that writes one of these slots + /// has been added to the block, enabling blind backrunning and reducing wasted inclusion + /// attempts. An empty list means the bundle is unconditional. + /// Only supported on bundle version >= V2. Currently honored by the sequential ordering + /// builder; the parallel builder does not gate on target slots. + pub target_storage_slots: Vec, } impl Bundle { @@ -888,6 +896,15 @@ impl Order { } } + /// Storage slots this order asks to be attempted after (see [`Bundle::target_storage_slots`]). + /// Plain mempool txs never target slots. + pub fn target_storage_slots(&self) -> &[SlotKey] { + match self { + Order::Bundle(bundle) => &bundle.target_storage_slots, + Order::Tx(_) => &[], + } + } + /// Address that signed the bundle request pub fn signer(&self) -> Option
{ match self { diff --git a/crates/rbuilder-primitives/src/order_builder.rs b/crates/rbuilder-primitives/src/order_builder.rs index 9d8efe9a6..158ef60c2 100644 --- a/crates/rbuilder-primitives/src/order_builder.rs +++ b/crates/rbuilder-primitives/src/order_builder.rs @@ -163,6 +163,7 @@ impl BundleBuilder { refund: self.refund, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: Default::default(), }; bundle.hash_slow(); bundle diff --git a/crates/rbuilder-primitives/src/serialize.rs b/crates/rbuilder-primitives/src/serialize.rs index d11436eb2..b9ad623b1 100644 --- a/crates/rbuilder-primitives/src/serialize.rs +++ b/crates/rbuilder-primitives/src/serialize.rs @@ -3,6 +3,7 @@ use super::{ Metadata, Order, RawTransactionDecodable, TransactionSignedEcRecoveredWithBlobs, TxWithBlobsCreateError, LAST_BUNDLE_VERSION, }; +use crate::evm_inspector::SlotKey; use alloy_consensus::constants::EIP4844_TX_TYPE_ID; use alloy_eips::eip2718::Eip2718Error; use alloy_primitives::{Address, Bytes, TxHash, B256, U64}; @@ -87,6 +88,15 @@ where deserialize_vec_from_null_or_string(deserializer) } +/// Wire representation of a single storage slot a bundle targets: a contract `address` and the +/// 32-byte `slot` key. Maps to [`SlotKey`] on the domain [`Bundle`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RawTargetStorageSlot { + pub address: Address, + pub slot: B256, +} + /// Struct to de/serialize JSON bundles data from bundles APIs and from/db, except transactions. /// To be used long with `RawBundle`. #[serde_as] @@ -156,6 +166,11 @@ pub struct RawBundleMetadata { /// Disable multiplexing bundle to other region builders. #[serde(default, skip_serializing_if = "is_false")] pub disable_cross_region_sharing: bool, + /// targetStorageSlots (Optional) `Array[{address,slot}]`, storage slots this bundle targets so + /// the builder attempts it only after an order writes one of them (blind backrunning). + /// Only for version None or >= v2. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub target_storage_slots: Vec, } fn is_false(b: &bool) -> bool { @@ -244,7 +259,15 @@ impl RawBundleMetadata { version, )); } - Ok(()) + self.target_storage_slots + .is_empty() + .then_some(()) + .ok_or_else(|| { + RawBundleConvertError::FieldNotSupportedByVersion( + "target_storage_slots".to_owned(), + version, + ) + }) } BundleVersion::V2 => Ok(()), } @@ -396,6 +419,14 @@ impl RawBundle { refund, version, external_hash: metadata.bundle_hash, + target_storage_slots: metadata + .target_storage_slots + .iter() + .map(|s| SlotKey { + address: s.address, + key: s.slot, + }) + .collect(), }; bundle.hash_slow(); Ok(RawBundleDecodeResult::NewBundle(bundle)) @@ -432,6 +463,14 @@ impl RawBundle { version: Some(Self::encode_version(value.version)), bundle_hash: value.external_hash, disable_cross_region_sharing: value.metadata.disable_cross_region_sharing, + target_storage_slots: value + .target_storage_slots + .iter() + .map(|s| RawTargetStorageSlot { + address: s.address, + slot: s.key, + }) + .collect(), }, } } @@ -870,6 +909,47 @@ mod tests { assert_eq!(bundle.uuid, uuid!("e2bdb8cd-9473-5a1b-b425-57fa7ecfe2c1")); } + #[test] + fn test_correct_bundle_decoding_target_storage_slots_v2() { + let bundle_json = r#" + { + "version": "v2", + "txs": [ + "0x02f86b83aa36a780800982520894f24a01ae29dec4629dfb4170647c4ed4efc392cd861ca62a4c95b880c080a07d37bb5a4da153a6fbe24cf1f346ef35748003d1d0fc59cf6c17fb22d49e42cea02c231ac233220b494b1ad501c440c8b1a34535cdb8ca633992d6f35b14428672" + ], + "blockNumber": 0, + "targetStorageSlots": [ + { + "address": "0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5", + "slot": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + ] + }"#; + + let bundle_request: RawBundle = + serde_json::from_str(bundle_json).expect("failed to decode bundle"); + + let bundle = bundle_request + .clone() + .decode_new_bundle(TxEncoding::WithBlobData) + .expect("failed to convert bundle request to bundle"); + + assert_eq!( + bundle.target_storage_slots, + vec![SlotKey { + address: address!("0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5"), + key: b256!("0x0000000000000000000000000000000000000000000000000000000000000001"), + }] + ); + + // The targeted slots survive a domain -> wire round-trip. + let reencoded = RawBundle::encode_no_blobs(bundle); + assert_eq!( + reencoded.metadata.target_storage_slots, + bundle_request.metadata.target_storage_slots + ); + } + /// If refundTxHashes is missing it should use the last tx and the id should be the same. #[test] fn test_correct_bundle_decoding_refund_hash_missing() { @@ -1035,6 +1115,7 @@ mod tests { r#" "refundTxHashes": ["0x75662ab9cb6d1be7334723db5587435616352c7e581a52867959ac24006ac1fe"] "#, r#" "delayedRefund": true "#, r#" "disableCrossRegionSharing": true "#, + r#" "targetStorageSlots": [{"address": "0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5", "slot": "0x0000000000000000000000000000000000000000000000000000000000000001"}] "#, ]; for field in extra_invalid_fields { diff --git a/crates/rbuilder-primitives/src/test_data_generator.rs b/crates/rbuilder-primitives/src/test_data_generator.rs index bbed1acd2..6615d40fd 100644 --- a/crates/rbuilder-primitives/src/test_data_generator.rs +++ b/crates/rbuilder-primitives/src/test_data_generator.rs @@ -102,6 +102,7 @@ impl TestDataGenerator { refund: None, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: vec![], }; res.hash_slow(); res @@ -139,6 +140,7 @@ impl TestDataGenerator { refund: None, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: vec![], }; bundle.hash_slow(); bundle diff --git a/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs b/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs index 3f337eef7..138ac9e7a 100644 --- a/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs +++ b/crates/rbuilder/src/backtest/build_block/synthetic_orders.rs @@ -104,6 +104,7 @@ impl SyntheticOrdersSource { refund: None, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: Default::default(), }; bundle.hash_slow(); orders.push(OrdersWithTimestamp { diff --git a/crates/rbuilder/src/backtest/restore_landed_orders/find_landed_orders.rs b/crates/rbuilder/src/backtest/restore_landed_orders/find_landed_orders.rs index 25cd536fe..6a57b6f08 100644 --- a/crates/rbuilder/src/backtest/restore_landed_orders/find_landed_orders.rs +++ b/crates/rbuilder/src/backtest/restore_landed_orders/find_landed_orders.rs @@ -881,6 +881,7 @@ mod tests { refund_identity: None, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: Default::default(), }); let expected = SimplifiedOrder::new( OrderId::Bundle(uuid::uuid!("00000000-0000-0000-0000-ffff00000002")), @@ -917,6 +918,7 @@ mod tests { refund_identity: None, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: Default::default(), }); let expected = SimplifiedOrder::new( OrderId::Bundle(uuid::uuid!("00000000-0000-0000-0000-ffff00000002")), diff --git a/crates/rbuilder/src/backtest/store.rs b/crates/rbuilder/src/backtest/store.rs index bfedefd23..72a98fd30 100644 --- a/crates/rbuilder/src/backtest/store.rs +++ b/crates/rbuilder/src/backtest/store.rs @@ -745,6 +745,7 @@ mod test { version: Some(RawBundle::encode_version(LAST_BUNDLE_VERSION)), bundle_hash: None, disable_cross_region_sharing: false, + target_storage_slots: Default::default(), }, })), } diff --git a/crates/rbuilder/src/building/block_orders/mod.rs b/crates/rbuilder/src/building/block_orders/mod.rs index 44c99743a..97fdaa293 100644 --- a/crates/rbuilder/src/building/block_orders/mod.rs +++ b/crates/rbuilder/src/building/block_orders/mod.rs @@ -121,7 +121,8 @@ pub fn block_orders_from_sim_orders( #[cfg(test)] mod test { - use rbuilder_primitives::BundledTxInfo; + use alloy_primitives::{Address, B256}; + use rbuilder_primitives::{evm_inspector::SlotKey, BundledTxInfo, Order}; use super::{ order_priority::{FullProfitInfoGetter, OrderMaxProfitPriority}, @@ -201,6 +202,23 @@ mod test { order } + /// Like `create_add_bundle_order` but the bundle declares the storage slots it targets, so + /// it stays pending until one of those slots is written (blind backrunning, issue #27). + pub fn create_add_bundle_order_with_target_slots( + &mut self, + txs_info: &[BundledTxInfo], + bundle_profit: u64, + target_storage_slots: Vec, + ) -> Arc { + let mut bundle = self.data_gen.base.create_bundle_multi_tx(0, txs_info, None); + bundle.target_storage_slots = target_storage_slots; + let order = + self.data_gen + .create_sim_order(Order::Bundle(bundle), bundle_profit, bundle_profit); + self.order_pool.insert_order(order.clone()); + order + } + /// create_add_bundle_order helper for 2 orders pub fn create_add_bundle_order_2_txs( &mut self, @@ -316,4 +334,34 @@ mod test { // No more orders context.assert_pop_none(); } + + #[test] + /// A bundle that targets a storage slot is only attemptable once an order writes that slot. + fn test_block_orders_target_storage_slots() { + let (nonce, mut context) = TestContext::new_1_account(0); + let target = SlotKey { + address: Address::repeat_byte(0x11), + key: B256::repeat_byte(0x22), + }; + let unrelated = SlotKey { + address: Address::repeat_byte(0x33), + key: B256::repeat_byte(0x44), + }; + let txs_info = [BundledTxInfo { + nonce: nonce.clone(), + optional: false, + }]; + let backrun = + context.create_add_bundle_order_with_target_slots(&txs_info, 5, vec![target.clone()]); + + // Nonce is satisfied but the target slot has not been written -> not attemptable yet. + context.assert_pop_none(); + // Writing an unrelated slot must not release it. + context.order_pool.notify_slots_written(&[unrelated]); + context.assert_pop_none(); + // Writing the target slot releases it for inclusion. + context.order_pool.notify_slots_written(&[target]); + context.assert_pop_order(&backrun); + context.assert_pop_none(); + } } diff --git a/crates/rbuilder/src/building/block_orders/prioritized_order_store.rs b/crates/rbuilder/src/building/block_orders/prioritized_order_store.rs index 155bfb099..9d5c370b4 100644 --- a/crates/rbuilder/src/building/block_orders/prioritized_order_store.rs +++ b/crates/rbuilder/src/building/block_orders/prioritized_order_store.rs @@ -6,7 +6,8 @@ use priority_queue::PriorityQueue; use crate::telemetry::mark_order_not_ready_for_immediate_inclusion; use rbuilder_primitives::{ - order_statistics::OrderStatistics, AccountNonce, Nonce, OrderId, SimulatedOrder, + evm_inspector::SlotKey, order_statistics::OrderStatistics, AccountNonce, Nonce, OrderId, + SimulatedOrder, }; use super::{OrderPriority, SimulatedOrderSink}; @@ -37,6 +38,12 @@ pub struct PrioritizedOrderStore { /// Orders waiting for an account to reach a particular nonce. pending_orders: HashMap>, + /// Storage slots written by orders already added to the block this round (monotonic). + /// Used to release bundles that asked to be attempted after one of their target slots is written. + written_slots: HashSet, + /// Bundles waiting for one of their target storage slots to be written (blind backrunning). + /// A bundle is listed under every slot it targets and released as soon as any one is written. + pending_on_slot: HashMap>, /// Id -> order for all orders we manage. Carefully maintained by remove/insert orders: HashMap>, /// Everything in orders @@ -54,6 +61,8 @@ impl PrioritizedOrderStore main_queue_nonces: HashMap::default(), onchain_nonces, pending_orders: HashMap::default(), + written_slots: HashSet::default(), + pending_on_slot: HashMap::default(), orders: HashMap::default(), orders_statistics: Default::default(), } @@ -151,6 +160,46 @@ impl PrioritizedOrderStore } } + /// A bundle that targets storage slots is only ready once at least one of those slots has been + /// written this block. Orders without target slots are always slot-ready. + fn order_slots_available(&self, sim_order: &SimulatedOrder) -> bool { + let target = sim_order.order.target_storage_slots(); + target.is_empty() || target.iter().any(|slot| self.written_slots.contains(slot)) + } + + /// Records the storage slots written by a freshly added order and releases any bundle that asked + /// to be attempted after one of its target slots was written (blind backrunning, issue #27). + /// Call this right after an order commits, with the slots that order wrote. + pub fn notify_slots_written(&mut self, written: &[SlotKey]) { + // Accumulate first so that released orders see the new writes as available. + let freshly_written = written + .iter() + .filter(|&slot| self.written_slots.insert(slot.clone())) + .cloned() + .collect::>(); + // Every unique bundle waiting on any of the newly written slots. + let released = freshly_written + .iter() + .filter_map(|slot| self.pending_on_slot.remove(slot)) + .flatten() + .collect::>(); + let ready = released + .into_iter() + .filter_map(|id| { + let order = self.remove_from_orders(&id)?; + // OR-semantics: a bundle listed under several target slots is released by the first + // one written, so drop it from the buckets of its other (still unwritten) slots. + order.order.target_storage_slots().iter().for_each(|slot| { + if let Some(bucket) = self.pending_on_slot.get_mut(slot) { + bucket.retain(|other| *other != id); + } + }); + Some(order) + }) + .collect::>(); + ready.into_iter().for_each(|order| self.insert_order(order)); + } + pub fn get_all_orders(&self) -> Vec> { self.orders.values().cloned().collect() } @@ -195,7 +244,8 @@ impl SimulatedOrderSink }); } } - if pending_nonces.is_empty() { + let slots_ready = self.order_slots_available(&sim_order); + if pending_nonces.is_empty() && slots_ready { self.main_queue .push(sim_order.id(), OrderPriorityType::new(sim_order.clone())); for nonce in sim_order.nonces() { @@ -211,6 +261,19 @@ impl SimulatedOrderSink pending.push(sim_order.id()); } } + if !slots_ready { + let id = sim_order.id(); + sim_order + .order + .target_storage_slots() + .iter() + .for_each(|slot| { + let pending = self.pending_on_slot.entry(slot.clone()).or_default(); + if !pending.contains(&id) { + pending.push(id); + } + }); + } } self.orders_statistics.add(&sim_order.order); // We don't check the result to update orders_statistics since we already checked !self.orders.contains_key diff --git a/crates/rbuilder/src/building/builders/ordering_builder.rs b/crates/rbuilder/src/building/builders/ordering_builder.rs index 96ef96134..de34fc02e 100644 --- a/crates/rbuilder/src/building/builders/ordering_builder.rs +++ b/crates/rbuilder/src/building/builders/ordering_builder.rs @@ -443,6 +443,20 @@ impl OrderingBuilderContext { }) .collect(); block_orders.update_onchain_nonces(&nonces_updated); + // Release any bundle waiting to be attempted after this order wrote one of its + // target storage slots (blind backrunning, see issue #27). The order's + // simulation trace already records the slots it writes. Note the trace also + // records a synthetic write to the signer's slot 0 for nonce tracking, so a + // bundle targeting {eoa, 0x0} may be released by any tx from that account; this + // is a benign over-report (never a missed release) rather than a miss. + if let Some(trace) = &sim_order.used_state_trace { + let written_slots = trace + .written_slot_values + .keys() + .cloned() + .collect::>(); + block_orders.notify_slots_written(&written_slots); + } } Err(err) => { if let ExecutionError::LowerInsertedValue { inplace, .. } = &err { 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 14d791571..bb400a118 100644 --- a/crates/rbuilder/src/building/builders/parallel_builder/conflict_resolvers.rs +++ b/crates/rbuilder/src/building/builders/parallel_builder/conflict_resolvers.rs @@ -536,6 +536,7 @@ mod tests { refund_identity: None, version: LAST_BUNDLE_VERSION, external_hash: None, + target_storage_slots: Default::default(), }; Arc::new(SimulatedOrder::new(