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
4 changes: 4 additions & 0 deletions crates/vm/levm/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ pub enum TxValidationError {
"Transaction gas limit exceeds maximum. Transaction hash: {tx_hash}, transaction gas limit: {tx_gas_limit}"
)]
TxMaxGasLimitExceeded { tx_hash: H256, tx_gas_limit: u64 },
#[error("Invalid frame transaction format: {0}")]
InvalidFrameTransactionFormat(String),
#[error("Invalid frame transaction: signature validation failed")]
InvalidFrameSignature,
#[error("Invalid frame transaction: VERIFY frame did not call APPROVE or payer not approved")]
InvalidFrameTransaction,
}
Expand Down
102 changes: 95 additions & 7 deletions crates/vm/levm/src/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1557,10 +1557,43 @@ impl<'a> VM<'a> {
// accounts).
let sender = frame_tx.sender;

// Validate static constraints (frame count, reserved modes, atomic batch flags)
if let Err(_e) = frame_tx.validate_static_constraints() {
// EIP-8141 blob rules that carry their own EIP-4844 exception: a wrong
// version byte is `TYPE_3_TX_INVALID_BLOB_VERSIONED_HASH` and too many
// blobs is `TYPE_3_TX_BLOB_COUNT_EXCEEDED`, not a generic frame-format
// error. Checked before `validate_static_constraints`, which also
// rejects a bad version byte but cannot name which rule failed.
self.validate_frame_tx_blobs(&frame_tx)?;

// Validate static constraints (frame count, reserved modes, atomic batch flags).
// The reason is carried through: a client that rejects the transaction for the
// right reason but reports the wrong one is indistinguishable from a client that
// rejected it by accident (see the mapper note above `TxValidationError`).
if let Err(e) = frame_tx.validate_static_constraints() {
return Err(VMError::TxValidation(
crate::errors::TxValidationError::InvalidFrameTransaction,
crate::errors::TxValidationError::InvalidFrameTransactionFormat(e),
));
}

// EIP-7825, as scoped by EIP-8141: the transaction's execution budget —
// the intrinsic cost plus the frames' gas limits, and the calldata floor
// measured the same way — must not exceed `TX_MAX_GAS_LIMIT`. `max_gas()`
// is the larger of those two anchors, so capping it covers both.
let max_gas = frame_tx.max_gas();
if max_gas > crate::constants::TX_MAX_GAS_LIMIT_AMSTERDAM {
return Err(VMError::TxValidation(
crate::errors::TxValidationError::TxMaxGasLimitExceeded {
tx_hash: self.tx.hash(self.crypto),
tx_gas_limit: max_gas,
},
));
}

// A nonce at the u64 ceiling can never be incremented, so the transaction
// is invalid on its own terms rather than merely mismatched against the
// sender's nonce. Checked first so the specific rule is the one reported.
if frame_tx.nonce == u64::MAX {
return Err(VMError::TxValidation(
crate::errors::TxValidationError::NonceIsMax,
));
}

Expand Down Expand Up @@ -1635,7 +1668,7 @@ impl<'a> VM<'a> {
self.crypto,
) {
return Err(VMError::TxValidation(
crate::errors::TxValidationError::InvalidFrameTransaction,
crate::errors::TxValidationError::InvalidFrameSignature,
));
}

Expand Down Expand Up @@ -2365,6 +2398,61 @@ impl<'a> VM<'a> {
/// result. Does NOT charge or refund gas. `canonical_paymaster_pay_frame`
/// is the index of a canonical paymaster's pay frame (always `None` today,
/// OQ1); when set, the access-restriction skip fires for that frame.
/// EIP-8141 blob rules that carry their own EIP-4844 exception.
///
/// A frame transaction reaches neither `validate_4844_tx` nor the default
/// hook, so the two blob rules whose exceptions are named by EIP-4844 rather
/// than by EIP-8141 are enforced here, mirroring `validate_4844_tx`: every
/// versioned hash must carry a recognised version byte, and the blob count
/// must fit both the fork's blob schedule and the per-transaction cap.
///
/// `validate_static_constraints` also rejects a wrong version byte, but it
/// reports a frame-format error; calling this first keeps the specific rule
/// the one the client reports.
fn validate_frame_tx_blobs(
&self,
frame_tx: &ethrex_common::types::FrameTransaction,
) -> Result<(), VMError> {
if frame_tx.blob_versioned_hashes.is_empty() {
return Ok(());
}

for blob_hash in &frame_tx.blob_versioned_hashes {
if blob_hash.as_bytes().first().is_some_and(|first_byte| {
!crate::constants::VALID_BLOB_PREFIXES.contains(first_byte)
}) {
return Err(
crate::errors::TxValidationError::Type3TxInvalidBlobVersionedHash.into(),
);
}
}

let max_blob_count: usize = self
.env
.config
.blob_schedule
.max
.try_into()
.map_err(|_| crate::errors::InternalError::TypeConversion)?;
let blob_count = frame_tx.blob_versioned_hashes.len();
if blob_count > max_blob_count {
return Err(crate::errors::TxValidationError::Type3TxBlobCountExceeded {
max_blob_count,
actual_blob_count: blob_count,
}
.into());
}
if self.env.config.fork >= Fork::Osaka && blob_count > crate::constants::MAX_BLOB_COUNT_TX {
return Err(crate::errors::TxValidationError::Type3TxBlobCountExceeded {
max_blob_count: crate::constants::MAX_BLOB_COUNT_TX,
actual_blob_count: blob_count,
}
.into());
}

Ok(())
}

pub fn run_frame_validation_prefix(
&mut self,
frame_indices: &[usize],
Expand All @@ -2390,9 +2478,9 @@ impl<'a> VM<'a> {

let sender = frame_tx.sender;

if frame_tx.validate_static_constraints().is_err() {
if let Err(e) = frame_tx.validate_static_constraints() {
return Err(VMError::TxValidation(
crate::errors::TxValidationError::InvalidFrameTransaction,
crate::errors::TxValidationError::InvalidFrameTransactionFormat(e),
));
}

Expand Down Expand Up @@ -2443,7 +2531,7 @@ impl<'a> VM<'a> {
self.crypto,
) {
return Err(VMError::TxValidation(
crate::errors::TxValidationError::InvalidFrameTransaction,
crate::errors::TxValidationError::InvalidFrameSignature,
));
}

Expand Down
202 changes: 193 additions & 9 deletions test/tests/levm/eip8141_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,7 +609,7 @@ fn approve_halts_when_frame_scope_is_none() {
// ==================== Batched VERIFY revert invalidates tx ====================

#[test]
fn batched_verify_revert_invalidates_tx() {
fn atomic_batch_flag_on_a_verify_frame_is_a_format_rejection() {
let reverter = Address::from_low_u64_be(0xF1);
let stop_ct = Address::from_low_u64_be(0xF2);
// frame0: VERIFY -> sender, runs APPROVE(3) -> sets payer=sender (tx would be valid).
Expand Down Expand Up @@ -649,16 +649,22 @@ fn batched_verify_revert_invalidates_tx() {
),
(stop_ct, U256::zero(), 0, Bytes::from(vec![0x00u8])), // STOP
];
// EIP-8141 forbids the atomic batch flag on a `VERIFY` frame, so this
// transaction never reaches the batch at all: it is rejected by static
// validation. The assertion used to read `InvalidFrameTransaction` and so
// passed for the wrong reason -- the reverting frame below is never
// executed, and a batched `VERIFY` revert is not constructible while the
// flag is forbidden.
let (result, db) = run_frame_tx(&accounts, tx);
assert!(
matches!(
result,
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::InvalidFrameTransaction
))
match result {
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::InvalidFrameTransactionFormat(ref reason),
)) => assert!(
reason.contains("atomic batch flag"),
"expected the atomic-batch-flag rule, got {reason:?}"
),
"a batched VERIFY revert must invalidate the tx; got {result:?}"
);
ref other => panic!("expected InvalidFrameTransactionFormat, got {other:?}"),
}
assert_db_cache_unchanged(&db, &accounts);
}

Expand Down Expand Up @@ -4004,3 +4010,181 @@ fn atomic_batch_unroll_keeps_frame_status_and_gas_but_drops_logs() {
"charging every batch frame its full gas_limit would over-bill the payer"
);
}

// ==================== Rejection-reason granularity ====================
//
// A frame transaction that is rejected for the right reason but reports the
// wrong one is indistinguishable, to a conformance harness, from one rejected
// by accident. These pin the reason, not just the rejection: every case below
// was already rejected before this suite existed, but every one of them
// reported `InvalidFrameTransaction` -- the VERIFY-frame-never-approved
// message -- regardless of what actually failed.

/// A frame with a reserved mode fails static validation, and the reason travels
/// with the error instead of being flattened into the approval message.
#[test]
fn static_constraint_failure_reports_the_format_reason() {
let mut tx = frame_tx_with_frames(vec![Frame {
mode: 0xFF, // no such mode
flags: 0,
target: Some(Address::from_low_u64_be(0xC0)),
gas_limit: 100_000,
value: U256::zero(),
data: Bytes::new(),
}]);
tx.nonce = 0;

let (result, _db) = run_frame_tx(&[], tx);
match result {
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::InvalidFrameTransactionFormat(reason),
)) => {
assert!(
!reason.is_empty(),
"the format error must carry the static-validation reason"
);
}
other => panic!("expected InvalidFrameTransactionFormat, got {other:?}"),
}
}

/// An empty frame list is a format failure, not an approval failure.
#[test]
fn empty_frame_list_reports_the_format_reason() {
let tx = frame_tx_with_frames(Vec::new());
let (result, _db) = run_frame_tx(&[], tx);
assert!(
matches!(
result,
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::InvalidFrameTransactionFormat(_)
))
),
"expected InvalidFrameTransactionFormat, got {result:?}"
);
}

