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
2 changes: 1 addition & 1 deletion crates/bundle/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ pub use call::{SignetBundleDriver, SignetCallBundle, SignetCallBundleResponse};
mod send;
pub use send::{
BundleInspector, BundleRecoverError, RecoverError, RecoveredBundle, SignetEthBundle,
SignetEthBundleDriver, SignetEthBundleError, SignetEthBundleInsp,
SignetEthBundleDriver, SignetEthBundleError, SignetEthBundleInsp, TxRequirement,
};
6 changes: 5 additions & 1 deletion crates/bundle/src/send/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use trevm::{
BundleError,
};

use crate::{BundleRecoverError, RecoveredBundle};
use crate::{BundleRecoverError, RecoverError, RecoveredBundle};

/// The inspector type required by the Signet bundle driver.
pub type BundleInspector<I = NoOpInspector> = Layered<TimeLimit, I>;
Expand Down Expand Up @@ -110,6 +110,10 @@ impl SignetEthBundle {
/// Create a [`RecoveredBundle`] from this bundle by decoding and recovering
/// all transactions, taking ownership of the bundle.
pub fn try_into_recovered(self) -> Result<RecoveredBundle, BundleRecoverError> {
if self.txs().is_empty() {
return Err(BundleRecoverError::new(RecoverError::EmptyBundle, false, 0));
}

let txs = self.recover_txs().collect::<Result<Vec<_>, _>>()?;

let host_txs = self.recover_host_txs().collect::<Result<Vec<_>, _>>()?;
Expand Down
71 changes: 65 additions & 6 deletions crates/bundle/src/send/decoded.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
use alloy::{
consensus::{transaction::Recovered, TxEnvelope},
primitives::{Address, TxHash},
consensus::{transaction::Recovered, Transaction, TxEnvelope},
primitives::{Address, TxHash, U256},
serde::OtherFields,
};

/// Transaction requirement info for a single transaction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TxRequirement {
/// Signer address
pub signer: Address,
/// Nonce
pub nonce: u64,
/// Max spend (max_fee_per_gas * gas_limit) + value
pub balance: U256,
}

/// Version of [`SignetEthBundle`] with decoded transactions.
///
/// [`SignetEthBundle`]: crate::send::bundle::SignetEthBundle
Expand Down Expand Up @@ -49,14 +60,15 @@ pub struct RecoveredBundle {
impl RecoveredBundle {
/// Instantiator. Generally recommend instantiating via conversion from
/// [`SignetEthBundle`] via [`SignetEthBundle::try_into_recovered`] or
/// [`SignetEthBundle::try_to_recovered`].
/// [`SignetEthBundle::try_to_recovered`]. This allows instantiating empty
/// bundles, which are otherwise disallowed and is used for testing.
///
/// [`SignetEthBundle`]: crate::send::bundle::SignetEthBundle
/// [`SignetEthBundle::try_into_recovered`]: crate::send::bundle::SignetEthBundle::try_into_recovered
/// [`SignetEthBundle::try_to_recovered`]: crate::send::bundle::SignetEthBundle::try_to_recovered
#[doc(hidden)]
#[allow(clippy::too_many_arguments)]
pub const fn new(
pub const fn new_unchecked(
txs: Vec<Recovered<TxEnvelope>>,
host_txs: Vec<Recovered<TxEnvelope>>,
block_number: u64,
Expand Down Expand Up @@ -106,21 +118,68 @@ impl RecoveredBundle {
self.host_txs.drain(..)
}

/// Get an iterator over the transaction requirements:
/// - signer address
/// - nonce
/// - min_balance ((max_fee_per_gas * gas_limit) + value)
pub fn tx_reqs(&self) -> impl Iterator<Item = TxRequirement> + '_ {
self.txs.iter().map(|tx| {
let balance = U256::from(tx.max_fee_per_gas() * tx.gas_limit() as u128) + tx.value();
TxRequirement { signer: tx.signer(), nonce: tx.nonce(), balance }
})
}

/// Get an iterator over the host transaction requirements:
/// - signer address
/// - nonce
/// - min_balance ((max_fee_per_gas * gas_limit) + value)
pub fn host_tx_reqs(&self) -> impl Iterator<Item = TxRequirement> + '_ {
self.host_txs.iter().map(|tx| {
let balance = U256::from(tx.max_fee_per_gas() * tx.gas_limit() as u128) + tx.value();
TxRequirement { signer: tx.signer(), nonce: tx.nonce(), balance }
})
}

/// Getter for block_number, a standard bundle prop.
pub const fn block_number(&self) -> u64 {
self.block_number
}

/// Get the valid timestamp range for this bundle.
pub const fn valid_timestamp_range(&self) -> std::ops::RangeInclusive<u64> {
let min = if let Some(min) = self.min_timestamp { min } else { 0 };
let max = if let Some(max) = self.max_timestamp { max } else { u64::MAX };
min..=max
}

/// Getter for min_timestamp, a standard bundle prop.
pub const fn min_timestamp(&self) -> Option<u64> {
pub const fn raw_min_timestamp(&self) -> Option<u64> {
self.min_timestamp
}

/// Getter for [`Self::raw_min_timestamp`], with default of 0.
pub const fn min_timestamp(&self) -> u64 {
if let Some(min) = self.min_timestamp {
min
} else {
0
}
}

/// Getter for max_timestamp, a standard bundle prop.
pub const fn max_timestamp(&self) -> Option<u64> {
pub const fn raw_max_timestamp(&self) -> Option<u64> {
self.max_timestamp
}

/// Getter for [`Self::raw_max_timestamp`], with default of `u64::MAX`.
pub const fn max_timestamp(&self) -> u64 {
if let Some(max) = self.max_timestamp {
max
} else {
u64::MAX
}
}

/// Getter for reverting_tx_hashes, a standard bundle prop.
pub const fn reverting_tx_hashes(&self) -> &[TxHash] {
self.reverting_tx_hashes.as_slice()
Expand Down
5 changes: 5 additions & 0 deletions crates/bundle/src/send/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ use trevm::{
/// bundles.
#[derive(Debug, thiserror::Error)]
pub enum RecoverError {
/// Bundle is empty. Bundles must contain at least one RU transaction.
#[error("Bundle must contain at least one RU transaction")]
EmptyBundle,

/// Error occurred while decoding the transaction.
#[error(transparent)]
Decoding(#[from] Eip2718Error),

/// Error occurred while recovering the signature.
#[error(transparent)]
Recovering(#[from] alloy::consensus::crypto::RecoveryError),
Expand Down
2 changes: 1 addition & 1 deletion crates/bundle/src/send/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ mod bundle;
pub use bundle::{BundleInspector, SignetEthBundle};

mod decoded;
pub use decoded::RecoveredBundle;
pub use decoded::{RecoveredBundle, TxRequirement};

mod driver;
pub use driver::{SignetEthBundleDriver, SignetEthBundleInsp};
Expand Down
3 changes: 2 additions & 1 deletion crates/sim/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ trevm.workspace = true
thiserror.workspace = true

parking_lot.workspace = true
lru = "0.16.2"

[dev-dependencies]
tracing-subscriber.workspace = true
alloy = { workspace = true, features = ["getrandom"] }
alloy = { workspace = true, features = ["getrandom"] }
6 changes: 3 additions & 3 deletions crates/sim/src/built.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use alloy::{
use core::fmt;
use signet_bundle::RecoveredBundle;
use signet_zenith::{encode_txns, Alloy2718Coder};
use std::sync::OnceLock;
use std::sync::{Arc, OnceLock};
use tracing::trace;

/// A block that has been built by the simulator.
Expand Down Expand Up @@ -137,8 +137,8 @@ impl BuiltBlock {
self.host_gas_used += item.host_gas_used;

match item.item {
SimItem::Bundle(bundle) => self.ingest_bundle(*bundle),
SimItem::Tx(tx) => self.ingest_tx(*tx),
SimItem::Bundle(bundle) => self.ingest_bundle(Arc::unwrap_or_clone(bundle)),
SimItem::Tx(tx) => self.ingest_tx(Arc::unwrap_or_clone(tx)),
}
}

Expand Down
File renamed without changes.
140 changes: 132 additions & 8 deletions crates/sim/src/item.rs → crates/sim/src/cache/item.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
use crate::CacheError;
use crate::{cache::StateSource, CacheError, SimItemValidity};
use alloy::{
consensus::{
transaction::{Recovered, SignerRecoverable},
Transaction, TxEnvelope,
},
primitives::TxHash,
primitives::{Address, TxHash, U256},
};
use signet_bundle::{RecoveredBundle, SignetEthBundle};
use signet_bundle::{RecoveredBundle, SignetEthBundle, TxRequirement};
use std::{
borrow::{Borrow, Cow},
collections::BTreeMap,
hash::Hash,
sync::Arc,
};

/// An item that can be simulated.
/// An item that can be simulated, wrapped in an Arc for cheap cloning.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SimItem {
/// A bundle to be simulated.
Bundle(Box<RecoveredBundle>),
Bundle(Arc<RecoveredBundle>),
/// A transaction to be simulated.
Tx(Box<Recovered<TxEnvelope>>),
Tx(Arc<Recovered<TxEnvelope>>),
}

impl TryFrom<SignetEthBundle> for SimItem {
Expand Down Expand Up @@ -57,15 +59,15 @@ impl TryFrom<TxEnvelope> for SimItem {

impl SimItem {
/// Get the bundle if it is a bundle.
pub const fn as_bundle(&self) -> Option<&RecoveredBundle> {
pub fn as_bundle(&self) -> Option<&RecoveredBundle> {
match self {
Self::Bundle(bundle) => Some(bundle),
Self::Tx(_) => None,
}
}

/// Get the transaction if it is a transaction.
pub const fn as_tx(&self) -> Option<&Recovered<TxEnvelope>> {
pub fn as_tx(&self) -> Option<&Recovered<TxEnvelope>> {
match self {
Self::Bundle(_) => None,
Self::Tx(tx) => Some(tx),
Expand Down Expand Up @@ -110,6 +112,128 @@ impl SimItem {
Self::Tx(tx) => SimIdentifier::Tx(*tx.inner().hash()),
}
}

fn check_tx<S>(&self, source: &S) -> Result<SimItemValidity, Box<dyn std::error::Error>>
where
S: StateSource,
{
let item = self.as_tx().expect("SimItem is not a Tx");

let total = U256::from(item.max_fee_per_gas() * item.gas_limit() as u128) + item.value();

source
.map(&item.signer(), |info| {
// if the chain nonce is greater than the tx nonce, it is
// no longer valid
if info.nonce > item.nonce() {
return SimItemValidity::Never;
}
// if the chain nonce is less than the tx nonce, we need to wait
if info.nonce < item.nonce() {
return SimItemValidity::Future;
}
// if the balance is insufficient, we need to wait
if info.balance < total {
return SimItemValidity::Future;
}
// nonce is equal and balance is sufficient
SimItemValidity::Now
})
.map_err(Into::into)
}

fn check_bundle_tx_list<S>(
items: impl Iterator<Item = TxRequirement>,
source: &S,
) -> Result<SimItemValidity, S::Error>
where
S: StateSource,
{
// For bundles, we want to check the nonce of each transaction. To do
// this, we build a small in memory cache so that if the same signer
// appears, we can reuse the nonce info. We do not check balances after
// the first tx, as they may have changed due to prior txs in the
// bundle.

let mut nonce_cache: BTreeMap<Address, u64> = BTreeMap::new();
let mut items = items.peekable();

// Peek to perform the balance check for the first tx
if let Some(first) = items.peek() {
Comment on lines +161 to +162
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this only checks the first transaction. if the bundle contains multiple senders, shouldn't we check the first tx balance for each?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

read the comment

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had already read it. I wanted stricter validation here, but I see how making it more strict can discard valid bundles

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

// We do not check balances after
// the first tx, as they may have changed due to prior txs in the
// bundle.

is there a way we can rewrite this to be more clear?

let info = source.account_details(&first.signer)?;

// check balance for the first tx is sufficient
if first.balance > info.balance {
return Ok(SimItemValidity::Future);
}

// Cache the nonce. This will be used for the first tx.
nonce_cache.insert(first.signer, info.nonce);
}

for requirement in items {
let state_nonce = match nonce_cache.get(&requirement.signer) {
Some(cached_nonce) => *cached_nonce,
None => {
let nonce = source.nonce(&requirement.signer)?;
nonce_cache.insert(requirement.signer, nonce);
nonce
}
};

if requirement.nonce < state_nonce {
return Ok(SimItemValidity::Never);
}
if requirement.nonce > state_nonce {
return Ok(SimItemValidity::Future);
}

// Increment the cached nonce for the next transaction from this
// signer. Map _must_ have the entry as we just either loaded or
// stored it above
nonce_cache.entry(requirement.signer).and_modify(|n| *n += 1);
}

// All transactions passed
Ok(SimItemValidity::Now)
}

fn check_bundle<S, S2>(
&self,
source: &S,
host_source: &S2,
) -> Result<SimItemValidity, Box<dyn std::error::Error>>
where
S: StateSource,
S2: StateSource,
{
let item = self.as_bundle().expect("SimItem is not a Bundle");

let ru_tx = Self::check_bundle_tx_list(item.tx_reqs(), source)?;
let host_tx = Self::check_bundle_tx_list(item.host_tx_reqs(), host_source)?;

// Check both the regular txs and the host txs.
Ok(ru_tx.min(host_tx))
}

/// Check if the item is valid against the provided state sources.
///
/// This will check that nonces and balances are sufficient for the item to
/// be included on the current state.
pub fn check<S, S2>(
&self,
source: &S,
host_source: &S2,
) -> Result<SimItemValidity, Box<dyn std::error::Error>>
where
S: StateSource,
S2: StateSource,
{
match self {
SimItem::Bundle(_) => self.check_bundle(source, host_source),
SimItem::Tx(_) => self.check_tx(source),
}
}
}

/// A simulation cache item identifier.
Expand Down
14 changes: 14 additions & 0 deletions crates/sim/src/cache/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
mod error;
pub use error::CacheError;

mod item;
pub use item::{SimIdentifier, SimItem};

mod state;
pub use state::StateSource;

mod store;
pub use store::SimCache;

mod validity;
pub use validity::SimItemValidity;
Loading