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
38 changes: 36 additions & 2 deletions bin/mega-evme/src/common/tx_override.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@

use std::cell::RefCell;

use alloy_primitives::{Address, Bytes, U256};
use alloy_eips::{Encodable2718, Typed2718};
use alloy_primitives::{Address, Bytes, TxHash, U256};
use clap::Args;
use mega_evm::{
alloy_evm::{IntoTxEnv, RecoveredTx},
MegaTransaction,
MegaTransaction, MegaTransactionExt,
};

use super::{load_hex, parse_ether_value, Result};
Expand Down Expand Up @@ -142,3 +143,36 @@ impl<Tx, T: RecoveredTx<Tx> + Copy> RecoveredTx<Tx> for OverriddenTx<T> {
self.inner.signer()
}
}

// Delegate Typed2718 to inner (required as the `Encodable2718` supertrait below).
impl<T: Typed2718 + Copy> Typed2718 for OverriddenTx<T> {
fn ty(&self) -> u8 {
self.inner.ty()
}
}

// Delegate Encodable2718 to inner. Overrides only affect the `TxEnv` produced by `IntoTxEnv`, not
// the EIP-2718 encoding, so the encoded size reflects the original transaction — matching the
// `tx_size`/`da_size` the executor charged before override support existed.
impl<T: Encodable2718 + Copy> Encodable2718 for OverriddenTx<T> {
fn type_flag(&self) -> Option<u8> {
self.inner.type_flag()
}

fn encode_2718_len(&self) -> usize {
self.inner.encode_2718_len()
}

fn encode_2718(&self, out: &mut dyn alloy_primitives::bytes::BufMut) {
self.inner.encode_2718(out)
}
}

// Delegate MegaTransactionExt to inner so `OverriddenTx` is accepted by `run_transaction`.
// `tx_size`/`estimated_da_size` fall back to the trait defaults (recomputed from the delegated
// encoding above); only `tx_hash` needs explicit forwarding.
impl<T: MegaTransactionExt + Copy> MegaTransactionExt for OverriddenTx<T> {
fn tx_hash(&self) -> TxHash {
self.inner.tx_hash()
}
}
4 changes: 4 additions & 0 deletions crates/mega-evm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ harness = false
name = "ctt"
harness = false

[[bench]]
name = "enriched_tx"
harness = false

[[bench]]
name = "mega_bench"
harness = false
Expand Down
103 changes: 103 additions & 0 deletions crates/mega-evm/benches/enriched_tx.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Benchmarks the cost of `MegaTransactionExt::{tx_size, estimated_da_size}` recompute vs the
//! `EnrichedMegaTx` cached fields.
//!
//! `EnrichedMegaTx` exists to precompute `tx_size`/`da_size` once (e.g. via `new_slow` from a
//! mempool-cached value) so block-execution callers can reuse them instead of recomputing on
//! every access. Three rows:
//! - `recompute_via_tx_unwrap` mirrors the `alloy_evm` block-execution path's call pattern
//! (`tx.tx().estimated_da_size()` / `tx.tx().tx_size()`), which unwraps `EnrichedMegaTx` down to
//! the raw inner transaction and always hits the recomputing default impl.
//! - `via_trait_dispatch` mirrors `MegaBlockExecutor::run_transaction`'s call pattern
//! (`tx.estimated_da_size()` / `tx.tx_size()` on the outer wrapper), which dispatches to the
//! stored fields for an `EnrichedMegaTx`.
//! - `cached_fields` reads the wrapper's precomputed fields directly, the floor
//! `via_trait_dispatch` should match.

#![allow(missing_docs)]

use alloy_consensus::{transaction::Recovered, Signed, TxLegacy};
use alloy_evm::RecoveredTx;
use alloy_primitives::{address, Address, Bytes, Signature, TxKind, U256};
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use mega_evm::{EnrichedMegaTx, MegaTransactionExt, MegaTxEnvelope};

const CALLER: Address = address!("2000000000000000000000000000000000000001");
const CONTRACT: Address = address!("3000000000000000000000000000000000000001");

/// Calldata sizes spanning a plain transfer up to a large multicall-style payload.
const CALLDATA_SIZES: &[usize] = &[0, 68, 180, 1000];

fn enriched_tx(calldata_len: usize) -> EnrichedMegaTx<Recovered<MegaTxEnvelope>> {
let tx = TxLegacy {
chain_id: Some(1),
nonce: 7,
gas_price: 9,
gas_limit: 21_000,
to: TxKind::Call(CONTRACT),
value: U256::from(11),
input: Bytes::from(vec![0xabu8; calldata_len]),
};
let envelope = MegaTxEnvelope::Legacy(Signed::new_unchecked(
tx,
Signature::test_signature(),
Default::default(),
));
let recovered = Recovered::new_unchecked(envelope, CALLER);
EnrichedMegaTx::new_slow(recovered)
}