/// EIP-7825 as scoped by EIP-8141: the intrinsic cost plus the frames' gas
/// limits must fit `TX_MAX_GAS_LIMIT`. Previously this transaction executed and
/// was only caught downstream.
#[test]
fn frame_gas_above_the_transaction_cap_reports_the_gas_cap() {
let tx = frame_tx_with_frames(vec![Frame {
mode: u8::from(FrameMode::Default),
flags: 0,
target: Some(Address::from_low_u64_be(0xC0)),
gas_limit: ethrex_common::constants::TX_MAX_GAS_LIMIT_AMSTERDAM,
value: U256::zero(),
data: Bytes::new(),
}]);
assert!(
tx.max_gas() > ethrex_common::constants::TX_MAX_GAS_LIMIT_AMSTERDAM,
"the frame gas plus the intrinsic cost must exceed the cap for this to test anything"
);

let (result, _db) = run_frame_tx(&[], tx);
assert!(
matches!(
result,
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::TxMaxGasLimitExceeded { .. }
))
),
"expected TxMaxGasLimitExceeded, got {result:?}"
);
}

/// A transaction sized exactly to the cap stays admissible, so the check is a
/// bound rather than an off-by-one.
#[test]
fn frame_gas_exactly_at_the_transaction_cap_is_not_rejected_for_gas() {
let cap = ethrex_common::constants::TX_MAX_GAS_LIMIT_AMSTERDAM;
let probe = frame_tx_with_frames(vec![Frame {
mode: u8::from(FrameMode::Default),
flags: 0,
target: Some(Address::from_low_u64_be(0xC0)),
gas_limit: 0,
value: U256::zero(),
data: Bytes::new(),
}]);
// `max_gas()` with a zero-gas frame is the intrinsic anchor; give the frame
// exactly the remainder so the total lands on the cap.
let headroom = cap - probe.max_gas();
let tx = frame_tx_with_frames(vec![Frame {
mode: u8::from(FrameMode::Default),
flags: 0,
target: Some(Address::from_low_u64_be(0xC0)),
gas_limit: headroom,
value: U256::zero(),
data: Bytes::new(),
}]);
assert_eq!(tx.max_gas(), cap, "this case must sit exactly on the cap");

let (result, _db) = run_frame_tx(&[], tx);
assert!(
!matches!(
result,
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::TxMaxGasLimitExceeded { .. }
))
),
"a transaction exactly at the cap must not be rejected for exceeding it; got {result:?}"
);
}

