From ad64526076fa74d5b2183fa3c05f81a7c960261c Mon Sep 17 00:00:00 2001 From: Lucas Fiegl Date: Wed, 5 Aug 2026 09:46:04 -0300 Subject: [PATCH] fix(levm): ban SLOTNUM in EIP-8141 validation prefix --- crates/networking/rpc/types/receipt.rs | 2 +- crates/vm/levm/src/vm.rs | 17 ++++++++++++----- docs/eip-8141.md | 2 +- test/tests/levm/eip8141_tests.rs | 23 +++++++++++++++++++++++ 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/crates/networking/rpc/types/receipt.rs b/crates/networking/rpc/types/receipt.rs index 15272a73021..e4aa4959037 100644 --- a/crates/networking/rpc/types/receipt.rs +++ b/crates/networking/rpc/types/receipt.rs @@ -33,7 +33,7 @@ pub struct RpcReceipt { #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RpcFrameReceipt { - /// EIP-8141 frame status code: 0 = failure, 1 = success, 3 = skipped + /// EIP-8141 frame status code: 0 = failure, 1 = success, 2 = skipped /// (atomic-batch failure). Serialized as a hex-encoded byte. #[serde(with = "serde_utils::u8::hex_str")] pub status: u8, diff --git a/crates/vm/levm/src/vm.rs b/crates/vm/levm/src/vm.rs index 36e8abe3880..1f787d7fe82 100644 --- a/crates/vm/levm/src/vm.rs +++ b/crates/vm/levm/src/vm.rs @@ -2857,13 +2857,19 @@ impl<'a> VM<'a> { /// /// Static bans: `ORIGIN`, `GASPRICE`, `BLOCKHASH`, `COINBASE`, `TIMESTAMP` /// (except when the current frame's target is EXPIRY_VERIFIER), `NUMBER`, - /// `PREVRANDAO`, `GASLIMIT`, `BASEFEE`, `BLOBHASH`, `BLOBBASEFEE`, `INVALID`, - /// `SELFDESTRUCT`, `BALANCE`, `SELFBALANCE`, `TLOAD`, `TSTORE`, and `CALLCODE` - /// in non-deploy prefix frames (ERC-7562 bans CALLCODE in validation; + /// `PREVRANDAO`, `GASLIMIT`, `BASEFEE`, `BLOBHASH`, `BLOBBASEFEE`, `SLOTNUM`, + /// `INVALID`, `SELFDESTRUCT`, `BALANCE`, `SELFBALANCE`, `TLOAD`, `TSTORE`, and + /// `CALLCODE` in non-deploy prefix frames (ERC-7562 bans CALLCODE in validation; /// DELEGATECALL is allowed subject to the CALL-family trace rules in the /// handlers). `SSTORE`/`CREATE`/`CREATE2` are allowed only inside the deploy /// frame and are enforced in their handlers (state-write rules), not here. /// + /// `SLOTNUM` (EIP-7843) joins the block-dependent set for the same reason as + /// `NUMBER` and `TIMESTAMP`: its value changes between admission and inclusion, + /// so a prefix branching on it can pass simulation and revert in the block. It + /// is not covered transitively, since the handler reads the header's slot + /// number rather than deriving it from `TIMESTAMP`. + /// /// Sequential `GAS` rule: `GAS` is allowed only immediately before a /// `*CALL` (`CALL`/`CALLCODE`/`DELEGATECALL`/`STATICCALL`). We detect this by /// remembering `last_opcode`: if the previous iteration was `GAS` and this @@ -2886,6 +2892,7 @@ impl<'a> VM<'a> { const BASEFEE: u8 = 0x48; const BLOBHASH: u8 = 0x49; const BLOBBASEFEE: u8 = 0x4A; + const SLOTNUM: u8 = 0x4B; const INVALID: u8 = 0xFE; const SELFDESTRUCT: u8 = 0xFF; const BALANCE: u8 = 0x31; @@ -2912,8 +2919,8 @@ impl<'a> VM<'a> { let banned = match opcode { ORIGIN | GASPRICE | BLOCKHASH | COINBASE | NUMBER | PREVRANDAO | GASLIMIT | BASEFEE - | BLOBHASH | BLOBBASEFEE | INVALID | SELFDESTRUCT | BALANCE | SELFBALANCE | TLOAD - | TSTORE => true, + | BLOBHASH | BLOBBASEFEE | SLOTNUM | INVALID | SELFDESTRUCT | BALANCE | SELFBALANCE + | TLOAD | TSTORE => true, // TIMESTAMP is permitted only when the currently executing contract // IS the EXPIRY_VERIFIER predeploy (checked by code_address so the // rule tracks the executing contract at every call depth, not just the diff --git a/docs/eip-8141.md b/docs/eip-8141.md index 8a4e6c17d20..fc232cec3a7 100644 --- a/docs/eip-8141.md +++ b/docs/eip-8141.md @@ -689,7 +689,7 @@ A frame tx carrying `blob_versioned_hashes` is rejected at mempool admission bec The full ERC-7562-style validation-prefix simulation is implemented as a local peer policy. Admission runs the following checks in order, all in addition to the static and signature checks above: 1. **Prefix recognition and structural validation**: the frame list is walked to identify the recognized shapes (`self_verify`, `deploy+self_verify`, `only_verify+pay`, `deploy+only_verify+pay`), and structural rules (frame modes, scope restrictions, gas budget) are validated. Unrecognized or invalid prefixes are rejected at admission. -2. **Validation-trace simulation**: a dedicated `ValidationObserver` on the VM enforces ERC-7562-style banned-opcode and storage-dependency rules during a throwaway prefix-only execution against the canonical head state. Banned opcodes (TIMESTAMP outside the expiry verifier, BALANCE, SELFBALANCE, BLOCKHASH, ORIGIN, COINBASE, GASPRICE, NUMBER, PREVRANDAO, GASLIMIT, BASEFEE, BLOBHASH, BLOBBASEFEE, SELFDESTRUCT, INVALID, TLOAD, TSTORE; SSTORE/CREATE/CREATE2 only inside a deploy frame; GAS only immediately before a call instruction) cause the tx to be rejected with `FrameTxValidationFailed`. +2. **Validation-trace simulation**: a dedicated `ValidationObserver` on the VM enforces ERC-7562-style banned-opcode and storage-dependency rules during a throwaway prefix-only execution against the canonical head state. Banned opcodes (TIMESTAMP outside the expiry verifier, BALANCE, SELFBALANCE, BLOCKHASH, ORIGIN, COINBASE, GASPRICE, NUMBER, PREVRANDAO, GASLIMIT, BASEFEE, BLOBHASH, BLOBBASEFEE, SLOTNUM, SELFDESTRUCT, INVALID, TLOAD, TSTORE; SSTORE/CREATE/CREATE2 only inside a deploy frame; GAS only immediately before a call instruction) cause the tx to be rejected with `FrameTxValidationFailed`. 3. **Paymaster availability accounting**: the simulation identifies the payer (paymaster) and reserves the tx's `max_cost` against the paymaster's on-chain balance. Concurrent pending reservations are summed so multiple sponsored txs cannot collectively overdraw the paymaster. 4. **Post-block revalidation**: after each new canonical block, pending frame txs are re-simulated against the updated head state and evicted if they no longer pass. Expiry-based eviction (deadline now behind the new block's timestamp) runs first without a full re-simulation. diff --git a/test/tests/levm/eip8141_tests.rs b/test/tests/levm/eip8141_tests.rs index 0b9aa178167..ea5f7f8943c 100644 --- a/test/tests/levm/eip8141_tests.rs +++ b/test/tests/levm/eip8141_tests.rs @@ -1944,6 +1944,7 @@ mod validation_observer_tests { assert_eq!(u8::from(Opcode::BASEFEE), 0x48); assert_eq!(u8::from(Opcode::BLOBHASH), 0x49); assert_eq!(u8::from(Opcode::BLOBBASEFEE), 0x4A); + assert_eq!(u8::from(Opcode::SLOTNUM), 0x4B); assert_eq!(u8::from(Opcode::INVALID), 0xFE); assert_eq!(u8::from(Opcode::SELFDESTRUCT), 0xFF); assert_eq!(u8::from(Opcode::BALANCE), 0x31); @@ -2178,6 +2179,28 @@ mod validation_observer_tests { ); } + #[test] + fn slotnum_is_banned() { + let sender = addr(0x4B00); + // SLOTNUM (0x4B) then STOP. EIP-7843's slot number changes between + // admission and inclusion exactly like NUMBER/TIMESTAMP, so a prefix + // branching on it could pass simulation and revert on inclusion. It is + // not covered transitively: the handler reads `env.slot_number` from the + // header rather than deriving it from TIMESTAMP. + let code = Bytes::from(vec![0x4B, 0x00]); + let tx = frame_tx_for_obs( + sender, + vec![verify_frame_obs(sender, 50_000, 0x03, Bytes::new())], + ); + let mut db = build_db(vec![(sender, account_with_code(0, code))]); + let (_result, violation) = run(&tx, &mut db, sender, &[0], None); + assert_eq!( + violation, + Some(FrameSimViolation::BannedOpcode(0x4B)), + "SLOTNUM must be a banned opcode during prefix simulation" + ); + } + #[test] fn sstore_outside_deploy_is_rejected() { let sender = addr(0x55_00);