/// The `alloy_evm` block-execution path's pattern: `tx.tx().<method>()` unwraps `EnrichedMegaTx`
/// down to the raw inner transaction, so `tx_size`/`estimated_da_size` always hit the recomputing
/// default impl even when the wrapper carries precomputed fields.
fn bench_recompute_via_tx_unwrap(c: &mut Criterion) {
let mut group = c.benchmark_group("tx_size_da_size/recompute_via_tx_unwrap");
for &len in CALLDATA_SIZES {
let tx = enriched_tx(len);
group.bench_with_input(BenchmarkId::new("estimated_da_size", len), &tx, |b, tx| {
b.iter(|| black_box(tx.tx()).estimated_da_size())
});
group.bench_with_input(BenchmarkId::new("tx_size", len), &tx, |b, tx| {
b.iter(|| black_box(tx.tx()).tx_size())
});
}
group.finish();
}

/// `MegaBlockExecutor::run_transaction`'s call pattern: `tx.<method>()` called directly on the
/// outer `EnrichedMegaTx`, dispatching to the stored fields via `MegaTransactionExt`.
fn bench_via_trait_dispatch(c: &mut Criterion) {
let mut group = c.benchmark_group("tx_size_da_size/via_trait_dispatch");
for &len in CALLDATA_SIZES {
let tx = enriched_tx(len);
group.bench_with_input(BenchmarkId::new("estimated_da_size", len), &tx, |b, tx| {
b.iter(|| black_box(tx).estimated_da_size())
});
group.bench_with_input(BenchmarkId::new("tx_size", len), &tx, |b, tx| {
b.iter(|| black_box(tx).tx_size())
});
}
group.finish();
}

/// Reading `EnrichedMegaTx`'s precomputed fields directly.
fn bench_cached_fields(c: &mut Criterion) {
let mut group = c.benchmark_group("tx_size_da_size/cached_fields");
for &len in CALLDATA_SIZES {
let tx = enriched_tx(len);
group.bench_with_input(BenchmarkId::new("estimated_da_size", len), &tx, |b, tx| {
b.iter(|| black_box(tx).da_size)
});
group.bench_with_input(BenchmarkId::new("tx_size", len), &tx, |b, tx| {
b.iter(|| black_box(tx).tx_size)
});
}
group.finish();
}

criterion_group!(
benches,
bench_recompute_via_tx_unwrap,
bench_via_trait_dispatch,
bench_cached_fields
);
criterion_main!(benches);
84 changes: 80 additions & 4 deletions crates/mega-evm/src/block/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -335,14 +335,49 @@ where
tx: Tx,
) -> Result<BlockMegaTransactionOutcome<Tx>, BlockExecutionError>
where
Tx: IntoTxEnv<MegaTransaction> + RecoveredTx<R::Transaction> + Copy,
Tx: IntoTxEnv<MegaTransaction>
+ RecoveredTx<R::Transaction>
+ MegaTransactionExt
+ Encodable2718
+ Copy,
{
self.run_transaction(tx)
}

