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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions packages/rs-drive-abci/src/execution/check_tx/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,41 @@ where
platform_ref: &PlatformRef<C>,
platform_version: &PlatformVersion,
) -> Result<ValidationResult<CheckTxResult, ConsensusError>, 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
}
}
294 changes: 294 additions & 0 deletions packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Comment on lines +3824 to +3837

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: Doc comment and PR description overstate the actual variant coverage

The doc comment calls this 'the broad-sweep guard for the 2026-06-10 devnet paloma chain halt' and the PR body lists explicit coverage across data contract create + identity top up + masternode vote + all six address-funded variants + all five shielded variants via an assert_check_tx_valid_at_all_levels helper that retrofits canonical valid-fixture tests. The shipped diff includes only three guard sites: this test, check_tx_does_not_mutate_committed_state_identity_top_up immediately below, and check_tx_fee_estimation_does_not_mutate_committed_state in identity_create_from_shielded_pool/tests.rs. The other address-funded fee-estimation arms (PaidFromAddressInputs, PaidFromAssetLockToPool), the remaining four shielded transition types, masternode vote, identity credit transfer/withdrawal, and the assert_check_tx_valid_at_all_levels helper are absent.

Either land the missing retrofits (preferably alongside the dispatcher guard from the previous finding) or trim the doc comment and PR body so future investigators do not over-trust a safety net that only covers three transitions.

source: ['claude']

#[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());
Comment on lines +3931 to +3945

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 Nitpick: Recheck is exercised without an intervening block commit

Both check_tx_does_not_mutate_committed_state_data_contract_create (lines 3931-3945) and check_tx_does_not_mutate_committed_state_identity_top_up (lines 4101-4115) run the same serialized transition through FirstTimeCheck and then immediately through Recheck without a block commit between them. In production, Recheck only runs after a block commits — re-validating mempool transitions against the new state — so this sequence does not match how Recheck is actually reached. The root-hash invariant still holds, but the recheck arm here exercises an unrealistic state shape. Either drop the Recheck half (the FirstTimeCheck half already pins the invariant for these transition types) or commit a block between the two check_tx calls so the recheck path is realistic.

source: ['claude']

}

/// 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());
}
}
2 changes: 1 addition & 1 deletion packages/rs-drive-abci/src/execution/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// Check tx module
mod check_tx;
pub(crate) mod check_tx;
/// Engine module
pub mod engine;
/// platform execution events
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,22 @@ where
platform_version: &PlatformVersion,
previous_fee_versions: &CachedEpochIndexFeeVersions,
) -> Result<ConsensusValidationResult<FeeResult>, 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
Expand All @@ -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
}
}
Loading
Loading