diff --git a/EVM.md b/EVM.md index e890f709ab..7e335eb954 100644 --- a/EVM.md +++ b/EVM.md @@ -18,7 +18,7 @@ Only EIPs referenced by this document or the current code are listed here. | [EIP-55][eip-55] | | Mixed-case checksum encoding for Ethereum addresses. Casper has similar checksummed hex helpers. | | [EIP-155][eip-155] | | Replay protection for legacy transactions by including a chain ID in the signing payload. EVM transactions must carry the configured Casper EVM chain ID. | | [EIP-2718][eip-2718] | | Typed transaction envelope format used by post-legacy Ethereum transaction types. The decoder accepts typed envelopes through Alloy. | -| [EIP-2930][eip-2930] | | Optional access-list transaction type. Empty access-list transactions decode; non-empty access lists are rejected for now. | +| [EIP-2930][eip-2930] | | Optional access-list transaction type. Access lists are preserved through decode and re-serialization, prepay 2,400 gas per address and 1,900 gas per storage key, and pre-warm those addresses and slots for the transaction. | | [EIP-1559][eip-1559] | | Dynamic-fee transaction type with max fee and priority fee. Casper accepts this envelope when its effective priority fee is zero. | | [EIP-1153][eip-1153] | | Cancun transient storage opcodes, `TLOAD` and `TSTORE`. | | [EIP-4844][eip-4844] | | Blob transaction support. Rejected because blob sidecars, blob gas, and KZG data are not modeled. | @@ -38,7 +38,7 @@ Only EIPs referenced by this document or the current code are listed here. | [EIP-7642][eip-7642] | | `eth/69` networking cleanup. Not contract-visible for Casper EVM. | | [EIP-7685][eip-7685] | | Prague execution-layer requests and `requests_hash` commitment. | | [EIP-7691][eip-7691] | | Prague blob throughput increase. | -| [EIP-7702][eip-7702] | | Set-code transactions for EOAs. Type `0x04` transactions are accepted with non-empty authorization lists; Casper still rejects non-empty access lists and positive effective priority fees. | +| [EIP-7702][eip-7702] | | Set-code transactions for EOAs. Type `0x04` transactions are accepted with non-empty authorization lists and access lists; Casper still rejects positive effective priority fees. | | [EIP-7840][eip-7840] | | Blob schedule in execution-layer config files. | ## Current Status @@ -91,9 +91,9 @@ Not implemented yet: - `eth_getStorageAt`, `eth_getTransactionByHash`, `eth_blobBaseFee`, and full transaction objects in block responses. - [EIP-4844][eip-4844] blob transactions. -- Non-empty [EIP-2930][eip-2930]/[EIP-1559][eip-1559] access lists. -- Non-empty [EIP-7702][eip-7702] access lists and positive effective priority - fees. +- Positive effective priority fees for [EIP-1559][eip-1559] and + [EIP-7702][eip-7702] transactions. +- Access-list parameters for `eth_call`-style speculative execution. - EVM log indexing optimized for historical queries. ### Prague Compatibility Matrix @@ -116,7 +116,7 @@ Ethereum JSON-RPC method names below refer to the Ethereum | [EIP-7623][eip-7623] calldata floor cost | Partial. | `revm` Prague should enforce checked execution semantics, but Casper needs acceptor/max-cost tests and pre-inclusion validation coverage for calldata-heavy transactions. | | [EIP-7685][eip-7685] execution-layer requests | Missing / decision needed. | Casper block headers do not carry Ethereum `requests_hash`; needed if EIP-6110, EIP-7002, or EIP-7251 are implemented with Ethereum semantics. | | [EIP-7691][eip-7691] blob throughput | Missing / blocked. | Blob throughput is moot while [EIP-4844][eip-4844] blob transactions are rejected. | -| [EIP-7702][eip-7702] set-code transactions | Partial. | Type `0x04` decode, authorization-list storage, and `revm` execution are implemented. Non-empty access lists and positive effective priority fees are rejected by Casper policy. | +| [EIP-7702][eip-7702] set-code transactions | Partial. | Type `0x04` decode, authorization-list storage, and `revm` execution are implemented. Positive effective priority fees are rejected by Casper policy. | | [EIP-7840][eip-7840] blob schedule config | Missing / blocked. | Requires blob support and Prague blob schedule configuration. | | [EIP-7642][eip-7642] `eth/69` networking | Not applicable. | Ethereum devp2p execution-layer networking is outside Casper EVM smart-contract compatibility. | | [EIP-1153][eip-1153] transient storage | Delegated to `revm`. | Expected to work under Prague; add Casper-owned tests for `TLOAD`, `TSTORE`, revert behavior, and static-call restrictions. | @@ -144,11 +144,11 @@ Ethereum JSON-RPC method names below refer to the Ethereum | Transaction / admission surface | Current status | Casper-specific gotchas / limitations | | --- | --- | --- | | Legacy transactions | Implemented. | Casper requires a chain ID; unprotected legacy transactions are rejected. Signed gas price must equal the configured EVM base fee. | -| [EIP-2930][eip-2930] access-list transactions | Partial. | Empty access-list envelopes work; non-empty access lists are rejected. | +| [EIP-2930][eip-2930] access-list transactions | Implemented. | Access-list entries are stored with the transaction, charged through `revm`'s intrinsic-gas accounting, and pre-warmed for EIP-2929 warm/cold access costs. | | [EIP-1559][eip-1559] dynamic-fee transactions | Partial. | Accepted when the effective priority fee is zero; a non-zero cap is allowed when the max fee leaves no tip headroom. | -| [EIP-7702][eip-7702] set-code transactions | Partial. | Authorization-list behavior is implemented, but access lists and positive effective priority fees are rejected. | +| [EIP-7702][eip-7702] set-code transactions | Partial. | Authorization-list and access-list behavior is implemented; positive effective priority fees are rejected. | | [EIP-4844][eip-4844] blob transactions | Missing. | Type `0x03` transactions are rejected before execution. | -| Non-empty access lists | Missing. | This affects EIP-2930, EIP-1559, and EIP-7702 tooling compatibility. | +| Non-empty access lists | Implemented. | Supported across EIP-2930, EIP-1559, and EIP-7702. Intrinsic gas includes 2,400 gas per access-list address and 1,900 gas per storage key; admission rejects transactions whose gas limit cannot cover the intrinsic gas. | | Positive effective priority fees | Casper policy limitation. | Rejecting effective tips avoids charging users for a priority signal the node does not honor, but it differs from Ethereum admission policy. | | Sidecar / JSON-RPC surface | Current status | Casper-specific gotchas / limitations | @@ -260,20 +260,20 @@ The current `eth_sendRawTransaction` flow is: 3. `from_signed_rlp` decodes the Ethereum envelope. 4. It rejects unsupported transaction forms: - [EIP-4844][eip-4844] blob transactions. - - Non-empty access lists. - Unknown typed transactions. -5. For [EIP-7702][eip-7702] type `0x04`, it requires a non-empty +5. It preserves EIP-2930 access-list entries on the transaction. +6. For [EIP-7702][eip-7702] type `0x04`, it requires a non-empty authorization list and a call target, then stores authorization tuples as EVM transaction data. -6. It extracts the unsigned Ethereum payload fields. -7. It recovers the secp256k1 public key and EVM address. -8. It converts the Ethereum signature into one `EvmApproval`: +7. It extracts the unsigned Ethereum payload fields. +8. It recovers the secp256k1 public key and EVM address. +9. It converts the Ethereum signature into one `EvmApproval`: - `Approval.signer` is the recovered secp256k1 public key. - `Approval.signature` is the 64-byte secp256k1 `(r, s)` signature. - `EvmApproval.y_parity` is the Ethereum recovery parity carried by the signed payload as legacy `v` or typed-transaction `yParity`. -9. It stores the Ethereum signed transaction hash. -10. Sidecar wraps the value as `Transaction::Evm` and submits it to node over +10. It stores the Ethereum signed transaction hash. +11. Sidecar wraps the value as `Transaction::Evm` and submits it to node over the existing binary-port transaction submission path. Node does not receive the raw RLP blob for `eth_sendRawTransaction`. Node @@ -350,23 +350,25 @@ For client-submitted EVM transactions, the acceptor currently validates: 4. `evm_transaction.chain_id()` must be present. 5. The EVM chain ID must equal `[evm].chain_id`. 6. The EVM gas limit must not exceed `[evm].block_gas_limit`. -7. Signed legacy and [EIP-2930][eip-2930] gas price must equal +7. A transaction with a non-empty EIP-2930 access list must have a gas limit + that covers its intrinsic gas, including access-list prepayments. +8. Signed legacy and [EIP-2930][eip-2930] gas price must equal `[evm].base_fee * [evm].wei_per_mote`. -8. [EIP-1559][eip-1559] `max_fee_per_gas` must be at least - `[evm].base_fee * [evm].wei_per_mote`. -9. [EIP-1559][eip-1559] `max_priority_fee_per_gas` must not exceed - `max_fee_per_gas`, and the resulting effective priority fee must be zero. - A non-zero cap is valid when `max_fee_per_gas` equals the base fee. -10. The transaction value must be exactly convertible from wei to motes using +9. [EIP-1559][eip-1559] `max_fee_per_gas` must be at least + `[evm].base_fee * [evm].wei_per_mote`. +10. [EIP-1559][eip-1559] `max_priority_fee_per_gas` must not exceed + `max_fee_per_gas`, and the resulting effective priority fee must be zero. + A non-zero cap is valid when `max_fee_per_gas` equals the base fee. +11. The transaction value must be exactly convertible from wei to motes using `[evm].wei_per_mote`. This conversion is used for ingress validation and required-balance accounting only; values containing a fractional mote are rejected rather than rounded. -11. The EVM account identity for `from` must resolve to a balance, or the +12. The EVM account identity for `from` must resolve to a balance, or the recovered secp256k1 signer must resolve to a Casper account balance or the address's deterministic EVM purse balance. -12. The transaction nonce must match the EVM nonce in global state, defaulting +13. The transaction nonce must match the EVM nonce in global state, defaulting to `0` before the first EVM transaction for that address. -13. That balance must meet the chain baseline motes requirement. +14. That balance must meet the chain baseline motes requirement. The acceptor does not require a Casper `AddressableEntity` for every EVM address. The EVM sender identity is `transaction.from()`. If the EVM address is @@ -495,6 +497,15 @@ that signal. A non-zero signed priority cap remains valid when `max_fee_per_gas == base_fee`, matching the fallback transaction shape emitted by MetaMask for custom networks. +Access-list entries add gas that must be payable before execution starts: +each entry prepays 2,400 gas for its address and 1,900 gas for each storage +key, and `revm` treats those addresses and slots as warm during execution. +For transactions with a non-empty access list, node admission compares the gas +limit against the full intrinsic gas (base stipend, calldata, contract +creation, access list, and authorizations). It rejects shortfalls before +packing because a `revm` transaction-validation failure during block execution +would abort the block. + The maximum fee is held from the resolved EVM payer. After execution: - Successful and reverted execution consume @@ -1207,4 +1218,4 @@ cargo build -p casper-sidecar [eip-7702]: https://eips.ethereum.org/EIPS/eip-7702 [eip-7840]: https://eips.ethereum.org/EIPS/eip-7840 [execution-apis]: https://ethereum.github.io/execution-apis/ -[geth-pubsub]: https://geth.ethereum.org/docs/interacting-with-geth/rpc/pubsub \ No newline at end of file +[geth-pubsub]: https://geth.ethereum.org/docs/interacting-with-geth/rpc/pubsub diff --git a/binary_port/src/error_code.rs b/binary_port/src/error_code.rs index 0bf00cb4f4..2589597026 100644 --- a/binary_port/src/error_code.rs +++ b/binary_port/src/error_code.rs @@ -385,6 +385,9 @@ pub enum ErrorCode { /// EVM transaction maximum priority fee per gas exceeds its maximum total fee per gas. #[error("the EVM transaction maximum priority fee per gas exceeds its maximum fee per gas")] InvalidTransactionEvmMaxPriorityFeePerGasExceedsMaxFeePerGas = 122, + /// EVM transaction gas limit is lower than its intrinsic gas. + #[error("the EVM transaction intrinsic gas exceeds its gas limit")] + InvalidTransactionEvmIntrinsicGasExceedsGasLimit = 123, } impl TryFrom for ErrorCode { @@ -421,6 +424,9 @@ impl From for ErrorCode { InvalidTransaction::Evm( EvmTransactionError::MaxPriorityFeePerGasExceedsMaxFeePerGas { .. }, ) => ErrorCode::InvalidTransactionEvmMaxPriorityFeePerGasExceedsMaxFeePerGas, + InvalidTransaction::Evm(EvmTransactionError::IntrinsicGasExceedsGasLimit { + .. + }) => ErrorCode::InvalidTransactionEvmIntrinsicGasExceedsGasLimit, _ => ErrorCode::InvalidTransactionOrDeployUnspecified, } } @@ -692,6 +698,21 @@ mod tests { assert_eq!(code as u16, 122); } + #[test] + fn evm_intrinsic_gas_above_gas_limit_has_specific_error_code() { + let error = InvalidTransaction::Evm(EvmTransactionError::IntrinsicGasExceedsGasLimit { + intrinsic_gas: 25_300, + gas_limit: 21_000, + }); + let code = ErrorCode::from(error); + + assert_eq!( + code, + ErrorCode::InvalidTransactionEvmIntrinsicGasExceedsGasLimit + ); + assert_eq!(code as u16, 123); + } + #[test] fn invalid_v1_eoa_initiator_has_specific_error_code() { let code = ErrorCode::from(InvalidTransactionV1::InvalidInitiatorAddr); diff --git a/executor/evm/src/tx.rs b/executor/evm/src/tx.rs index b2ee12b762..973322b100 100644 --- a/executor/evm/src/tx.rs +++ b/executor/evm/src/tx.rs @@ -1,5 +1,6 @@ //! Translation from Casper-owned EVM requests into revm transaction environments. +use alloy_eips::eip2930::AccessList as RevmAccessList; use alloy_eips::eip7702::{ Authorization as RevmAuthorization, SignedAuthorization as RevmSignedAuthorization, }; @@ -21,7 +22,8 @@ pub(crate) fn build_tx_env(config: &EvmConfig, kind: &ExecuteKind) -> Result builder.gas_price( @@ -109,6 +111,22 @@ pub(crate) fn to_revm_address(address: evm::Address) -> Address { Address::from(address.value()) } +pub(crate) fn to_revm_access_list(access_list: &[evm::EvmAccessListItem]) -> RevmAccessList { + RevmAccessList( + access_list + .iter() + .map(|item| alloy_eips::eip2930::AccessListItem { + address: to_revm_address(item.address), + storage_keys: item + .storage_keys + .iter() + .map(|key| B256::from(key.value())) + .collect(), + }) + .collect(), + ) +} + fn to_revm_authorization(authorization: &evm::SetCodeAuthorization) -> RevmSignedAuthorization { RevmSignedAuthorization::new_unchecked( RevmAuthorization { diff --git a/executor/evm/tests/executor.rs b/executor/evm/tests/executor.rs index 906efc77af..5de6054738 100644 --- a/executor/evm/tests/executor.rs +++ b/executor/evm/tests/executor.rs @@ -1,8 +1,11 @@ use std::path::PathBuf; -use alloy_consensus::{crypto::secp256k1, SignableTransaction, TxEip7702, TxEnvelope, TxLegacy}; +use alloy_consensus::{ + crypto::secp256k1, SignableTransaction, TxEip1559, TxEip2930, TxEip7702, TxEnvelope, TxLegacy, +}; use alloy_eips::{ eip2718::Encodable2718, + eip2930::{AccessList, AccessListItem}, eip7702::{ Authorization as AlloyAuthorization, SignedAuthorization as AlloySignedAuthorization, }, @@ -615,6 +618,24 @@ fn eip7702_transaction( authorization_nonce: u64, transaction_nonce: u64, input: Vec, +) -> (EvmTransaction, evm::Address) { + eip7702_transaction_with_access_list( + to, + delegate, + authorization_nonce, + transaction_nonce, + input, + AccessList::default(), + ) +} + +fn eip7702_transaction_with_access_list( + to: evm::Address, + delegate: evm::Address, + authorization_nonce: u64, + transaction_nonce: u64, + input: Vec, + access_list: AccessList, ) -> (EvmTransaction, evm::Address) { let authorization = signed_authorization(delegate, authorization_nonce); let authority = alloy_address_to_evm( @@ -630,7 +651,7 @@ fn eip7702_transaction( max_priority_fee_per_gas: 0, to: to_alloy_address(to), value: U256::ZERO, - access_list: Default::default(), + access_list, authorization_list: vec![authorization], input: input.into(), }; @@ -646,6 +667,56 @@ fn eip7702_transaction( (transaction, authority) } +fn eip2930_transaction(access_list: AccessList, nonce: u64) -> EvmTransaction { + let tx = TxEip2930 { + chain_id: 7, + nonce, + gas_price: 1, + gas_limit: 1_000_000, + to: TxKind::Call(AlloyAddress::from([2u8; 20])), + value: U256::ZERO, + input: Default::default(), + access_list, + }; + let signature = secp256k1::sign_message(B256::from(SIGNING_SECRET), tx.signature_hash()) + .expect("transaction signing should succeed"); + let envelope: TxEnvelope = tx.into_signed(signature).into(); + EvmTransaction::from_signed_rlp( + envelope.encoded_2718(), + Timestamp::zero(), + casper_types::TimeDiff::from_seconds(60), + ) + .expect("transaction should decode") +} + +fn eip1559_transaction_with_access_list( + to: evm::Address, + input: Vec, + access_list: AccessList, + nonce: u64, +) -> EvmTransaction { + let tx = TxEip1559 { + chain_id: 7, + nonce, + gas_limit: 1_000_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 0, + to: TxKind::Call(to_alloy_address(to)), + value: U256::ZERO, + input: input.into(), + access_list, + }; + let signature = secp256k1::sign_message(B256::from(SIGNING_SECRET), tx.signature_hash()) + .expect("transaction signing should succeed"); + let envelope: TxEnvelope = tx.into_signed(signature).into(); + EvmTransaction::from_signed_rlp( + envelope.encoded_2718(), + Timestamp::zero(), + casper_types::TimeDiff::from_seconds(60), + ) + .expect("transaction should decode") +} + fn signed_authorization(delegate: evm::Address, nonce: u64) -> AlloySignedAuthorization { let authorization = AlloyAuthorization { chain_id: U256::from(7), @@ -1224,6 +1295,127 @@ fn eip7702_delegation_persists_when_call_reverts() { ); } +#[test] +fn eip2930_access_list_prepays_intrinsic_gas() { + let executor = executor(EvmSpec::Prague); + let (mut tracking_copy, data_access_layer, _tempdir) = tracking_copy(); + + let transaction = eip2930_transaction( + AccessList(vec![AccessListItem { + address: AlloyAddress::from([2u8; 20]), + storage_keys: vec![B256::from([3u8; 32])], + }]), + 0, + ); + seed_evm_balance( + &mut tracking_copy, + transaction.from(), + U512::from(1_000_000_000u64), + ); + + let outcome = execute_transaction( + &executor, + &data_access_layer, + &mut tracking_copy, + transaction, + ); + + assert_eq!(outcome.status, ExecutionStatus::Success); + // 21,000 base gas plus the 2,400 per-address and 1,900 per-storage-key + // access-list prepayments charged before execution. + assert_eq!(outcome.gas_used, 21_000 + 2_400 + 1_900); +} + +#[test] +fn eip1559_access_list_warms_counter_storage_slots() { + let executor = executor(EvmSpec::Prague); + let deployer = evm::Address::new([1; 20]); + let (mut tracking_copy, data_access_layer, _tempdir) = tracking_copy(); + let counter = deploy( + &executor, + &data_access_layer, + &mut tracking_copy, + deployer, + "Counter", + ); + execute_call( + &executor, + &data_access_layer, + &mut tracking_copy, + deployer, + Some(counter), + selector("increment()"), + ); + + let prewarmed = eip1559_transaction_with_access_list( + counter, + selector("get()"), + AccessList(vec![AccessListItem { + address: to_alloy_address(counter), + storage_keys: vec![B256::from([0u8; 32])], + }]), + 0, + ); + let cold = + eip1559_transaction_with_access_list(counter, selector("get()"), AccessList::default(), 1); + seed_evm_balance( + &mut tracking_copy, + prewarmed.from(), + U512::from(1_000_000_000u64), + ); + + let prewarmed_outcome = + execute_transaction(&executor, &data_access_layer, &mut tracking_copy, prewarmed); + let cold_outcome = execute_transaction(&executor, &data_access_layer, &mut tracking_copy, cold); + + assert_eq!(prewarmed_outcome.status, ExecutionStatus::Success); + assert_eq!(cold_outcome.status, ExecutionStatus::Success); + // The access list raises intrinsic gas by 2,400 + 1,900 but turns the + // cold `SLOAD` (2,100 gas) into a warm read (100 gas). + let delta = prewarmed_outcome.gas_used as i64 - cold_outcome.gas_used as i64; + assert_eq!(delta, (2_400 + 1_900 - 2_100 + 100) as i64); +} + +#[test] +fn eip7702_access_list_prepays_intrinsic_gas() { + let executor = executor(EvmSpec::Prague); + let authority = authorization_authority(); + let (mut tracking_copy, data_access_layer, _tempdir) = tracking_copy(); + let delegate = evm::Address::new([2; 20]); + seed_evm_code(&mut tracking_copy, delegate, vec![opcode::STOP]); + let (transaction, recovered_authority) = eip7702_transaction_with_access_list( + authority, + delegate, + 0, + 0, + Vec::new(), + AccessList(vec![AccessListItem { + address: to_alloy_address(delegate), + storage_keys: vec![B256::from([0u8; 32])], + }]), + ); + assert_eq!(recovered_authority, authority); + seed_evm_balance( + &mut tracking_copy, + transaction.from(), + U512::from(1_000_000_000u64), + ); + + let outcome = execute_transaction( + &executor, + &data_access_layer, + &mut tracking_copy, + transaction, + ); + + assert_eq!(outcome.status, ExecutionStatus::Success); + assert_eq!(outcome.output, Vec::::new()); + // 21,000 base gas, 25,000 per authorization entry, and the 2,400 + + // 1,900 access-list prepayments. The authority does not exist yet, so + // no EIP-7702 authorization refund applies. + assert_eq!(outcome.gas_used, 21_000 + 25_000 + 2_400 + 1_900); +} + #[test] fn eip7702_stale_authorization_is_skipped() { let executor = executor(EvmSpec::Prague); diff --git a/node/src/types/transaction/meta_transaction.rs b/node/src/types/transaction/meta_transaction.rs index bb81143a81..87116328df 100644 --- a/node/src/types/transaction/meta_transaction.rs +++ b/node/src/types/transaction/meta_transaction.rs @@ -547,8 +547,12 @@ mod tests { use alloy_consensus::{ SignableTransaction, TxEip1559, TxEip2930, TxEip7702, TxEnvelope, TxLegacy, }; - use alloy_eips::{eip2718::Encodable2718, eip7702::Authorization as AlloyAuthorization}; - use alloy_primitives::{Address as AlloyAddress, Signature, TxKind, U256}; + use alloy_eips::{ + eip2718::Encodable2718, + eip2930::{AccessList, AccessListItem}, + eip7702::Authorization as AlloyAuthorization, + }; + use alloy_primitives::{Address as AlloyAddress, Signature, TxKind, B256, U256}; use casper_types::{ evm, EvmTransactionError, InitiatorAddr, TransactionLaneDefinition, DEFAULT_WEI_PER_MOTE, }; @@ -732,6 +736,53 @@ mod tests { .expect("EIP-2930 gas price equal to base fee should be accepted"); } + #[test] + fn evm_config_compliance_accepts_eip2930_with_access_list() { + let chainspec = chainspec(); + // Intrinsic gas: 21,000 base plus one 2,400 address and one 1,900 + // storage key prepayment. + let meta = evm_meta( + &chainspec, + eip2930_transaction_with_access_list( + BASE_FEE_WEI, + 21_000 + 2_400 + 1_900, + AccessList(vec![AccessListItem { + address: AlloyAddress::from([2u8; 20]), + storage_keys: vec![B256::from([3u8; 32])], + }]), + ), + ); + + meta.is_config_compliant(&chainspec, TimeDiff::from_seconds(0), Timestamp::zero()) + .expect("access-list transaction with sufficient gas limit should be accepted"); + } + + #[test] + fn evm_config_compliance_rejects_access_list_intrinsic_gas_above_gas_limit() { + let chainspec = chainspec(); + let meta = evm_meta( + &chainspec, + eip2930_transaction_with_access_list( + BASE_FEE_WEI, + 21_000 + 2_400 + 1_900 - 1, + AccessList(vec![AccessListItem { + address: AlloyAddress::from([2u8; 20]), + storage_keys: vec![B256::from([3u8; 32])], + }]), + ), + ); + + assert!(matches!( + meta.is_config_compliant(&chainspec, TimeDiff::from_seconds(0), Timestamp::zero()), + Err(InvalidTransaction::Evm( + EvmTransactionError::IntrinsicGasExceedsGasLimit { + intrinsic_gas, + gas_limit + } + )) if intrinsic_gas == 21_000 + 2_400 + 1_900 && gas_limit == 21_000 + 2_400 + 1_900 - 1 + )); + } + #[test] fn evm_config_compliance_rejects_eip2930_positive_effective_priority_fee() { let chainspec = chainspec(); @@ -1122,6 +1173,14 @@ mod tests { } fn eip2930_transaction(gas_price: u128, gas_limit: u64) -> EvmTransaction { + eip2930_transaction_with_access_list(gas_price, gas_limit, AccessList::default()) + } + + fn eip2930_transaction_with_access_list( + gas_price: u128, + gas_limit: u64, + access_list: AccessList, + ) -> EvmTransaction { let tx = TxEip2930 { chain_id: CHAIN_ID, nonce: 0, @@ -1129,7 +1188,7 @@ mod tests { gas_limit, to: TxKind::Call(AlloyAddress::from([1u8; 20])), value: U256::ZERO, - access_list: Default::default(), + access_list, input: Default::default(), }; signed_transaction(tx.into_signed(Signature::test_signature()).into()) diff --git a/node/src/types/transaction/meta_transaction/meta_evm.rs b/node/src/types/transaction/meta_transaction/meta_evm.rs index db175c26f4..7222ccb420 100644 --- a/node/src/types/transaction/meta_transaction/meta_evm.rs +++ b/node/src/types/transaction/meta_transaction/meta_evm.rs @@ -114,6 +114,19 @@ impl MetaEvmTransaction { }); } + // A non-empty access list increases intrinsic gas. Reject a shortfall + // before packing because `revm` reports transaction-validation errors + // as fatal block-execution errors. + if !transaction.access_list().is_empty() { + let intrinsic_gas = transaction.intrinsic_gas(); + if intrinsic_gas > gas_limit as u128 { + return Err(EvmTransactionError::IntrinsicGasExceedsGasLimit { + intrinsic_gas, + gas_limit, + }); + } + } + if !transaction.is_unsigned_call() && evm_config.value_motes(transaction.value()).is_none() { return Err(EvmTransactionError::ValueNotRepresentable { diff --git a/resources/test/sse_data_schema.json b/resources/test/sse_data_schema.json index fec4b08f07..f0168f210e 100644 --- a/resources/test/sse_data_schema.json +++ b/resources/test/sse_data_schema.json @@ -1804,6 +1804,7 @@ "description": "An unsigned Ethereum transaction payload plus one Ethereum-style approval.", "type": "object", "required": [ + "access_list", "authorization_list", "from", "gas_limit", @@ -1884,6 +1885,12 @@ "minimum": 0.0 } }, + "access_list": { + "type": "array", + "items": { + "$ref": "#/definitions/EvmAccessListItem" + } + }, "chain_id": { "type": [ "integer", @@ -1947,6 +1954,35 @@ "description": "Decimal representation of a 256-bit integer.", "type": "string" }, + "EvmAccessListItem": { + "description": "A single EIP-2930 access-list entry.\n\nThe entry pre-pays and pre-warms one address and a set of its storage slots for the duration of the transaction, following EIP-2929 warm/cold access accounting.", + "type": "object", + "required": [ + "address", + "storage_keys" + ], + "properties": { + "address": { + "description": "The 20-byte account or contract address being pre-warmed.", + "allOf": [ + { + "$ref": "#/definitions/Address" + } + ] + }, + "storage_keys": { + "description": "The 32-byte storage slot keys being pre-warmed for the address.", + "type": "array", + "items": { + "$ref": "#/definitions/EvmHash" + } + } + } + }, + "EvmHash": { + "description": "A 32-byte EVM hash encoded as 0x-prefixed hexadecimal.", + "type": "string" + }, "SetCodeAuthorization": { "description": "A signed EIP-7702 authorization-list item.", "type": "object", @@ -6021,4 +6057,4 @@ ] } } -} +} \ No newline at end of file diff --git a/types/src/evm.rs b/types/src/evm.rs index 7de738e962..9bf53836b3 100644 --- a/types/src/evm.rs +++ b/types/src/evm.rs @@ -31,5 +31,6 @@ pub use transaction::{ pub use config::{EvmConfig, EvmSpec, DEFAULT_WEI_PER_MOTE, MINIMUM_WEI_PER_MOTE}; pub use evm_addr::EvmAddr; pub use transaction::{ - EvmApproval, EvmTransaction, EvmTransactionError, EvmTransactionHash, EvmTransactionKind, + EvmAccessListItem, EvmApproval, EvmTransaction, EvmTransactionError, EvmTransactionHash, + EvmTransactionKind, }; diff --git a/types/src/evm/transaction.rs b/types/src/evm/transaction.rs index 0df1129623..29680f6bda 100644 --- a/types/src/evm/transaction.rs +++ b/types/src/evm/transaction.rs @@ -13,7 +13,7 @@ use alloy_consensus::{ }; use alloy_eips::{ eip2718::{Decodable2718, Encodable2718}, - eip2930::AccessList, + eip2930::{AccessList, AccessListItem as AlloyAccessListItem}, eip7702::{ Authorization as AlloyAuthorization, SignedAuthorization as AlloyAuthorizationListItem, }, @@ -46,7 +46,18 @@ use crate::{ }; const TRANSACTION_KIND_SERIALIZED_LENGTH: usize = U8_SERIALIZED_LENGTH; -const EVM_TRANSACTION_MAX_CURRENT_FIELDS: u32 = 15; +const EVM_TRANSACTION_MAX_CURRENT_FIELDS: u32 = 16; + +// Prague initial-transaction-gas parameters, matching the values revm uses +// for the configured `EvmSpec::Prague` execution rules. +const TX_BASE_GAS: u128 = 21_000; +const TX_CALLDATA_TOKEN_COST: u128 = 4; +const TX_CALLDATA_NON_ZERO_BYTE_MULTIPLIER: u128 = 4; +const TX_ACCESS_LIST_ADDRESS_GAS: u128 = 2_400; +const TX_ACCESS_LIST_STORAGE_KEY_GAS: u128 = 1_900; +const TX_CREATE_GAS: u128 = 32_000; +const TX_INITCODE_WORD_GAS: u128 = 2; +const TX_PER_AUTHORIZATION_GAS: u128 = 25_000; const TIMESTAMP_FIELD_INDEX: u16 = 0; const TTL_FIELD_INDEX: u16 = 1; @@ -67,15 +78,23 @@ const LEGACY_INPUT_FIELD_INDEX: u16 = 10; const LEGACY_CHAIN_ID_FIELD_INDEX: u16 = 11; const LEGACY_APPROVAL_FIELD_INDEX: u16 = 12; +// The EIP-2930 access list is appended after the approval field so that +// payloads written before access-list support was introduced still decode. +// It is only written for EIP-2930 transactions; legacy transactions never +// carry an access list. +const LEGACY_ACCESS_LIST_FIELD_INDEX: u16 = 13; + const DYNAMIC_MAX_FEE_PER_GAS_FIELD_INDEX: u16 = 8; const DYNAMIC_MAX_PRIORITY_FEE_PER_GAS_FIELD_INDEX: u16 = 9; const DYNAMIC_VALUE_FIELD_INDEX: u16 = 10; const DYNAMIC_INPUT_FIELD_INDEX: u16 = 11; const DYNAMIC_CHAIN_ID_FIELD_INDEX: u16 = 12; const DYNAMIC_APPROVAL_FIELD_INDEX: u16 = 13; +const DYNAMIC_ACCESS_LIST_FIELD_INDEX: u16 = 14; const EIP7702_AUTHORIZATION_LIST_FIELD_INDEX: u16 = 13; const EIP7702_APPROVAL_FIELD_INDEX: u16 = 14; +const EIP7702_ACCESS_LIST_FIELD_INDEX: u16 = 15; /// Ethereum transaction type ID for legacy transactions. pub const LEGACY_TRANSACTION_TYPE_ID: u8 = 0; @@ -415,6 +434,76 @@ impl FromBytes for SetCodeAuthorization { } } +/// A single EIP-2930 access-list entry. +/// +/// The entry pre-pays and pre-warms one address and a set of its storage +/// slots for the duration of the transaction, following EIP-2929 warm/cold +/// access accounting. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "datasize", derive(DataSize))] +#[cfg_attr(feature = "json-schema", derive(JsonSchema))] +pub struct EvmAccessListItem { + /// The 20-byte account or contract address being pre-warmed. + pub address: Address, + /// The 32-byte storage slot keys being pre-warmed for the address. + pub storage_keys: Vec, +} + +impl EvmAccessListItem { + fn from_alloy(value: &AlloyAccessListItem) -> Self { + EvmAccessListItem { + address: alloy_address_to_address(value.address), + storage_keys: value + .storage_keys + .iter() + .map(|key| Hash::new(key.0)) + .collect(), + } + } + + fn to_alloy(&self) -> AlloyAccessListItem { + AlloyAccessListItem { + address: to_alloy_address(self.address), + storage_keys: self + .storage_keys + .iter() + .map(|key| B256::from(key.value())) + .collect(), + } + } +} + +impl ToBytes for EvmAccessListItem { + fn to_bytes(&self) -> Result, bytesrepr::Error> { + let mut buffer = bytesrepr::allocate_buffer(self)?; + self.write_bytes(&mut buffer)?; + Ok(buffer) + } + + fn serialized_length(&self) -> usize { + self.address.serialized_length() + self.storage_keys.serialized_length() + } + + fn write_bytes(&self, writer: &mut Vec) -> Result<(), bytesrepr::Error> { + self.address.write_bytes(writer)?; + self.storage_keys.write_bytes(writer) + } +} + +impl FromBytes for EvmAccessListItem { + fn from_bytes(bytes: &[u8]) -> Result<(Self, &[u8]), bytesrepr::Error> { + let (address, remainder) = Address::from_bytes(bytes)?; + let (storage_keys, remainder) = Vec::::from_bytes(remainder)?; + Ok(( + EvmAccessListItem { + address, + storage_keys, + }, + remainder, + )) + } +} + /// Errors returned while decoding or validating EVM transactions. #[derive(Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] #[cfg_attr(feature = "datasize", derive(DataSize))] @@ -426,8 +515,13 @@ pub enum EvmTransactionError { Disabled, /// The transaction envelope type is not supported by this first-pass executor. UnsupportedTransactionType(u8), - /// The transaction contains an access list, which this first-pass executor does not model. - UnsupportedAccessList, + /// The transaction gas limit is lower than the intrinsic gas required before execution. + IntrinsicGasExceedsGasLimit { + /// Intrinsic gas required by the transaction. + intrinsic_gas: u128, + /// EvmTransaction gas limit. + gas_limit: u64, + }, /// Only EIP-7702 transactions may carry a set-code authorization list. UnexpectedAuthorizationList, /// An EIP-7702 transaction must contain at least one authorization. @@ -527,8 +621,14 @@ impl Display for EvmTransactionError { EvmTransactionError::UnsupportedTransactionType(kind) => { write!(formatter, "unsupported EVM transaction type: {kind}") } - EvmTransactionError::UnsupportedAccessList => { - formatter.write_str("unsupported EVM transaction access list") + EvmTransactionError::IntrinsicGasExceedsGasLimit { + intrinsic_gas, + gas_limit, + } => { + write!( + formatter, + "EVM transaction intrinsic gas {intrinsic_gas} exceeds gas limit {gas_limit}" + ) } EvmTransactionError::UnexpectedAuthorizationList => { formatter.write_str("unexpected EVM set-code authorization list") @@ -674,6 +774,9 @@ pub struct EvmTransaction { max_priority_fee_per_gas: Option, value: U256, input: Vec, + // EIP-2930 access-list entries. Empty for legacy transactions and for + // typed transactions signed without an access list. + access_list: Vec, chain_id: Option, authorization_list: Vec, approval: Option, @@ -695,6 +798,7 @@ struct EvmTransactionSerHelper<'a> { max_priority_fee_per_gas: Option, value: U256, input: &'a Vec, + access_list: &'a Vec, chain_id: Option, authorization_list: &'a Vec, approval: &'a Option, @@ -716,6 +820,7 @@ struct EvmTransactionDeserHelper { max_priority_fee_per_gas: Option, value: U256, input: Vec, + access_list: Vec, chain_id: Option, authorization_list: Vec, approval: Option, @@ -738,6 +843,7 @@ impl Serialize for EvmTransaction { max_priority_fee_per_gas: self.max_priority_fee_per_gas, value: self.value, input: &self.input, + access_list: &self.access_list, chain_id: self.chain_id, authorization_list: &self.authorization_list, approval: &self.approval, @@ -764,6 +870,7 @@ impl<'de> Deserialize<'de> for EvmTransaction { max_priority_fee_per_gas: helper.max_priority_fee_per_gas, value: helper.value, input: helper.input, + access_list: helper.access_list, chain_id: helper.chain_id, authorization_list: helper.authorization_list, approval: helper.approval, @@ -807,6 +914,7 @@ impl EvmTransaction { max_priority_fee_per_gas: None, value, input, + access_list: Vec::new(), chain_id: Some(chain_id), authorization_list: Vec::new(), approval: None, @@ -861,7 +969,16 @@ impl EvmTransaction { self.gas_limit.serialized_length(), ]; match self.kind { - EvmTransactionKind::Legacy | EvmTransactionKind::Eip2930 => { + EvmTransactionKind::Legacy => { + field_lengths.extend([ + self.gas_price.serialized_length(), + self.value.serialized_length(), + input_length, + self.chain_id.serialized_length(), + self.approval.serialized_length(), + ]); + } + EvmTransactionKind::Eip2930 => { field_lengths.extend([ self.gas_price.serialized_length(), self.value.serialized_length(), @@ -869,6 +986,9 @@ impl EvmTransaction { self.chain_id.serialized_length(), self.approval.serialized_length(), ]); + if !self.access_list.is_empty() { + field_lengths.push(self.access_list.serialized_length()); + } } EvmTransactionKind::Eip1559 => { field_lengths.extend([ @@ -879,6 +999,9 @@ impl EvmTransaction { self.chain_id.serialized_length(), self.approval.serialized_length(), ]); + if !self.access_list.is_empty() { + field_lengths.push(self.access_list.serialized_length()); + } } EvmTransactionKind::Eip7702 => { field_lengths.extend([ @@ -890,6 +1013,9 @@ impl EvmTransaction { self.authorization_list.serialized_length(), self.approval.serialized_length(), ]); + if !self.access_list.is_empty() { + field_lengths.push(self.access_list.serialized_length()); + } } } field_lengths @@ -936,12 +1062,6 @@ impl EvmTransaction { "trailing bytes after transaction envelope".to_string(), )); } - if envelope - .access_list() - .is_some_and(|access_list| !access_list.is_empty()) - { - return Err(EvmTransactionError::UnsupportedAccessList); - } let kind = if envelope.is_legacy() { EvmTransactionKind::Legacy @@ -995,6 +1115,16 @@ impl EvmTransaction { max_priority_fee_per_gas: envelope.max_priority_fee_per_gas(), value: alloy_u256_to_casper(envelope.value()), input: envelope.input().to_vec(), + access_list: envelope + .access_list() + .map(|access_list| { + access_list + .0 + .iter() + .map(EvmAccessListItem::from_alloy) + .collect() + }) + .unwrap_or_default(), chain_id: envelope.chain_id(), authorization_list, approval: Some(approval), @@ -1186,6 +1316,14 @@ impl EvmTransaction { &self.input } + /// Returns the EIP-2930 access-list entries. + /// + /// Legacy transactions and typed transactions signed without an access + /// list return an empty slice. + pub fn access_list(&self) -> &[EvmAccessListItem] { + &self.access_list + } + /// Returns the Ethereum chain ID encoded in the transaction, if present. pub fn chain_id(&self) -> Option { self.chain_id @@ -1258,6 +1396,42 @@ impl EvmTransaction { evm_config.gas_fee_motes(self.gas_limit, self.maximum_fee_per_gas()) } + /// Returns the intrinsic gas that must be available before execution can start. + /// + /// The returned `intrinsic_gas` follows the Prague initial-transaction-gas + /// rules: the 21,000 gas base stipend, calldata token costs, + /// contract-creation and EIP-3860 initcode costs, EIP-2930 access-list + /// prepayments of 2,400 gas per address and 1,900 gas per storage key, + /// and EIP-7702 authorization costs of 25,000 gas per entry. + /// + /// Node admission compares this value against the transaction gas limit so + /// an access list cannot introduce a transaction-validation failure during + /// block execution. + pub fn intrinsic_gas(&self) -> u128 { + let zero_bytes = self.input.iter().filter(|byte| **byte == 0).count() as u128; + let non_zero_bytes = self.input.len() as u128 - zero_bytes; + let tokens_in_calldata = zero_bytes + non_zero_bytes * TX_CALLDATA_NON_ZERO_BYTE_MULTIPLIER; + + let access_list_addresses = self.access_list.len() as u128; + let access_list_storage_keys = self + .access_list + .iter() + .map(|item| item.storage_keys.len() as u128) + .sum::(); + + let mut intrinsic_gas = TX_BASE_GAS + + tokens_in_calldata * TX_CALLDATA_TOKEN_COST + + access_list_addresses * TX_ACCESS_LIST_ADDRESS_GAS + + access_list_storage_keys * TX_ACCESS_LIST_STORAGE_KEY_GAS + + self.authorization_list.len() as u128 * TX_PER_AUTHORIZATION_GAS; + if self.to.is_none() { + let initcode_words = (self.input.len() as u128).div_ceil(32); + intrinsic_gas += TX_CREATE_GAS + initcode_words * TX_INITCODE_WORD_GAS; + } + + intrinsic_gas + } + /// Returns the balance needed for value transfer plus the supplied fee amount, in motes. pub fn required_balance(&self, fee_amount: U512, evm_config: &EvmConfig) -> Option { let value_motes = evm_config.value_motes(self.value)?; @@ -1330,7 +1504,7 @@ impl EvmTransaction { gas_limit: self.gas_limit, to, value, - access_list: AccessList::default(), + access_list: self.alloy_access_list(), input, })), EvmTransactionKind::Eip1559 => Ok(TypedTransaction::Eip1559(TxEip1559 { @@ -1343,7 +1517,7 @@ impl EvmTransaction { .ok_or(EvmTransactionError::MissingMaxPriorityFeePerGas)?, to, value, - access_list: AccessList::default(), + access_list: self.alloy_access_list(), input, })), EvmTransactionKind::Eip7702 => { @@ -1358,7 +1532,7 @@ impl EvmTransaction { .ok_or(EvmTransactionError::MissingMaxPriorityFeePerGas)?, to: to_alloy_address(address), value, - access_list: AccessList::default(), + access_list: self.alloy_access_list(), authorization_list: self .authorization_list .iter() @@ -1370,6 +1544,15 @@ impl EvmTransaction { } } + fn alloy_access_list(&self) -> AccessList { + AccessList( + self.access_list + .iter() + .map(EvmAccessListItem::to_alloy) + .collect(), + ) + } + fn approval_signature( &self, signature_hash: &B256, @@ -1418,7 +1601,7 @@ impl ToBytes for EvmTransaction { .add_field(GAS_LIMIT_FIELD_INDEX, &self.gas_limit)?; match self.kind { - EvmTransactionKind::Legacy | EvmTransactionKind::Eip2930 => { + EvmTransactionKind::Legacy => { let input = Bytes::from(self.input.clone()); builder .add_field(LEGACY_GAS_PRICE_FIELD_INDEX, &self.gas_price)? @@ -1428,9 +1611,27 @@ impl ToBytes for EvmTransaction { .add_field(LEGACY_APPROVAL_FIELD_INDEX, &self.approval)? .binary_payload_bytes() } + EvmTransactionKind::Eip2930 => { + let input = Bytes::from(self.input.clone()); + let builder = builder + .add_field(LEGACY_GAS_PRICE_FIELD_INDEX, &self.gas_price)? + .add_field(LEGACY_VALUE_FIELD_INDEX, &self.value)? + .add_field(LEGACY_INPUT_FIELD_INDEX, &input)? + .add_field(LEGACY_CHAIN_ID_FIELD_INDEX, &self.chain_id)? + .add_field(LEGACY_APPROVAL_FIELD_INDEX, &self.approval)?; + // The access list is only written when non-empty so that + // transactions without one keep their pre-access-list byte + // representation. + let builder = if self.access_list.is_empty() { + builder + } else { + builder.add_field(LEGACY_ACCESS_LIST_FIELD_INDEX, &self.access_list)? + }; + builder.binary_payload_bytes() + } EvmTransactionKind::Eip1559 => { let input = Bytes::from(self.input.clone()); - builder + let builder = builder .add_field(DYNAMIC_MAX_FEE_PER_GAS_FIELD_INDEX, &self.max_fee_per_gas)? .add_field( DYNAMIC_MAX_PRIORITY_FEE_PER_GAS_FIELD_INDEX, @@ -1439,12 +1640,17 @@ impl ToBytes for EvmTransaction { .add_field(DYNAMIC_VALUE_FIELD_INDEX, &self.value)? .add_field(DYNAMIC_INPUT_FIELD_INDEX, &input)? .add_field(DYNAMIC_CHAIN_ID_FIELD_INDEX, &self.chain_id)? - .add_field(DYNAMIC_APPROVAL_FIELD_INDEX, &self.approval)? - .binary_payload_bytes() + .add_field(DYNAMIC_APPROVAL_FIELD_INDEX, &self.approval)?; + let builder = if self.access_list.is_empty() { + builder + } else { + builder.add_field(DYNAMIC_ACCESS_LIST_FIELD_INDEX, &self.access_list)? + }; + builder.binary_payload_bytes() } EvmTransactionKind::Eip7702 => { let input = Bytes::from(self.input.clone()); - builder + let builder = builder .add_field(DYNAMIC_MAX_FEE_PER_GAS_FIELD_INDEX, &self.max_fee_per_gas)? .add_field( DYNAMIC_MAX_PRIORITY_FEE_PER_GAS_FIELD_INDEX, @@ -1457,8 +1663,13 @@ impl ToBytes for EvmTransaction { EIP7702_AUTHORIZATION_LIST_FIELD_INDEX, &self.authorization_list, )? - .add_field(EIP7702_APPROVAL_FIELD_INDEX, &self.approval)? - .binary_payload_bytes() + .add_field(EIP7702_APPROVAL_FIELD_INDEX, &self.approval)?; + let builder = if self.access_list.is_empty() { + builder + } else { + builder.add_field(EIP7702_ACCESS_LIST_FIELD_INDEX, &self.access_list)? + }; + builder.binary_payload_bytes() } } } @@ -1506,7 +1717,52 @@ impl EvmTransaction { let (gas_limit, window) = window.deserialize_and_maybe_next::()?; let transaction = match kind { - EvmTransactionKind::Legacy | EvmTransactionKind::Eip2930 => { + EvmTransactionKind::Legacy => { + let window = window.ok_or(bytesrepr::Error::Formatting)?; + window.verify_index(LEGACY_GAS_PRICE_FIELD_INDEX)?; + let (gas_price, window) = window.deserialize_and_maybe_next::>()?; + let window = window.ok_or(bytesrepr::Error::Formatting)?; + window.verify_index(LEGACY_VALUE_FIELD_INDEX)?; + let (value, window) = window.deserialize_and_maybe_next::()?; + let window = window.ok_or(bytesrepr::Error::Formatting)?; + window.verify_index(LEGACY_INPUT_FIELD_INDEX)?; + let (input, window) = window.deserialize_and_maybe_next::()?; + let window = window.ok_or(bytesrepr::Error::Formatting)?; + window.verify_index(LEGACY_CHAIN_ID_FIELD_INDEX)?; + let (chain_id, window) = window.deserialize_and_maybe_next::>()?; + let window = window.ok_or(bytesrepr::Error::Formatting)?; + window.verify_index(LEGACY_APPROVAL_FIELD_INDEX)?; + let (approval, window) = + window.deserialize_and_maybe_next::>()?; + if window.is_some() { + return Err(bytesrepr::Error::Formatting); + } + let max_fee_per_gas = if approval.is_none() { + 0 + } else { + gas_price.unwrap_or_default() + }; + EvmTransaction { + timestamp, + ttl, + hash, + from, + kind, + to, + nonce, + gas_limit, + gas_price, + max_fee_per_gas, + max_priority_fee_per_gas: None, + value, + input: input.into(), + access_list: Vec::new(), + chain_id, + authorization_list: Vec::new(), + approval, + } + } + EvmTransactionKind::Eip2930 => { let window = window.ok_or(bytesrepr::Error::Formatting)?; window.verify_index(LEGACY_GAS_PRICE_FIELD_INDEX)?; let (gas_price, window) = window.deserialize_and_maybe_next::>()?; @@ -1523,6 +1779,16 @@ impl EvmTransaction { window.verify_index(LEGACY_APPROVAL_FIELD_INDEX)?; let (approval, window) = window.deserialize_and_maybe_next::>()?; + let (access_list, window) = match window { + Some(access_list_window) + if access_list_window.field_index() == LEGACY_ACCESS_LIST_FIELD_INDEX => + { + access_list_window.deserialize_and_maybe_next::>()? + } + // Payloads written before access-list support carry no + // access-list field. + _ => (Vec::new(), window), + }; if window.is_some() { return Err(bytesrepr::Error::Formatting); } @@ -1545,6 +1811,7 @@ impl EvmTransaction { max_priority_fee_per_gas: None, value, input: input.into(), + access_list, chain_id, authorization_list: Vec::new(), approval, @@ -1571,6 +1838,14 @@ impl EvmTransaction { window.verify_index(DYNAMIC_APPROVAL_FIELD_INDEX)?; let (approval, window) = window.deserialize_and_maybe_next::>()?; + let (access_list, window) = match window { + Some(access_list_window) + if access_list_window.field_index() == DYNAMIC_ACCESS_LIST_FIELD_INDEX => + { + access_list_window.deserialize_and_maybe_next::>()? + } + _ => (Vec::new(), window), + }; if window.is_some() { return Err(bytesrepr::Error::Formatting); } @@ -1588,6 +1863,7 @@ impl EvmTransaction { max_priority_fee_per_gas, value, input: input.into(), + access_list, chain_id, authorization_list: Vec::new(), approval, @@ -1618,6 +1894,14 @@ impl EvmTransaction { window.verify_index(EIP7702_APPROVAL_FIELD_INDEX)?; let (approval, window) = window.deserialize_and_maybe_next::>()?; + let (access_list, window) = match window { + Some(access_list_window) + if access_list_window.field_index() == EIP7702_ACCESS_LIST_FIELD_INDEX => + { + access_list_window.deserialize_and_maybe_next::>()? + } + _ => (Vec::new(), window), + }; if window.is_some() { return Err(bytesrepr::Error::Formatting); } @@ -1635,6 +1919,7 @@ impl EvmTransaction { max_priority_fee_per_gas, value, input: input.into(), + access_list, chain_id, authorization_list, approval, diff --git a/types/src/lib.rs b/types/src/lib.rs index aaa41dac1f..34108a7ddc 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -166,8 +166,9 @@ pub use digest::{ pub use display_iter::DisplayIter; pub use era_id::EraId; pub use evm::{ - EvmAddr, EvmApproval, EvmConfig, EvmSpec, EvmTransaction, EvmTransactionError, - EvmTransactionHash, EvmTransactionKind, DEFAULT_WEI_PER_MOTE, MINIMUM_WEI_PER_MOTE, + EvmAccessListItem, EvmAddr, EvmApproval, EvmConfig, EvmSpec, EvmTransaction, + EvmTransactionError, EvmTransactionHash, EvmTransactionKind, DEFAULT_WEI_PER_MOTE, + MINIMUM_WEI_PER_MOTE, }; pub use gas::Gas; #[cfg(feature = "json-schema")] diff --git a/types/src/transaction/serialization/mod.rs b/types/src/transaction/serialization/mod.rs index cba5c2e344..bca282e053 100644 --- a/types/src/transaction/serialization/mod.rs +++ b/types/src/transaction/serialization/mod.rs @@ -145,7 +145,7 @@ pub struct CalltableFieldsIterator<'a> { parent: &'a CalltableSerializationEnvelope, } -impl CalltableFieldsIterator<'_> { +impl<'a> CalltableFieldsIterator<'a> { pub fn verify_index(&self, expected_index: u16) -> Result<(), Error> { let field = self.field; if field.index != expected_index { @@ -154,14 +154,25 @@ impl CalltableFieldsIterator<'_> { Ok(()) } + /// Returns the calltable field index this iterator is currently positioned at. + /// + /// Used to detect optional trailing fields written by newer encoders. + pub fn field_index(&self) -> u16 { + self.field.index + } + pub fn deserialize_and_maybe_next( &self, - ) -> Result<(T, Option>), Error> { + ) -> Result<(T, Option>), Error> { let (t, maybe_window) = self.step()?; Ok((t, maybe_window)) } - fn step(&self) -> Result<(T, Option>), Error> { + // The next window only borrows from the parent envelope, so the returned + // iterator is tied to the envelope's data lifetime rather than to the + // borrow of `self`. This lets callers keep iterating after the window + // they inspected is dropped. + fn step(&self) -> Result<(T, Option>), Error> { let (t, remainder) = T::from_bytes(self.bytes)?; let parent_fields = &self.parent.fields; let parent_fields_len = parent_fields.len(); diff --git a/types/tests/evm_transaction.rs b/types/tests/evm_transaction.rs index d4e1df226b..7e889e330b 100644 --- a/types/tests/evm_transaction.rs +++ b/types/tests/evm_transaction.rs @@ -15,9 +15,9 @@ use alloy_primitives::{Address as AlloyAddress, Signature, TxKind, B256, U256 as use casper_types::{ bytesrepr::{FromBytes, ToBytes}, evm::{Address, Hash, EIP4844_TRANSACTION_TYPE_ID}, - Approval, ApprovalsHash, Digest, EvmApproval, EvmTransaction, EvmTransactionError, - EvmTransactionHash, EvmTransactionKind, InitiatorAddr, PublicKey, SecretKey, TimeDiff, - Timestamp, Transaction as CasperTransaction, TransactionHash, U256, + Approval, ApprovalsHash, Digest, EvmAccessListItem, EvmApproval, EvmTransaction, + EvmTransactionError, EvmTransactionHash, EvmTransactionKind, InitiatorAddr, PublicKey, + SecretKey, TimeDiff, Timestamp, Transaction as CasperTransaction, TransactionHash, U256, }; const SIGNING_SECRET: [u8; 32] = [7; 32]; @@ -147,18 +147,132 @@ fn unsupported_typed_transactions_are_clear_errors() { } #[test] -fn non_empty_access_lists_are_rejected() { - let timestamp = Timestamp::zero(); - let ttl = TimeDiff::from_seconds(60); +fn non_empty_access_lists_are_preserved() { + let eip2930 = signed_eip2930_with_access_list(); + let transaction = decode(eip2930.raw_rlp.clone()); + assert_eq!(transaction.kind(), EvmTransactionKind::Eip2930); assert_eq!( - EvmTransaction::from_signed_rlp(signed_eip2930_with_access_list(), timestamp, ttl), - Err(EvmTransactionError::UnsupportedAccessList) + transaction.access_list(), + &[EvmAccessListItem { + address: address(8), + storage_keys: vec![Hash::new([9u8; 32])], + }] ); + assert_eq!(transaction.from(), eip2930.sender); + transaction + .verify() + .expect("EIP-2930 transaction with access list should verify"); assert_eq!( - EvmTransaction::from_signed_rlp(signed_eip7702_with_access_list(), timestamp, ttl), - Err(EvmTransactionError::UnsupportedAccessList) + transaction.signed_rlp().unwrap(), + eip2930.raw_rlp, + "reconstructed signed RLP should match the original bytes" ); + bytesrepr_roundtrip(&transaction); + + let eip7702 = signed_eip7702_with_access_list(); + let transaction = decode(eip7702.raw_rlp.clone()); + + assert_eq!(transaction.kind(), EvmTransactionKind::Eip7702); + assert_eq!( + transaction.access_list(), + &[EvmAccessListItem { + address: address(8), + storage_keys: vec![Hash::new([9u8; 32])], + }] + ); + assert_eq!(transaction.authorization_list().len(), 1); + transaction + .verify() + .expect("EIP-7702 transaction with access list should verify"); + assert_eq!( + transaction.signed_rlp().unwrap(), + eip7702.raw_rlp, + "reconstructed signed RLP should match the original bytes" + ); + bytesrepr_roundtrip(&transaction); +} + +#[test] +fn decodes_eip1559_signed_rlp_with_access_list() { + let tx = TxEip1559 { + chain_id: 7, + nonce: 2, + gas_limit: 60_000, + max_fee_per_gas: 2_000_000_000, + max_priority_fee_per_gas: 0, + to: TxKind::Call(alloy_address(3)), + value: AlloyU256::from(789u64), + access_list: AccessList(vec![AccessListItem { + address: alloy_address(5), + storage_keys: vec![B256::from([6u8; 32]), B256::from([7u8; 32])], + }]), + input: vec![0xab, 0xcd].into(), + }; + let raw_rlp = { + let signature = sign_transaction(&tx); + let envelope: TxEnvelope = tx.into_signed(signature).into(); + envelope.encoded_2718() + }; + let transaction = decode(raw_rlp.clone()); + + assert_eq!(transaction.kind(), EvmTransactionKind::Eip1559); + assert_eq!( + transaction.access_list(), + &[EvmAccessListItem { + address: address(5), + storage_keys: vec![Hash::new([6u8; 32]), Hash::new([7u8; 32])], + }] + ); + transaction + .verify() + .expect("EIP-1559 transaction with access list should verify"); + assert_eq!(transaction.signed_rlp().unwrap(), raw_rlp); + bytesrepr_roundtrip(&transaction); +} + +#[test] +fn intrinsic_gas_matches_prague_rules() { + // A plain legacy transfer has the 21,000 base-gas charge. + let transaction = decode(signed_legacy_transaction().raw_rlp); + assert_eq!(transaction.intrinsic_gas(), 21_000); + + // Two non-zero calldata bytes cost 16 gas each. + let transaction = decode(signed_eip1559_transaction().raw_rlp); + assert_eq!(transaction.intrinsic_gas(), 21_000 + 32); +} + +#[test] +fn intrinsic_gas_includes_access_list_and_authorization_costs() { + // One access-list address (2,400), one storage key (1,900), and two + // non-zero calldata bytes (32). + let transaction = decode(signed_eip2930_with_access_list().raw_rlp); + assert_eq!(transaction.intrinsic_gas(), 21_000 + 2_400 + 1_900 + 32); + + // One authorization (25,000) on top of the base stipend and calldata. + let transaction = decode(signed_eip7702_transaction().raw_rlp); + assert_eq!(transaction.intrinsic_gas(), 21_000 + 25_000 + 32); +} + +#[test] +fn intrinsic_gas_includes_creation_and_initcode_costs() { + // A contract creation with 40 zero bytes of initcode: 40 calldata tokens + // (160 gas), the 32,000 create cost, and two EIP-3860 initcode words + // (4 gas). + let tx = TxLegacy { + chain_id: Some(7), + nonce: 0, + gas_price: 1_000_000_000, + gas_limit: 100_000, + to: TxKind::Create, + value: AlloyU256::ZERO, + input: vec![0u8; 40].into(), + }; + let signature = sign_transaction(&tx); + let envelope: TxEnvelope = tx.into_signed(signature).into(); + let transaction = decode(envelope.encoded_2718()); + + assert_eq!(transaction.intrinsic_gas(), 21_000 + 160 + 32_000 + 4); } #[test] @@ -424,14 +538,14 @@ fn signed_eip7702_with_authorization_list( signed_eip7702_envelope(authorization_list, access_list).encoded_2718() } -fn signed_eip7702_with_access_list() -> Vec { - signed_eip7702_with_authorization_list( +fn signed_eip7702_with_access_list() -> SignedTransaction { + signed_transaction(signed_eip7702_envelope( vec![signed_authorization(alloy_address(9), 4)], AccessList(vec![AccessListItem { address: alloy_address(8), storage_keys: vec![B256::from([9u8; 32])], }]), - ) + )) } fn signed_eip7702_envelope( @@ -517,7 +631,7 @@ fn alloy_u256_to_casper(value: AlloyU256) -> U256 { U256::from_big_endian(&value.to_be_bytes::<32>()) } -fn signed_eip2930_with_access_list() -> Vec { +fn signed_eip2930_with_access_list() -> SignedTransaction { let tx = TxEip2930 { chain_id: 7, nonce: 0, @@ -531,7 +645,6 @@ fn signed_eip2930_with_access_list() -> Vec { storage_keys: vec![B256::from([9u8; 32])], }]), }; - let tx = tx.into_signed(Signature::test_signature()); - let envelope: TxEnvelope = tx.into(); - envelope.encoded_2718() + let signature = sign_transaction(&tx); + signed_transaction(tx.into_signed(signature).into()) }