/// Execute a transaction with a commit condition function without committing the execution
/// result to the block executor's inner state.
///
/// `tx_size`/`da_size` are resolved from `Tx` through [`MegaTransactionExt`]: a `Tx` that
/// carries precomputed values (e.g. [`crate::EnrichedMegaTx`]) reuses them, while any other
/// `Tx` falls back to the trait's default, which recomputes them from the EIP-2718 encoding.
/// The choice is resolved at compile time by trait dispatch, so callers do not pick a
/// variant — this is the single execution entry point regardless of whether the transaction
/// carries a size cache.
///
/// The `alloy_evm` block-execution path
/// ([`alloy_evm::block::BlockExecutor::execute_transaction_with_commit_condition`]) does not
/// route through this method: its `tx: impl ExecutableTx<Self>` parameter cannot be required
/// to implement [`MegaTransactionExt`], so it recomputes the sizes itself and calls
/// [`MegaBlockExecutor::run_transaction_with_sizes`] directly.
///
/// # Correctness
///
/// `tx_size`/`da_size` feed directly into [`BlockLimiter::pre_execution_check`]'s
/// `tx_encode_size_limit`/`tx_da_size_limit`/block cumulative-size checks. When `Tx`
/// overrides the defaults with cached values, those values are trusted with no validation
/// against the real encoded transaction: callers MUST ensure `Tx::tx_size()`/
/// `Tx::estimated_da_size()` accurately reflect `tx`'s actual EIP-2718 encoding — an
/// understated value lets a transaction bypass a limit it should have been rejected by.
/// This is safe for the sequencer's own block-building path (the cache is computed by the
/// same trusted process, e.g. at mempool insertion), but a `Tx` whose cached sizes could
/// come from an untrusted or stale source (e.g. block validation of another party's block)
/// must not be fed here without first re-establishing that invariant. A `Tx` that uses the
/// recomputing default (e.g. a bare `Recovered<...>`) is unconditionally safe.
///
/// A `debug_assert` cross-checks the resolved values against a fresh recompute, so a caller
/// bug is caught in tests/CI; it is compiled out in release builds. For the recomputing
/// default it is a no-op; for a cached override it is the real safety net.
///
/// # Parameters
///
/// - `tx`: The transaction to execute.
Expand All @@ -355,12 +390,48 @@ where
&mut self,
tx: Tx,
) -> Result<BlockMegaTransactionOutcome<Tx>, BlockExecutionError>
where
Tx: IntoTxEnv<MegaTransaction>
+ RecoveredTx<R::Transaction>
+ MegaTransactionExt
+ Encodable2718
+ Copy,
{
let tx_size = tx.tx_size();
let da_size = tx.estimated_da_size();
debug_assert_eq!(
tx_size,
tx.encode_2718_len() as u64,
"run_transaction: Tx-reported tx_size does not match a fresh recompute from the \
encoded transaction"
);
debug_assert_eq!(
da_size,
op_alloy_flz::tx_estimated_size_fjord_bytes(tx.encoded_2718().as_slice()),
"run_transaction: Tx-reported da_size does not match a fresh recompute from the \
encoded transaction"
);
self.run_transaction_with_sizes(tx, tx_size, da_size)
}

/// Shared body of [`MegaBlockExecutor::run_transaction`] and the `alloy_evm`
/// block-execution path: `tx_size`/`da_size` are resolved by the caller (recomputed or read
/// from a cache), this only consumes them.
///
/// This is the escape hatch for callers whose `Tx` cannot implement [`MegaTransactionExt`]
/// (e.g. the `alloy_evm` `ExecutableTx`-constrained path): they resolve the sizes themselves
/// and pass them in. Prefer [`MegaBlockExecutor::run_transaction`] otherwise, which resolves
/// the sizes for you and cross-checks any cached values.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Minor] run_transaction_with_sizes is now pub, but its docstring lacks the trust-boundary warning that run_transaction carries. Sizes flow directly into BlockLimiter::pre_execution_check (via its tx_encode_size_limit/tx_da_size_limit/block cumulative-size checks) with no validation and — unlike run_transaction — no debug_assert cross-check, so an external caller reaching for this as a mere "I already have the sizes, skip recomputation" optimization can silently bypass consensus-relevant limits.

The only in-crate caller besides run_transaction is execute_transaction_with_commit_condition in the same file (which recomputes fresh sizes on line 649–650), so the pub visibility is only meaningful for external callers. If pub is intentional, please duplicate (or explicitly link to) the "Correctness" section from run_transaction so callers see the same warning they would have seen if they had implemented MegaTransactionExt. Alternatively consider pub(crate) — external callers who want the shortcut can implement MegaTransactionExt and get the debug_assert safety net for free.

Suggested change
/// the sizes for you and cross-checks any cached values.
/// Shared body of [`MegaBlockExecutor::run_transaction`] and the `alloy_evm`
/// block-execution path: `tx_size`/`da_size` are resolved by the caller (recomputed or read
/// from a cache), this only consumes them.
///
/// This is the escape hatch for callers whose `Tx` cannot implement [`MegaTransactionExt`]
/// (e.g. the `alloy_evm` `ExecutableTx`-constrained path): they resolve the sizes themselves
/// and pass them in. Prefer [`MegaBlockExecutor::run_transaction`] otherwise, which resolves
/// the sizes for you and cross-checks any cached values.
///
/// # Correctness
///
/// `tx_size`/`da_size` feed directly into `BlockLimiter::pre_execution_check` (via its
/// `tx_encode_size_limit`/`tx_da_size_limit`/block cumulative-size checks) with **no**
/// validation against the real encoded transaction, and (unlike
/// [`MegaBlockExecutor::run_transaction`]) **no** `debug_assert` cross-check. Callers MUST
/// ensure the values accurately reflect `tx` — an understated value lets a transaction
/// bypass a limit it should have been rejected by. Prefer implementing
/// [`MegaTransactionExt`] on `Tx` and calling [`MegaBlockExecutor::run_transaction`]
/// instead, which gains the cross-check safety net for free.

