From 49f399319a00440cfe02871f0d5128a2ee597898 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 10 Jun 2026 16:10:53 +0200 Subject: [PATCH 1/4] test(drive-abci): assert check_tx never mutates committed grovedb state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CheckTx fee estimation runs validate_fees_of_event with transaction: None, so any eager GroveDB write on that path (e.g. inside a drive-op converter) commits straight to disk, diverges the on-disk root from the signed app hash and deterministically halts the chain — exactly what happened on devnet paloma at height 788 via the pre-#3823 shielded InsertNullifiers converter arm. #3823 removed the bug; these regression tests guard the class: - new test helper assert_committed_root_hash_unchanged that asserts the committed (transaction: None) root hash is byte-identical around a CheckTx-path call - targeted test driving validate_fees_of_event for a synthetic type-20 PaidFromShieldedPoolToNewIdentity event whose ops carry InsertNullifiers + InsertNote + UpdateTotalBalance (the exact shape that halted paloma, no Halo2 proving needed) - broad-sweep tests wrapping full check_tx (FirstTimeCheck + Recheck) for data contract create (Paid arm) and identity top up (PaidFromAssetLock arm) Co-Authored-By: Claude Fable 5 --- .../src/execution/check_tx/v0/mod.rs | 294 ++++++++++++++++++ .../tests.rs | 102 ++++++ .../rs-drive-abci/src/test/helpers/mod.rs | 2 + .../src/test/helpers/state_mutation_guard.rs | 52 ++++ 4 files changed, 450 insertions(+) create mode 100644 packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 1df1e0dfa87..1acba7cfbf1 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -3820,4 +3820,298 @@ mod tests { )) )); } + + /// CheckTx must NEVER mutate committed GroveDB state — the broad-sweep guard for the + /// 2026-06-10 devnet paloma chain halt (height 788). + /// + /// CheckTx fee estimation runs `validate_fees_of_event(..., transaction: None, ...)` (see + /// `check_tx_v0` above), so an eager GroveDB write anywhere on the CheckTx path (e.g. inside + /// a drive-op converter, as the pre-#3823 shielded `InsertNullifiers` arm did with + /// `store_nullifiers_for_block`) commits straight to disk on every node that validates the + /// gossiped transition. The on-disk root then diverges from the signed app hash and every + /// proposer panics with "drive and platform state app hash mismatch" — a chain halt that + /// restarts cannot heal because the write is durable. This test covers the identity-paid + /// (`ExecutionEvent::Paid`) estimation arm; the asset-lock arm is covered by + /// `check_tx_does_not_mutate_committed_state_identity_top_up` below, and the exact type-20 + /// shape that halted paloma by `check_tx_fee_estimation_does_not_mutate_committed_state` in + /// `identity_create_from_shielded_pool/tests.rs`. + #[test] + fn check_tx_does_not_mutate_committed_state_data_contract_create() { + use crate::test::helpers::state_mutation_guard::assert_committed_root_hash_unchanged; + + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + let platform = TestPlatformBuilder::new() + .with_config(platform_config) + .build_with_mock_rpc(); + + let platform_state = platform.state.load(); + let protocol_version = platform_state.current_protocol_version_in_consensus(); + let platform_version = PlatformVersion::get(protocol_version).unwrap(); + + let (key, private_key) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + Some(1), + platform_version, + ) + .expect("expected to get key pair"); + + platform + .drive + .create_initial_state_structure(None, platform_version) + .expect("expected to create state structure"); + let identity: Identity = IdentityV0 { + id: Identifier::new([ + 158, 113, 180, 126, 91, 83, 62, 44, 83, 54, 97, 88, 240, 215, 84, 139, 167, 156, + 166, 203, 222, 4, 64, 31, 215, 199, 149, 151, 190, 246, 251, 44, + ]), + public_keys: BTreeMap::from([(1, key.clone())]), + balance: 25_000_000_000, // 0.25 Dash + revision: 0, + } + .into(); + + let dashpay = get_dashpay_contract_fixture(Some(identity.id()), 1, protocol_version); + let mut create_contract_state_transition: StateTransition = dashpay + .try_into_platform_versioned(platform_version) + .expect("expected a state transition"); + create_contract_state_transition + .sign(&key, private_key.as_slice(), &NativeBlsModule) + .expect("expected to sign transition"); + let serialized = create_contract_state_transition + .serialize_to_bytes() + .expect("serialized state transition"); + platform + .drive + .add_new_identity( + identity, + false, + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to insert identity"); + + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + let first_time_result = assert_committed_root_hash_unchanged( + &platform.drive, + platform_version, + "check_tx FirstTimeCheck (data contract create)", + || { + platform.check_tx( + serialized.as_slice(), + FirstTimeCheck, + &platform_ref, + platform_version, + ) + }, + ) + .expect("expected to check tx"); + // The fixture must stay a VALID transition: an early consensus rejection would skip the + // fee-estimation stage and the invariant would be checked against a no-op. + assert!( + first_time_result.is_valid(), + "fixture must remain valid so fee estimation actually runs: {:?}", + first_time_result.errors + ); + + let recheck_result = assert_committed_root_hash_unchanged( + &platform.drive, + platform_version, + "check_tx Recheck (data contract create)", + || { + platform.check_tx( + serialized.as_slice(), + Recheck, + &platform_ref, + platform_version, + ) + }, + ) + .expect("expected to check tx"); + assert!(recheck_result.is_valid()); + } + + /// CheckTx for an asset-lock-funded transition (identity top up) must not mutate committed + /// GroveDB state. Same invariant as + /// `check_tx_does_not_mutate_committed_state_data_contract_create` (see its docs for the + /// paloma height-788 halt), exercised through the `PaidFromAssetLock` fee-estimation arm and + /// the asset-lock Recheck path. + #[tokio::test] + async fn check_tx_does_not_mutate_committed_state_identity_top_up() { + use crate::test::helpers::state_mutation_guard::assert_committed_root_hash_unchanged; + + let platform_config = PlatformConfig { + testing_configs: PlatformTestConfig { + disable_instant_lock_signature_verification: true, + ..Default::default() + }, + ..Default::default() + }; + + let platform = TestPlatformBuilder::new() + .with_config(platform_config) + .build_with_mock_rpc(); + + let platform_state = platform.state.load(); + let platform_version = platform_state.current_platform_version().unwrap(); + + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + let mut signer = SimpleSigner::default(); + + let mut rng = StdRng::seed_from_u64(567); + + let (master_key, master_private_key) = + IdentityPublicKey::random_ecdsa_master_authentication_key(0, Some(3), platform_version) + .expect("expected to get key pair"); + + signer.add_identity_public_key(master_key.clone(), master_private_key); + + let (key, private_key) = IdentityPublicKey::random_ecdsa_critical_level_authentication_key( + 1, + Some(19), + platform_version, + ) + .expect("expected to get key pair"); + + signer.add_identity_public_key(key.clone(), private_key); + + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(&mut rng, platform_version) + .unwrap(); + + let asset_lock_proof = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + None, + ); + + let identifier = asset_lock_proof + .create_identifier() + .expect("expected an identifier"); + + let identity: Identity = IdentityV0 { + id: identifier, + public_keys: BTreeMap::from([(0, master_key.clone()), (1, key.clone())]), + balance: 1000000000, + revision: 0, + } + .into(); + + let identity_create_transition: StateTransition = + IdentityCreateTransition::try_from_identity_with_signer_and_private_key( + &identity, + asset_lock_proof, + pk.as_slice(), + &signer, + &NativeBlsModule, + 0, + platform_version, + ) + .await + .expect("expected an identity create transition"); + + let identity_create_serialized_transition = identity_create_transition + .serialize_to_bytes() + .expect("serialized state transition"); + + platform + .drive + .create_initial_state_structure(None, platform_version) + .expect("expected to create state structure"); + + let transaction = platform.drive.grove.start_transaction(); + + let validation_result = platform + .execute_tx(identity_create_serialized_transition, &transaction) + .expect("expected to execute identity_create tx"); + assert!(matches!(validation_result, SuccessfulPaidExecution(..))); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + let (_, pk) = ECDSA_SECP256K1 + .random_public_and_private_key_data(&mut rng, platform_version) + .unwrap(); + + let asset_lock_proof_top_up = instant_asset_lock_proof_fixture( + Some(PrivateKey::from_byte_array(&pk, Network::Testnet).unwrap()), + None, + ); + + let identity_top_up_transition: StateTransition = + IdentityTopUpTransition::try_from_identity_with_private_key( + &identity, + asset_lock_proof_top_up, + pk.as_slice(), + 0, + platform_version, + None, + ) + .expect("expected an identity create transition"); + + let identity_top_up_serialized_transition = identity_top_up_transition + .serialize_to_bytes() + .expect("serialized state transition"); + + let first_time_result = assert_committed_root_hash_unchanged( + &platform.drive, + platform_version, + "check_tx FirstTimeCheck (identity top up)", + || { + platform.check_tx( + identity_top_up_serialized_transition.as_slice(), + FirstTimeCheck, + &platform_ref, + platform_version, + ) + }, + ) + .expect("expected to check tx"); + // The fixture must stay a VALID transition: an early consensus rejection would skip the + // fee-estimation stage and the invariant would be checked against a no-op. + assert!( + first_time_result.is_valid(), + "fixture must remain valid so fee estimation actually runs: {:?}", + first_time_result.errors + ); + + let recheck_result = assert_committed_root_hash_unchanged( + &platform.drive, + platform_version, + "check_tx Recheck (identity top up)", + || { + platform.check_tx( + identity_top_up_serialized_transition.as_slice(), + Recheck, + &platform_ref, + platform_version, + ) + }, + ) + .expect("expected to check tx"); + assert!(recheck_result.is_valid()); + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index edf94c07034..83de4ceb8f6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -497,6 +497,108 @@ fn failure_path_charge_executes_through_execute_event() { ); } +/// BLOCKING regression for the 2026-06-10 devnet paloma chain halt (height 788): CheckTx fee +/// estimation must NEVER mutate committed GroveDB state. +/// +/// `check_tx_v0` estimates fees via `validate_fees_of_event(event, last_block_info, +/// transaction: None, ...)` -> `apply_drive_operations(ops, apply: false, ..., transaction: None)`. +/// Pre-#3823, the `ShieldedPoolOperationType::InsertNullifiers` low-level converter arm eagerly +/// called `drive.store_nullifiers_for_block(...)` — a direct write — with whatever +/// `TransactionArg` it was handed, on the assumption it always ran under the block transaction. +/// During CheckTx that argument is `None`, so the eager write committed DIRECTLY to disk on every +/// node that validated the gossiped type-20 transition; the on-disk root diverged from the signed +/// app hash and every proposer panicked ("drive and platform state app hash mismatch" in +/// `prepare_proposal.rs`) — and restarts panic forever in `info.rs` because the write is durable. +/// #3823 made the converter pure; this test pins the invariant for the exact event/op shape that +/// halted paloma. No Halo 2 proving is needed — the event is built from a synthetic action. +#[test] +fn check_tx_fee_estimation_does_not_mutate_committed_state() { + use crate::execution::types::execution_event::ExecutionEvent; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use crate::test::helpers::state_mutation_guard::assert_committed_root_hash_unchanged; + use dpp::block::epoch::Epoch; + use dpp::fee::default_costs::CachedEpochIndexFeeVersions; + use drive::util::batch::drive_op_batch::ShieldedPoolOperationType; + use drive::util::batch::DriveOperation; + + let platform_version = PlatformVersion::latest(); + let platform = setup_platform(); + + set_pool_total_balance(&platform, DENOMINATION * 10); + insert_anchor_into_state(&platform, &ANCHOR); + let min_notes = platform_version + .drive_abci + .validation_and_processing + .event_constants + .minimum_pool_notes_for_outgoing; + insert_dummy_encrypted_notes(&platform, min_notes.max(1)); + + let st = transition(vec![master_key()], vec![action(40), action(41)]); + let mut execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version) + .expect("execution context"); + let success_action = + build_success_action(&platform, &st, &mut execution_context, platform_version); + + let event = ExecutionEvent::create_from_state_transition_action( + StateTransitionAction::IdentityCreateFromShieldedPoolAction(success_action), + None, + &Epoch::new(0).unwrap(), + execution_context, + platform_version, + ) + .expect("create execution event"); + + // The event must carry ALL the shielded converter ops that ran on paloma — InsertNullifiers + // (the arm that held the eager write), InsertNote and UpdateTotalBalance — so estimation + // exercises every arm of the shielded low-level converter. + let ExecutionEvent::PaidFromShieldedPoolToNewIdentity { operations, .. } = &event else { + panic!("expected a PaidFromShieldedPoolToNewIdentity execution event"); + }; + let has_op = |pred: fn(&ShieldedPoolOperationType) -> bool| { + operations.iter().any(|op| match op { + DriveOperation::ShieldedPoolOperation(shielded_op) => pred(shielded_op), + _ => false, + }) + }; + assert!( + has_op(|op| matches!(op, ShieldedPoolOperationType::InsertNullifiers { .. })), + "event must carry InsertNullifiers (the arm that eagerly wrote pre-#3823)" + ); + assert!( + has_op(|op| matches!(op, ShieldedPoolOperationType::InsertNote { .. })), + "event must carry InsertNote" + ); + assert!( + has_op(|op| matches!(op, ShieldedPoolOperationType::UpdateTotalBalance { .. })), + "event must carry UpdateTotalBalance" + ); + + // Run the EXACT CheckTx estimation call (transaction = None, apply = false) and assert the + // committed root hash is byte-identical: a single eager write in any converter arm trips this. + let platform_state = platform.state.load(); + let fee_versions = CachedEpochIndexFeeVersions::new(); + let fee_result = assert_committed_root_hash_unchanged( + &platform.drive, + platform_version, + "validate_fees_of_event (type-20 pool->new-identity, CheckTx estimation mode)", + || { + platform.platform.validate_fees_of_event( + &event, + platform_state.last_block_info(), + None, + platform_version, + &fee_versions, + ) + }, + ) + .expect("fee estimation must not error"); + assert!( + fee_result.data.is_some(), + "estimation must produce a fee result" + ); +} + /// Sum-tree credit-conservation regression for the pool->new-identity exit. /// /// Applies the converter's high-level drive operations through a REAL Drive and asserts the diff --git a/packages/rs-drive-abci/src/test/helpers/mod.rs b/packages/rs-drive-abci/src/test/helpers/mod.rs index 11a8dc90c9c..1a104a14a09 100644 --- a/packages/rs-drive-abci/src/test/helpers/mod.rs +++ b/packages/rs-drive-abci/src/test/helpers/mod.rs @@ -3,6 +3,8 @@ pub mod fast_forward_to_block; pub mod fee_pools; pub mod setup; +#[cfg(test)] +pub mod state_mutation_guard; // TODO: Move tests to appropriate place #[cfg(test)] diff --git a/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs b/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs new file mode 100644 index 00000000000..b64003b270b --- /dev/null +++ b/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs @@ -0,0 +1,52 @@ +//! CheckTx committed-state mutation guard. +//! +//! CheckTx (mempool validation and fee estimation) runs OUTSIDE any block transaction: +//! `check_tx_v0` calls `validate_fees_of_event(..., transaction: None, ...)`, which applies the +//! event's drive operations in estimation mode (`apply: false`). Any code on that path that +//! eagerly writes to GroveDB instead of returning operations therefore commits DIRECTLY to disk +//! on every node that validates the gossiped transition. The on-disk root hash then diverges from +//! the last signed app hash, every proposer panics with "drive and platform state app hash +//! mismatch" (`abci/handler/prepare_proposal.rs`), and restarts panic forever in +//! `abci/handler/info.rs` because the write is durable. +//! +//! This exact class halted devnet paloma at height 788 on 2026-06-10: pre-#3823, the +//! `ShieldedPoolOperationType::InsertNullifiers` low-level converter arm eagerly called +//! `drive.store_nullifiers_for_block(...)` with whatever `TransactionArg` it was handed (`None` +//! during CheckTx fee estimation). #3823 made the converter pure; the helpers here guard the +//! CLASS by asserting the committed root hash is byte-identical around CheckTx-path entry points. + +use dpp::version::PlatformVersion; +use drive::drive::Drive; + +/// The committed (`transaction: None`, i.e. on-disk) GroveDB root hash. +pub fn committed_root_hash(drive: &Drive, platform_version: &PlatformVersion) -> [u8; 32] { + drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .expect("expected to fetch the committed grovedb root hash") +} + +/// Runs `action` and asserts the committed GroveDB root hash is byte-identical before and after, +/// returning `action`'s result. +/// +/// Wrap CheckTx-path entry points (`check_tx`, `state_transition_to_execution_event_for_check_tx`, +/// `validate_fees_of_event` with `transaction: None`) in this guard: CheckTx must NEVER mutate +/// committed state (see the module docs for the paloma height-788 halt this guards against). +pub fn assert_committed_root_hash_unchanged( + drive: &Drive, + platform_version: &PlatformVersion, + context: &str, + action: impl FnOnce() -> R, +) -> R { + let before = committed_root_hash(drive, platform_version); + let result = action(); + let after = committed_root_hash(drive, platform_version); + assert_eq!( + before, after, + "{context} mutated committed grovedb state: CheckTx runs with transaction = None, so an \ + eager write on this path commits straight to disk, diverging the root from the signed \ + app hash and halting the chain (devnet paloma, height 788)" + ); + result +} From 06536c138f809d561ca568821779737850965793 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 10 Jun 2026 16:28:15 +0200 Subject: [PATCH 2/4] test(drive-abci): use a valid secp256k1 key in the type-20 estimation test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fee estimation converts the AddNewIdentity op, which parses the key bytes — the dummy zeroed key the validate_state tests use fails with "unable to create pub key". Build the synthetic transition from a real random ECDSA master key instead. Co-Authored-By: Claude Fable 5 --- .../tests.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs index 83de4ceb8f6..3dc9319e803 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_shielded_pool/tests.rs @@ -533,7 +533,23 @@ fn check_tx_fee_estimation_does_not_mutate_committed_state() { .minimum_pool_notes_for_outgoing; insert_dummy_encrypted_notes(&platform, min_notes.max(1)); - let st = transition(vec![master_key()], vec![action(40), action(41)]); + // Unlike the validate_state tests above, fee estimation CONVERTS the `AddNewIdentity` op, + // which parses the key bytes — so the key must be a real secp256k1 point, not dummy zeros. + let (valid_master_key, _) = + IdentityPublicKey::random_ecdsa_master_authentication_key(0, Some(11), platform_version) + .expect("expected a valid master key"); + let key_in_creation = IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: 0, + key_type: valid_master_key.key_type(), + purpose: valid_master_key.purpose(), + security_level: valid_master_key.security_level(), + contract_bounds: None, + read_only: false, + data: valid_master_key.data().clone(), + signature: BinaryData::default(), + }); + + let st = transition(vec![key_in_creation], vec![action(40), action(41)]); let mut execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version) .expect("execution context"); From ce544a82641f06a6e22bfa83f603a5fb2fd17539 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 10 Jun 2026 17:05:42 +0200 Subject: [PATCH 3/4] test(drive-abci): enforce check_tx state-immutability across all 21 state transition types Makes the paloma h788 guard airtight: - cfg(test) auto-guards in the Platform::check_tx and validate_fees_of_event (transaction: None) dispatchers assert the committed grovedb root hash is byte-identical around every call, so every present and future test exercising any transition through CheckTx enforces the invariant automatically (zero production cost) - new assert_check_tx_valid_at_all_levels test helper runs FirstTimeCheck + Recheck and asserts validity so fee estimation genuinely executes for the canonical valid fixture of each type - wired the helper into the canonical success tests of every state transition type not already covered by existing check_tx tests: identity credit transfer/withdrawal, masternode vote (via the shared perform_vote helper, covering all vote tests), the six address-funded types, and the five shielded types (whose fixtures carry real Halo2 proofs, so full check_tx passes proof validation and reaches estimation) - execution::check_tx module raised to pub(crate) so the test helper can name CheckTxLevel Co-Authored-By: Claude Fable 5 --- .../src/execution/check_tx/mod.rs | 32 +++++++++++- packages/rs-drive-abci/src/execution/mod.rs | 2 +- .../validate_fees_of_event/mod.rs | 34 +++++++++++- .../address_credit_withdrawal/tests.rs | 15 ++++++ .../address_funding_from_asset_lock/tests.rs | 15 ++++++ .../address_funds_transfer/tests.rs | 15 ++++++ .../identity_create_from_addresses/tests.rs | 15 ++++++ .../identity_credit_transfer/mod.rs | 9 ++++ .../tests.rs | 15 ++++++ .../identity_credit_withdrawal/mod.rs | 9 ++++ .../identity_top_up_from_addresses/tests.rs | 15 ++++++ .../state_transition/state_transitions/mod.rs | 11 ++++ .../state_transitions/shield/tests.rs | 15 ++++++ .../shield_from_asset_lock/tests.rs | 15 ++++++ .../shielded_transfer/tests.rs | 15 ++++++ .../shielded_withdrawal/tests.rs | 15 ++++++ .../state_transitions/unshield/tests.rs | 15 ++++++ .../src/test/helpers/state_mutation_guard.rs | 52 +++++++++++++++++++ 18 files changed, 310 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/check_tx/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/mod.rs index 83f0b2896d7..3cf2d475212 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/mod.rs @@ -95,13 +95,41 @@ where platform_ref: &PlatformRef, platform_version: &PlatformVersion, ) -> Result, Error> { - match platform_version.drive_abci.methods.engine.check_tx { + // CheckTx runs OUTSIDE any block transaction, so it must NEVER mutate committed + // GroveDB state: an eager write here commits straight to disk, diverging the on-disk + // root from the signed app hash and deterministically halting the chain (devnet + // paloma, height 788 — see test::helpers::state_mutation_guard). In tests, EVERY + // check_tx call asserts the committed root hash is byte-identical around the call, so + // any state-transition fixture exercised through check_tx picks up the invariant + // automatically. + #[cfg(test)] + let committed_root_hash_before_check_tx = + crate::test::helpers::state_mutation_guard::committed_root_hash( + &self.drive, + platform_version, + ); + + let result = match platform_version.drive_abci.methods.engine.check_tx { 0 => self.check_tx_v0(raw_tx, check_tx_level, platform_ref, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "check_tx".to_string(), known_versions: vec![0], received: version, })), - } + }; + + #[cfg(test)] + assert_eq!( + committed_root_hash_before_check_tx, + crate::test::helpers::state_mutation_guard::committed_root_hash( + &self.drive, + platform_version, + ), + "check_tx mutated committed grovedb state: it runs with transaction = None, so an \ + eager write on this path commits straight to disk, diverging the root from the \ + signed app hash and halting the chain (devnet paloma, height 788)" + ); + + result } } diff --git a/packages/rs-drive-abci/src/execution/mod.rs b/packages/rs-drive-abci/src/execution/mod.rs index 0e4b73c3f00..d69358efc32 100644 --- a/packages/rs-drive-abci/src/execution/mod.rs +++ b/packages/rs-drive-abci/src/execution/mod.rs @@ -1,5 +1,5 @@ /// Check tx module -mod check_tx; +pub(crate) mod check_tx; /// Engine module pub mod engine; /// platform execution events diff --git a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/mod.rs index b3dff473711..fa58f448d76 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/state_transition_processing/validate_fees_of_event/mod.rs @@ -44,7 +44,22 @@ where platform_version: &PlatformVersion, previous_fee_versions: &CachedEpochIndexFeeVersions, ) -> Result, Error> { - match platform_version + // Fee validation with `transaction: None` is the CheckTx estimation mode: it runs + // OUTSIDE any block transaction, so it must NEVER mutate committed GroveDB state — + // an eager write inside an op converter here commits straight to disk and halts the + // chain (devnet paloma, height 788 — see test::helpers::state_mutation_guard). In + // tests, every transactionless call asserts the committed root hash is byte-identical + // around the call, so every event/op shape estimated in tests picks up the invariant + // automatically. + #[cfg(test)] + let committed_root_hash_before_estimation = transaction.is_none().then(|| { + crate::test::helpers::state_mutation_guard::committed_root_hash( + &self.drive, + platform_version, + ) + }); + + let result = match platform_version .drive_abci .methods .state_transition_processing @@ -62,6 +77,23 @@ where known_versions: vec![0], received: version, })), + }; + + #[cfg(test)] + if let Some(root_hash_before) = committed_root_hash_before_estimation { + assert_eq!( + root_hash_before, + crate::test::helpers::state_mutation_guard::committed_root_hash( + &self.drive, + platform_version, + ), + "validate_fees_of_event with transaction = None mutated committed grovedb \ + state: an eager write on the estimation path commits straight to disk, \ + diverging the root from the signed app hash and halting the chain (devnet \ + paloma, height 788)" + ); } + + result } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_credit_withdrawal/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_credit_withdrawal/tests.rs index d93ef44dee9..e2ea0840ec6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_credit_withdrawal/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_credit_withdrawal/tests.rs @@ -985,6 +985,21 @@ mod tests { let platform_state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "address credit withdrawal", + ); + } + let processing_result = platform .platform .process_raw_state_transitions( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs index 94532007b58..942da423aa5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funding_from_asset_lock/tests.rs @@ -1026,6 +1026,21 @@ mod tests { let platform_state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "address funding from asset lock", + ); + } + let processing_result = platform .platform .process_raw_state_transitions( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs index bb6a49b3595..63e1a2f184e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/address_funds_transfer/tests.rs @@ -1425,6 +1425,21 @@ mod tests { let transaction = platform.drive.grove.start_transaction(); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "address funds transfer", + ); + } + let processing_result = platform .platform .process_raw_state_transitions( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs index 3a669499d72..e3bd41dc828 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_create_from_addresses/tests.rs @@ -1037,6 +1037,21 @@ mod tests { let platform_state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "identity create from addresses", + ); + } + let processing_result = platform .platform .process_raw_state_transitions( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer/mod.rs index e0aeb60b421..5148be630d7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer/mod.rs @@ -583,6 +583,15 @@ mod tests { ) .await; + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the canonical + // valid fixture through it pins the invariant for this transition type. + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &transfer_bytes, + "identity credit transfer", + ); + let transaction = platform.drive.grove.start_transaction(); let processing_result = platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer_to_addresses/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer_to_addresses/tests.rs index 8b1698f952c..cacdbb9309b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer_to_addresses/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_transfer_to_addresses/tests.rs @@ -479,6 +479,21 @@ mod tests { let platform_state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "identity credit transfer to addresses", + ); + } + let processing_result = platform .platform .process_raw_state_transitions( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_withdrawal/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_withdrawal/mod.rs index 48bd8344e1c..cf9db2fbd55 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_withdrawal/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_credit_withdrawal/mod.rs @@ -258,6 +258,15 @@ mod tests { .serialize_to_bytes() .expect("expected documents batch serialized state transition"); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the canonical + // valid fixture through it pins the invariant for this transition type. + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &credit_withdrawal_transition_serialized_transition, + "identity credit withdrawal", + ); + let transaction = platform.drive.grove.start_transaction(); let processing_result = platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_addresses/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_addresses/tests.rs index 04781ed09dd..a8ce747c4a8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_addresses/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/identity_top_up_from_addresses/tests.rs @@ -1514,6 +1514,21 @@ mod tests { let platform_state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "identity top up from addresses", + ); + } + let processing_result = platform .platform .process_raw_state_transitions( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index bf2db56b270..6d430d375aa 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -2143,6 +2143,17 @@ pub(in crate::execution) mod tests { .serialize_to_bytes() .expect("expected documents batch serialized state transition"); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so every valid vote + // fixture going through this shared helper pins the invariant for masternode votes. + if expect_error.is_none() { + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + platform, + &masternode_vote_serialized_transition, + "masternode vote", + ); + } + let transaction = platform.drive.grove.start_transaction(); let processing_result = platform 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 9f337b5ffb2..a4fca3b89b3 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 @@ -857,6 +857,21 @@ mod tests { v0.input_witnesses = witnesses; } + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = st + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "shield", + ); + } + let processing_result = process_transition(&platform, st, platform_version); assert_matches!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs index 59fee552721..f61f042efe9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs @@ -547,6 +547,21 @@ mod tests { binding_sig, ); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "shield from asset lock", + ); + } + let processing_result = process_transition(&platform, transition, platform_version); assert_matches!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs index e5866da4ce3..b33c48a6a3a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_transfer/tests.rs @@ -372,6 +372,21 @@ mod tests { binding_sig, ); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "shielded transfer", + ); + } + let processing_result = process_transition(&platform, transition, platform_version); assert_matches!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs index 70bd4d83f61..f40be3e4b71 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shielded_withdrawal/tests.rs @@ -488,6 +488,21 @@ mod tests { output_script, ); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "shielded withdrawal", + ); + } + let processing_result = process_transition(&platform, transition, platform_version); assert_matches!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs index 8903d657be0..879729c88fc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/unshield/tests.rs @@ -522,6 +522,21 @@ mod tests { binding_sig, ); + // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under + // cfg(test) that it never mutates committed grovedb state, so running the + // canonical valid fixture through it pins the invariant for this transition type. + { + use dpp::serialization::PlatformSerializable; + let guard_serialized_transition = transition + .serialize_to_bytes() + .expect("expected to serialize transition for the check_tx guard"); + crate::test::helpers::state_mutation_guard::assert_check_tx_valid_at_all_levels( + &platform, + &guard_serialized_transition, + "unshield", + ); + } + let processing_result = process_transition(&platform, transition, platform_version); assert_matches!( diff --git a/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs b/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs index b64003b270b..a588390ef11 100644 --- a/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs +++ b/packages/rs-drive-abci/src/test/helpers/state_mutation_guard.rs @@ -15,6 +15,11 @@ //! during CheckTx fee estimation). #3823 made the converter pure; the helpers here guard the //! CLASS by asserting the committed root hash is byte-identical around CheckTx-path entry points. +use crate::execution::check_tx::CheckTxLevel; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::TempPlatform; use dpp::version::PlatformVersion; use drive::drive::Drive; @@ -50,3 +55,50 @@ pub fn assert_committed_root_hash_unchanged( ); result } + +/// Runs `check_tx` at BOTH levels (`FirstTimeCheck`, then `Recheck`) on a serialized state +/// transition and asserts each returns a VALID result. +/// +/// Call this from every state-transition type's canonical valid-fixture test, BEFORE the fixture +/// is processed: it proves the type's full CheckTx pipeline (decode -> validation -> fee +/// estimation) runs against committed state without errors, and — because `Platform::check_tx` +/// itself asserts committed-root-hash invariance under `cfg(test)` — that nothing on the type's +/// CheckTx path mutates committed GroveDB state (the devnet paloma height-788 halt class). +/// +/// The validity assertions matter for the guard's strength: an early consensus rejection would +/// skip fee estimation, leaving the type's drive-op converters unexercised in estimation mode. +pub fn assert_check_tx_valid_at_all_levels( + platform: &TempPlatform, + serialized_transition: &[u8], + context: &str, +) { + let platform_state = platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected the current platform version"); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + for level in [CheckTxLevel::FirstTimeCheck, CheckTxLevel::Recheck] { + let result = platform + .check_tx( + serialized_transition, + level, + &platform_ref, + platform_version, + ) + .unwrap_or_else(|error| { + panic!("{context}: check_tx {level:?} must not error, got {error}") + }); + assert!( + result.is_valid(), + "{context}: check_tx {level:?} must pass for the canonical valid fixture so fee \ + estimation actually runs; got {:?}", + result.errors + ); + } +} From c5ad3c71bc0eebf7e29d2564d2dcfdbab5b193cc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 10 Jun 2026 17:11:53 +0200 Subject: [PATCH 4/4] chore: retrigger ci Co-Authored-By: Claude Fable 5