Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ mod decode_raw_state_transitions;
mod execute_event;
mod process_raw_state_transitions;
mod process_validation_result;

#[cfg(test)]
pub(crate) use process_raw_state_transitions::test_fault_injection;
mod record_added_balance_outputs;
mod store_address_balances_to_recent_block_storage;
mod validate_fees_of_event;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
mod v0;

#[cfg(test)]
pub(crate) use v0::test_fault_injection;

use crate::error::execution::ExecutionError;
use crate::error::Error;
use crate::metrics::HistogramTiming;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,25 @@ use crate::platform_types::state_transitions_processing_result::{
use dpp::util::hash::hash_single;
use dpp::version::PlatformVersion;
use drive::grovedb::Transaction;
use drive::grovedb_storage::Error::RocksDBError;
use std::time::Instant;

use super::super::StateTransitionAwareError;

/// Test-only fault injection: force the next successfully executed state transition to be
/// reported as an `InternalError` AFTER its drive operations were applied. This models the
/// only way an `InternalError` can carry state (an `Err` surfacing after
/// `apply_drive_operations(apply = true)`, e.g. the address-input fee coverage guard failing
/// on an under-estimated `Shield`) without depending on any particular estimation bug.
#[cfg(test)]
pub(crate) mod test_fault_injection {
use std::cell::Cell;

thread_local! {
pub static FAIL_NEXT_SUCCESSFUL_EXECUTION: Cell<bool> = const { Cell::new(false) };
}
}

impl<C> Platform<C>
where
C: CoreRPCLike,
Expand Down Expand Up @@ -69,6 +84,33 @@ where
let state_transition_container =
self.decode_raw_state_transitions(raw_state_transitions, platform_version)?;

// PROPOSER-SIDE ONLY (consensus-invisible, hence no protocol-version gate): while
// building a proposal, wrap each executed state transition in a savepoint and roll
// back if its result strips it from the block (`TxAction::Removed`). Execution can
// write into the shared block transaction before failing (the address-input fee flow
// is apply-then-check), and without the rollback the gossiped block omits the
// transition while the advertised app hash includes its writes — no validator can
// reproduce the hash, and every proposer carrying the transition burns its round
// (mainnet evo1 stalls of 2026-08-14/15, after heights 415652 and 415661).
//
// The validation path (`proposing_state_transitions == false`) is deliberately
// untouched: rolling back there would change what state a received block evaluates
// to, which is a consensus change that must ride a protocol-version gate (it does,
// from v14). This proposer-side rollback only changes which blocks this node BUILDS —
// the published block and app hash are exactly what any un-upgraded validator
// computes from that block, so mixed networks cannot diverge.
//
// The genesis height is excluded because its re-proposal path relies on a
// single-savepoint discipline: init_chain sets one savepoint, and each genesis round
// rewinds to it with one `rollback_to_savepoint()` (see prepare_proposal /
// process_proposal). Savepoints of KEPT transitions stay on the stack — RocksDB
// exposes no pop-without-rollback — and extra savepoints on the genesis transaction
// would redirect that rewind. At every other height each proposal round runs in a
// freshly started transaction that is either committed (leftover savepoints are inert
// markers) or dropped when the round ends, so the residue can affect nothing.
let rollback_dropped_transitions =
proposing_state_transitions && block_info.height != self.config.abci.genesis_height;

let mut processing_result = StateTransitionsProcessingResult::default();

for decoded_state_transition in state_transition_container.into_iter() {
Expand Down Expand Up @@ -108,6 +150,12 @@ where
);
}

// Mark the state we can return to if this transition's result strips
// it from the block (see `rollback_dropped_transitions` above).
if rollback_dropped_transitions {
transaction.set_savepoint();
}

// Validate state transition and produce an execution event
let execution_result = process_state_transition(
&platform_ref,
Expand Down Expand Up @@ -137,6 +185,57 @@ where
})
.unwrap_or_else(error_to_internal_error_execution_result);

#[cfg(test)]
let execution_result = if matches!(
execution_result,
StateTransitionExecutionResult::SuccessfulExecution { .. }
)
&& test_fault_injection::FAIL_NEXT_SUCCESSFUL_EXECUTION
.with(|flag| flag.replace(false))
{
StateTransitionExecutionResult::InternalError(
"injected post-apply failure (test_fault_injection)".to_string(),
)
} else {
execution_result
};

if rollback_dropped_transitions {
match &execution_result {
StateTransitionExecutionResult::InternalError(_)
| StateTransitionExecutionResult::UnpaidConsensusError(_) => {
// This transition will be stripped from the proposal
// (`TxAction::Removed`), so none of its writes may remain
// in the state the app hash is computed over. A rollback
// failure means the proposal can no longer match the
// block — fail it rather than continue on leaked state.
transaction.rollback_to_savepoint().map_err(|e| {
drive::grovedb::error::Error::StorageError(RocksDBError(e))
})?;
}
StateTransitionExecutionResult::SuccessfulExecution { .. }
| StateTransitionExecutionResult::PaidConsensusError { .. } => {
// The transition stays in the block
// (`TxAction::Unmodified`), so its writes stay. Its
// savepoint is intentionally left on the stack (see
// `rollback_dropped_transitions` above: no
// pop-without-rollback exists, and at non-genesis heights
// the residue is inert).
}
StateTransitionExecutionResult::NotExecuted(_) => {
// Delayed to a later block (`TxAction::Delayed`) without
// having been executed: nothing was written since the
// savepoint, so rolling back and leaving it are
// equivalent. Leave it, like the kept outcomes above.
//
// Deliberately exhaustive: a new execution result variant
// must make an explicit savepoint decision here — the
// rollback classification must match the `TxAction`
// classification in `prepare_proposal`.
}
}
}
Comment thread
QuantumExplorer marked this conversation as resolved.

// Store metrics
let elapsed_time = start_time.elapsed() + decoding_elapsed_time;

Expand Down
Loading
Loading