Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -179,11 +179,18 @@ where
// Defense in depth: the deduction min-caps each step, so an under-funded input set would
// remove < `total_fee` from the inputs while the full `total_fee` is still booked to the
// fee pools — minting the difference (`CorruptedCreditsNotBalanced` -> chain halt).
// `validate_fees_of_event` is re-run on this exact state immediately before execution and
// already rejects an under-funded transition, so this cannot trigger today. Guard anyway:
// if those two paths ever diverged, fail closed at the source with an actionable error
// rather than committing a mint that only surfaces as an opaque end-of-block sum-tree
// imbalance.
// `validate_fees_of_event` runs on the same state immediately before execution, but it
// prices the batch with the ESTIMATED cost model (`apply_drive_operations` with
// `apply = false`) while `total_fee` here is the ACTUAL metered cost — so this guard
// triggers whenever `estimated < actual` for a transition funded in between (the
// mainnet evo1 stalls of 2026-08-14/15; the keyless commitment-tree append is skipped
// in estimation, dashpay/grovedb#812). The invariant this guard actually enforces is
// `estimated >= actual`. Note the ops were already applied above: an Err here leaves
// this transition's writes in the block transaction. That is safe because the
// processing loop rolls dropped transitions back while proposing (the 4.1.1 fix),
// and a block that carries such a transition anyway is rejected wholesale by
// process_proposal's unexpected_execution_results gate, discarding the round's
// transaction along with the writes.
if !fee_deduction_result.fee_fully_covered {
return Err(Error::Execution(ExecutionError::CorruptedCodeExecution(
"address-input fee not fully covered at execution; validate_fees_of_event should have rejected the under-funded transition",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,32 @@ use super::super::StateTransitionAwareError;
/// on an under-estimated `Shield`) without depending on any particular estimation bug.
#[cfg(test)]
pub(crate) mod test_fault_injection {
use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult;
use std::cell::Cell;

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

/// If armed, consume the flag and replace a successful execution with the
/// `InternalError` a post-apply failure would produce; identity for every other result
/// and while unarmed. Kept here so the processing loop carries a single call instead of
/// the override logic.
pub(crate) fn maybe_override(
execution_result: StateTransitionExecutionResult,
) -> StateTransitionExecutionResult {
if matches!(
execution_result,
StateTransitionExecutionResult::SuccessfulExecution { .. }
) && FAIL_NEXT_SUCCESSFUL_EXECUTION.with(|flag| flag.replace(false))
{
StateTransitionExecutionResult::InternalError(
"injected post-apply failure (test_fault_injection)".to_string(),
)
} else {
execution_result
}
}
}

impl<C> Platform<C>
Expand Down Expand Up @@ -95,10 +116,14 @@ where
//
// 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.
// to, which is a consensus change needing a protocol-version gate — and one that is
// unnecessary, because `process_proposal` already REJECTS any block whose execution
// produced an `InternalError` or `UnpaidConsensusError` result (the
// `unexpected_execution_results` gate), so a block that carries such a transition
// can never commit and its writes die with the rejected round's transaction. 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
Expand Down Expand Up @@ -186,19 +211,8 @@ 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
};
let execution_result =
test_fault_injection::maybe_override(execution_result);

if rollback_dropped_transitions {
match &execution_result {
Expand Down
Loading
Loading