/// A nonce at the u64 ceiling can never be incremented, so it is invalid on its
/// own terms rather than merely mismatched against the sender's nonce.
#[test]
fn nonce_at_the_u64_ceiling_reports_nonce_is_max() {
let mut tx = frame_tx_with_frames(vec![Frame {
mode: u8::from(FrameMode::Default),
flags: 0,
target: Some(Address::from_low_u64_be(0xC0)),
gas_limit: 100_000,
value: U256::zero(),
data: Bytes::new(),
}]);
tx.nonce = u64::MAX;

// `run_frame_tx` seeds the sender at `tx.nonce`, so the mismatch check
// cannot fire and the ceiling rule is the only one left to report.
let (result, _db) = run_frame_tx(&[], tx);
assert!(
matches!(
result,
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::NonceIsMax
))
),
"expected NonceIsMax, got {result:?}"
);
}

/// A versioned hash with an unrecognised version byte is an EIP-4844 failure,
/// not a generic frame-format one.
#[test]
fn wrong_blob_version_byte_reports_the_blob_hash_rule() {
let mut tx = frame_tx_with_frames(vec![Frame {
mode: u8::from(FrameMode::Default),
flags: 0,
target: Some(Address::from_low_u64_be(0xC0)),
gas_limit: 100_000,
value: U256::zero(),
data: Bytes::new(),
}]);
let mut hash = [0u8; 32];
hash[0] = 0x02; // not VERSIONED_HASH_VERSION_KZG
tx.blob_versioned_hashes = vec![H256(hash)];
tx.max_fee_per_blob_gas = U256::from(1_000_000_000u64);

let (result, _db) = run_frame_tx(&[], tx);
assert!(
matches!(
result,
Err(VMError::TxValidation(
ethrex_levm::errors::TxValidationError::Type3TxInvalidBlobVersionedHash
))
),
"expected Type3TxInvalidBlobVersionedHash, got {result:?}"
);
}
Loading