Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions crates/pipe-exec-layer-ext-v2/execute/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,14 +1090,36 @@ impl<Storage: GravityStorage> Core<Storage> {
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 {
Expand Down
70 changes: 67 additions & 3 deletions crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SystemTxnError> {
// Need at least 4 bytes for selector
if output.len() < 4 {
Expand Down Expand Up @@ -214,12 +243,27 @@ pub fn decode_revert_error(output: &Bytes) -> Option<SystemTxnError> {
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,
Expand Down Expand Up @@ -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),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,19 @@ pub(crate) fn system_txns_into_executed_ordered_block_result(
pub fn transact_system_txn(
evm: &mut impl Evm<DB = impl Database, Error: Debug, Tx = TxEnv, HaltReason = HaltReason>,
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
Expand All @@ -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)
Expand Down
22 changes: 16 additions & 6 deletions crates/pipe-exec-layer-ext-v2/execute/src/onchain_config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,11 @@ pub fn convert_validator_consensus_info(

GravityValidatorInfo::new(
account_address,
power_ether.to::<u64>(),
// Defensive: `to::<u64>()` 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::<u64>(),
ValidatorConfig::new(
info.consensusPubkey.clone().into(),
info.networkAddresses.to_vec(),
Expand All @@ -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::<u128>()).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::<u128>())
.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::<u128>()).sum();
let total_joining_power: u128 = pending_active
.iter()
.map(|v| wei_to_ether(v.votingPower).saturating_to::<u128>())
.fold(0u128, |acc, x| acc.saturating_add(x));

let gravity_validator_set = GravityValidatorSet {
active_validators: active_validators.iter().map(convert_validator_consensus_info).collect(),
Expand Down
97 changes: 97 additions & 0 deletions crates/pipe-exec-layer-ext-v2/execute/src/tx_filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,33 @@ pub(crate) fn filter_invalid_txs<DB: ParallelDatabase>(
// recovery runs only for type-4 txs.
let mut sim: HashMap<Address, Option<AccountInfo>> = HashMap::default();
let mut invalid_tx_idxs: HashSet<usize> = 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<Address> = 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).
Expand Down Expand Up @@ -379,6 +402,13 @@ pub(crate) fn filter_invalid_txs<DB: ParallelDatabase>(
*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);
}
}
}
}
Expand Down Expand Up @@ -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:?}"
);
}
}
Loading