pub fn run_transaction_with_sizes<Tx>(
&mut self,
tx: Tx,
tx_size: u64,
da_size: u64,
) -> Result<BlockMegaTransactionOutcome<Tx>, BlockExecutionError>
where
Tx: IntoTxEnv<MegaTransaction> + RecoveredTx<R::Transaction> + Copy,
{
let is_deposit = tx.tx().ty() == DEPOSIT_TRANSACTION_TYPE;
let tx_size = tx.tx().encode_2718_len() as u64;
let da_size = tx.tx().estimated_da_size();

// Check transaction-level and block-level limits before transaction execution
self.block_limiter.pre_execution_check(
Expand Down Expand Up @@ -572,7 +643,12 @@ where
tx: impl ExecutableTx<Self>,
f: impl FnOnce(&ExecutionResult<<Self::Evm as alloy_evm::Evm>::HaltReason>) -> CommitChanges,
) -> Result<Option<u64>, BlockExecutionError> {
let outcome = self.run_transaction(tx)?;
// `tx: impl ExecutableTx<Self>` cannot be required to implement `MegaTransactionExt`, so
// this path recomputes the sizes from the raw inner transaction and bypasses
// `run_transaction` (which reads them via the trait). See `run_transaction`'s docs.
let tx_size = tx.tx().encode_2718_len() as u64;
let da_size = tx.tx().estimated_da_size();
let outcome = self.run_transaction_with_sizes(tx, tx_size, da_size)?;
if f(&outcome.result).should_commit() {
let gas_used = self.commit_execution_outcome(outcome)?;
Ok(Some(gas_used))
Expand Down
26 changes: 26 additions & 0 deletions crates/mega-evm/src/block/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ use alloy_consensus::{transaction::Recovered, Transaction};
use alloy_eips::{eip2930::AccessList, eip7702::SignedAuthorization, Encodable2718, Typed2718};
use alloy_evm::{IntoTxEnv, RecoveredTx};
use alloy_primitives::{Address, Bytes, ChainId, Selector, TxHash, TxKind, B256, U256};
use auto_impl::auto_impl;
use delegate::delegate;

use crate::MegaTxEnvelope;

/// Helper trait that allows attaching extra information to a transaction.
#[auto_impl(&)]
pub trait MegaTransactionExt {
/// Get the estimated data availability size of the transaction.
///
Expand Down Expand Up @@ -37,6 +39,12 @@ impl MegaTransactionExt for Recovered<MegaTxEnvelope> {
}
}

impl MegaTransactionExt for Recovered<&MegaTxEnvelope> {
fn tx_hash(&self) -> TxHash {
self.inner().tx_hash()
}
}

impl MegaTransactionExt for MegaTxEnvelope {
fn tx_hash(&self) -> TxHash {
self.tx_hash()
Expand Down Expand Up @@ -66,6 +74,14 @@ pub struct EnrichedMegaTx<T> {

impl<T> EnrichedMegaTx<T> {
/// Create a new `WithDASize` wrapper with a known data availability size.
///
/// `da_size`/`tx_size` are trusted as-is and not validated against `inner`'s actual
/// encoding. When this wrapper reaches [`crate::MegaBlockExecutor::run_transaction`], these
/// values are read via [`MegaTransactionExt`] and used directly for
/// `tx_da_size_limit`/`tx_encode_size_limit`/block cumulative-size enforcement, so an
/// inaccurate value can let a transaction bypass a limit it should have been rejected by.
/// Only pass values computed from `inner` itself (e.g. by a trusted mempool at insertion
/// time); prefer [`EnrichedMegaTx::new_slow`] when in doubt.
pub fn new(inner: T, tx_hash: TxHash, da_size: u64, tx_size: u64) -> Self {
Self { inner, tx_hash, da_size, tx_size }
}
Expand All @@ -84,6 +100,16 @@ impl<T: Encodable2718> EnrichedMegaTx<T> {
}
}

impl<T: Encodable2718> Encodable2718 for EnrichedMegaTx<T> {
delegate! {
to self.inner {
fn type_flag(&self) -> Option<u8>;
fn encode_2718_len(&self) -> usize;
fn encode_2718(&self, out: &mut dyn alloy_primitives::bytes::BufMut);
}
}
}
Comment thread
claude[bot] marked this conversation as resolved.

impl<T> MegaTransactionExt for EnrichedMegaTx<T> {
fn estimated_da_size(&self) -> u64 {
self.da_size
Expand Down
Loading
Loading