From 0c0bd2d2373a264e27870b599481e611f132e2e5 Mon Sep 17 00:00:00 2001 From: Richard1048576 <178553006+Richard1048576@users.noreply.github.com> Date: Sun, 12 Jul 2026 21:37:52 +0800 Subject: [PATCH] fix(tx-filter): emergency EIP-7702 lockdown for the audit#838 nonce halt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neutralise the EIP-7702 nonce-bump executor-halt class at the pre-execution transaction filter, gated by a compile-time const EIP7702_LOCKDOWN (currently `true` — this build ships it active) until the durable executor-skip fix (gravity-reth #388 + grevm #110) is deployed. Three deterministic drops against certified block-start state, applied when the lockdown is on: L1 reject every type-4 (SetCode) tx - no new delegations (covers audit#822) L2 drop any tx FROM a delegated account - its execution-time CREATE cannot leave a same-block stale tx (audit#838) L3 drop any tx TO a delegated account - no inbound CALL triggers the delegated CREATE The filter's in-block simulation models the 7702 authorization-apply nonce bump (audit#822) but not the execution-time CREATE bump (audit#838): a delegated account's current-nonce tx reached the executor as NonceTooLow and panicked it at lib.rs:1137. Dropped txs are excluded via the existing invalid_tx_idxs path, so the drop itself cannot halt the chain. EIP7702_LOCKDOWN is a compile-time const (not a CLI/env flag) because it is consensus-critical: a per-node value would fork the chain, so fleet-consistency is made structural by baking it into the binary — the coordinated upgrade IS the binary version. Set it back to false and rebuild to revert once the skip fix is deployed and 7702 can be re-enabled. Trade-off: freezes delegated accounts' tx origination and disables new 7702 usage while active. Tests: filter_invalid_txs takes an eip7702_lockdown param so the unit tests cover both states (L1/L2/L3 + the audit#838 attack shape + non-delegated-traffic-unaffected). The full-pipeline regression test test_finding_a_lockdown_survives_grevm drives OrderedBlock -> filter_invalid_txs -> executor: with the lockdown the attack block survives; without it the executor panics at lib.rs:1137 with NonceTooLow{tx:0,state:1}. The P-13/P-14 7702-delegation positive controls are #[ignore]'d while the lockdown ships (7702 delegation is disabled by it). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/gravity-primitives/src/config.rs | 15 + crates/gravity-primitives/src/lib.rs | 4 +- .../pipe-exec-layer-ext-v2/execute/src/lib.rs | 1 + .../execute/src/tx_filter.rs | 498 ++++++++++++++++-- .../execute/tests/gravity_eip7702_test.rs | 177 +++++++ 5 files changed, 644 insertions(+), 51 deletions(-) diff --git a/crates/gravity-primitives/src/config.rs b/crates/gravity-primitives/src/config.rs index 773c1a68cb..bb3ad1a8d3 100644 --- a/crates/gravity-primitives/src/config.rs +++ b/crates/gravity-primitives/src/config.rs @@ -9,6 +9,21 @@ use std::sync::OnceLock; /// gravity-audit#712. pub const PIPE_BLOCK_GAS_LIMIT: u64 = 1_000_000_000; +/// Emergency EIP-7702 lockdown (gravity-audit#838). When `true`, `filter_invalid_txs` +/// additionally drops every type-4 (`SetCode`) tx (L1) and every tx from/to a currently-delegated +/// account (L2/L3), neutralising the 7702 nonce-bump executor-halt class until the durable +/// executor-skip fix ships. +/// +/// This is a **compile-time constant on purpose**: like `PIPE_BLOCK_GAS_LIMIT` it is +/// consensus-critical (it changes which txs execute), so every node MUST agree on it. Baking it +/// into the binary makes that guarantee structural — a mixed value would fork the chain, and a +/// hardcoded const cannot be misconfigured per-node the way a CLI/env flag could. +/// +/// Currently `true` — this build ships the lockdown ACTIVE. To REVERT, once the durable +/// executor-skip fix (gravity-reth #388 + grevm #110) is deployed and 7702 can be re-enabled, +/// set this back to `false` and rebuild (a coordinated upgrade, same as any binary version). +pub const EIP7702_LOCKDOWN: bool = true; + /// Configuration options for the Gravity Reth. #[derive(Debug, Clone)] pub struct Config { diff --git a/crates/gravity-primitives/src/lib.rs b/crates/gravity-primitives/src/lib.rs index 968b02e659..a4f6012803 100644 --- a/crates/gravity-primitives/src/lib.rs +++ b/crates/gravity-primitives/src/lib.rs @@ -1,4 +1,6 @@ //! Common types in gravity-reth. mod config; -pub use config::{get_gravity_config, init_gravity_config, Config, PIPE_BLOCK_GAS_LIMIT}; +pub use config::{ + get_gravity_config, init_gravity_config, Config, EIP7702_LOCKDOWN, PIPE_BLOCK_GAS_LIMIT, +}; diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/lib.rs b/crates/pipe-exec-layer-ext-v2/execute/src/lib.rs index 260777c697..a1da17f9ab 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/lib.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/lib.rs @@ -1490,6 +1490,7 @@ impl Core { &self.chain_spec, block_timestamp, block_number, + gravity_primitives::EIP7702_LOCKDOWN, ); if invalid_idxs.is_empty() { let mut txs_info = Vec::with_capacity(txs.len()); diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/tx_filter.rs b/crates/pipe-exec-layer-ext-v2/execute/src/tx_filter.rs index aad6da2829..e6a33b7a16 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/tx_filter.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/tx_filter.rs @@ -89,6 +89,10 @@ pub(crate) fn filter_invalid_txs( chain_spec: &ChainSpec, block_timestamp: u64, block_number: u64, + // Emergency EIP-7702 lockdown (gravity-audit#838). When false, base behaviour is + // unchanged; when true, additionally drop every type-4 tx (L1) and every tx from/to a + // delegated account (L2/L3). CONSENSUS-CRITICAL — must be identical on every node. + eip7702_lockdown: bool, ) -> HashSet { let spec_id = revm_spec_by_timestamp_and_block_number(chain_spec, block_timestamp, block_number); @@ -177,12 +181,28 @@ pub(crate) fn filter_invalid_txs( ); return false; } - // EIP-7702 gates. Pre-Prague: reject the whole tx type (revm fires - // `Eip7702NotSupported`; `calculate_initial_tx_gas` below also ignores auth-list - // cost pre-Prague, so a 21k-gas TxEip7702 would slip the intrinsic check). - // Post-Prague: reject empty authorization_list (revm fires - // `EmptyAuthorizationList`). Closes audit#696 P-2 and audit#710 gap 3. + // EIP-7702 gates. + // + // Base (always): reject a pre-Prague type-4 tx (revm `Eip7702NotSupported`) and a + // post-Prague type-4 tx with an empty `authorization_list` (`EmptyAuthorizationList`); + // both would otherwise reach the executor as InvalidTransaction. Closes audit#696 P-2 + // and audit#710 gap 3. + // + // EMERGENCY LOCKDOWN — L1 (audit#838 + #822, gated on `eip7702_lockdown`): when enabled, + // reject the ENTIRE type-4 tx type wholesale (a strict superset of the base gate) so no + // NEW delegations can be created. Together with the from-/to-delegated drops (L2/L3) + // below this closes the 7702 nonce-bump halt surface — the authorization-apply bump + // (#822) and the un-modelled execution-time CREATE bump (#838). REVERT (drop the flag) + // once the executor-skip fix (gravity-reth #388 + grevm #110) is deployed. if tx.is_eip7702() { + if eip7702_lockdown { + info!(target: "filter_invalid_txs", + tx_hash=?tx.hash(), + sender=?sender, + "7702-lockdown: EIP-7702 (SetCode) tx rejected wholesale (audit#838)" + ); + return false; + } if !spec_id.is_enabled_in(SpecId::PRAGUE) { info!(target: "filter_invalid_txs", tx_hash=?tx.hash(), @@ -192,8 +212,7 @@ pub(crate) fn filter_invalid_txs( ); return false; } - let auth_count = tx.authorization_list().map(|l| l.len()).unwrap_or(0); - if auth_count == 0 { + if tx.authorization_list().map(|l| l.len()).unwrap_or(0) == 0 { info!(target: "filter_invalid_txs", tx_hash=?tx.hash(), sender=?sender, @@ -302,6 +321,22 @@ pub(crate) fn filter_invalid_txs( .unwrap_or(false) }; + // EMERGENCY EIP-7702 LOCKDOWN — L2/L3 helper. `true` if the account CURRENTLY + // carries a 7702 delegation designator (`0xef 0x01 0x00 || address`), read from + // block-start state. Used to drop any tx originated BY (L2) or sent TO (L3) a + // delegated account, so neither the delegated account's own stale tx nor an + // inbound CALL that triggers its delegated CREATE can reach the executor. Note + // this differs from `code_permits`: an empty-code EOA is NOT delegated. + // REVERT WHEN THE SKIP FIX IS DEPLOYED. + let is_delegated = |acct: &AccountInfo| -> bool { + acct.code_hash != KECCAK_EMPTY && + acct.code + .clone() + .or_else(|| db.code_by_hash_ref(acct.code_hash).ok()) + .map(|b| b.is_eip7702()) + .unwrap_or(false) + }; + // Block-order sequential simulation. `sim[addr]` is the address's account evolved // in-block (nonce + balance), seeded lazily from the certified parent state; `None` // marks an address absent from state. It holds BOTH tx senders AND EIP-7702 @@ -320,6 +355,25 @@ pub(crate) fn filter_invalid_txs( let tx = &txs[idx]; let sender = senders[idx]; + // EMERGENCY EIP-7702 LOCKDOWN — L3 (gated on `eip7702_lockdown`): drop any tx whose + // recipient is a currently-delegated account, so no inbound CALL can trigger the + // callee's delegated CREATE (which bumps the callee's own nonce mid-block — the #838 + // primitive). Read against block-start state (L1 rejects all type-4, so no delegation + // is created in-block). `to()` is `None` for a contract-creation tx. + if eip7702_lockdown && let Some(to) = tx.to() { + let to_delegated = + db.basic_ref(to).ok().flatten().map(|a| is_delegated(&a)).unwrap_or(false); + if to_delegated { + info!(target: "filter_invalid_txs", + tx_hash=?tx.hash(), + to=?to, + "7702-lockdown: tx to delegated account rejected (audit#838)" + ); + invalid_tx_idxs.insert(idx); + continue; + } + } + // Validate the tx against the simulated sender account and apply the caller nonce // bump + balance deduction. Scoped so the `sim[sender]` borrow is released before // the authorization loop re-borrows `sim` (an authority may be any account). @@ -329,7 +383,18 @@ pub(crate) fn filter_invalid_txs( // Sender absent from state -> cannot pay for / originate the tx. None => false, Some(account) => { - if !code_permits(account) { + if eip7702_lockdown && is_delegated(account) { + // EMERGENCY EIP-7702 LOCKDOWN — L2 (gated on `eip7702_lockdown`): the + // delegated sender's own execution-time CREATE can bump its nonce (not + // modelled by this filter's auth-list simulation), making this + // current-nonce tx `NonceTooLow` -> executor halt (audit#838). Drop it. + info!(target: "filter_invalid_txs", + tx_hash=?tx.hash(), + sender=?sender, + "7702-lockdown: tx from delegated account rejected (audit#838)" + ); + false + } else if !code_permits(account) { info!(target: "filter_invalid_txs", sender=?sender, code_hash=?account.code_hash, @@ -478,6 +543,36 @@ mod tests { ) } + /// Legacy tx with a caller-chosen recipient — for the 7702-lockdown L3 + /// (tx-to-delegated) tests. + fn create_test_transaction_to( + nonce: u64, + gas_limit: u64, + gas_price: u128, + to: Address, + ) -> TransactionSigned { + TransactionSigned::new_unhashed( + Transaction::Legacy(TxLegacy { + nonce, + gas_price, + gas_limit, + to: TxKind::Call(to), + ..Default::default() + }), + Signature::test_signature(), + ) + } + + /// A block-start-state account carrying a 7702 delegation designator to `target`. + fn delegated_account(target: Address) -> AccountInfo { + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u64), + nonce: 0, + code_hash: B256::repeat_byte(0xcd), + code: Some(Bytecode::new_eip7702(target)), + } + } + fn create_test_transaction_with_value( nonce: u64, gas_limit: u64, @@ -546,6 +641,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert!(invalid_idxs.is_empty()); } @@ -571,6 +667,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 1); assert!(invalid_idxs.contains(&0)); @@ -606,6 +703,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 1); assert!(invalid_idxs.contains(&0)); @@ -644,6 +742,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 2); assert!(invalid_idxs.contains(&0)); @@ -681,6 +780,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 1); assert!(invalid_idxs.contains(&1)); @@ -718,6 +818,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 2); assert!(invalid_idxs.contains(&0)); @@ -755,6 +856,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert!(invalid_idxs.is_empty()); } @@ -815,6 +917,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 5, "invalid_idxs: {invalid_idxs:?}"); assert!(invalid_idxs.contains(&1)); @@ -858,12 +961,14 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 1, "intrinsic-gas-too-low 7702 tx should be discarded"); assert!(invalid_idxs.contains(&0)); } - /// Sanity check: same 7702 tx with a `gas_limit` at or above the floor passes the filter. + /// Sanity check (lockdown OFF): a 7702 tx with `gas_limit` at/above the intrinsic floor + /// passes the filter — base behaviour, unchanged by the lockdown flag. #[test] fn test_filter_invalid_txs_eip7702_intrinsic_gas_just_enough_under_prague() { let mut db = MockDatabase::new(); @@ -878,7 +983,7 @@ mod tests { }, ); - // exactly 21000 + 25000 = 46000 — at the floor, should pass + // exactly 21000 + 25000 = 46000 — at the floor, should pass (lockdown off). let tx = create_test_7702_transaction(0, 46_000, 1); let txs = vec![tx]; let senders = vec![sender]; @@ -894,12 +999,16 @@ mod tests { &prague_chain_spec(), 0, 0, + false, + ); + assert!( + invalid_idxs.is_empty(), + "base 7702 tx at floor must pass (lockdown off): {invalid_idxs:?}" ); - assert!(invalid_idxs.is_empty(), "got: {invalid_idxs:?}"); } - /// U-1 (acceptance design §3.1): a 7702 tx with `authorization_list.len() == 2` and - /// `gas_limit = 21000 + 25000 * 2 + 1000 = 72000` passes the filter under Prague. + /// U-1 (acceptance design §3.1, lockdown OFF): a 7702 tx with two authorizations and + /// sufficient gas passes the filter — base behaviour, unchanged by the lockdown flag. #[test] fn test_filter_invalid_txs_eip7702_two_auths_gas_sufficient() { let mut db = MockDatabase::new(); @@ -918,11 +1027,20 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert!( invalid_idxs.is_empty(), - "two-auth 7702 tx with 72k gas must pass: {invalid_idxs:?}" + "base two-auth 7702 tx with 72k gas must pass (lockdown off): {invalid_idxs:?}" ); } @@ -946,8 +1064,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 1, "three-auth 7702 tx at 21k gas must be discarded"); assert!(invalid_idxs.contains(&0)); } @@ -977,8 +1104,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &shanghai_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &shanghai_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 1, "7702 tx must be discarded when spec_id < PRAGUE"); assert!(invalid_idxs.contains(&0)); } @@ -1004,8 +1140,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert!( invalid_idxs.is_empty(), "legacy 21k-gas tx must not be regressed by the 7702 intrinsic fix: {invalid_idxs:?}" @@ -1074,8 +1219,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 1, "type-3 (blob) tx must be discarded on Gravity"); assert!(invalid_idxs.contains(&0)); } @@ -1103,8 +1257,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!( invalid_idxs.len(), 1, @@ -1137,8 +1300,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 10_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 10_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert!( invalid_idxs.is_empty(), "Create tx at exactly MAX_INITCODE_SIZE must pass the size gate: {invalid_idxs:?}" @@ -1242,6 +1414,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 1, "legacy tx with gas_price < base_fee must be discarded"); assert!(invalid_idxs.contains(&0)); @@ -1277,6 +1450,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!(invalid_idxs.len(), 1, "1559 tx with max_fee < base_fee must be discarded"); assert!(invalid_idxs.contains(&0)); @@ -1305,8 +1479,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 1, "1559 tx with prio > max must be discarded"); assert!(invalid_idxs.contains(&0)); } @@ -1335,8 +1518,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!( invalid_idxs.len(), 1, @@ -1385,6 +1577,7 @@ mod tests { &prague_chain_spec(), 0, 0, + false, ); assert_eq!( invalid_idxs.len(), @@ -1416,8 +1609,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 1, "1559 tx with wrong chain_id must be discarded"); assert!(invalid_idxs.contains(&0)); } @@ -1441,8 +1643,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 1, "legacy tx with chain_id=Some(2) must be discarded"); assert!(invalid_idxs.contains(&0)); } @@ -1467,8 +1678,17 @@ mod tests { let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert!( invalid_idxs.is_empty(), "pre-EIP-155 legacy tx must pass the chain-id gate: {invalid_idxs:?}" @@ -1503,40 +1723,218 @@ mod tests { let txs = vec![tx1, tx2]; let senders = vec![sender, sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); assert_eq!(invalid_idxs.len(), 2, "all txs from coded sender must be discarded"); assert!(invalid_idxs.contains(&0)); assert!(invalid_idxs.contains(&1)); } - /// EIP-3607 delegation exception: sender whose code is an EIP-7702 delegation - /// designator (`0xef 0x01 0x00 + address`) is allowed to send txs — Pectra - /// relaxed 3607 precisely to permit delegated EOAs. + /// 7702 LOCKDOWN (L2): pre-lockdown, EIP-3607 was relaxed so a delegated EOA could + /// originate txs. Under the lockdown a tx FROM a delegated account is dropped, because + /// its execution-time CREATE can bump its own nonce and halt the executor (#838). + /// Revert to "accepted" when the skip fix is deployed. #[test] - fn test_filter_invalid_txs_sender_with_7702_delegation_accepted() { + fn test_filter_invalid_txs_sender_with_7702_delegation_rejected_under_lockdown() { let mut db = MockDatabase::new(); let sender = Address::random(); let target = Address::repeat_byte(0x42); + db.insert_account(sender, delegated_account(target)); + + let tx = create_test_transaction(0, 21_000, 25_000_000_000); + let txs = vec![tx]; + let senders = vec![sender]; + + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + true, + ); + assert_eq!( + invalid_idxs.len(), + 1, + "7702-lockdown L2 must drop a tx from a delegated sender: {invalid_idxs:?}" + ); + assert!(invalid_idxs.contains(&0)); + } + + /// EIP-3607 delegation exception (lockdown OFF): a delegated EOA (`0xef0100…` designator) + /// may originate txs — Pectra relaxed 3607 for delegated EOAs. Base behaviour, unchanged + /// by the lockdown flag. + #[test] + fn test_filter_invalid_txs_sender_with_7702_delegation_accepted_base() { + let mut db = MockDatabase::new(); + let sender = Address::random(); + db.insert_account(sender, delegated_account(Address::repeat_byte(0x42))); + + let tx = create_test_transaction(0, 21_000, 25_000_000_000); + let txs = vec![tx]; + let senders = vec![sender]; + + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + false, + ); + assert!( + invalid_idxs.is_empty(), + "delegated sender must pass with lockdown off (EIP-3607 exception): {invalid_idxs:?}" + ); + } + + /// 7702 LOCKDOWN (L3): a tx whose recipient is a delegated account is dropped, so an + /// inbound CALL cannot trigger the callee's delegated CREATE. + #[test] + fn test_filter_invalid_txs_recipient_delegated_rejected_under_lockdown() { + let mut db = MockDatabase::new(); + let caller = Address::random(); + let delegated = Address::repeat_byte(0xa1); + db.insert_account( + caller, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + db.insert_account(delegated, delegated_account(Address::repeat_byte(0x42))); + + // caller (normal EOA) -> delegated account. + let tx = create_test_transaction_to(0, 21_000, 25_000_000_000, delegated); + let txs = vec![tx]; + let senders = vec![caller]; + + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + true, + ); + assert_eq!( + invalid_idxs.len(), + 1, + "7702-lockdown L3 must drop a tx to a delegated recipient: {invalid_idxs:?}" + ); + assert!(invalid_idxs.contains(&0)); + } + + /// 7702 LOCKDOWN — audit#838 attack shape neutralised: block = [X (funder -> delegated + /// A), A@M (from delegated A)]. Pre-lockdown A@M reached the executor as `NonceTooLow` + /// after X's inbound CALL bumped A's nonce via a delegated CREATE -> halt. Under the + /// lockdown BOTH are dropped: X by L3 (to delegated), A@M by L2 (from delegated). + #[test] + fn test_filter_invalid_txs_audit838_attack_shape_neutralised() { + let mut db = MockDatabase::new(); + let funder = Address::random(); + let a = Address::repeat_byte(0xaa); + db.insert_account( + funder, + AccountInfo { + balance: U256::from(1_000_000_000_000_000_000u64), + nonce: 0, + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + db.insert_account(a, delegated_account(Address::repeat_byte(0xcc))); + + let x = create_test_transaction_to(0, 21_000, 25_000_000_000, a); // funder -> A (L3) + let a_at_m = create_test_transaction(0, 21_000, 25_000_000_000); // from A (L2) + let txs = vec![x, a_at_m]; + let senders = vec![funder, a]; + + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + true, + ); + assert_eq!( + invalid_idxs.len(), + 2, + "both legs of the audit#838 attack shape must be dropped: {invalid_idxs:?}" + ); + assert!(invalid_idxs.contains(&0) && invalid_idxs.contains(&1)); + } + + /// 7702 LOCKDOWN precision: a normal tx between NON-delegated accounts (recipient has + /// ordinary contract code, not a 7702 designator) is unaffected — the lockdown only + /// touches 7702 delegations, not general traffic. + #[test] + fn test_filter_invalid_txs_non_delegated_traffic_unaffected_by_lockdown() { + let mut db = MockDatabase::new(); + let sender = Address::random(); + let contract = Address::repeat_byte(0xbe); db.insert_account( sender, AccountInfo { balance: U256::from(1_000_000_000_000_000_000u64), nonce: 0, - code_hash: B256::repeat_byte(0xcd), - code: Some(Bytecode::new_eip7702(target)), + code_hash: KECCAK_EMPTY, + code: None, + }, + ); + // Recipient has ordinary (non-7702) code. + db.insert_account( + contract, + AccountInfo { + balance: U256::ZERO, + nonce: 1, + code_hash: B256::repeat_byte(0x11), + code: Some(Bytecode::new_raw(Bytes::from_static(&[0x60, 0x00, 0x60, 0x00]))), }, ); - let tx = create_test_transaction(0, 21_000, 25_000_000_000); + let tx = create_test_transaction_to(0, 21_000, 25_000_000_000, contract); let txs = vec![tx]; let senders = vec![sender]; - let invalid_idxs = - filter_invalid_txs(&db, &txs, &senders, 0, 30_000_000, &prague_chain_spec(), 0, 0); + let invalid_idxs = filter_invalid_txs( + &db, + &txs, + &senders, + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + true, + ); assert!( invalid_idxs.is_empty(), - "tx from EIP-7702-delegated sender must pass: {invalid_idxs:?}" + "non-delegated traffic must be unaffected by the 7702 lockdown: {invalid_idxs:?}" ); } } diff --git a/crates/pipe-exec-layer-ext-v2/execute/tests/gravity_eip7702_test.rs b/crates/pipe-exec-layer-ext-v2/execute/tests/gravity_eip7702_test.rs index f0f0f718f7..ae0af6ced7 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/tests/gravity_eip7702_test.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/tests/gravity_eip7702_test.rs @@ -108,6 +108,38 @@ fn gravity_prague_chainspec(prague_time: Option) -> String { json.to_string() } +/// Chainspec for the Finding-A / audit#838 lockdown test. On top of Prague activation, +/// pre-seeds two accounts into the genesis alloc: +/// - `c` — code = minimal CREATE runtime (`0x600060006000f0`). +/// - `a` — code = the 7702 delegation designator `0xef0100 || c`, funded, nonce 0. +/// +/// Seeding A as *already delegated* models the exploit faithfully: Finding A attacks an +/// account delegated in a PRIOR (permissionless) block, before the lockdown filter is in +/// force — the lockdown's L1 blocks NEW delegations, so an in-band `SetCode` could not set it up. +fn finding_a_chainspec(prague_time: Option, a: Address, c: Address) -> String { + let mut json: serde_json::Value = + serde_json::from_str(include_str!("../gravity_hardfork.json")) + .expect("gravity_hardfork.json must parse as JSON"); + if let Some(ts) = prague_time { + json["config"]["pragueTime"] = serde_json::json!(ts); + } + let a_key = format!("0x{}", alloy_primitives::hex::encode(a.as_slice())); + let c_key = format!("0x{}", alloy_primitives::hex::encode(c.as_slice())); + let designator = { + let mut v = vec![0xefu8, 0x01, 0x00]; + v.extend_from_slice(c.as_slice()); + format!("0x{}", alloy_primitives::hex::encode(&v)) + }; + json["alloc"][&c_key] = + serde_json::json!({ "balance": "0x0", "nonce": 0, "code": "0x600060006000f0" }); + json["alloc"][&a_key] = serde_json::json!({ + "balance": "0x3635c9adc5dea00000", // 1000 ETH + "nonce": 0, + "code": designator, + }); + json.to_string() +} + /// Anvil account 0 — pre-funded in `gravity_hardfork.json` (`0x2e51 ETH`). /// Used as the tx sender (wallet_B / relayer) in every 7702 scenario. const FUNDED_PRIVKEY_HEX: &[u8; 32] = &[ @@ -121,6 +153,13 @@ const FUNDED_ADDR: Address = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb9226 /// thereof) only matters if the authority is subsequently CALLed. const TARGET_ADDR: Address = address!("0x0000000000000000000000000000000000001234"); +/// Finding-A / audit#838 scenario: `FA_C_ADDR` is genesis-seeded with the minimal +/// CREATE runtime `PUSH1 0; PUSH1 0; PUSH1 0; CREATE` (`0x600060006000f0`). An account +/// delegated to it bumps its OWN nonce whenever it is CALLed. `DEAD_ADDR` is A@M's inert +/// recipient. +const FA_C_ADDR: Address = address!("0x000000000000000000000000000000000000c0de"); +const DEAD_ADDR: Address = address!("0x000000000000000000000000000000000000dEaD"); + fn funded_signer() -> PrivateKeySigner { PrivateKeySigner::from_bytes(&B256::from(*FUNDED_PRIVKEY_HEX)) .expect("funded test key must parse") @@ -197,6 +236,37 @@ fn build_signed_eip7702_tx( (signed_tx, sender.address()) } +/// Build + sign a plain EIP-1559 tx (used for the Finding-A attack legs X and A@M, both of +/// which are ordinary txs — not type-4). Returns the tx plus its recovered sender. +fn build_signed_1559_tx( + sender: &PrivateKeySigner, + nonce: u64, + gas_limit: u64, + to: alloy_primitives::TxKind, + input: Bytes, +) -> (TransactionSigned, Address) { + use alloy_consensus::{SignableTransaction, TxEip1559}; + + let tx = TxEip1559 { + chain_id: CHAIN_ID, + nonce, + gas_limit, + max_fee_per_gas: 1_000_000_000, + max_priority_fee_per_gas: 0, + to, + value: U256::ZERO, + access_list: Default::default(), + input, + }; + let sig_hash = tx.signature_hash(); + let signature: Signature = sender.sign_hash_sync(&sig_hash).expect("tx signing must succeed"); + let signed = tx.into_signed(signature); + let (tx, sig, _hash) = signed.into_parts(); + let signed_tx = TransactionSigned::new_unhashed(Transaction::Eip1559(tx), sig); + let _ = signed_tx.hash(); + (signed_tx, sender.address()) +} + // --------------------------------------------------------------------------- // OrderedBlock helpers // --------------------------------------------------------------------------- @@ -503,6 +573,11 @@ async fn run_p13_happy_path( Ok(state_root) } +// P-13/P-14 are positive controls that a valid 7702 SetCode tx delegates the authority. The +// EIP7702_LOCKDOWN build (const = true) intentionally drops ALL type-4 txs (L1), so 7702 +// delegation cannot happen and these tests cannot pass. Ignored while the lockdown ships; +// un-ignore when the const is flipped back to false (skip fix deployed, 7702 re-enabled). +#[ignore = "7702 delegation is disabled by EIP7702_LOCKDOWN; un-ignore when the lockdown is lifted"] #[test] fn test_p13_happy_path_grevm() { let state_root = run_pipe_e2e_test( @@ -514,6 +589,7 @@ fn test_p13_happy_path_grevm() { println!("[eip7702_test] grevm state root = {state_root:?}"); } +#[ignore = "7702 delegation is disabled by EIP7702_LOCKDOWN; un-ignore when the lockdown is lifted"] #[test] fn test_p13_happy_path_disable_grevm() { let state_root = run_pipe_e2e_test( @@ -607,6 +683,107 @@ fn test_p12_low_gas_eip7702_discarded_disable_grevm() { ); } +// --------------------------------------------------------------------------- +// Finding A / audit#838 — EIP-7702 lockdown end-to-end. +// +// A is genesis-delegated to a CREATE contract C (already-delegated variant — the +// permissionless prior-block setup the lockdown's L1 can no longer produce). The +// activation block carries the attack pair: +// X : FUNDER -> A (an inbound CALL that runs A's delegated CREATE, bumping A's nonce) +// A@M : A -> DEAD (A's current-nonce tx, which the CREATE bump would turn NonceTooLow) +// +// WITHOUT the lockdown, A@M reaches the executor as NonceTooLow and panics it at lib.rs +// (whole-network halt — the control, reproduced live on the isolated devnet earlier). WITH +// the lockdown, L3 drops X (to a delegated account) and L2 drops A@M (from a delegated +// account), so the block executes with zero user txs and the chain keeps advancing. +// --------------------------------------------------------------------------- + +async fn run_finding_a_lockdown_survive( + builder: WithLaunchContext, ChainSpec>>, +) -> eyre::Result { + let (_chain_spec, provider, pipeline_api, latest_block_number) = boot_pipeline(builder).await?; + + let mut epoch: u64 = pipeline_api + .fetch_config_bytes(OnChainConfig::Epoch, BlockNumber::Latest) + .unwrap() + .try_into() + .unwrap(); + + let consensus = MockConsensus::new(pipeline_api, Box::new(p3_ts_us)); + consensus.push_empty_range(&mut epoch, latest_block_number + 1, P3_ACTIVATION_BLOCK - 1).await; + + let funder = funded_signer(); + let a = authority_signer(0xAA); + + // Sanity: the genesis seeding really made A an already-delegated account. + assert_designator(&provider, P3_ACTIVATION_BLOCK - 1, a.address(), FA_C_ADDR); + + // Attack block 100 (Prague active): [X (FUNDER -> A), A@M (A -> DEAD, nonce 0)]. + let (x_tx, x_from) = build_signed_1559_tx( + &funder, + 0, + 200_000, + alloy_primitives::TxKind::Call(a.address()), + Bytes::new(), + ); + let (am_tx, am_from) = build_signed_1559_tx( + &a, + 0, + 40_000, + alloy_primitives::TxKind::Call(DEAD_ADDR), + Bytes::new(), + ); + + let block = ordered_block_with_txs( + epoch, + P3_ACTIVATION_BLOCK, + mock_block_id(P3_ACTIVATION_BLOCK), + mock_block_id(P3_ACTIVATION_BLOCK - 1), + p3_ts_us(P3_ACTIVATION_BLOCK), + vec![x_tx, am_tx], + vec![x_from, am_from], + ); + // If the lockdown filter did NOT drop both legs, A@M would reach the executor as + // NonceTooLow and the panic hook would `process::exit(1)` — so a clean return IS survival. + let result = consensus.push_one(&mut epoch, block).await; + let pipeline_api = consensus.into_inner(); + pipeline_api.wait_for_block_persistence(P3_ACTIVATION_BLOCK).await.unwrap(); + println!( + "[eip7702_test] ✅ Finding-A attack block executed WITHOUT halt: {:?}", + result.block_hash + ); + + // Both attack legs were filtered: A is untouched (still delegated, CREATE never fired). + assert_designator(&provider, P3_ACTIVATION_BLOCK, a.address(), FA_C_ADDR); + + // Prove the chain keeps making progress past the attack block. + let mut epoch_after = epoch; + let consensus = MockConsensus::new(pipeline_api, Box::new(p3_ts_us)); + consensus + .push_empty_range(&mut epoch_after, P3_ACTIVATION_BLOCK + 1, P3_ACTIVATION_BLOCK + 1) + .await; + println!("[eip7702_test] ✅ Finding-A lockdown: chain progressed past the attack block."); + + Ok(read_state_root(&provider, P3_ACTIVATION_BLOCK)) +} + +// Full-pipeline proof of the lockdown: with `EIP7702_LOCKDOWN = true` (this build) the audit#838 +// attack block [X -> delegated A, A@M] executes with zero user txs — both legs filtered (X by L3, +// A@M by L2) — and the chain advances, instead of panicking the executor at lib.rs on A@M's +// NonceTooLow. If the const is flipped back to `false`, this same block would instead halt the +// executor (the survive-vs-halt A/B was verified manually both ways). +#[test] +fn test_finding_a_lockdown_survives_grevm() { + let a = authority_signer(0xAA).address(); + let spec = finding_a_chainspec(Some(PRAGUE_TS_BLOCK_100), a, FA_C_ADDR); + let _ = run_pipe_e2e_test( + &spec, + "data/gravity_eip7702_finding_a_lockdown_grevm", + false, + run_finding_a_lockdown_survive, + ); +} + // --------------------------------------------------------------------------- // Test harness — single entry point that boots the node CLI. // ---------------------------------------------------------------------------