Skip to content
Open
Changes from 2 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
106 changes: 105 additions & 1 deletion src/payload/exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ impl<P: Platform> Executable<P> {
DB: DatabaseRef<Error = ProviderError> + Debug,
{
match self {
Self::Bundle(_) => unreachable!("asd"),
Self::Bundle(bundle) => Self::simulate_bundle(bundle, block, db),
Self::Transaction(tx) => Self::simulate_transaction(tx, block, db)
.map_err(ExecutionError::InvalidTransaction),
}
Expand Down Expand Up @@ -312,6 +312,110 @@ impl<P: Platform> Executable<P> {
state: BundleState::default(),
})
}

/// Simulates a bundle of transactions and returns the simulated execution
/// outcome of all transactions in the bundle. No state changes are
/// persisted.
///
/// Notes:
/// - Bundles that are not eligible for execution in the current block are
/// considered invalid, and no execution result will be produced.
///
/// - All transactions in the bundle are simulated in the order in which they
/// were defined in the bundle.
///
/// - Each transaction is simulated on the in-memory state produced by the
/// previous transaction in the bundle, but changes are not committed or
/// persisted.
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

Documentation Inaccuracy: State changes ARE persisted in simulation

The documentation states "Each transaction is simulated on the in-memory state produced by the previous transaction in the bundle, but changes are not committed or persisted" (lines 327-329). However, this is misleading.

While the final state is not returned (line 416 returns BundleState::default()), the in-memory state changes should still be committed within the State object between transactions for the simulation to work correctly. The documentation should clarify that:

  • State changes are committed in-memory between transactions within the bundle
  • The final aggregated state is not persisted/returned (unlike execute_bundle)

Consider revising to: "Each transaction is simulated on the in-memory state produced by the previous transaction in the bundle. State changes are applied in-memory for subsequent transactions, but the final state is not persisted."

Suggested change
/// previous transaction in the bundle, but changes are not committed or
/// persisted.
/// previous transaction in the bundle. State changes are applied in-memory
/// for subsequent transactions, but the final state is not persisted.

Copilot uses AI. Check for mistakes.
///
/// - Transactions that cause EVM errors will invalidate the bundle, and no
/// execution result will be produced. Bundle transactions can be marked
/// optional [`Bundle::is_optional`], and invalid outcomes are handled by
/// discarding them.
///
/// - Transactions that fail gracefully (revert or halt) and are not optional
/// will invalidate the bundle, and no execution result will be produced.
/// Bundle transactions can be marked as allowed to fail
/// [`Bundle::is_allowed_to_fail`], and failure outcomes are handled by
/// including them if allowed.
///
/// See truth table (same as `execute_bundle`):
/// | success | `allowed_to_fail` | optional | Action |
/// | ------: | :---------------: | :------: | :------ |
/// | true | *don’t care* | *any* | include |
/// | false | true | *any* | include |
/// | false | false | true | discard |
/// | false | false | false | error |
///
/// Post-execution validation is skipped for simulation, as no state changes
/// are persisted.
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't see why we shouldn't do post-execution validation on simulated bundle as well.
The implementation should be almost same as execute_bundle, but without merging transitions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

totally agree, thanks for the feeback julio. addressing it

fn simulate_bundle<DB>(
bundle: types::Bundle<P>,
block: &BlockContext<P>,
db: &DB,
) -> Result<ExecutionResult<P>, ExecutionError<P>>
where
DB: DatabaseRef<Error = ProviderError> + Debug,
{
let eligible = bundle.is_eligible(block);
if !eligible {
return Err(ExecutionError::IneligibleBundle(eligible));
}

let evm_env = block.evm_env();
let evm_config = block.evm_config();
let mut state = State::builder().with_database(WrapDatabaseRef(db)).build();

let mut discarded = Vec::new();
let mut results = Vec::with_capacity(bundle.transactions().len());

for transaction in bundle.transactions_encoded() {
let tx_hash = *transaction.tx_hash();
let optional = bundle.is_optional(&tx_hash);
let allowed_to_fail = bundle.is_allowed_to_fail(&tx_hash);

let result = evm_config
.evm_with_env(&mut state, evm_env.clone())
.transact(&transaction);

match result {
// Valid transaction or allowed to fail: include it in the bundle
Ok(ExecResultAndState { result, .. })
if result.is_success() || allowed_to_fail =>
{
results.push(result);
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

Critical Bug: State changes are not committed between bundle transactions

The documentation states that "Each transaction is simulated on the in-memory state produced by the previous transaction in the bundle" (lines 327-329), but the implementation does not commit state changes after successful transactions.

In the execute_bundle method (line 222), db.commit(state) is called after each successful transaction to ensure subsequent transactions see the state changes. However, in this simulation implementation, the state from ExecResultAndState is discarded without being committed.

Solution: Add state.commit(tx_state) after pushing the result:

Ok(ExecResultAndState { result, state: tx_state })
    if result.is_success() || allowed_to_fail =>
{
    results.push(result);
    state.commit(tx_state); // Commit to allow next tx to see changes
    // Note: No db.commit(state) for simulation
}

Without this fix, each transaction in the bundle will execute against the original database state, not the cumulative state from previous transactions, which breaks bundle semantics.

Suggested change
Ok(ExecResultAndState { result, .. })
if result.is_success() || allowed_to_fail =>
{
results.push(result);
Ok(ExecResultAndState { result, state: tx_state })
if result.is_success() || allowed_to_fail =>
{
results.push(result);
state.commit(tx_state); // Commit to allow next tx to see changes

Copilot uses AI. Check for mistakes.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah, you should do db.commit
But you shouldn't do merge_transitions

// Note: No db.commit(state) for simulation
Copy link

Copilot AI Nov 18, 2025

Choose a reason for hiding this comment

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

Misleading comment about state commitment

The comment says "No db.commit(state) for simulation" but the variable is named state, not db. This is inconsistent with the actual code and could be confusing.

Additionally, this comment is misleading because state changes should be committed between transactions (as noted in the bug comment above). The comment should clarify that:

  • State changes ARE committed in-memory between transactions (via state.commit(tx_state))
  • The final state is not returned (line 416 returns BundleState::default())

Consider revising to: "State changes committed in-memory but not returned (simulation)"

Suggested change
// Note: No db.commit(state) for simulation
// State changes committed in-memory but not returned (simulation)

Copilot uses AI. Check for mistakes.
}
// Optional failing transaction, not allowed to fail
// or optional invalid transaction: discard it
Ok(_) | Err(_) if optional => {
discarded.push(tx_hash);
}
// Non-Optional failing transaction, not allowed to fail: invalidate the
// bundle
Ok(_) => {
return Err(ExecutionError::BundleTransactionReverted(tx_hash));
}
// Non-Optional invalid transaction: invalidate the bundle
Err(err) => {
return Err(ExecutionError::InvalidBundleTransaction(tx_hash, err));
}
}
}

// Reduce the bundle by removing discarded transactions
let bundle = discarded
.into_iter()
.fold(bundle, |b, tx| b.without_transaction(tx));

// Return ExecutionResult with simulated results and default state (no
// persistence)
Ok(ExecutionResult {
source: Executable::Bundle(bundle),
results,
state: BundleState::default(),
})
}
}

impl<P: Platform> Executable<P> {
Expand Down
Loading