use bytes::Bytes;
use ethrex_common::types::Fork;
use ethrex_crypto::Crypto;
use crate::errors::{InternalError, PrecompileError, VMError};
use crate::precompiles::increase_precompile_consumed_gas;
pub const EXECUTE_GAS_PER_WITNESS_BYTE: u64 = 16;
pub fn execute_precompile(
calldata: &Bytes,
gas_remaining: &mut u64,
_fork: Fork,
_crypto: &dyn Crypto,
stateless_validator: Option<&dyn crate::StatelessValidator>,
) -> Result<Bytes, VMError> {
let validator = stateless_validator.ok_or_else(|| {
VMError::Internal(InternalError::Custom(
"EXECUTE precompile requires a StatelessValidator but none was provided".to_string(),
))
})?;
run_execute(validator, calldata, gas_remaining)
}
fn run_execute(
validator: &dyn crate::StatelessValidator,
calldata: &Bytes,
gas_remaining: &mut u64,
) -> Result<Bytes, VMError> {
use ethrex_common::types::stateless_ssz::{
STATELESS_INPUT_SCHEMA_ID, STATELESS_INPUT_SCHEMA_ID_SIZE, SszStatelessInput,
};
use libssz::SszDecode;
let (schema_bytes, body) = calldata
.split_first_chunk::<STATELESS_INPUT_SCHEMA_ID_SIZE>()
.ok_or(VMError::from(PrecompileError::ExecuteInvalidInput))?;
let schema_id = u16::from_be_bytes(*schema_bytes);
if schema_id != STATELESS_INPUT_SCHEMA_ID {
return Err(VMError::from(PrecompileError::ExecuteInvalidInput));
}
let input = SszStatelessInput::from_ssz_bytes(body)
.map_err(|_| VMError::from(PrecompileError::ExecuteInvalidInput))?;
validate_l2_constraints(&input)?;
let gas_limit = input.new_payload_request.execution_payload.gas_limit;
#[expect(
clippy::as_conversions,
reason = "usize to u64: safe on 64-bit targets; calldata len cannot exceed u64::MAX"
)]
let witness_charge = (calldata.len() as u64).saturating_mul(EXECUTE_GAS_PER_WITNESS_BYTE);
let charge = gas_limit.saturating_add(witness_charge);
increase_precompile_consumed_gas(charge, gas_remaining)?;
let result = validator.verify(&input)?;
use ethrex_common::types::stateless_ssz::SszStatelessValidationResult;
let parsed = SszStatelessValidationResult::from_ssz_bytes(&result).map_err(|e| {
VMError::Internal(InternalError::Custom(format!(
"EXECUTE: bad validation result: {e}"
)))
})?;
if !parsed.successful_validation {
return Err(PrecompileError::ExecuteValidationFailed.into());
}
Ok(Bytes::from(result))
}
fn validate_l2_constraints(
input: ðrex_common::types::stateless_ssz::SszStatelessInput,
) -> Result<(), VMError> {
let payload = &input.new_payload_request.execution_payload;
if payload.blob_gas_used != 0 {
return Err(PrecompileError::ExecuteInvalidInput.into());
}
if payload.excess_blob_gas != 0 {
return Err(PrecompileError::ExecuteInvalidInput.into());
}
if !payload.withdrawals.is_empty() {
return Err(PrecompileError::ExecuteInvalidInput.into());
}
let reqs = &input.new_payload_request.execution_requests;
if !reqs.deposits.is_empty()
|| !reqs.withdrawals.is_empty()
|| !reqs.consolidations.is_empty()
|| !reqs.builder_deposits.is_empty()
|| !reqs.builder_exits.is_empty()
{
return Err(PrecompileError::ExecuteInvalidInput.into());
}
for tx_bytes in payload.transactions.iter() {
if let Some(&(0x03 | 0x06 | 0x7e)) = tx_bytes.iter().next() {
return Err(PrecompileError::ExecuteInvalidInput.into());
}
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::arithmetic_side_effects,
clippy::as_conversions
)]
mod tests {
use super::{EXECUTE_GAS_PER_WITNESS_BYTE, run_execute};
use bytes::Bytes;
use ethrex_common::types::stateless_ssz::{
BuilderDepositRequest, BuilderExitRequest, Bytes20, DepositRequest, ExecutionPayload,
ExecutionRequests, NewPayloadRequest, SszExecutionWitness, SszStatelessInput,
SszStatelessValidationResult, Withdrawal,
};
use libssz::SszEncode;
struct MockValidator;
struct MockInvalidValidator;
impl crate::StatelessValidator for MockValidator {
fn verify(&self, _input: &SszStatelessInput) -> Result<Vec<u8>, crate::errors::VMError> {
let result = SszStatelessValidationResult {
new_payload_request_root: [0u8; 32],
successful_validation: true,
chain_id: 1,
schema_id: 0x1501,
};
let mut buf = Vec::new();
result.ssz_append(&mut buf);
Ok(buf)
}
}
fn calldata_for(input: &SszStatelessInput) -> Bytes {
let mut buf = ethrex_common::types::stateless_ssz::STATELESS_INPUT_SCHEMA_ID
.to_be_bytes()
.to_vec();
input.ssz_append(&mut buf);
Bytes::from(buf)
}
fn assert_invalid_input(err: &crate::errors::VMError, what: &str) {
assert_eq!(
err,
&crate::errors::VMError::from(crate::errors::PrecompileError::ExecuteInvalidInput),
"{what} must be rejected as ExecuteInvalidInput; got: {err:?}"
);
assert!(
!err.should_propagate(),
"{what} must be a CALL-level failure (non-propagating); got: {err:?}"
);
}
fn l2_valid_input(gas_used: u64, gas_limit: u64) -> SszStatelessInput {
SszStatelessInput {
new_payload_request: NewPayloadRequest {
execution_payload: ExecutionPayload {
parent_hash: [0u8; 32],
fee_recipient: Bytes20([0u8; 20]),
state_root: [0u8; 32],
receipts_root: [0u8; 32],
logs_bloom: vec![0u8; 256].try_into().expect("logs_bloom"),
prev_randao: [0u8; 32],
block_number: 1,
gas_limit,
gas_used,
timestamp: 1_000_000,
extra_data: vec![].try_into().expect("extra_data"),
base_fee_per_gas: [0u8; 32],
block_hash: [0u8; 32],
transactions: Vec::new().into(),
withdrawals: Vec::new().into(),
blob_gas_used: 0,
excess_blob_gas: 0,
block_access_list: Vec::new().into(),
slot_number: 0,
},
versioned_hashes: Vec::new().into(),
parent_beacon_block_root: [0u8; 32],
execution_requests: ExecutionRequests {
deposits: Vec::new().into(),
withdrawals: Vec::new().into(),
consolidations: Vec::new().into(),
builder_deposits: Vec::new().into(),
builder_exits: Vec::new().into(),
},
},
witness: SszExecutionWitness {
state: vec![].try_into().expect("state"),
codes: vec![].try_into().expect("codes"),
headers: vec![].try_into().expect("headers"),
},
chain_id: 1,
public_keys: vec![].try_into().expect("public_keys"),
}
}
impl crate::StatelessValidator for MockInvalidValidator {
fn verify(&self, _input: &SszStatelessInput) -> Result<Vec<u8>, crate::errors::VMError> {
let result = SszStatelessValidationResult {
new_payload_request_root: [0u8; 32],
successful_validation: false,
chain_id: 1,
schema_id: 0x1501,
};
let mut buf = Vec::new();
result.ssz_append(&mut buf);
Ok(buf)
}
}
#[test]
fn execute_fails_closed_on_invalid() {
let input = l2_valid_input(0, 1_000_000);
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
let mock_invalid = MockInvalidValidator;
let r = run_execute(&mock_invalid, &calldata, &mut gas);
let err = r.expect_err("invalid validation must revert (fail-closed), not return Ok");
assert!(
!err.should_propagate(),
"invalid EXECUTE must be a CALL-level failure (includable, attacker pays), not a tx-abort; got: {err:?}"
);
let mut gas2 = 100_000_000u64;
let mock_valid = MockValidator;
let r2 = run_execute(&mock_valid, &calldata, &mut gas2);
assert!(r2.is_ok(), "valid validation must return Ok(result)");
}
#[test]
fn execute_charges_gas_limit_not_gas_used() {
let input = l2_valid_input(0, 1_000_000);
let calldata = calldata_for(&input);
let start_gas = 100_000_000u64;
let mut gas_remaining = start_gas;
let mock = MockValidator;
let _ = run_execute(&mock, &calldata, &mut gas_remaining);
let charged = start_gas.saturating_sub(gas_remaining);
let expected = 1_000_000u64.saturating_add(
u64::try_from(calldata.len())
.expect("calldata len fits u64")
.saturating_mul(EXECUTE_GAS_PER_WITNESS_BYTE),
);
assert!(
charged >= 1_000_000,
"must charge at least gas_limit, got {charged}"
);
assert_eq!(
charged, expected,
"charge must be gas_limit + calldata.len() * EXECUTE_GAS_PER_WITNESS_BYTE"
);
}
#[test]
fn execute_rejects_malformed_ssz_input() {
let calldata = Bytes::from(vec![0xffu8, 0xff, 0xff, 0xff]);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata, &mut gas)
.expect_err("malformed SSZ input must be rejected");
assert_invalid_input(&err, "malformed SSZ input");
}
#[test]
fn execute_rejects_blob_gas_used_nonzero() {
let mut input = l2_valid_input(0, 1_000_000);
input.new_payload_request.execution_payload.blob_gas_used = 1;
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata, &mut gas)
.expect_err("blob_gas_used != 0 must be rejected");
assert_invalid_input(&err, "blob_gas_used != 0");
}
#[test]
fn execute_rejects_excess_blob_gas_nonzero() {
let mut input = l2_valid_input(0, 1_000_000);
input.new_payload_request.execution_payload.excess_blob_gas = 1;
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata, &mut gas)
.expect_err("excess_blob_gas != 0 must be rejected");
assert_invalid_input(&err, "excess_blob_gas != 0");
}
#[test]
fn execute_rejects_nonempty_withdrawals() {
let mut input = l2_valid_input(0, 1_000_000);
input.new_payload_request.execution_payload.withdrawals = vec![Withdrawal {
index: 0,
validator_index: 0,
address: Bytes20([0u8; 20]),
amount: 1,
}]
.try_into()
.expect("withdrawals");
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata, &mut gas)
.expect_err("non-empty withdrawals must be rejected");
assert_invalid_input(&err, "non-empty withdrawals");
}
#[test]
fn execute_rejects_nonempty_execution_requests() {
let mut input = l2_valid_input(0, 1_000_000);
input.new_payload_request.execution_requests.deposits = vec![DepositRequest {
pubkey: [0u8; 48],
withdrawal_credentials: [0u8; 32],
amount: 1,
signature: [0u8; 96],
index: 0,
}]
.try_into()
.expect("deposits");
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata, &mut gas)
.expect_err("non-empty execution_requests must be rejected");
assert_invalid_input(&err, "non-empty execution_requests");
}
#[test]
fn execute_rejects_nonempty_builder_requests() {
let mut input = l2_valid_input(0, 1_000_000);
input
.new_payload_request
.execution_requests
.builder_deposits = vec![BuilderDepositRequest {
pubkey: [0u8; 48],
withdrawal_credentials: [0u8; 32],
amount: 1,
signature: [0u8; 96],
}]
.try_into()
.expect("builder_deposits");
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata_for(&input), &mut gas)
.expect_err("non-empty builder_deposits must be rejected");
assert_invalid_input(&err, "non-empty builder_deposits");
let mut input = l2_valid_input(0, 1_000_000);
input.new_payload_request.execution_requests.builder_exits = vec![BuilderExitRequest {
source_address: Bytes20([0u8; 20]),
pubkey: [0u8; 48],
}]
.try_into()
.expect("builder_exits");
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata_for(&input), &mut gas)
.expect_err("non-empty builder_exits must be rejected");
assert_invalid_input(&err, "non-empty builder_exits");
}
#[test]
fn execute_rejects_unsupported_transaction_types() {
for (type_byte, what) in [
(0x03u8, "blob-typed transaction"),
(0x06u8, "frame transaction"),
(0x7eu8, "privileged transaction"),
] {
let mut input = l2_valid_input(0, 1_000_000);
let tx = vec![type_byte, 0x00, 0x00].try_into().expect("tx bytes");
input.new_payload_request.execution_payload.transactions =
vec![tx].try_into().expect("transactions");
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &calldata, &mut gas)
.expect_err("unsupported transaction type must be rejected");
assert_invalid_input(&err, what);
}
let mut input = l2_valid_input(0, 1_000_000);
let tx = vec![0x02u8, 0x00, 0x00].try_into().expect("tx bytes");
input.new_payload_request.execution_payload.transactions =
vec![tx].try_into().expect("transactions");
let calldata = calldata_for(&input);
let mut gas = 100_000_000u64;
run_execute(&MockValidator, &calldata, &mut gas)
.expect("an EIP-1559 transaction must be accepted");
}
#[test]
fn execute_requires_the_expected_schema_prefix() {
let input = l2_valid_input(0, 1_000_000);
let mut body = Vec::new();
input.ssz_append(&mut body);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &Bytes::from(body.clone()), &mut gas)
.expect_err("unprefixed input must be rejected");
assert_invalid_input(&err, "unprefixed input");
let mut wrong = vec![0x15u8, 0x02];
wrong.extend_from_slice(&body);
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &Bytes::from(wrong), &mut gas)
.expect_err("unexpected schema id must be rejected");
assert_invalid_input(&err, "unexpected schema id");
let mut gas = 100_000_000u64;
let err = run_execute(&MockValidator, &Bytes::from(vec![0x15u8]), &mut gas)
.expect_err("truncated input must be rejected");
assert_invalid_input(&err, "input too short for a schema id");
let mut gas = 100_000_000u64;
run_execute(&MockValidator, &calldata_for(&input), &mut gas)
.expect("the expected schema prefix must be accepted");
}
}