Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion crates/rbuilder-primitives/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<B256>,

/// 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<SlotKey>,
}

impl Bundle {
Expand Down Expand Up @@ -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<Address> {
match self {
Expand Down
1 change: 1 addition & 0 deletions crates/rbuilder-primitives/src/order_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 82 additions & 1 deletion crates/rbuilder-primitives/src/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<T>`.
#[serde_as]
Expand Down Expand Up @@ -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<RawTargetStorageSlot>,
}

fn is_false(b: &bool) -> bool {
Expand Down Expand Up @@ -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(()),
}
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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(),
},
}
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions crates/rbuilder-primitives/src/test_data_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ impl TestDataGenerator {
refund: None,
version: LAST_BUNDLE_VERSION,
external_hash: None,
target_storage_slots: vec![],
};
res.hash_slow();
res
Expand Down Expand Up @@ -139,6 +140,7 @@ impl TestDataGenerator {
refund: None,
version: LAST_BUNDLE_VERSION,
external_hash: None,
target_storage_slots: vec![],
};
bundle.hash_slow();
bundle
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ impl<ConfigType: LiveBuilderConfig> SyntheticOrdersSource<ConfigType> {
refund: None,
version: LAST_BUNDLE_VERSION,
external_hash: None,
target_storage_slots: Default::default(),
};
bundle.hash_slow();
orders.push(OrdersWithTimestamp {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down Expand Up @@ -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")),
Expand Down
1 change: 1 addition & 0 deletions crates/rbuilder/src/backtest/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
})),
}
Expand Down
50 changes: 49 additions & 1 deletion crates/rbuilder/src/building/block_orders/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ pub fn block_orders_from_sim_orders<OrderPriorityType: OrderPriority>(

#[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},
Expand Down Expand Up @@ -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<SlotKey>,
) -> Arc<SimulatedOrder> {
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,
Expand Down Expand Up @@ -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();
}
}
Loading