From f434ee69f502fe81dc898404c6235af8a06ab0b8 Mon Sep 17 00:00:00 2001 From: Richard1048576 <178553006+Richard1048576@users.noreply.github.com> Date: Sat, 11 Jul 2026 08:14:51 +0800 Subject: [PATCH 1/2] fix(exec): stopgap for the EIP-7702 delegate-then-CREATE nonce halt + defensive hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three hardening changes on the deterministic ordered-block execution path. Stacked on #385 (the tx_filter stopgap builds on that PR's block-order sequential filter). 1. tx_filter: CREATE-halt stopgap (gravity-audit#838). A 7702 authority delegated in-block to a non-zero delegate can, if its delegated code runs CREATE/CREATE2, have its nonce bumped a *second* time during execution — an effect the filter cannot model without executing. Any *later* same-block tx from such an account is now dropped (halt-safe over-rejection), keeping the executor's NonceTooLow -> panic path unreachable for this vector. KNOWN GAP (documented in code): does not cover an account already delegated at block start that is then called via an inner call the filter cannot see; closing that fully would require rejecting all delegated-EOA txs, which breaks legitimate 7702. The complete fix is executor-side (do not panic on a tx-level InvalidTransaction), tracked in #838 / #823. Not RPC-reachable: a future-nonce follow-up tx is parked in reth's queued sub-pool and never proposed, so this vector requires a byzantine block proposer (see #838). 2. onchain_config/metadata_txn: the free `transact_system_txn` helper returns `Result` instead of `.unwrap()`-panicking on a tx-level error (defensive; no production caller today, so wiring it into the live path later cannot reintroduce a halt). 3. onchain_config/types: validator voting-power casts use `saturating_to` + a saturating fold so a pathological `votingPower` cannot overflow-panic on the epoch-boundary NewEpochEvent path (gravity-audit#823; a safety net, only reachable above ~1.8e19 tokens for one validator, far past total supply). Verified on a Prague single-node devnet: 28 tx_filter unit tests pass (including a new stopgap test; no regression), and the Prague e2e suite passes — the only "failure" is the halt-repro test correctly NOT halting on the fixed binary, which confirms the fix. --- .../src/onchain_config/metadata_txn.rs | 13 ++- .../execute/src/onchain_config/types.rs | 22 +++-- .../execute/src/tx_filter.rs | 97 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/metadata_txn.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/metadata_txn.rs index 2991f0fe1d..0d64c5afeb 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/metadata_txn.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/metadata_txn.rs @@ -211,12 +211,19 @@ pub(crate) fn system_txns_into_executed_ordered_block_result( pub fn transact_system_txn( evm: &mut impl Evm, txn: TransactionSigned, -) -> (SystemTxnResult, EvmState) { +) -> Result<(SystemTxnResult, EvmState), String> { use reth_evm::IntoTxEnv; use reth_primitives::Recovered; let tx_env = Recovered::new_unchecked(txn.clone(), SYSTEM_CALLER).into_tx_env(); - let result = evm.transact_raw(tx_env).unwrap(); + // A system tx is node-constructed (SYSTEM_CALLER), so revm should never reject it at + // tx-level validation — but if it ever does, return a recoverable error instead of + // `.unwrap()`-panicking, which on the deterministic execution path halts every validator + // (gravity-audit#822 class). This free helper has NO production caller today (the live + // path uses the executor method); hardened so wiring it in cannot reintroduce a halt. + let result = evm + .transact_raw(tx_env) + .map_err(|e| format!("system tx transact_raw returned tx-level error: {e:?}"))?; // DESIGN: System transaction failures are intentionally logged, not asserted. // DKG and JWK system transactions can legitimately fail or revert, so a hard @@ -226,7 +233,7 @@ pub fn transact_system_txn( super::errors::log_execution_error(&result.result); } - (SystemTxnResult { result: result.result, txn }, result.state) + Ok((SystemTxnResult { result: result.result, txn }, result.state)) } /// Execute a metadata contract call (onBlockStart from Blocker.sol) diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/types.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/types.rs index a22784137a..e21aa092f2 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/types.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/types.rs @@ -111,7 +111,11 @@ pub fn convert_validator_consensus_info( GravityValidatorInfo::new( account_address, - power_ether.to::(), + // Defensive: `to::()` panics on overflow, which on the epoch-boundary + // NewEpochEvent path would halt the node. Clamp instead (only reachable above ~1.8e19 + // tokens for a single validator — far past total supply; a safety net, not a live + // path). gravity-audit#823. + power_ether.saturating_to::(), ValidatorConfig::new( info.consensusPubkey.clone().into(), info.networkAddresses.to_vec(), @@ -135,13 +139,19 @@ pub fn convert_validators_to_bcs( pending_active: &[ValidatorConsensusInfo], pending_inactive: &[ValidatorConsensusInfo], ) -> Bytes { - // Calculate total voting power from active validators (in Ether units) - let total_voting_power: u128 = - active_validators.iter().map(|v| wei_to_ether(v.votingPower).to::()).sum(); + // Calculate total voting power from active validators (in Ether units). Saturating + // per-element cast + saturating fold so a pathological votingPower cannot panic (overflow) + // on the epoch-boundary path and halt the node. gravity-audit#823. + let total_voting_power: u128 = active_validators + .iter() + .map(|v| wei_to_ether(v.votingPower).saturating_to::()) + .fold(0u128, |acc, x| acc.saturating_add(x)); // Calculate total joining power from pending_active validators - let total_joining_power: u128 = - pending_active.iter().map(|v| wei_to_ether(v.votingPower).to::()).sum(); + let total_joining_power: u128 = pending_active + .iter() + .map(|v| wei_to_ether(v.votingPower).saturating_to::()) + .fold(0u128, |acc, x| acc.saturating_add(x)); let gravity_validator_set = GravityValidatorSet { active_validators: active_validators.iter().map(convert_validator_consensus_info).collect(), 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..75ff0ef4a5 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 @@ -316,10 +316,33 @@ pub(crate) fn filter_invalid_txs( // recovery runs only for type-4 txs. let mut sim: HashMap> = HashMap::default(); let mut invalid_tx_idxs: HashSet = HashSet::default(); + // CREATE-halt stopgap (gravity-audit#838). An account delegated in-block via a 7702 + // authorization can, if its delegated code runs CREATE/CREATE2, have its nonce bumped a + // *second* time during execution — an effect the filter cannot model without executing. + // Once an account is delegated here, every LATER same-block tx from it has an unpredictable + // nonce, so we drop those txs (halt-safe over-rejection). KNOWN GAP: this does not cover an + // account already delegated at block start that is then called (possibly via an inner call + // the filter cannot see); closing that fully would require rejecting all delegated-EOA txs, + // which breaks legitimate 7702. The complete fix is executor-side (do not panic on a + // tx-level InvalidTransaction); tracked in gravity-audit#838 / #823. + let mut delegated_in_block: HashSet
= HashSet::default(); for idx in 0..gas_limit_exceeded_tx_idx { let tx = &txs[idx]; let sender = senders[idx]; + // Stopgap gate: `sender` was delegated earlier in this block, so its post-execution + // nonce is not predictable here — drop this later tx rather than risk NonceTooLow -> + // executor panic (gravity-audit#838). + if delegated_in_block.contains(&sender) { + info!(target: "filter_invalid_txs", + tx_hash=?tx.hash(), + sender=?sender, + "sender delegated as a 7702 authority earlier in-block; dropping later tx (gravity-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). @@ -379,6 +402,13 @@ pub(crate) fn filter_invalid_txs( *auth_acct = Some(AccountInfo { nonce: 1, ..Default::default() }) } } + // This authorization installs runnable 7702 code on `authority` (a + // non-zero delegate; the zero address is a revocation that installs empty + // code and cannot CREATE). Mark it so its LATER same-block txs are + // dropped by the stopgap gate above (gravity-audit#838). + if !auth.address().is_zero() { + delegated_in_block.insert(authority); + } } } } @@ -1539,4 +1569,71 @@ mod tests { "tx from EIP-7702-delegated sender must pass: {invalid_idxs:?}" ); } + + /// gravity-audit#838 stopgap: once an account is delegated in-block via a 7702 + /// authorization to non-zero code, its LATER same-block txs are dropped (their nonce may be + /// bumped a second time by an execution-induced CREATE the filter cannot model). Without the + /// stopgap the filter admits `tx2` (its nonce matches the post-auth-bump sim nonce) — that + /// is the halt. tx1 (the delegating tx) itself is kept. + #[test] + fn test_filter_drops_later_tx_from_in_block_delegated_authority() { + use alloy_consensus::TxEip7702; + use alloy_signer::SignerSync; + use alloy_signer_local::PrivateKeySigner; + + let sponsor = Address::repeat_byte(0x11); + let authority_signer = PrivateKeySigner::from_bytes(&B256::with_last_byte(0x22)).unwrap(); + let authority = authority_signer.address(); + let implementation = Address::repeat_byte(0x33); + let funded = U256::from(1_000_000_000_000_000_000u64); + + // non-zero delegate => installs runnable 7702 code on `authority`. + let auth = Authorization { + chain_id: U256::from(MAINNET_CHAIN_ID), + address: implementation, + nonce: 0, + }; + let signed_auth = auth + .clone() + .into_signed(authority_signer.sign_hash_sync(&auth.signature_hash()).unwrap()); + + let tx1 = TransactionSigned::new_unhashed( + Transaction::Eip7702(TxEip7702 { + chain_id: MAINNET_CHAIN_ID, + nonce: 0, + gas_limit: 200_000, + max_fee_per_gas: 1, + max_priority_fee_per_gas: 0, + to: authority, + authorization_list: vec![signed_auth], + ..Default::default() + }), + Signature::test_signature(), + ); + // authority's later tx at its post-auth-bump nonce (1) — admitted without the stopgap. + let tx2 = create_test_transaction(1, 21_000, 1); + + let mut db = MockDatabase::new(); + for a in [sponsor, authority] { + db.insert_account( + a, + AccountInfo { balance: funded, nonce: 0, code_hash: KECCAK_EMPTY, code: None }, + ); + } + let invalid = filter_invalid_txs( + &db, + &[tx1, tx2], + &[sponsor, authority], + 0, + 30_000_000, + &prague_chain_spec(), + 0, + 0, + ); + assert!(!invalid.contains(&0), "tx1 (the delegating tx) must be kept: {invalid:?}"); + assert!( + invalid.contains(&1), + "tx2 (later tx from in-block-delegated authority) must be dropped by #838 stopgap: {invalid:?}" + ); + } } From 37fe70e0bcd36522efedd665f48cde92d2253d78 Mon Sep 17 00:00:00 2001 From: Richard1048576 <178553006+Richard1048576@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:20:07 +0800 Subject: [PATCH 2/2] fix(execute): log oracle NonceNotSequential replay reverts at WARN not ERROR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A duplicate / already-committed oracle attestation rejected by NativeOracle's sequential-nonce replay guard (NonceNotSequential, selector 0x32e429cd; legacy NonceNotIncreasing 0xc778b1a4) is benign — the replay guard working as intended. It was logged at ERROR on mainnet, tripping alerting and masking real failures. Decode the revert selector, classify severity, and log recoverable system-tx reverts at WARN; unknown / fatal reverts stay ERROR. Folds gravity-reth#359 into this PR so it ships as one execute-layer change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pipe-exec-layer-ext-v2/execute/src/lib.rs | 38 +++++++--- .../execute/src/onchain_config/errors.rs | 70 ++++++++++++++++++- 2 files changed, 97 insertions(+), 11 deletions(-) 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..ebbde1c6d9 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/lib.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/lib.rs @@ -1090,14 +1090,36 @@ impl Core { let validator_result = SystemTxnResult { result: execution_result, txn }; if !validator_result.result.is_success() { - error!(target: "execute_ordered_block", - index=?index, - is_dkg=?is_dkg, - block_number=?block_number, - gas_used=?validator_result.result.gas_used(), - output=?validator_result.result.output(), - "validator system transaction reverted" - ); + // Classify the revert by severity. A recoverable revert — e.g. a duplicate + // oracle attestation rejected by NativeOracle's sequential-nonce replay guard + // (NonceNotSequential) — is expected and must log at WARN, not ERROR, so it + // does not look like a failure or trip alerting. Unknown/fatal reverts stay ERROR. + let recoverable = validator_result + .result + .output() + .and_then(|out| onchain_config::errors::decode_revert_error(out)) + .is_some_and(|e| { + e.severity == onchain_config::errors::ErrorSeverity::Recoverable + }); + if recoverable { + warn!(target: "execute_ordered_block", + index=?index, + is_dkg=?is_dkg, + block_number=?block_number, + gas_used=?validator_result.result.gas_used(), + output=?validator_result.result.output(), + "validator system transaction reverted (recoverable)" + ); + } else { + error!(target: "execute_ordered_block", + index=?index, + is_dkg=?is_dkg, + block_number=?block_number, + gas_used=?validator_result.result.gas_used(), + output=?validator_result.result.output(), + "validator system transaction reverted" + ); + } } else { // DKG transactions may trigger epoch change if is_dkg { diff --git a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs index 2538c0795a..1ffd3106f0 100644 --- a/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs +++ b/crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs @@ -53,7 +53,16 @@ sol! { error DKGNotInitialized(); // -------------------- NativeOracle Errors -------------------- - /// @notice Nonce must be strictly increasing for each source + /// @notice Oracle nonce must be sequential (== currentNonce + 1) for each source. + /// Thrown by NativeOracle._updateNonce when a duplicate / already-committed + /// attestation is replayed; matches NativeOracle.sol (selector 0x32e429cd). + /// This is what the currently deployed contract reverts with. + error NonceNotSequential(uint32 sourceType, uint256 sourceId, uint128 expectedNonce, uint128 providedNonce); + + /// @notice Legacy oracle-nonce error (selector 0xc778b1a4). The deployed contract + /// no longer throws this — `NonceNotSequential` superseded it — but it is kept in + /// the decode table so that a node running against an older contract build still + /// classifies the revert as Recoverable instead of falling through to ERROR. error NonceNotIncreasing(uint32 sourceType, uint256 sourceId, uint128 currentNonce, uint128 providedNonce); /// @notice Batch arrays have mismatched lengths @@ -114,6 +123,26 @@ pub enum SystemTxnExecutionResult { /// Decode a revert output into a known error type /// /// Uses 4-byte selector matching for O(1) lookup, then decodes only the matched error. +/// +/// Severity classification (drives the log level at the call sites — Fatal => ERROR, +/// Recoverable => WARN). Keep this list in sync with the match arms below: +/// +/// - **Fatal** (a real protocol violation / bug — should never happen in normal operation): +/// - `Unauthorized` — system call from a non-system caller. +/// - `TimestampMustAdvance` / `TimestampMustEqual` — block timestamp invariant broken. +/// - `ValidatorIndexOutOfBounds` — validator index outside the active set. +/// - `ReconfigurationNotInitialized` / `DKGNotInitialized` — required module not initialized. +/// - `OracleBatchArrayLengthMismatch` — malformed oracle batch (nonces/payloads/gas mismatched). +/// +/// - **Recoverable** (expected under benign races / replays — safe to ignore, logged at WARN): +/// - `ReconfigurationNotInProgress` / `ReconfigurationInProgress` — epoch-transition state race. +/// - `DKGNotInProgress` / `DKGInProgress` — DKG-session state race. +/// - `NonceNotSequential` — duplicate/already-committed oracle attestation rejected by the +/// sequential-nonce replay guard (current contract; selector 0x32e429cd). +/// - `NonceNotIncreasing` — legacy form of the same oracle-nonce replay rejection, superseded by +/// `NonceNotSequential`; kept for compatibility with older contract builds. +/// +/// An unknown selector returns `None`, which the call sites log at ERROR. pub fn decode_revert_error(output: &Bytes) -> Option { // Need at least 4 bytes for selector if output.len() < 4 { @@ -214,12 +243,27 @@ pub fn decode_revert_error(output: &Bytes) -> Option { severity: ErrorSeverity::Recoverable, }), + s if s == NonceNotSequential::SELECTOR => { + let err = NonceNotSequential::abi_decode(output).ok()?; + Some(SystemTxnError { + name: "NonceNotSequential".into(), + details: format!( + "Oracle nonce not sequential (duplicate/replayed attestation rejected by the nonce guard): sourceType={}, sourceId={}, expected={}, provided={}", + err.sourceType, err.sourceId, err.expectedNonce, err.providedNonce + ), + severity: ErrorSeverity::Recoverable, + }) + } + + // Legacy oracle-nonce error. The deployed contract emits NonceNotSequential + // instead, but keep this arm so a node run against an older contract build + // still classifies the replay rejection as Recoverable. s if s == NonceNotIncreasing::SELECTOR => { let err = NonceNotIncreasing::abi_decode(output).ok()?; Some(SystemTxnError { name: "NonceNotIncreasing".into(), details: format!( - "Oracle nonce not increasing: sourceType={}, sourceId={}, current={}, provided={}", + "Oracle nonce not increasing (legacy; superseded by NonceNotSequential): sourceType={}, sourceId={}, current={}, provided={}", err.sourceType, err.sourceId, err.currentNonce, err.providedNonce ), severity: ErrorSeverity::Recoverable, @@ -318,7 +362,27 @@ mod tests { } #[test] - fn test_decode_nonce_not_increasing() { + fn test_decode_nonce_not_sequential() { + // expected = currentNonce + 1, provided = currentNonce: the duplicate-attestation + // replay shape seen in production (selector 0x32e429cd, classified Recoverable). + let error = NonceNotSequential { + sourceType: 0, + sourceId: alloy_primitives::U256::from(1), + expectedNonce: 11, + providedNonce: 10, + }; + let encoded = error.abi_encode(); + let result = decode_revert_error(&encoded.into()); + + assert!(result.is_some()); + let err = result.unwrap(); + assert_eq!(err.name, "NonceNotSequential"); + assert_eq!(err.severity, ErrorSeverity::Recoverable); + } + + #[test] + fn test_decode_nonce_not_increasing_legacy() { + // Legacy selector (0xc778b1a4), kept for older contract builds; still Recoverable. let error = NonceNotIncreasing { sourceType: 1, sourceId: alloy_primitives::U256::from(42),