From c2f28ebc369b3512625b6e89e4a0a1604a83c5e2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 17 Aug 2026 15:35:50 +0700 Subject: [PATCH 1/6] test(drive-abci): pin the mainnet shield-halt leak and probe the savepoint fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repro tests for the 2026-08-14/15 evo1 stalls (after heights 415652 and 415661). A Shield funded inside the estimated-vs-actual fee band is accepted by validate_fees_of_event (apply=false synthetic cost model, which skips the keyless commitment-tree append) and then rejected by paid_from_address_inputs_and_outputs on the real apply=true cost — after its drive operations were already written to the shared block transaction. prepare_proposal maps the resulting InternalError to TxAction::Removed, so the gossiped block omits the transition while the proposer's app hash still reflects its writes; validators can never reproduce that hash and the chain stalls. Three tests, currently red — they assert the invariants the fix must restore: - shield_fee_estimate_and_actual_must_not_leave_a_halting_band: binary-searches both edges of the funding band; asserts it is empty. - dropped_shield_must_not_mutate_state: a transition dropped as InternalError must leave pool balance, note count, and root hash untouched. - savepoint_rollback_must_undo_an_applied_shield: decides the fix implementation — if rollback_to_savepoint() restores the pre-apply root hash mid-transaction, a per-transition savepoint is viable; if not, the fix must re-execute the proposal without removed txs. Estimator side of the bug is filed upstream as dashpay/grovedb#812. Co-Authored-By: Claude Fable 5 --- .../state_transitions/shield/tests.rs | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index 168c813aa9a..d961c89611d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -1747,4 +1747,451 @@ mod tests { ); } } + + /// MAINNET HALT INVESTIGATION (evo1, 2026-08-14/15: ~2h stalls after 415652 and after 415661). + /// + /// `execute_event_v0` validates a `Shield`'s fee TWICE against two DIFFERENT cost models: + /// `validate_fees_of_event` estimates with `apply_drive_operations(.., apply=false, ..)`, which + /// prices the batch from a SYNTHETIC layer model, while `paid_from_address_inputs_and_outputs` + /// re-meters with `apply=true` against the REAL tree. If the synthetic estimate can come in + /// LOWER than the real cost, there is a band of funding levels where validation ACCEPTS the + /// transition and execution then rejects it — and that rejection (`CorruptedCodeExecution`, + /// "address-input fee not fully covered at execution") happens AFTER the shield's writes were + /// already applied to the shared block transaction, with no per-transition rollback. + /// + /// These tests answer, empirically: does such a band exist, and does a transition landing in it + /// leave state behind? + mod mainnet_halt_repro { + use super::*; + use crate::execution::validation::state_transition::state_transitions::test_helpers::insert_dummy_encrypted_notes; + use dpp::block::block_info::BlockInfo; + + /// Note count on mainnet's shielded commitment tree around the halt. + const MAINNET_NOTES: u64 = 494; + + #[derive(Debug, PartialEq, Eq, Clone, Copy)] + enum Outcome { + Success, + NotEnoughFunds, + Internal, + Other, + } + + /// The reusable pieces of a valid shield bundle. Orchard proof generation dominates the + /// runtime, so it is done once and the transition re-signed per funding level. + struct Bundle { + actions: Vec, + shield_amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_sig: [u8; 64], + } + + fn build_bundle() -> Bundle { + let mut rng = OsRng; + let pk = get_proving_key(); + let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); + let fvk = FullViewingKey::from(&sk); + let recipient = fvk.address_at(0u32, Scope::External); + + let mut builder = Builder::::new( + BundleType::Transactional { + flags: OrchardFlags::SPENDS_DISABLED, + bundle_required: false, + }, + Anchor::empty_tree(), + ); + builder + .add_output(None, recipient, NoteValue::from_raw(5000u64), [0u8; 36]) + .unwrap(); + let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); + let commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&commitment, &[]); + let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); + let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); + + let (actions, _flags, value_balance, anchor, proof, binding_sig) = + serialize_authorized_bundle_with_flags(&bundle); + assert!( + value_balance < 0, + "a shield must have negative value balance" + ); + Bundle { + actions, + shield_amount: (-value_balance) as u64, + anchor, + proof, + binding_sig, + } + } + + /// Build a signed `Shield` spending `declared_input` credits from `addr`. + async fn build_signed( + b: &Bundle, + signer: &TestAddressSigner, + addr: PlatformAddress, + declared_input: u64, + ) -> StateTransition { + let mut inputs = BTreeMap::new(); + inputs.insert(addr, (1 as AddressNonce, declared_input)); + + let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: inputs.clone(), + actions: b.actions.clone(), + amount: b.shield_amount, + anchor: b.anchor, + proof: b.proof.clone(), + binding_signature: b.binding_sig, + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + let signable = st.signable_bytes().expect("should compute signable bytes"); + let mut witnesses: Vec = Vec::with_capacity(inputs.len()); + for a in inputs.keys() { + witnesses.push( + signer + .sign_create_witness(a, &signable) + .await + .expect("sign"), + ); + } + if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + v0.input_witnesses = witnesses; + } + st + } + + /// Run the shield on a fresh platform whose input address holds exactly + /// `shield_amount + headroom`, i.e. `headroom` credits are available to pay the fee. + async fn run_at(headroom: u64, b: &Bundle, pv: &PlatformVersion) -> (Outcome, String) { + let mut platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); + + let mut signer = TestAddressSigner::new(); + let addr = signer.add_p2pkh([1u8; 32]); + let declared_input = b.shield_amount + headroom; + setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); + + let st = build_signed(b, &signer, addr, declared_input).await; + let result = process_transition(&platform, st, pv); + match result.execution_results().first() { + Some(StateTransitionExecutionResult::SuccessfulExecution { .. }) => { + (Outcome::Success, String::new()) + } + Some(StateTransitionExecutionResult::InternalError(msg)) => { + (Outcome::Internal, msg.clone()) + } + Some(StateTransitionExecutionResult::UnpaidConsensusError(e)) => { + let rendered = format!("{:?}", e); + if rendered.contains("AddressesNotEnoughFunds") { + (Outcome::NotEnoughFunds, rendered) + } else { + (Outcome::Other, rendered) + } + } + other => (Outcome::Other, format!("{:?}", other)), + } + } + + /// Binary-search both edges of the funding range to measure the band where + /// `validate_fees_of_event` accepts a shield that execution then rejects. + /// + /// * lower edge = the ESTIMATED fee (below it, validation rejects cleanly) + /// * upper edge = the ACTUAL metered fee (at or above it, the shield executes) + /// + /// If the two cost models agreed, the edges would coincide and the band would be empty. + /// Any width is a range of funding levels that halts the chain. + #[tokio::test] + async fn shield_fee_estimate_and_actual_must_not_leave_a_halting_band() { + let pv = PlatformVersion::latest(); + let b = build_bundle(); + const CEILING: u64 = 5_000_000_000; // 0.05 DASH, far above any plausible shield fee + + let (top, top_msg) = run_at(CEILING, &b, pv).await; + assert_eq!( + top, + Outcome::Success, + "sanity: the upper bound must comfortably fund the shield ({top_msg})" + ); + + // Upper edge: least headroom that actually executes == the ACTUAL metered fee. + let (mut lo, mut hi) = (0u64, CEILING); + while lo + 1 < hi { + let mid = lo + (hi - lo) / 2; + if run_at(mid, &b, pv).await.0 == Outcome::Success { + hi = mid; + } else { + lo = mid; + } + } + let actual_fee = hi; + + // Lower edge: below the flat structural minimum shielded fee the transition is + // rejected in BASIC validation (`ShieldedInvalidValueBalanceError`) before any write, + // which is safe. The dangerous band starts where that gate stops rejecting. + let (mut lo2, mut hi2) = (0u64, actual_fee); + while lo2 + 1 < hi2 { + let mid = lo2 + (hi2 - lo2) / 2; + if run_at(mid, &b, pv).await.0 == Outcome::Internal { + hi2 = mid; + } else { + lo2 = mid; + } + } + let band_start = hi2; + + let (edge_outcome, edge_msg) = run_at(band_start - 1, &b, pv).await; + + println!("shield_amount = {}", b.shield_amount); + println!("band start = {band_start} (first headroom that reaches execution)"); + println!("actual fee = {actual_fee} (execution, apply=true)"); + println!("just below band = {edge_outcome:?} :: {edge_msg}"); + println!( + "HALTING BAND = [{band_start}, {actual_fee}) width = {} credits ({:.1}% of the fee)", + actual_fee - band_start, + 100.0 * (actual_fee - band_start) as f64 / actual_fee as f64 + ); + + assert_eq!( + band_start, actual_fee, + "HALTING BAND: any shield whose fee headroom falls in [{band_start}, {actual_fee}) \ + clears both the structural minimum-fee gate and validate_fees_of_event (which \ + prices the batch with the synthetic apply=false cost model, and which SKIPS the \ + keyless commitment-tree append entirely), and is then REJECTED by \ + paid_from_address_inputs_and_outputs on the real apply=true cost — after its drive \ + operations were already written to the block transaction. Such a transition is \ + stripped from the block as TxAction::Removed while its writes remain in the \ + proposer's app hash, so no validator can reproduce that hash." + ); + } + + /// The consequence: a transition rejected this way is reported as `InternalError`, which + /// `prepare_proposal` maps to `TxAction::Removed` — Tenderdash strips it from the gossiped + /// block. But nothing rolls its writes back, so the proposer's app hash (computed over the + /// block transaction) reflects a shield the block does not contain. Validators replaying + /// the block without it compute a different app hash and can never agree. + /// + /// This pins the invariant that a dropped transition must not mutate state. + #[tokio::test] + async fn dropped_shield_must_not_mutate_state() { + let pv = PlatformVersion::latest(); + let b = build_bundle(); + // Sits inside the measured band: accepted by validation, rejected by execution. + let headroom = 177_215_759u64; + + let mut platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); + let mut signer = TestAddressSigner::new(); + let addr = signer.add_p2pkh([1u8; 32]); + let declared_input = b.shield_amount + headroom; + setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); + + let pool_before = platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool balance"); + let notes_before = platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes count"); + let hash_before = platform + .drive + .grove + .root_hash(None, &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + + let st = build_signed(&b, &signer, addr, declared_input).await; + let bytes = st.serialize_to_bytes().expect("serialize"); + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes], + &state, + &BlockInfo::default(), + &transaction, + pv, + true, // proposing, exactly as prepare_proposal does + None, + ) + .expect("processing must not be a block-level error"); + + let dropped = matches!( + result.execution_results().first(), + Some(StateTransitionExecutionResult::InternalError(_)) + ); + assert!( + dropped, + "expected the mid-band shield to be dropped as InternalError, got {:?}", + result.execution_results() + ); + + let pool_after = platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) + .expect("pool balance"); + let notes_after = platform + .drive + .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) + .expect("notes count"); + let hash_after = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + + println!("shielded pool : {pool_before} -> {pool_after}"); + println!("notes in tree : {notes_before} -> {notes_after}"); + println!( + "app hash : {} -> {}", + hex::encode(hash_before), + hex::encode(hash_after) + ); + + assert_eq!( + pool_after, + pool_before, + "STATE LEAK: a transition that was DROPPED from the block still credited the \ + shielded pool by {}. Tenderdash gossips the block without this transition, so \ + every other validator computes an app hash without this credit.", + pool_after.saturating_sub(pool_before) + ); + assert_eq!( + notes_after, notes_before, + "STATE LEAK: a dropped transition still appended a note commitment" + ); + assert_eq!( + hash_before, hash_after, + "APP HASH DIVERGENCE: a dropped transition changed the proposer's app hash. \ + Validators replaying the gossiped block (which excludes it) cannot reproduce this \ + hash, so the proposal is rejected every round and the chain stalls." + ); + } + + /// Can a per-transition savepoint undo a shield's writes mid-transaction? + /// + /// The candidate fix for the leak above is `set_savepoint()` before each transition and + /// `rollback_to_savepoint()` on failure, inside `process_raw_state_transitions`. That is + /// only sound if a rollback restores everything the apply touched — including GroveDB's + /// in-memory Merk state, not just the RocksDB write batch. This runs a fully-funded shield + /// (so the writes are the same ones a mid-band shield leaks), rolls it back, and checks + /// pool balance, note count, and root hash against the pre-apply snapshot. + #[tokio::test] + async fn savepoint_rollback_must_undo_an_applied_shield() { + let pv = PlatformVersion::latest(); + let b = build_bundle(); + // Generous headroom: this shield must SUCCEED so its writes all land. + let headroom = 5_000_000_000u64; + + let mut platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); + let mut signer = TestAddressSigner::new(); + let addr = signer.add_p2pkh([1u8; 32]); + let declared_input = b.shield_amount + headroom; + setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); + + let st = build_signed(&b, &signer, addr, declared_input).await; + let bytes = st.serialize_to_bytes().expect("serialize"); + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let pool_before = platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) + .expect("pool balance"); + let notes_before = platform + .drive + .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) + .expect("notes count"); + let hash_before = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + + transaction.set_savepoint(); + + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes], + &state, + &BlockInfo::default(), + &transaction, + pv, + true, + None, + ) + .expect("processing must not be a block-level error"); + assert!( + matches!( + result.execution_results().first(), + Some(StateTransitionExecutionResult::SuccessfulExecution { .. }) + ), + "sanity: the fully-funded shield must execute, got {:?}", + result.execution_results() + ); + + let hash_applied = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + assert_ne!( + hash_before, hash_applied, + "sanity: the applied shield must have changed the root hash" + ); + + transaction + .rollback_to_savepoint() + .expect("rollback to savepoint"); + + let pool_after = platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) + .expect("pool balance"); + let notes_after = platform + .drive + .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) + .expect("notes count"); + let hash_after = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + + println!("shielded pool : {pool_before} -> {pool_after} (want {pool_before})"); + println!("notes in tree : {notes_before} -> {notes_after} (want {notes_before})"); + println!( + "root hash : applied {} -> rolled back {} (want {})", + hex::encode(hash_applied), + hex::encode(hash_after), + hex::encode(hash_before) + ); + + assert_eq!( + pool_after, pool_before, + "savepoint rollback did not undo the shielded pool credit" + ); + assert_eq!( + notes_after, notes_before, + "savepoint rollback did not undo the note commitment append" + ); + assert_eq!( + hash_before, hash_after, + "savepoint rollback did not restore the root hash: GroveDB's in-memory Merk \ + state survives a RocksDB-level rollback, so a per-transition savepoint is NOT a \ + sound implementation of the leak fix (use proposal re-execution instead)" + ); + } + } } From ab7561e1848a0cbf693fdd437b50587f995fb3e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 17 Aug 2026 15:42:03 +0700 Subject: [PATCH 2/6] test(drive-abci): prove savepoint rollback also restores the write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the savepoint spike: after rolling back an applied shield, re-apply the identical shield onto the same transaction and assert it reproduces the first apply's root hash exactly. Rollback restoring reads is necessary but not sufficient for the per-transition-savepoint fix — the next transition in the block applies onto the rolled-back transaction, so stale in-memory Merk state would make that apply build on phantom nodes and diverge. Result: both the read path and the write path are fully restored, so a per-transition savepoint is a sound implementation of the leak fix. Co-Authored-By: Claude Fable 5 --- .../state_transitions/shield/tests.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index d961c89611d..40df832e998 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -2192,6 +2192,51 @@ mod tests { state survives a RocksDB-level rollback, so a per-transition savepoint is NOT a \ sound implementation of the leak fix (use proposal re-execution instead)" ); + + // Rollback restoring READS is necessary but not sufficient: in the fix, the next + // transition in the block applies onto the rolled-back transaction. If any in-memory + // Merk state survived the rollback, that second apply would build on phantom nodes and + // diverge. Re-applying the identical shield onto the restored state is deterministic, + // so it must reproduce the first apply's root hash exactly. + let bytes_again = st.serialize_to_bytes().expect("serialize"); + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes_again], + &state, + &BlockInfo::default(), + &transaction, + pv, + true, + None, + ) + .expect("processing must not be a block-level error"); + assert!( + matches!( + result.execution_results().first(), + Some(StateTransitionExecutionResult::SuccessfulExecution { .. }) + ), + "the shield must execute again on the rolled-back state (its nonce and balance \ + were restored), got {:?}", + result.execution_results() + ); + let hash_reapplied = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + println!( + "root hash : re-applied {} (want {})", + hex::encode(hash_reapplied), + hex::encode(hash_applied) + ); + assert_eq!( + hash_applied, hash_reapplied, + "applying onto a rolled-back transaction diverged from applying onto the \ + original state: stale in-memory Merk state survived the rollback, so a \ + per-transition savepoint is NOT a sound implementation of the leak fix" + ); } } } From 2b9ee83375276810fa8a54d9e9f35764beee4da2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 17 Aug 2026 16:51:10 +0700 Subject: [PATCH 3/6] fix(drive-abci)!: roll back dropped state transitions so they cannot poison the app hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the mainnet evo1 stalls of 2026-08-14/15 (after heights 415652 and 415661). Execution can write into the shared block transaction before failing — the address-input fee flow is apply-then-check, and the estimated fee used for admission can undershoot the actual metered fee (dashpay/grovedb#812) — so a transition dropped as InternalError left its writes in the transaction. prepare_proposal stripped the transition from the block (TxAction::Removed) while the app hash was computed over state that still contained its writes, so no validator could ever reproduce the proposer's hash and the chain stalled for a full proposer rotation. From protocol v14, process_raw_state_transitions v1 wraps every executed state transition in a GroveDB savepoint and rolls back when the result strips the transition from the block (InternalError or UnpaidConsensusError, both mapped to TxAction::Removed). The rollback runs on every node, proposer or validator, so a block that carries such a transition anyway (malicious proposer) also yields identical clean state everywhere instead of identically leaked state. Savepoints of kept transitions stay on the stack — RocksDB exposes no pop-without-rollback — and die with the per-round transaction. The one other consumer of that stack, the genesis-height re-proposal path (prepare_proposal, process_proposal, mimic), now drains the stack instead of popping once, so the residue cannot redirect it; the bottom of the stack always records the post-init-chain state. The v0 loop is byte-identical for pre-v14 nodes; the bump lives in DRIVE_ABCI_METHOD_VERSIONS_V10, active from v14 only, because rolling back changes the app hash of any block that drops such a transition. Tests: dropped_shield_must_not_mutate_state (the halt repro) now passes; injected_post_apply_failure_must_not_mutate_state pins the rollback via a test-only fault hook, independent of the fee-estimation trigger; the halting-band measurement test is ignored until the grovedb#812 estimator fix is pinned. Also corrects the execute_event comment that claimed the coverage guard could not trigger. Co-Authored-By: Claude Fable 5 --- .../src/abci/handler/prepare_proposal.rs | 7 + .../src/abci/handler/process_proposal.rs | 7 + .../execute_event/v0/mod.rs | 15 +- .../state_transition_processing/mod.rs | 3 + .../process_raw_state_transitions/mod.rs | 15 +- .../process_raw_state_transitions/v0/mod.rs | 5 +- .../process_raw_state_transitions/v1/mod.rs | 297 ++++++++++++++++++ .../state_transitions/shield/tests.rs | 113 ++++++- packages/rs-drive-abci/src/mimic/mod.rs | 5 + .../drive_abci_method_versions/mod.rs | 1 + .../drive_abci_method_versions/v10.rs | 156 +++++++++ .../rs-platform-version/src/version/v14.rs | 4 +- 12 files changed, 617 insertions(+), 11 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs create mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs diff --git a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs index 10a4bd95631..eed62c6b6bb 100644 --- a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs @@ -143,6 +143,13 @@ where if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() .map_err(|e| drive::grovedb::error::Error::StorageError(RocksDBError(e)))?; + // Drain the rest of the savepoint stack: from protocol v14 the state-transition + // loop leaves one savepoint per executed transition on the stack (see + // process_raw_state_transitions_v1), so a single rollback may only rewind to the + // last transition of the previous round. Every savepoint on this stack records the + // post-init-chain state or later, and the bottom one records exactly it, so + // draining until empty always lands on the post-init-chain state. + while tx.rollback_to_savepoint().is_ok() {} tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index a64aba5013b..7afb328d1b4 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -174,6 +174,13 @@ where if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() .map_err(|e| drive::grovedb::error::Error::StorageError(RocksDBError(e)))?; + // Drain the rest of the savepoint stack: from protocol v14 the state-transition + // loop leaves one savepoint per executed transition on the stack (see + // process_raw_state_transitions_v1), so a single rollback may only rewind to the + // last transition of the previous round. Every savepoint on this stack records the + // post-init-chain state or later, and the bottom one records exactly it, so + // draining until empty always lands on the post-init-chain state. + while tx.rollback_to_savepoint().is_ok() {} tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 8d6fe37d5c0..2c53b51a6a2 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -179,11 +179,16 @@ 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, which is only safe because the + // v14+ processing loop rolls dropped transitions back (process_raw_state_transitions + // v1); under v0 those writes leak into the proposer's app hash. 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", diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs index 82d014283d2..e7ad6d90e97 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/mod.rs @@ -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; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs index 70a5d0155a0..05a07800b42 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs @@ -1,4 +1,8 @@ mod v0; +mod v1; + +#[cfg(test)] +pub(crate) use v1::test_fault_injection; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -62,9 +66,18 @@ where proposing_state_transitions, timer, ), + 1 => self.process_raw_state_transitions_v1( + raw_state_transitions, + block_platform_state, + block_info, + transaction, + platform_version, + proposing_state_transitions, + timer, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "process_raw_state_transitions".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index d8596ae2eb9..3e6654da000 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -117,8 +117,9 @@ where ) .map(|validation_result| { // Dispatch to the versioned helper (v0 = pre-v13, v1 = records - // paid-invalid balance effects). Only this helper changed at v13; the outer - // loop is version-agnostic, so it must NOT be versioned. + // paid-invalid balance effects). Only this helper changed at v13, so this + // outer loop stayed at v0 across that gate. (The loop itself changed at + // v14 — see _v1, which rolls back dropped transitions.) self.process_validation_result( raw_state_transition, &state_transition_name, diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs new file mode 100644 index 00000000000..ffc7a08c05e --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs @@ -0,0 +1,297 @@ +use crate::error::Error; +use crate::platform_types::platform::{Platform, PlatformRef}; +use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::consensus::codes::ErrorWithCode; + +use crate::execution::types::state_transition_container::v0::{ + DecodedStateTransition, InvalidStateTransition, InvalidWithProtocolErrorStateTransition, + SuccessfullyDecodedStateTransition, +}; +use crate::execution::validation::state_transition::processor::process_state_transition; +use crate::metrics::{state_transition_execution_histogram, HistogramTiming}; +use crate::platform_types::state_transitions_processing_result::{ + NotExecutedReason, StateTransitionExecutionResult, StateTransitionsProcessingResult, +}; +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)`) without depending on any particular estimation +/// bug, so the rollback below stays pinned even once every known trigger is fixed. +#[cfg(test)] +pub(crate) mod test_fault_injection { + use std::cell::Cell; + + thread_local! { + pub static FAIL_NEXT_SUCCESSFUL_EXECUTION: Cell = const { Cell::new(false) }; + } +} + +impl Platform +where + C: CoreRPCLike, +{ + /// Processes the given raw state transitions based on the `block_info` and `transaction`. + /// + /// Differs from v0 in one way: every executed state transition is wrapped in a GroveDB + /// savepoint, and a result that strips the transition from the block (`InternalError` / + /// `UnpaidConsensusError`, both mapped to `TxAction::Removed` by `prepare_proposal`) rolls + /// the savepoint back. Execution can write into the shared block transaction before + /// failing (the fee flow is apply-then-check), so without the rollback a dropped + /// transition's writes stay in the transaction and pollute the proposer's app hash while + /// the gossiped block omits the transition — no validator can then reproduce the hash + /// (mainnet evo1 stalls of 2026-08-14/15, after heights 415652 and 415661). + /// + /// Savepoints of kept transitions are left on the transaction's savepoint stack: RocksDB + /// has no exposed way to pop one without rolling back, leftover savepoints are inert for + /// commit, and the per-round transaction they live in is dropped when the round ends. The + /// one consumer of that stack — the genesis-height re-proposal path — drains the whole + /// stack rather than popping once, precisely so this residue cannot redirect it. + /// + /// # Arguments + /// + /// * `raw_state_transitions` - A reference to a vector of raw state transitions. + /// * `block_info` - Information about the current block being processed. + /// * `transaction` - The transaction associated with the raw state transitions. + /// + /// # Returns + /// + /// * `Result` - If the processing is successful, it returns + /// a `StateTransitionsProcessingResult` with state transition execution results and aggregated information. + /// If the processing fails, it returns an `Error`. + /// + /// # Errors + /// + /// This function may return an `Error` variant if there is a problem with deserializing the raw + /// state transitions, processing state transitions, executing events, or rolling back a + /// dropped transition's savepoint. + #[allow(clippy::too_many_arguments)] + pub(super) fn process_raw_state_transitions_v1( + &self, + raw_state_transitions: &[Vec], + block_platform_state: &PlatformState, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + proposing_state_transitions: bool, + timer: Option<&HistogramTiming>, + ) -> Result { + let platform_ref = PlatformRef { + drive: &self.drive, + state: block_platform_state, + config: &self.config, + core_rpc: &self.core_rpc, + }; + + let state_transition_container = + self.decode_raw_state_transitions(raw_state_transitions, platform_version)?; + + let mut processing_result = StateTransitionsProcessingResult::default(); + + for decoded_state_transition in state_transition_container.into_iter() { + // If we propose state transitions, we need to check if we have a time limit for processing + // set and if we have exceeded it. + let execution_result = if proposing_state_transitions + && timer.is_some_and(|timer| { + timer.elapsed().as_millis() + > self + .config + .abci + .proposer_tx_processing_time_limit + .unwrap_or(u16::MAX) as u128 + }) { + StateTransitionExecutionResult::NotExecuted(NotExecutedReason::ProposerRanOutOfTime) + } else { + match decoded_state_transition { + DecodedStateTransition::SuccessfullyDecoded( + SuccessfullyDecodedStateTransition { + decoded: state_transition, + raw: raw_state_transition, + elapsed_time: decoding_elapsed_time, + }, + ) => { + let start_time = Instant::now(); + + let state_transition_name = state_transition.name(); + + if tracing::enabled!(tracing::Level::TRACE) { + let st_hash = hex::encode(hash_single(raw_state_transition)); + + tracing::trace!( + ?state_transition, + st_hash, + "Processing {} state transition", + state_transition_name + ); + } + + // Execution may write into the shared block transaction before its + // result is known, so mark the state we can return to if the result + // strips this transition from the block. + transaction.set_savepoint(); + + // Validate state transition and produce an execution event + let execution_result = process_state_transition( + &platform_ref, + block_info, + state_transition, + Some(transaction), + ) + .map(|validation_result| { + // Dispatch to the versioned helper (v0 = pre-v13, v1 = records + // paid-invalid balance effects). + self.process_validation_result( + raw_state_transition, + &state_transition_name, + validation_result, + block_info, + transaction, + platform_version, + platform_ref.state.previous_fee_versions(), + ) + .unwrap_or_else(error_to_internal_error_execution_result) + }) + .map_err(|error| StateTransitionAwareError { + error, + raw_state_transition, + state_transition_name: Some(state_transition_name.to_string()), + }) + .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 + }; + + match &execution_result { + StateTransitionExecutionResult::InternalError(_) + | StateTransitionExecutionResult::UnpaidConsensusError(_) => { + // This transition will be stripped from the block + // (`TxAction::Removed`), so none of its writes may remain in + // the state the app hash is computed over. A rollback failure + // means we can no longer produce a state matching the block — + // fail the whole proposal rather than continue on leaked state. + transaction.rollback_to_savepoint().map_err(|e| { + drive::grovedb::error::Error::StorageError(RocksDBError(e)) + })?; + } + _ => { + // The transition stays in the block, so its writes stay. Its + // savepoint is intentionally left on the stack (see the method + // documentation). + } + } + + // Store metrics + let elapsed_time = start_time.elapsed() + decoding_elapsed_time; + + let code = match &execution_result { + StateTransitionExecutionResult::SuccessfulExecution { .. } => 0, + StateTransitionExecutionResult::PaidConsensusError { + error, .. + } => error.code(), + StateTransitionExecutionResult::UnpaidConsensusError(error) => { + error.code() + } + StateTransitionExecutionResult::InternalError(_) => 1, + StateTransitionExecutionResult::NotExecuted(_) => 1, //todo + }; + + state_transition_execution_histogram( + elapsed_time, + &state_transition_name, + code, + ); + + execution_result + } + DecodedStateTransition::InvalidEncoding(InvalidStateTransition { + raw, + error, + elapsed_time: decoding_elapsed_time, + }) => { + if tracing::enabled!(tracing::Level::DEBUG) { + let st_hash = hex::encode(hash_single(raw)); + + tracing::debug!( + ?error, + st_hash, + "Invalid unknown state transition ({}): {}", + st_hash, + error + ); + } + + // Store metrics + state_transition_execution_histogram( + decoding_elapsed_time, + "Unknown", + error.code(), + ); + + StateTransitionExecutionResult::UnpaidConsensusError(error) + } + DecodedStateTransition::FailedToDecode( + InvalidWithProtocolErrorStateTransition { + raw, + error: protocol_error, + elapsed_time: decoding_elapsed_time, + }, + ) => { + // Store metrics + state_transition_execution_histogram(decoding_elapsed_time, "Unknown", 1); + + error_to_internal_error_execution_result(StateTransitionAwareError { + error: protocol_error.into(), + raw_state_transition: raw, + state_transition_name: None, + }) + } + } + }; + + processing_result.add(execution_result)?; + } + + Ok(processing_result) + } +} + +fn error_to_internal_error_execution_result( + error_with_st: StateTransitionAwareError, +) -> StateTransitionExecutionResult { + if tracing::enabled!(tracing::Level::ERROR) { + let st_hash = hex::encode(hash_single(error_with_st.raw_state_transition)); + + tracing::error!( + error = ?error_with_st.error, + raw_state_transition = ?error_with_st.raw_state_transition, + st_hash, + "Failed to process {} state transition ({}) : {}", + error_with_st.state_transition_name.unwrap_or_else(|| "unknown".to_string()), + st_hash, + error_with_st.error, + ); + } + + StateTransitionExecutionResult::InternalError(error_with_st.error.to_string()) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index 40df832e998..1ac732ca23e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -1903,7 +1903,15 @@ mod tests { /// * upper edge = the ACTUAL metered fee (at or above it, the shield executes) /// /// If the two cost models agreed, the edges would coincide and the band would be empty. - /// Any width is a range of funding levels that halts the chain. + /// Any width is a range of funding levels where the transition is accepted by validation + /// and then dropped at execution — no longer a chain halt since the v14 per-transition + /// rollback, but still a transition that can never confirm despite paying the quoted fee. + /// + /// Ignored until the estimation gap is closed: the estimated-cost path skips the keyless + /// commitment-tree append entirely (dashpay/grovedb#812), so the band is measurably open + /// (18,919,200 credits, 10.7% of the fee, at 494 notes). Re-enable with the grovedb pin + /// bump that fixes it. Also ~40 full Orchard proving runs, so keep it out of routine CI. + #[ignore = "open until the grovedb#812 estimator fix is pinned; ~40 Orchard proving runs"] #[tokio::test] async fn shield_fee_estimate_and_actual_must_not_leave_a_halting_band() { let pv = PlatformVersion::latest(); @@ -2238,5 +2246,108 @@ mod tests { per-transition savepoint is NOT a sound implementation of the leak fix" ); } + + /// Generic leak guard, independent of the fee bug: force a fully-funded shield — one + /// that executes successfully and therefore definitely wrote — to be reported as + /// `InternalError` after the fact, and assert the processing loop rolls its writes + /// back. An `InternalError` maps to `TxAction::Removed`, so ANY path that produces one + /// after `apply_drive_operations(apply = true)` must leave no trace in the state. This + /// pins the v14 per-transition rollback even after the estimation gap + /// (dashpay/grovedb#812) is closed and no real transition can reach execution + /// under-funded anymore. + #[tokio::test] + async fn injected_post_apply_failure_must_not_mutate_state() { + use crate::execution::platform_events::state_transition_processing::test_fault_injection::FAIL_NEXT_SUCCESSFUL_EXECUTION; + + let pv = PlatformVersion::latest(); + let b = build_bundle(); + // Fully funded: without the injected failure this shield would execute and land. + let headroom = 5_000_000_000u64; + + let mut platform = setup_platform(); + insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); + let mut signer = TestAddressSigner::new(); + let addr = signer.add_p2pkh([1u8; 32]); + let declared_input = b.shield_amount + headroom; + setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); + + let st = build_signed(&b, &signer, addr, declared_input).await; + let bytes = st.serialize_to_bytes().expect("serialize"); + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + + let pool_before = platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) + .expect("pool balance"); + let notes_before = platform + .drive + .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) + .expect("notes count"); + let hash_before = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + + FAIL_NEXT_SUCCESSFUL_EXECUTION.with(|flag| flag.set(true)); + + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes], + &state, + &BlockInfo::default(), + &transaction, + pv, + true, + None, + ) + .expect("processing must not be a block-level error"); + + assert!( + !FAIL_NEXT_SUCCESSFUL_EXECUTION.with(|flag| flag.get()), + "sanity: the injection must have been consumed (the shield must have executed \ + successfully before being overridden)" + ); + assert!( + matches!( + result.execution_results().first(), + Some(StateTransitionExecutionResult::InternalError(_)) + ), + "expected the injected InternalError, got {:?}", + result.execution_results() + ); + + let pool_after = platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) + .expect("pool balance"); + let notes_after = platform + .drive + .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) + .expect("notes count"); + let hash_after = platform + .drive + .grove + .root_hash(Some(&transaction), &pv.drive.grove_version) + .unwrap() + .expect("root hash"); + + assert_eq!( + pool_after, pool_before, + "STATE LEAK: a transition reported InternalError kept its shielded pool credit" + ); + assert_eq!( + notes_after, notes_before, + "STATE LEAK: a transition reported InternalError kept its note commitments" + ); + assert_eq!( + hash_before, hash_after, + "APP HASH DIVERGENCE: a transition reported InternalError (and therefore \ + stripped from the block as TxAction::Removed) changed the root hash" + ); + } } } diff --git a/packages/rs-drive-abci/src/mimic/mod.rs b/packages/rs-drive-abci/src/mimic/mod.rs index cf37b659df3..3f1f8c768a4 100644 --- a/packages/rs-drive-abci/src/mimic/mod.rs +++ b/packages/rs-drive-abci/src/mimic/mod.rs @@ -351,6 +351,11 @@ impl FullAbciApplication<'_, C> { transaction .rollback_to_savepoint() .expect("expected to rollback to savepoint"); + // Drain per-transition savepoints left by process_raw_state_transitions_v1 + // (protocol v14+) so we land on the post-init-chain state, matching the + // genesis-path drain in prepare_proposal/process_proposal. The root-hash + // assertion below verifies the landing point. + while transaction.rollback_to_savepoint().is_ok() {} transaction.set_savepoint(); let start_root_hash = self diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index 08921d8bd9f..189e3ccc7ab 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -1,6 +1,7 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; +pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs new file mode 100644 index 00000000000..f0de39b2617 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -0,0 +1,156 @@ +use crate::version::drive_abci_versions::drive_abci_method_versions::{ + DriveAbciBlockEndMethodVersions, DriveAbciBlockFeeProcessingMethodVersions, + DriveAbciBlockStartMethodVersions, DriveAbciCoreBasedUpdatesMethodVersions, + DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciCoreInstantSendLockMethodVersions, + DriveAbciEngineMethodVersions, DriveAbciEpochMethodVersions, + DriveAbciFeePoolInwardsDistributionMethodVersions, + DriveAbciFeePoolOutwardsDistributionMethodVersions, + DriveAbciIdentityCreditWithdrawalMethodVersions, DriveAbciInitializationMethodVersions, + DriveAbciMasternodeIdentitiesUpdatesMethodVersions, DriveAbciMethodVersions, + DriveAbciPlatformStateStorageMethodVersions, DriveAbciProtocolUpgradeMethodVersions, + DriveAbciStateTransitionProcessingMethodVersions, DriveAbciTokensProcessingMethodVersions, + DriveAbciVotingMethodVersions, +}; + +// Introduced in Protocol version 14. +// +// Identical to DRIVE_ABCI_METHOD_VERSIONS_V9 (the protocol-v13 method set) except +// `process_raw_state_transitions` is bumped from 0 to 1: the v1 outer loop wraps every +// executed state transition in a GroveDB savepoint and rolls back on a result that +// strips the transition from the block (InternalError / UnpaidConsensusError). Under +// v0, execution could write into the shared block transaction before failing +// (apply-then-check fee flow), so a dropped transition's writes leaked into the +// proposer's app hash while the gossiped block omitted the transition — no validator +// could reproduce the hash and the chain stalled (mainnet evo1, 2026-08-14/15, after +// heights 415652 and 415661). Rolling back changes the app hash of any block that +// drops such a transition, so the bump MUST stay inactive on v13 (see +// DRIVE_ABCI_METHOD_VERSIONS_V9, which keeps the field at 0). +pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { + engine: DriveAbciEngineMethodVersions { + init_chain: 0, + check_tx: 0, + run_block_proposal: 0, + finalize_block_proposal: 0, + consensus_params_update: 1, + }, + initialization: DriveAbciInitializationMethodVersions { + initial_core_height_and_time: 0, + create_genesis_state: 1, + }, + core_based_updates: DriveAbciCoreBasedUpdatesMethodVersions { + update_core_info: 0, + update_masternode_list: 0, + update_quorum_info: 0, + masternode_updates: DriveAbciMasternodeIdentitiesUpdatesMethodVersions { + get_voter_identity_key: 0, + get_operator_identity_keys: 0, + get_owner_identity_withdrawal_key: 0, + get_owner_identity_owner_key: 0, + get_voter_identifier_from_masternode_list_item: 0, + get_operator_identifier_from_masternode_list_item: 0, + create_operator_identity: 0, + create_owner_identity: 1, + create_voter_identity: 0, + disable_identity_keys: 0, + update_masternode_identities: 0, + update_operator_identity: 0, + update_owner_withdrawal_address: 1, + update_voter_identity: 0, + }, + }, + protocol_upgrade: DriveAbciProtocolUpgradeMethodVersions { + check_for_desired_protocol_upgrade: 1, + upgrade_protocol_version_on_epoch_change: 0, + perform_events_on_first_block_of_protocol_change: Some(1), + protocol_version_upgrade_percentage_needed: 67, + }, + block_fee_processing: DriveAbciBlockFeeProcessingMethodVersions { + add_process_epoch_change_operations: 0, + process_block_fees_and_validate_sum_trees: 1, + }, + tokens_processing: DriveAbciTokensProcessingMethodVersions { + validate_token_aggregated_balance: 0, + }, + core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { + choose_quorum: 0, + verify_chain_lock: 0, + verify_chain_lock_locally: 0, + verify_chain_lock_through_core: 0, + make_sure_core_is_synced_to_chain_lock: 0, + recent_block_count_amount: 2, + }, + core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { + verify_recent_signature_locally: 0, + }, + fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { + add_distribute_block_fees_into_pools_operations: 0, + add_distribute_storage_fee_to_epochs_operations: 0, + }, + fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { + add_distribute_fees_from_oldest_unpaid_epoch_pool_to_proposers_operations: 1, + add_epoch_pool_to_proposers_payout_operations: 0, + find_oldest_epoch_needing_payment: 0, + fetch_reward_shares_list_for_masternode: 0, + }, + withdrawals: DriveAbciIdentityCreditWithdrawalMethodVersions { + build_untied_withdrawal_transactions_from_documents: 0, + dequeue_and_build_unsigned_withdrawal_transactions: 0, + fetch_transactions_block_inclusion_status: 0, + pool_withdrawals_into_transactions_queue: 1, + update_broadcasted_withdrawal_statuses: 0, + rebroadcast_expired_withdrawal_documents: 1, + append_signatures_and_broadcast_withdrawal_transactions: 0, + cleanup_expired_locks_of_withdrawal_amounts: 0, + }, + voting: DriveAbciVotingMethodVersions { + keep_record_of_finished_contested_resource_vote_poll: 0, + clean_up_after_vote_poll_end: 0, + clean_up_after_contested_resources_vote_poll_end: 1, + check_for_ended_vote_polls: 0, + tally_votes_for_contested_document_resource_vote_poll: 0, + award_document_to_winner: 0, + delay_vote_poll: 0, + run_dao_platform_events: 0, + remove_votes_for_removed_masternodes: 0, + }, + state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { + execute_event: 0, + // changed: v1 wraps each executed state transition in a savepoint and rolls back when the + // result strips the transition from the block, so dropped transitions leave no trace in + // the app hash. Kept in a real _v1 so _v0 stays byte-identical for pre-v14 nodes. + process_raw_state_transitions: 1, + // changed: v1 records the balance effects of paid-INVALID / unsuccessful-paid transitions + // (charged fees, adjusted outputs, applied chargeable-failure credits) that v0 dropped. Same + // v13 recorded-set expansion as `record_added_balance_outputs` below; kept in a real _v1 so + // no version conditional lives inside the _v0 helper. + process_validation_result: 1, + decode_raw_state_transitions: 0, + validate_fees_of_event: 0, + store_address_balances_to_recent_block_storage: Some(0), + cleanup_recent_block_storage_address_balances: Some(0), + // changed: v1 records shielded-spend transparent credits (Unshield net output, + // ShieldFromAssetLock surplus, identity-create fallback) folded at the executor. The storage + // method above is unchanged — only which credits feed its map did. + record_added_balance_outputs: 1, + }, + epoch: DriveAbciEpochMethodVersions { + gather_epoch_info: 0, + get_genesis_time: 0, + }, + block_start: DriveAbciBlockStartMethodVersions { + clear_drive_block_cache: 0, + }, + block_end: DriveAbciBlockEndMethodVersions { + update_state_cache: 0, + update_drive_cache: 0, + validator_set_update: 2, + should_checkpoint: Some(0), + update_checkpoints: Some(0), + record_shielded_pool_anchor: Some(0), + prune_shielded_pool_anchors: Some(0), + }, + platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { + fetch_platform_state: 0, + store_platform_state: 0, + }, +}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index cfa1f2a0efc..a401adcd4f5 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -15,7 +15,7 @@ use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VE use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; -use crate::version::drive_abci_versions::drive_abci_method_versions::v9::DRIVE_ABCI_METHOD_VERSIONS_V9; +use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; use crate::version::drive_abci_versions::drive_abci_structure_versions::v1::DRIVE_ABCI_STRUCTURE_VERSIONS_V1; use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; @@ -126,7 +126,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, - methods: DRIVE_ABCI_METHOD_VERSIONS_V9, + methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: process_raw_state_transitions v1 — per-transition savepoint; dropped transitions no longer leak writes into the app hash validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2, query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate From 5b9cbb306ce0841f6fb402bb89b5f5f6b7966281 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 14:53:56 +0700 Subject: [PATCH 4/6] refactor(drive-abci): scope v1 savepoints to non-genesis heights, drop the drains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the genesis savepoint-stack drains with not creating per-ST savepoints at the genesis height in the first place, mirroring the shipped 4.1.1 hotfix design. The exclusion is deterministic across nodes (genesis_height is chain configuration), so it is part of the v14 consensus rule. prepare_proposal, process_proposal and mimic revert to their base state — no drain loops, no swallowed errors, and the genesis single-savepoint discipline is untouched. At every other height each proposal round runs in a freshly started transaction, so savepoints left by kept transitions are provably inert. Also from review: make the rollback classification exhaustive over every StateTransitionExecutionResult variant so a future variant forces an explicit savepoint decision; move the fault-injection override logic into the cfg(test) module so the processing loop carries a single call; document in the savepoint spike test that under v1 its rollback pops the processing loop's savepoint, which records the same state. Co-Authored-By: Claude Fable 5 --- .../src/abci/handler/prepare_proposal.rs | 7 -- .../src/abci/handler/process_proposal.rs | 7 -- .../process_raw_state_transitions/v1/mod.rs | 118 +++++++++++++----- .../state_transitions/shield/tests.rs | 7 ++ packages/rs-drive-abci/src/mimic/mod.rs | 5 - 5 files changed, 92 insertions(+), 52 deletions(-) diff --git a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs index eed62c6b6bb..10a4bd95631 100644 --- a/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/prepare_proposal.rs @@ -143,13 +143,6 @@ where if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() .map_err(|e| drive::grovedb::error::Error::StorageError(RocksDBError(e)))?; - // Drain the rest of the savepoint stack: from protocol v14 the state-transition - // loop leaves one savepoint per executed transition on the stack (see - // process_raw_state_transitions_v1), so a single rollback may only rewind to the - // last transition of the previous round. Every savepoint on this stack records the - // post-init-chain state or later, and the bottom one records exactly it, so - // draining until empty always lands on the post-init-chain state. - while tx.rollback_to_savepoint().is_ok() {} tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs index 7afb328d1b4..a64aba5013b 100644 --- a/packages/rs-drive-abci/src/abci/handler/process_proposal.rs +++ b/packages/rs-drive-abci/src/abci/handler/process_proposal.rs @@ -174,13 +174,6 @@ where if let Some(tx) = transaction_guard.as_ref() { tx.rollback_to_savepoint() .map_err(|e| drive::grovedb::error::Error::StorageError(RocksDBError(e)))?; - // Drain the rest of the savepoint stack: from protocol v14 the state-transition - // loop leaves one savepoint per executed transition on the stack (see - // process_raw_state_transitions_v1), so a single rollback may only rewind to the - // last transition of the previous round. Every savepoint on this stack records the - // post-init-chain state or later, and the bottom one records exactly it, so - // draining until empty always lands on the post-init-chain state. - while tx.rollback_to_savepoint().is_ok() {} tx.set_savepoint(); } transaction_guard diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs index ffc7a08c05e..0cee9568b7b 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs @@ -29,11 +29,32 @@ use super::super::StateTransitionAwareError; /// bug, so the rollback below stays pinned even once every known trigger is fixed. #[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 = 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 Platform @@ -54,8 +75,10 @@ where /// Savepoints of kept transitions are left on the transaction's savepoint stack: RocksDB /// has no exposed way to pop one without rolling back, leftover savepoints are inert for /// commit, and the per-round transaction they live in is dropped when the round ends. The - /// one consumer of that stack — the genesis-height re-proposal path — drains the whole - /// stack rather than popping once, precisely so this residue cannot redirect it. + /// genesis height is excluded from the savepoint discipline entirely (deterministically — + /// `genesis_height` is chain configuration), because its re-proposal path rewinds to a + /// single savepoint set by init_chain and extra savepoints on that stack would redirect + /// the rewind. See `rollback_dropped_transitions` in the body. /// /// # Arguments /// @@ -95,6 +118,25 @@ where let state_transition_container = self.decode_raw_state_transitions(raw_state_transitions, platform_version)?; + // Wrap each executed state transition in a savepoint and roll back when its result + // strips it from the block, ON EVERY NODE — proposer and validator alike. This is a + // consensus rule from protocol v14: a block carrying such a transition evaluates to + // the state WITHOUT its writes on every node, closing both the honest-proposer app + // hash poisoning (mainnet evo1 stalls of 2026-08-14/15) and the malicious-proposer + // variant where deliberately including the transition would commit identically + // leaked state everywhere. + // + // The genesis height is excluded — deterministically, since `genesis_height` is + // chain configuration, so all nodes agree. 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 = block_info.height != self.config.abci.genesis_height; + let mut processing_result = StateTransitionsProcessingResult::default(); for decoded_state_transition in state_transition_container.into_iter() { @@ -136,8 +178,11 @@ where // Execution may write into the shared block transaction before its // result is known, so mark the state we can return to if the result - // strips this transition from the block. - transaction.set_savepoint(); + // strips this transition 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( @@ -168,36 +213,43 @@ 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); - match &execution_result { - StateTransitionExecutionResult::InternalError(_) - | StateTransitionExecutionResult::UnpaidConsensusError(_) => { - // This transition will be stripped from the block - // (`TxAction::Removed`), so none of its writes may remain in - // the state the app hash is computed over. A rollback failure - // means we can no longer produce a state matching the block — - // fail the whole proposal rather than continue on leaked state. - transaction.rollback_to_savepoint().map_err(|e| { - drive::grovedb::error::Error::StorageError(RocksDBError(e)) - })?; - } - _ => { - // The transition stays in the block, so its writes stay. Its - // savepoint is intentionally left on the stack (see the method - // documentation). + if rollback_dropped_transitions { + match &execution_result { + StateTransitionExecutionResult::InternalError(_) + | StateTransitionExecutionResult::UnpaidConsensusError(_) => { + // This transition will be stripped from the block + // (`TxAction::Removed`), so none of its writes may remain + // in the state the app hash is computed over. A rollback + // failure means we can no longer produce a state matching + // the block — fail the whole run 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`. + } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index 1ac732ca23e..eed66926c61 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -2124,6 +2124,13 @@ mod tests { .unwrap() .expect("root hash"); + // Note on savepoint provenance: under protocol v14 the v1 processing loop sets + // its OWN savepoint (recording this same state — nothing is written in between) + // on top of this one, and leaves it on the stack for a kept transition. The + // `rollback_to_savepoint()` below therefore pops the LOOP's savepoint, not this + // one, which stays on the stack unused. Both record identical state, so every + // assertion is unaffected; this savepoint documents the mechanism under test and + // kept the test meaningful when the loop was still v0. transaction.set_savepoint(); let result = platform diff --git a/packages/rs-drive-abci/src/mimic/mod.rs b/packages/rs-drive-abci/src/mimic/mod.rs index 3f1f8c768a4..cf37b659df3 100644 --- a/packages/rs-drive-abci/src/mimic/mod.rs +++ b/packages/rs-drive-abci/src/mimic/mod.rs @@ -351,11 +351,6 @@ impl FullAbciApplication<'_, C> { transaction .rollback_to_savepoint() .expect("expected to rollback to savepoint"); - // Drain per-transition savepoints left by process_raw_state_transitions_v1 - // (protocol v14+) so we land on the post-init-chain state, matching the - // genesis-path drain in prepare_proposal/process_proposal. The root-hash - // assertion below verifies the landing point. - while transaction.rollback_to_savepoint().is_ok() {} transaction.set_savepoint(); let start_root_hash = self From 0b265a13a6a63b5c18d1b5b33fdc4c4d2810b76f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 16:16:23 +0700 Subject: [PATCH 5/6] refactor(drive-abci)!: drop the v14 validator-side rollback; port the 4.1.1 proposer fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove process_raw_state_transitions v1 and DRIVE_ABCI_METHOD_VERSIONS_V10, rewiring protocol v14 back to V9. Review of process_proposal established that its unexpected_execution_results gate (present since early 2024) already rejects any block whose execution yields InternalError or UnpaidConsensusError results, so a block that includes a written-then- failed transition can never commit — the v14 validator-side rollback closed no reachable hole, only cleaned state inside a transaction the reject path discards, while its genesis carve-out would have become a permanent consensus rule (flagged as blocking in review). In its place, forward-port the shipped 4.1.1 proposer-side rollback (#4409) into v0, since v4.2-dev still carries the halt: savepoint per executed transition while proposing at non-genesis heights, rollback on drop-class results, exhaustive classification, and the fault-injection hook (as the extracted maybe_override helper requested in review). The halt regression tests now pin this path. The breaking marker on this commit reflects reverting the earlier v14 method-table change within this branch; net of the branch, the PR no longer contains any consensus change. Co-Authored-By: Claude Fable 5 --- .../execute_event/v0/mod.rs | 8 +- .../process_raw_state_transitions/mod.rs | 14 +- .../process_raw_state_transitions/v0/mod.rs | 118 +++++- .../process_raw_state_transitions/v1/mod.rs | 349 ------------------ .../state_transitions/shield/tests.rs | 21 +- .../drive_abci_method_versions/mod.rs | 1 - .../drive_abci_method_versions/v10.rs | 156 -------- .../rs-platform-version/src/version/v14.rs | 4 +- 8 files changed, 135 insertions(+), 536 deletions(-) delete mode 100644 packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs delete mode 100644 packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs index 2c53b51a6a2..9d2631c3082 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/execute_event/v0/mod.rs @@ -186,9 +186,11 @@ where // 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, which is only safe because the - // v14+ processing loop rolls dropped transitions back (process_raw_state_transitions - // v1); under v0 those writes leak into the proposer's app hash. + // 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", diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs index 05a07800b42..77b47f89f03 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/mod.rs @@ -1,8 +1,7 @@ mod v0; -mod v1; #[cfg(test)] -pub(crate) use v1::test_fault_injection; +pub(crate) use v0::test_fault_injection; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -66,18 +65,9 @@ where proposing_state_transitions, timer, ), - 1 => self.process_raw_state_transitions_v1( - raw_state_transitions, - block_platform_state, - block_info, - transaction, - platform_version, - proposing_state_transitions, - timer, - ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "process_raw_state_transitions".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs index 3e6654da000..4e19577b925 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v0/mod.rs @@ -17,10 +17,46 @@ 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 crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use std::cell::Cell; + + thread_local! { + pub static FAIL_NEXT_SUCCESSFUL_EXECUTION: Cell = 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 Platform where C: CoreRPCLike, @@ -69,6 +105,37 @@ 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 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 + // 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() { @@ -108,6 +175,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, @@ -117,9 +190,8 @@ where ) .map(|validation_result| { // Dispatch to the versioned helper (v0 = pre-v13, v1 = records - // paid-invalid balance effects). Only this helper changed at v13, so this - // outer loop stayed at v0 across that gate. (The loop itself changed at - // v14 — see _v1, which rolls back dropped transitions.) + // paid-invalid balance effects). Only this helper changed at v13; the outer + // loop is version-agnostic, so it must NOT be versioned. self.process_validation_result( raw_state_transition, &state_transition_name, @@ -138,6 +210,46 @@ where }) .unwrap_or_else(error_to_internal_error_execution_result); + #[cfg(test)] + let execution_result = + test_fault_injection::maybe_override(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`. + } + } + } + // Store metrics let elapsed_time = start_time.elapsed() + decoding_elapsed_time; diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs deleted file mode 100644 index 0cee9568b7b..00000000000 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/process_raw_state_transitions/v1/mod.rs +++ /dev/null @@ -1,349 +0,0 @@ -use crate::error::Error; -use crate::platform_types::platform::{Platform, PlatformRef}; -use crate::platform_types::platform_state::{PlatformState, PlatformStateV0Methods}; -use crate::rpc::core::CoreRPCLike; -use dpp::block::block_info::BlockInfo; -use dpp::consensus::codes::ErrorWithCode; - -use crate::execution::types::state_transition_container::v0::{ - DecodedStateTransition, InvalidStateTransition, InvalidWithProtocolErrorStateTransition, - SuccessfullyDecodedStateTransition, -}; -use crate::execution::validation::state_transition::processor::process_state_transition; -use crate::metrics::{state_transition_execution_histogram, HistogramTiming}; -use crate::platform_types::state_transitions_processing_result::{ - NotExecutedReason, StateTransitionExecutionResult, StateTransitionsProcessingResult, -}; -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)`) without depending on any particular estimation -/// bug, so the rollback below stays pinned even once every known trigger is fixed. -#[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 = 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 Platform -where - C: CoreRPCLike, -{ - /// Processes the given raw state transitions based on the `block_info` and `transaction`. - /// - /// Differs from v0 in one way: every executed state transition is wrapped in a GroveDB - /// savepoint, and a result that strips the transition from the block (`InternalError` / - /// `UnpaidConsensusError`, both mapped to `TxAction::Removed` by `prepare_proposal`) rolls - /// the savepoint back. Execution can write into the shared block transaction before - /// failing (the fee flow is apply-then-check), so without the rollback a dropped - /// transition's writes stay in the transaction and pollute the proposer's app hash while - /// the gossiped block omits the transition — no validator can then reproduce the hash - /// (mainnet evo1 stalls of 2026-08-14/15, after heights 415652 and 415661). - /// - /// Savepoints of kept transitions are left on the transaction's savepoint stack: RocksDB - /// has no exposed way to pop one without rolling back, leftover savepoints are inert for - /// commit, and the per-round transaction they live in is dropped when the round ends. The - /// genesis height is excluded from the savepoint discipline entirely (deterministically — - /// `genesis_height` is chain configuration), because its re-proposal path rewinds to a - /// single savepoint set by init_chain and extra savepoints on that stack would redirect - /// the rewind. See `rollback_dropped_transitions` in the body. - /// - /// # Arguments - /// - /// * `raw_state_transitions` - A reference to a vector of raw state transitions. - /// * `block_info` - Information about the current block being processed. - /// * `transaction` - The transaction associated with the raw state transitions. - /// - /// # Returns - /// - /// * `Result` - If the processing is successful, it returns - /// a `StateTransitionsProcessingResult` with state transition execution results and aggregated information. - /// If the processing fails, it returns an `Error`. - /// - /// # Errors - /// - /// This function may return an `Error` variant if there is a problem with deserializing the raw - /// state transitions, processing state transitions, executing events, or rolling back a - /// dropped transition's savepoint. - #[allow(clippy::too_many_arguments)] - pub(super) fn process_raw_state_transitions_v1( - &self, - raw_state_transitions: &[Vec], - block_platform_state: &PlatformState, - block_info: &BlockInfo, - transaction: &Transaction, - platform_version: &PlatformVersion, - proposing_state_transitions: bool, - timer: Option<&HistogramTiming>, - ) -> Result { - let platform_ref = PlatformRef { - drive: &self.drive, - state: block_platform_state, - config: &self.config, - core_rpc: &self.core_rpc, - }; - - let state_transition_container = - self.decode_raw_state_transitions(raw_state_transitions, platform_version)?; - - // Wrap each executed state transition in a savepoint and roll back when its result - // strips it from the block, ON EVERY NODE — proposer and validator alike. This is a - // consensus rule from protocol v14: a block carrying such a transition evaluates to - // the state WITHOUT its writes on every node, closing both the honest-proposer app - // hash poisoning (mainnet evo1 stalls of 2026-08-14/15) and the malicious-proposer - // variant where deliberately including the transition would commit identically - // leaked state everywhere. - // - // The genesis height is excluded — deterministically, since `genesis_height` is - // chain configuration, so all nodes agree. 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 = block_info.height != self.config.abci.genesis_height; - - let mut processing_result = StateTransitionsProcessingResult::default(); - - for decoded_state_transition in state_transition_container.into_iter() { - // If we propose state transitions, we need to check if we have a time limit for processing - // set and if we have exceeded it. - let execution_result = if proposing_state_transitions - && timer.is_some_and(|timer| { - timer.elapsed().as_millis() - > self - .config - .abci - .proposer_tx_processing_time_limit - .unwrap_or(u16::MAX) as u128 - }) { - StateTransitionExecutionResult::NotExecuted(NotExecutedReason::ProposerRanOutOfTime) - } else { - match decoded_state_transition { - DecodedStateTransition::SuccessfullyDecoded( - SuccessfullyDecodedStateTransition { - decoded: state_transition, - raw: raw_state_transition, - elapsed_time: decoding_elapsed_time, - }, - ) => { - let start_time = Instant::now(); - - let state_transition_name = state_transition.name(); - - if tracing::enabled!(tracing::Level::TRACE) { - let st_hash = hex::encode(hash_single(raw_state_transition)); - - tracing::trace!( - ?state_transition, - st_hash, - "Processing {} state transition", - state_transition_name - ); - } - - // Execution may write into the shared block transaction before its - // result is known, so mark the state we can return to if the result - // strips this transition 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, - block_info, - state_transition, - Some(transaction), - ) - .map(|validation_result| { - // Dispatch to the versioned helper (v0 = pre-v13, v1 = records - // paid-invalid balance effects). - self.process_validation_result( - raw_state_transition, - &state_transition_name, - validation_result, - block_info, - transaction, - platform_version, - platform_ref.state.previous_fee_versions(), - ) - .unwrap_or_else(error_to_internal_error_execution_result) - }) - .map_err(|error| StateTransitionAwareError { - error, - raw_state_transition, - state_transition_name: Some(state_transition_name.to_string()), - }) - .unwrap_or_else(error_to_internal_error_execution_result); - - #[cfg(test)] - let execution_result = - test_fault_injection::maybe_override(execution_result); - - if rollback_dropped_transitions { - match &execution_result { - StateTransitionExecutionResult::InternalError(_) - | StateTransitionExecutionResult::UnpaidConsensusError(_) => { - // This transition will be stripped from the block - // (`TxAction::Removed`), so none of its writes may remain - // in the state the app hash is computed over. A rollback - // failure means we can no longer produce a state matching - // the block — fail the whole run 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`. - } - } - } - - // Store metrics - let elapsed_time = start_time.elapsed() + decoding_elapsed_time; - - let code = match &execution_result { - StateTransitionExecutionResult::SuccessfulExecution { .. } => 0, - StateTransitionExecutionResult::PaidConsensusError { - error, .. - } => error.code(), - StateTransitionExecutionResult::UnpaidConsensusError(error) => { - error.code() - } - StateTransitionExecutionResult::InternalError(_) => 1, - StateTransitionExecutionResult::NotExecuted(_) => 1, //todo - }; - - state_transition_execution_histogram( - elapsed_time, - &state_transition_name, - code, - ); - - execution_result - } - DecodedStateTransition::InvalidEncoding(InvalidStateTransition { - raw, - error, - elapsed_time: decoding_elapsed_time, - }) => { - if tracing::enabled!(tracing::Level::DEBUG) { - let st_hash = hex::encode(hash_single(raw)); - - tracing::debug!( - ?error, - st_hash, - "Invalid unknown state transition ({}): {}", - st_hash, - error - ); - } - - // Store metrics - state_transition_execution_histogram( - decoding_elapsed_time, - "Unknown", - error.code(), - ); - - StateTransitionExecutionResult::UnpaidConsensusError(error) - } - DecodedStateTransition::FailedToDecode( - InvalidWithProtocolErrorStateTransition { - raw, - error: protocol_error, - elapsed_time: decoding_elapsed_time, - }, - ) => { - // Store metrics - state_transition_execution_histogram(decoding_elapsed_time, "Unknown", 1); - - error_to_internal_error_execution_result(StateTransitionAwareError { - error: protocol_error.into(), - raw_state_transition: raw, - state_transition_name: None, - }) - } - } - }; - - processing_result.add(execution_result)?; - } - - Ok(processing_result) - } -} - -fn error_to_internal_error_execution_result( - error_with_st: StateTransitionAwareError, -) -> StateTransitionExecutionResult { - if tracing::enabled!(tracing::Level::ERROR) { - let st_hash = hex::encode(hash_single(error_with_st.raw_state_transition)); - - tracing::error!( - error = ?error_with_st.error, - raw_state_transition = ?error_with_st.raw_state_transition, - st_hash, - "Failed to process {} state transition ({}) : {}", - error_with_st.state_transition_name.unwrap_or_else(|| "unknown".to_string()), - st_hash, - error_with_st.error, - ); - } - - StateTransitionExecutionResult::InternalError(error_with_st.error.to_string()) -} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index eed66926c61..8526b6b3cd7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -1904,8 +1904,9 @@ mod tests { /// /// If the two cost models agreed, the edges would coincide and the band would be empty. /// Any width is a range of funding levels where the transition is accepted by validation - /// and then dropped at execution — no longer a chain halt since the v14 per-transition - /// rollback, but still a transition that can never confirm despite paying the quoted fee. + /// and then dropped at execution — no longer a chain halt since the proposer-side + /// per-transition rollback (shipped in 4.1.1), but still a transition that can never + /// confirm despite paying the quoted fee. /// /// Ignored until the estimation gap is closed: the estimated-cost path skips the keyless /// commitment-tree append entirely (dashpay/grovedb#812), so the band is measurably open @@ -2124,13 +2125,13 @@ mod tests { .unwrap() .expect("root hash"); - // Note on savepoint provenance: under protocol v14 the v1 processing loop sets - // its OWN savepoint (recording this same state — nothing is written in between) - // on top of this one, and leaves it on the stack for a kept transition. The - // `rollback_to_savepoint()` below therefore pops the LOOP's savepoint, not this - // one, which stays on the stack unused. Both record identical state, so every - // assertion is unaffected; this savepoint documents the mechanism under test and - // kept the test meaningful when the loop was still v0. + // Note on savepoint provenance: while proposing at a non-genesis height the + // processing loop sets its OWN savepoint (recording this same state — nothing is + // written in between) on top of this one, and leaves it on the stack for a kept + // transition. The `rollback_to_savepoint()` below therefore pops the LOOP's + // savepoint, not this one, which stays on the stack unused. Both record identical + // state, so every assertion is unaffected; this savepoint documents the mechanism + // under test and kept the test meaningful before the loop rolled back on its own. transaction.set_savepoint(); let result = platform @@ -2259,7 +2260,7 @@ mod tests { /// `InternalError` after the fact, and assert the processing loop rolls its writes /// back. An `InternalError` maps to `TxAction::Removed`, so ANY path that produces one /// after `apply_drive_operations(apply = true)` must leave no trace in the state. This - /// pins the v14 per-transition rollback even after the estimation gap + /// pins the proposer-side per-transition rollback even after the estimation gap /// (dashpay/grovedb#812) is closed and no real transition can reach execution /// under-funded anymore. #[tokio::test] diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs index 189e3ccc7ab..08921d8bd9f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/mod.rs @@ -1,7 +1,6 @@ use versioned_feature_core::{FeatureVersion, OptionalFeatureVersion}; pub mod v1; -pub mod v10; pub mod v2; pub mod v3; pub mod v4; diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs deleted file mode 100644 index f0de39b2617..00000000000 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ /dev/null @@ -1,156 +0,0 @@ -use crate::version::drive_abci_versions::drive_abci_method_versions::{ - DriveAbciBlockEndMethodVersions, DriveAbciBlockFeeProcessingMethodVersions, - DriveAbciBlockStartMethodVersions, DriveAbciCoreBasedUpdatesMethodVersions, - DriveAbciCoreChainLockMethodVersionsAndConstants, DriveAbciCoreInstantSendLockMethodVersions, - DriveAbciEngineMethodVersions, DriveAbciEpochMethodVersions, - DriveAbciFeePoolInwardsDistributionMethodVersions, - DriveAbciFeePoolOutwardsDistributionMethodVersions, - DriveAbciIdentityCreditWithdrawalMethodVersions, DriveAbciInitializationMethodVersions, - DriveAbciMasternodeIdentitiesUpdatesMethodVersions, DriveAbciMethodVersions, - DriveAbciPlatformStateStorageMethodVersions, DriveAbciProtocolUpgradeMethodVersions, - DriveAbciStateTransitionProcessingMethodVersions, DriveAbciTokensProcessingMethodVersions, - DriveAbciVotingMethodVersions, -}; - -// Introduced in Protocol version 14. -// -// Identical to DRIVE_ABCI_METHOD_VERSIONS_V9 (the protocol-v13 method set) except -// `process_raw_state_transitions` is bumped from 0 to 1: the v1 outer loop wraps every -// executed state transition in a GroveDB savepoint and rolls back on a result that -// strips the transition from the block (InternalError / UnpaidConsensusError). Under -// v0, execution could write into the shared block transaction before failing -// (apply-then-check fee flow), so a dropped transition's writes leaked into the -// proposer's app hash while the gossiped block omitted the transition — no validator -// could reproduce the hash and the chain stalled (mainnet evo1, 2026-08-14/15, after -// heights 415652 and 415661). Rolling back changes the app hash of any block that -// drops such a transition, so the bump MUST stay inactive on v13 (see -// DRIVE_ABCI_METHOD_VERSIONS_V9, which keeps the field at 0). -pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMethodVersions { - engine: DriveAbciEngineMethodVersions { - init_chain: 0, - check_tx: 0, - run_block_proposal: 0, - finalize_block_proposal: 0, - consensus_params_update: 1, - }, - initialization: DriveAbciInitializationMethodVersions { - initial_core_height_and_time: 0, - create_genesis_state: 1, - }, - core_based_updates: DriveAbciCoreBasedUpdatesMethodVersions { - update_core_info: 0, - update_masternode_list: 0, - update_quorum_info: 0, - masternode_updates: DriveAbciMasternodeIdentitiesUpdatesMethodVersions { - get_voter_identity_key: 0, - get_operator_identity_keys: 0, - get_owner_identity_withdrawal_key: 0, - get_owner_identity_owner_key: 0, - get_voter_identifier_from_masternode_list_item: 0, - get_operator_identifier_from_masternode_list_item: 0, - create_operator_identity: 0, - create_owner_identity: 1, - create_voter_identity: 0, - disable_identity_keys: 0, - update_masternode_identities: 0, - update_operator_identity: 0, - update_owner_withdrawal_address: 1, - update_voter_identity: 0, - }, - }, - protocol_upgrade: DriveAbciProtocolUpgradeMethodVersions { - check_for_desired_protocol_upgrade: 1, - upgrade_protocol_version_on_epoch_change: 0, - perform_events_on_first_block_of_protocol_change: Some(1), - protocol_version_upgrade_percentage_needed: 67, - }, - block_fee_processing: DriveAbciBlockFeeProcessingMethodVersions { - add_process_epoch_change_operations: 0, - process_block_fees_and_validate_sum_trees: 1, - }, - tokens_processing: DriveAbciTokensProcessingMethodVersions { - validate_token_aggregated_balance: 0, - }, - core_chain_lock: DriveAbciCoreChainLockMethodVersionsAndConstants { - choose_quorum: 0, - verify_chain_lock: 0, - verify_chain_lock_locally: 0, - verify_chain_lock_through_core: 0, - make_sure_core_is_synced_to_chain_lock: 0, - recent_block_count_amount: 2, - }, - core_instant_send_lock: DriveAbciCoreInstantSendLockMethodVersions { - verify_recent_signature_locally: 0, - }, - fee_pool_inwards_distribution: DriveAbciFeePoolInwardsDistributionMethodVersions { - add_distribute_block_fees_into_pools_operations: 0, - add_distribute_storage_fee_to_epochs_operations: 0, - }, - fee_pool_outwards_distribution: DriveAbciFeePoolOutwardsDistributionMethodVersions { - add_distribute_fees_from_oldest_unpaid_epoch_pool_to_proposers_operations: 1, - add_epoch_pool_to_proposers_payout_operations: 0, - find_oldest_epoch_needing_payment: 0, - fetch_reward_shares_list_for_masternode: 0, - }, - withdrawals: DriveAbciIdentityCreditWithdrawalMethodVersions { - build_untied_withdrawal_transactions_from_documents: 0, - dequeue_and_build_unsigned_withdrawal_transactions: 0, - fetch_transactions_block_inclusion_status: 0, - pool_withdrawals_into_transactions_queue: 1, - update_broadcasted_withdrawal_statuses: 0, - rebroadcast_expired_withdrawal_documents: 1, - append_signatures_and_broadcast_withdrawal_transactions: 0, - cleanup_expired_locks_of_withdrawal_amounts: 0, - }, - voting: DriveAbciVotingMethodVersions { - keep_record_of_finished_contested_resource_vote_poll: 0, - clean_up_after_vote_poll_end: 0, - clean_up_after_contested_resources_vote_poll_end: 1, - check_for_ended_vote_polls: 0, - tally_votes_for_contested_document_resource_vote_poll: 0, - award_document_to_winner: 0, - delay_vote_poll: 0, - run_dao_platform_events: 0, - remove_votes_for_removed_masternodes: 0, - }, - state_transition_processing: DriveAbciStateTransitionProcessingMethodVersions { - execute_event: 0, - // changed: v1 wraps each executed state transition in a savepoint and rolls back when the - // result strips the transition from the block, so dropped transitions leave no trace in - // the app hash. Kept in a real _v1 so _v0 stays byte-identical for pre-v14 nodes. - process_raw_state_transitions: 1, - // changed: v1 records the balance effects of paid-INVALID / unsuccessful-paid transitions - // (charged fees, adjusted outputs, applied chargeable-failure credits) that v0 dropped. Same - // v13 recorded-set expansion as `record_added_balance_outputs` below; kept in a real _v1 so - // no version conditional lives inside the _v0 helper. - process_validation_result: 1, - decode_raw_state_transitions: 0, - validate_fees_of_event: 0, - store_address_balances_to_recent_block_storage: Some(0), - cleanup_recent_block_storage_address_balances: Some(0), - // changed: v1 records shielded-spend transparent credits (Unshield net output, - // ShieldFromAssetLock surplus, identity-create fallback) folded at the executor. The storage - // method above is unchanged — only which credits feed its map did. - record_added_balance_outputs: 1, - }, - epoch: DriveAbciEpochMethodVersions { - gather_epoch_info: 0, - get_genesis_time: 0, - }, - block_start: DriveAbciBlockStartMethodVersions { - clear_drive_block_cache: 0, - }, - block_end: DriveAbciBlockEndMethodVersions { - update_state_cache: 0, - update_drive_cache: 0, - validator_set_update: 2, - should_checkpoint: Some(0), - update_checkpoints: Some(0), - record_shielded_pool_anchor: Some(0), - prune_shielded_pool_anchors: Some(0), - }, - platform_state_storage: DriveAbciPlatformStateStorageMethodVersions { - fetch_platform_state: 0, - store_platform_state: 0, - }, -}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a401adcd4f5..cfa1f2a0efc 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -15,7 +15,7 @@ use crate::version::dpp_versions::dpp_validation_versions::v5::DPP_VALIDATION_VE use crate::version::dpp_versions::dpp_voting_versions::v2::VOTING_VERSION_V2; use crate::version::dpp_versions::DPPVersion; use crate::version::drive_abci_versions::drive_abci_checkpoint_parameters::v1::DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1; -use crate::version::drive_abci_versions::drive_abci_method_versions::v10::DRIVE_ABCI_METHOD_VERSIONS_V10; +use crate::version::drive_abci_versions::drive_abci_method_versions::v9::DRIVE_ABCI_METHOD_VERSIONS_V9; use crate::version::drive_abci_versions::drive_abci_query_versions::v3::DRIVE_ABCI_QUERY_VERSIONS_V3; use crate::version::drive_abci_versions::drive_abci_structure_versions::v1::DRIVE_ABCI_STRUCTURE_VERSIONS_V1; use crate::version::drive_abci_versions::drive_abci_validation_versions::v10::DRIVE_ABCI_VALIDATION_VERSIONS_V10; @@ -126,7 +126,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive: DRIVE_VERSION_V9, // changed: drive document method versions v4 — v2 index walkers (shared-prefix aggregate indexes become insertable) + the detect_ranked_mode slot drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, - methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: process_raw_state_transitions v1 — per-transition savepoint; dropped transitions no longer leak writes into the app hash + methods: DRIVE_ABCI_METHOD_VERSIONS_V9, validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2, query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate From 266d7095e91d9cab3492293829830216a968c5b7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 19 Aug 2026 16:52:47 +0700 Subject: [PATCH 6/6] test(drive-abci): consolidate the halt test modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the cherry-picked proposer_rollback_hotfix module into mainnet_halt_repro: drop its two tests that duplicated existing coverage (same injected fault and assertions; same band-edge headroom), port its run_injected helper, and keep its one unique guard — validating_must_not_roll_back_and_preserves_prior_behavior, which pins that the validating path sets no savepoint and preserves prior behavior bit-for-bit, with process_proposal's reject gate as that path's guard. One module, one set of bundle helpers, five tests. Co-Authored-By: Claude Fable 5 --- .../state_transitions/shield/tests.rs | 345 ++---------------- 1 file changed, 23 insertions(+), 322 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index c212bb6783a..c538ac479fd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -2255,220 +2255,6 @@ mod tests { ); } - /// Generic leak guard, independent of the fee bug: force a fully-funded shield — one - /// that executes successfully and therefore definitely wrote — to be reported as - /// `InternalError` after the fact, and assert the processing loop rolls its writes - /// back. An `InternalError` maps to `TxAction::Removed`, so ANY path that produces one - /// after `apply_drive_operations(apply = true)` must leave no trace in the state. This - /// pins the proposer-side per-transition rollback even after the estimation gap - /// (dashpay/grovedb#812) is closed and no real transition can reach execution - /// under-funded anymore. - #[tokio::test] - async fn injected_post_apply_failure_must_not_mutate_state() { - use crate::execution::platform_events::state_transition_processing::test_fault_injection::FAIL_NEXT_SUCCESSFUL_EXECUTION; - - let pv = PlatformVersion::latest(); - let b = build_bundle(); - // Fully funded: without the injected failure this shield would execute and land. - let headroom = 5_000_000_000u64; - - let mut platform = setup_platform(); - insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); - let mut signer = TestAddressSigner::new(); - let addr = signer.add_p2pkh([1u8; 32]); - let declared_input = b.shield_amount + headroom; - setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - - let st = build_signed(&b, &signer, addr, declared_input).await; - let bytes = st.serialize_to_bytes().expect("serialize"); - let state = platform.state.load(); - let transaction = platform.drive.grove.start_transaction(); - - let pool_before = platform - .drive - .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) - .expect("pool balance"); - let notes_before = platform - .drive - .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) - .expect("notes count"); - let hash_before = platform - .drive - .grove - .root_hash(Some(&transaction), &pv.drive.grove_version) - .unwrap() - .expect("root hash"); - - FAIL_NEXT_SUCCESSFUL_EXECUTION.with(|flag| flag.set(true)); - - let result = platform - .platform - .process_raw_state_transitions( - &vec![bytes], - &state, - &BlockInfo::default(), - &transaction, - pv, - true, - None, - ) - .expect("processing must not be a block-level error"); - - assert!( - !FAIL_NEXT_SUCCESSFUL_EXECUTION.with(|flag| flag.get()), - "sanity: the injection must have been consumed (the shield must have executed \ - successfully before being overridden)" - ); - assert!( - matches!( - result.execution_results().first(), - Some(StateTransitionExecutionResult::InternalError(_)) - ), - "expected the injected InternalError, got {:?}", - result.execution_results() - ); - - let pool_after = platform - .drive - .read_shielded_pool_total_balance(Some(&transaction), &mut vec![], pv) - .expect("pool balance"); - let notes_after = platform - .drive - .shielded_pool_notes_count(Some(&transaction), &mut vec![], pv) - .expect("notes count"); - let hash_after = platform - .drive - .grove - .root_hash(Some(&transaction), &pv.drive.grove_version) - .unwrap() - .expect("root hash"); - - assert_eq!( - pool_after, pool_before, - "STATE LEAK: a transition reported InternalError kept its shielded pool credit" - ); - assert_eq!( - notes_after, notes_before, - "STATE LEAK: a transition reported InternalError kept its note commitments" - ); - assert_eq!( - hash_before, hash_after, - "APP HASH DIVERGENCE: a transition reported InternalError (and therefore \ - stripped from the block as TxAction::Removed) changed the root hash" - ); - } - } - - /// MAINNET HALT HOTFIX (evo1, 2026-08-14/15: ~2h stalls after 415652 and 415661). - /// - /// Execution can write into the shared block transaction before failing (the address-input - /// fee flow is apply-then-check), and a transition whose result strips it from the block - /// (`InternalError` -> `TxAction::Removed`) left those writes behind: the proposer gossiped - /// a block WITHOUT the transition while advertising an app hash computed WITH its writes, - /// so no validator could reproduce the hash and every proposer carrying the transition - /// burned its round. - /// - /// The hotfix rolls such transitions back on the PROPOSING path only. These tests pin both - /// halves of that contract: the proposing path leaves no trace, and the validating path is - /// byte-identical to v4.1.0 (rolling back there would change what a received block - /// evaluates to — a consensus change that must ride a protocol-version gate, not a hotfix). - /// - /// Both tests use a fault hook rather than a real under-funded shield so they are - /// independent of the fee-estimation constants that made the mainnet transitions fail - /// (dashpay/grovedb#812). - mod proposer_rollback_hotfix { - use super::*; - use crate::execution::platform_events::state_transition_processing::test_fault_injection::FAIL_NEXT_SUCCESSFUL_EXECUTION; - use crate::execution::validation::state_transition::state_transitions::test_helpers::insert_dummy_encrypted_notes; - use dpp::block::block_info::BlockInfo; - - /// Note count on mainnet's shielded commitment tree around the halt. - const MAINNET_NOTES: u64 = 494; - - struct Bundle { - actions: Vec, - shield_amount: u64, - anchor: [u8; 32], - proof: Vec, - binding_sig: [u8; 64], - } - - fn build_bundle() -> Bundle { - let mut rng = OsRng; - let pk = get_proving_key(); - let sk = SpendingKey::from_bytes([0u8; 32]).unwrap(); - let fvk = FullViewingKey::from(&sk); - let recipient = fvk.address_at(0u32, Scope::External); - - let mut builder = Builder::::new( - BundleType::Transactional { - flags: OrchardFlags::SPENDS_DISABLED, - bundle_required: false, - }, - Anchor::empty_tree(), - ); - builder - .add_output(None, recipient, NoteValue::from_raw(5000u64), [0u8; 36]) - .unwrap(); - let (unauthorized, _) = builder.build::(&mut rng).unwrap().unwrap(); - let commitment: [u8; 32] = unauthorized.commitment().into(); - let sighash = compute_platform_sighash(&commitment, &[]); - let proven = unauthorized.create_proof(pk, &mut rng).unwrap(); - let bundle = proven.apply_signatures(rng, sighash, &[]).unwrap(); - - let (actions, _flags, value_balance, anchor, proof, binding_sig) = - serialize_authorized_bundle_with_flags(&bundle); - assert!( - value_balance < 0, - "a shield must have negative value balance" - ); - Bundle { - actions, - shield_amount: (-value_balance) as u64, - anchor, - proof, - binding_sig, - } - } - - async fn build_signed( - b: &Bundle, - signer: &TestAddressSigner, - addr: PlatformAddress, - declared_input: u64, - ) -> StateTransition { - let mut inputs = BTreeMap::new(); - inputs.insert(addr, (1 as AddressNonce, declared_input)); - - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { - inputs: inputs.clone(), - actions: b.actions.clone(), - amount: b.shield_amount, - anchor: b.anchor, - proof: b.proof.clone(), - binding_signature: b.binding_sig, - fee_strategy: AddressFundsFeeStrategy::from(vec![ - AddressFundsFeeStrategyStep::DeductFromInput(0), - ]), - user_fee_increase: 0, - input_witnesses: vec![], - })); - let signable = st.signable_bytes().expect("should compute signable bytes"); - let mut witnesses: Vec = Vec::with_capacity(inputs.len()); - for a in inputs.keys() { - witnesses.push( - signer - .sign_create_witness(a, &signable) - .await - .expect("sign"), - ); - } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { - v0.input_witnesses = witnesses; - } - st - } - struct RunOutcome { dropped_as_internal_error: bool, pool_delta: i128, @@ -2479,6 +2265,8 @@ mod tests { /// Run a fully-funded shield with the post-apply fault injected, on the proposing or /// validating path, and report what it left behind. async fn run_injected(proposing: bool) -> RunOutcome { + use crate::execution::platform_events::state_transition_processing::test_fault_injection::FAIL_NEXT_SUCCESSFUL_EXECUTION; + let pv = PlatformVersion::latest(); let b = build_bundle(); // Fully funded: without the injected failure this shield would execute and land. @@ -2560,12 +2348,16 @@ mod tests { } } - /// The fix: a transition dropped as `InternalError` while PROPOSING must leave the - /// shielded pool, the note commitment tree, and the root hash untouched — the proposal - /// then omits the transition AND its app hash omits its writes, so any validator - /// (including un-upgraded v4.1.0 ones) reproduces the hash and the round commits. + /// Generic leak guard, independent of the fee bug: force a fully-funded shield — one + /// that executes successfully and therefore definitely wrote — to be reported as + /// `InternalError` after the fact, and assert the processing loop rolls its writes + /// back while PROPOSING. An `InternalError` maps to `TxAction::Removed`, so any path + /// that produces one after `apply_drive_operations(apply = true)` must leave no trace + /// in the proposal state. This pins the proposer-side per-transition rollback even + /// after the estimation gap (dashpay/grovedb#812) is closed and no real transition can + /// reach execution under-funded anymore. #[tokio::test] - async fn proposing_must_not_leave_state_of_dropped_transition() { + async fn injected_post_apply_failure_must_not_mutate_state() { let outcome = run_injected(true).await; assert!( outcome.dropped_as_internal_error, @@ -2584,20 +2376,19 @@ mod tests { assert!( !outcome.hash_changed, "APP HASH POISONED: a transition dropped from the proposal changed the app \ - hash; validators replaying the block (which omits it) can never reproduce \ - this hash and the chain stalls" + hash; validators replaying the block (which omits it) can never reproduce it" ); } - /// The consensus-invisibility guarantee: the VALIDATING path must behave exactly as - /// v4.1.0 did — no savepoint, no rollback, the leak preserved. Rolling back here would - /// change what state a received block evaluates to, i.e. a consensus change: an - /// upgraded validator would then disagree with un-upgraded ones about any block that - /// carries such a transition. That change is version-gated to protocol v14 and MUST - /// NOT be active in this hotfix. If this test ever fails because the deltas became - /// zero, the hotfix has silently become a fork. + /// The consensus-invisibility guarantee of the proposer-side fix: the VALIDATING path + /// must not roll anything back — no savepoint is set there, and the leak is preserved + /// bit-for-bit. Rolling back while validating would change what state a received + /// block evaluates to, a consensus change; the guard on that path is instead + /// `process_proposal`'s `unexpected_execution_results` gate, which rejects any block + /// carrying such a transition wholesale. If this test ever fails because the deltas + /// became zero, the node has silently forked from un-upgraded peers. #[tokio::test] - async fn validating_must_behave_exactly_as_v4_1_0() { + async fn validating_must_not_roll_back_and_preserves_prior_behavior() { let outcome = run_injected(false).await; assert!( outcome.dropped_as_internal_error, @@ -2605,106 +2396,16 @@ mod tests { ); assert_eq!( outcome.pool_delta, 5000, - "the validating path must keep v4.1.0 behavior bit-for-bit (leak preserved)" + "the validating path must keep prior behavior bit-for-bit (leak preserved)" ); assert_eq!( outcome.notes_delta, 2, - "the validating path must keep v4.1.0 behavior bit-for-bit (leak preserved)" + "the validating path must keep prior behavior bit-for-bit (leak preserved)" ); assert!( outcome.hash_changed, - "the validating path must keep v4.1.0 behavior bit-for-bit (leak preserved)" - ); - } - - /// The real mainnet scenario, no fault hook: a shield funded at the edge of the - /// estimated-vs-actual fee band measured on v4.2-dev (actual metered fee - /// 177,215,760 credits at 494 notes; headroom one credit below). The grovedb pin - /// differs on the v4.1 line so the exact constants may shift; whatever this funding - /// level produces here, the proposing path must leave state consistent with it: - /// a dropped or rejected transition leaves NO trace (this was the halt), and only a - /// genuinely successful one changes state. - #[tokio::test] - async fn proposing_real_underfunded_shield_leaves_no_trace() { - let pv = PlatformVersion::latest(); - let b = build_bundle(); - let headroom = 177_215_759u64; - - let mut platform = setup_platform(); - insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); - let mut signer = TestAddressSigner::new(); - let addr = signer.add_p2pkh([1u8; 32]); - let declared_input = b.shield_amount + headroom; - setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - - let st = build_signed(&b, &signer, addr, declared_input).await; - let bytes = st.serialize_to_bytes().expect("serialize"); - let state = platform.state.load(); - let transaction = platform.drive.grove.start_transaction(); - - let hash_before = platform - .drive - .grove - .root_hash(Some(&transaction), &pv.drive.grove_version) - .unwrap() - .expect("root hash"); - - let result = platform - .platform - .process_raw_state_transitions( - &vec![bytes], - &state, - &BlockInfo::default(), - &transaction, - pv, - true, // proposing, exactly as prepare_proposal does - None, - ) - .expect("processing must not be a block-level error"); - - let hash_after = platform - .drive - .grove - .root_hash(Some(&transaction), &pv.drive.grove_version) - .unwrap() - .expect("root hash"); - - println!( - "outcome at band-edge headroom: {:?}", - result.execution_results().first() + "the validating path must keep prior behavior bit-for-bit (leak preserved)" ); - match result.execution_results().first() { - Some(StateTransitionExecutionResult::InternalError(msg)) => { - // The mainnet halt case: accepted by estimated-fee validation, failed by - // actual-fee execution, dropped from the proposal. Must leave no trace. - assert!( - msg.contains("not fully covered"), - "expected the fee coverage guard, got: {msg}" - ); - assert_eq!( - hash_before, hash_after, - "APP HASH POISONED: the exact mainnet halt scenario leaked state on \ - the proposing path" - ); - } - Some(StateTransitionExecutionResult::UnpaidConsensusError(_)) => { - // Fee constants on this line put the estimate above this funding level: - // rejected before execution. Fine — but still must leave no trace. - assert_eq!( - hash_before, hash_after, - "a validation-rejected shield must not touch state" - ); - } - Some(StateTransitionExecutionResult::SuccessfulExecution { .. }) => { - // Fee constants on this line put the actual fee at or below this funding - // level: the shield legitimately landed, so state MUST have changed. - assert_ne!( - hash_before, hash_after, - "a successful shield must change state" - ); - } - other => panic!("unexpected execution result: {other:?}"), - } } } }