From c7c4e71cca04ed17b45b32f50a53e075919d3dd6 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 17 Jun 2026 17:12:13 -0500 Subject: [PATCH 1/9] fix(dpp): reject documentsKeepHistory + canBeDeleted in DocumentType schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit documentsKeepHistory: true together with canBeDeleted: true is self-contradictory — rs-drive's force-delete path unconditionally refuses to delete documents whose type keeps history (InvalidDeletionOfDocumentThatKeepsHistory). Catch the combination at contract creation time in the V2 document-type schema parser, mirroring the existing cross-flag rule for ContestedUniqueIndexOnMutableDocumentTypeError, so SDK users get a clean InvalidContractStructure error instead of a runtime delete failure. Gated by full_validation so already-deployed contradictory contracts continue to load through the V2 parser at v12+ (the rs-drive-abci delete-transition guard surfaces them as clean invalid-paid transitions in a follow-up commit). Refs #3927 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../class_methods/try_from_schema/v2/mod.rs | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs index 43e48a2eaba..6524800d83d 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs @@ -302,6 +302,34 @@ impl DocumentTypeV2 { platform_version, )?; + // `documentsKeepHistory: true` + `canBeDeleted: true` is self-contradictory: + // rs-drive unconditionally refuses to delete a document whose type keeps + // history (`force_delete_document_for_contract_operations_v0` returns + // `InvalidDeletionOfDocumentThatKeepsHistory`), so `canBeDeleted: true` + // advertises a capability the storage layer will always reject. Catching + // it at parse time turns the contradiction into a clean validation error + // at contract creation, before any delete is attempted. Mirrors the + // existing cross-flag rule for + // `ContestedUniqueIndexOnMutableDocumentTypeError`. + // + // Gated by `full_validation` so already-deployed contradictory contracts + // (e.g. testnet `5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC` document + // type `note`) continue to load when re-parsed at v12+ — the drive-abci + // delete-transition guard turns their deletes into normal invalid (paid) + // transitions instead of internal errors at that layer. + #[cfg(feature = "validation")] + if full_validation && v1.documents_keep_history && v1.documents_can_be_deleted { + return Err(ProtocolError::DataContractError( + DataContractError::InvalidContractStructure(format!( + "document type \"{}\" sets both `documentsKeepHistory: true` and \ + `canBeDeleted: true`, but the storage layer unconditionally refuses to \ + delete a document whose type keeps history. Set one of the two flags to \ + false (or omit it).", + name, + )), + )); + } + // Convert to V2 and set the new fields let mut v2: DocumentTypeV2 = v1.into(); v2.documents_countable = documents_countable || range_countable; @@ -689,6 +717,9 @@ mod tests { /// AND that both flags survive into the parsed `v2`. #[test] fn doctype_keep_history_with_documents_summable_accepted() { + // `canBeDeleted: false` is required alongside `documentsKeepHistory: true` + // because the contract config's default for `canBeDeleted` is `true` and + // the cross-flag check rejects `keepHistory && canBeDeleted`. let schema = platform_value!({ "type": "object", "properties": { @@ -702,6 +733,7 @@ mod tests { "required": ["score"], "additionalProperties": false, "documentsKeepHistory": true, + "canBeDeleted": false, "documentsSummable": "score", }); let v2 = parse(schema).expect( @@ -727,6 +759,8 @@ mod tests { /// `CountSumTree` / `ProvableCountSumTree` variant. #[test] fn doctype_keep_history_with_documents_averageable_accepted() { + // `canBeDeleted: false` is required alongside `documentsKeepHistory: true` + // — see sibling `doctype_keep_history_with_documents_summable_accepted`. let schema = platform_value!({ "type": "object", "properties": { @@ -740,6 +774,7 @@ mod tests { "required": ["score"], "additionalProperties": false, "documentsKeepHistory": true, + "canBeDeleted": false, "documentsAverageable": "score", }); let v2 = parse(schema).expect( @@ -758,6 +793,8 @@ mod tests { /// every existing keep-history doctype. #[test] fn doctype_keep_history_without_summable_accepted() { + // `canBeDeleted: false` is required alongside `documentsKeepHistory: true` + // — see sibling `doctype_keep_history_with_documents_summable_accepted`. let schema = platform_value!({ "type": "object", "properties": { @@ -769,6 +806,7 @@ mod tests { }, "additionalProperties": false, "documentsKeepHistory": true, + "canBeDeleted": false, }); let v2 = parse(schema).expect("keep-history without summable must parse cleanly"); assert!( @@ -920,6 +958,92 @@ mod tests { ); } + /// `documentsKeepHistory: true` + `canBeDeleted: true` is + /// self-contradictory: rs-drive unconditionally refuses to delete + /// a document whose type keeps history + /// (`InvalidDeletionOfDocumentThatKeepsHistory`), so `canBeDeleted: + /// true` advertises a capability the storage layer will always + /// reject. The parser must reject the combination at contract + /// creation time so an SDK user gets a clean validation error + /// instead of the delete failing as an internal error at execution. + #[test] + fn doctype_keep_history_with_can_be_deleted_rejected() { + let schema = platform_value!({ + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 50, + "position": 0, + }, + }, + "additionalProperties": false, + "documentsKeepHistory": true, + "canBeDeleted": true, + }); + let result = parse(schema); + assert!( + result.is_err(), + "documentsKeepHistory: true + canBeDeleted: true must be rejected" + ); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"), + "error must reference both documentsKeepHistory and canBeDeleted; got {msg}" + ); + } + + /// Guard against an over-broad fix: `documentsKeepHistory: true` + + /// `canBeDeleted: false` is consistent (the doctype is append-only) + /// and must continue to parse cleanly. Same for `documentsKeepHistory: + /// true` with `canBeDeleted` omitted — covered by the existing + /// `doctype_keep_history_without_summable_accepted` test, which + /// leaves `canBeDeleted` at its config default (false). + #[test] + fn doctype_keep_history_with_can_be_deleted_false_accepted() { + let schema = platform_value!({ + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 50, + "position": 0, + }, + }, + "additionalProperties": false, + "documentsKeepHistory": true, + "canBeDeleted": false, + }); + let v2 = parse(schema).expect( + "documentsKeepHistory: true + canBeDeleted: false is consistent and must parse", + ); + assert!(v2.documents_keep_history); + assert!(!v2.documents_can_be_deleted); + } + + /// Symmetric guard: `canBeDeleted: true` on a non-keep-history + /// doctype must continue to parse cleanly. Catches a predicate that + /// triggers on `canBeDeleted: true` alone instead of the AND. + #[test] + fn doctype_can_be_deleted_without_keep_history_accepted() { + let schema = platform_value!({ + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 50, + "position": 0, + }, + }, + "additionalProperties": false, + "canBeDeleted": true, + }); + let v2 = parse(schema) + .expect("canBeDeleted: true without documentsKeepHistory must parse cleanly"); + assert!(!v2.documents_keep_history); + assert!(v2.documents_can_be_deleted); + } + /// Symmetric: `documentsSummable` on a NON-keep-history doctype /// stays valid. Guards against a rejection that triggers on /// summable alone instead of the AND. From 0f305580c80c22134f0652c4d3f4555520cc43ee Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 17 Jun 2026 17:19:04 -0500 Subject: [PATCH 2/9] fix(drive-abci): reject delete transitions against keep-history doctypes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this guard, a delete state transition against a document type whose schema sets documentsKeepHistory: true passes the structure validator (which only checks documents_can_be_deleted()) and reaches rs-drive's force-delete path, where it triggers InvalidDeletionOfDocumentThatKeepsHistory. The processor reclassifies that drive-layer error as ExecutionResult::InternalError — the transition is neither valid nor invalid-paid, leaving the SDK with no clean accept/reject signal. Add a documents_keep_history() check beside the existing documents_can_be_deleted() check in the delete-transition advanced structure validator so the contradiction surfaces as a normal invalid (paid) consensus error. This fixes the classification even for already-deployed contradictory contracts (e.g. testnet 5CBPiadGmx3Zsjc26g5onopcx7pdxHPbrRAUD2T2yAbC), which the DPP cross-flag rule from the previous commit cannot retroactively fix. The new note-contract-keep-history-and-can-be-deleted.json fixture is loaded with full_validation=false so it bypasses the DPP guard and exercises this layer directly. Refs #3927 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../advanced_structure_v0/mod.rs | 26 ++- .../batch/tests/document/deletion.rs | 188 ++++++++++++++++++ ...tract-keep-history-and-can-be-deleted.json | 29 +++ 3 files changed, 239 insertions(+), 4 deletions(-) create mode 100644 packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs index 0a9cbfe8929..00976fd685a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs @@ -27,15 +27,33 @@ impl DocumentDeleteTransitionActionStructureValidationV0 for DocumentDeleteTrans }; if !document_type.documents_can_be_deleted() { - Ok(SimpleConsensusValidationResult::new_with_error( + return Ok(SimpleConsensusValidationResult::new_with_error( InvalidDocumentTransitionActionError::new(format!( "documents of type {} can not be deleted", document_type_name )) .into(), - )) - } else { - Ok(SimpleConsensusValidationResult::new()) + )); } + + // rs-drive's `force_delete_document_for_contract_operations_v0` returns + // `InvalidDeletionOfDocumentThatKeepsHistory` when the doctype keeps + // history. Without this check the transition reaches execution and + // surfaces as `ExecutionResult::InternalError` (neither valid nor + // invalid-paid). Rejecting here turns the delete into a normal invalid + // (paid) consensus error so already-deployed contradictory contracts — + // which the DPP cross-flag rule in `try_from_schema` cannot + // retroactively fix — fail fast and legibly. See issue #3927. + if document_type.documents_keep_history() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "documents of type {} keep history and therefore can not be deleted", + document_type_name + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::new()) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index beacc1093a3..a6921ddf570 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -361,6 +361,194 @@ mod deletion_tests { assert_eq!(processing_result.aggregated_fees().processing_fee, 445700); } + /// Regression test for #3927: a delete state transition against a + /// document type whose schema sets both `documentsKeepHistory: true` + /// and `canBeDeleted: true` must be classified as an invalid (paid) + /// consensus error, NOT surfaced as `ExecutionResult::InternalError`. + /// + /// Before the fix, the structure validator only checked + /// `documents_can_be_deleted()` and let the transition through; at + /// execution rs-drive's force-delete path returned + /// `InvalidDeletionOfDocumentThatKeepsHistory` which the processor + /// reclassified as `InternalError` — the transition was neither + /// valid nor invalid-paid, leaving the SDK with no clean accept/ + /// reject signal. The fixture is loaded with `full_validation: false` + /// to simulate an already-deployed contradictory contract (the DPP + /// `try_from_schema` cross-flag rule prevents NEW contracts with + /// this combination but cannot fix on-chain ones). + #[tokio::test] + async fn test_document_delete_on_document_type_that_keeps_history_is_rejected() { + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_initial_state_structure(); + + let contract_path = "tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json"; + + let platform_state = platform.state.load(); + let platform_version = platform_state + .current_platform_version() + .expect("expected to get current platform version"); + + // `full_validation: false` bypasses the DPP cross-flag check so the + // intentionally-contradictory fixture loads — mirrors the + // already-deployed-contract scenario this guard is meant to handle. + let note_contract = json_document_to_contract(contract_path, false, platform_version) + .expect("expected to get data contract"); + platform + .drive + .apply_contract( + ¬e_contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + platform_version, + ) + .expect("expected to apply contract successfully"); + + let mut rng = StdRng::seed_from_u64(437); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let note_document_type = note_contract + .document_type_for_name("note") + .expect("expected the note document type"); + + assert!( + note_document_type.documents_keep_history(), + "fixture sanity: doctype must keep history" + ); + assert!( + note_document_type.documents_can_be_deleted(), + "fixture sanity: doctype must advertise canBeDeleted" + ); + + let entropy = Bytes32::random_with_rng(&mut rng); + + let document = note_document_type + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + + let mut altered_document = document.clone(); + altered_document.set_revision(Some(1)); + + // Create the document (must succeed — keep-history doctypes accept + // creates, the contradiction only bites at delete time). + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + note_document_type, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_create_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + assert_eq!(processing_result.valid_count(), 1); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + // Now attempt the delete — pre-fix this surfaces as InternalError, + // post-fix it's a clean invalid-paid consensus error. + let documents_batch_deletion_transition = + BatchTransition::new_document_deletion_transition_from_document( + altered_document, + note_document_type, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_deletion_serialized_transition = documents_batch_deletion_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_deletion_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "delete against keep-history doctype must be classified as invalid-paid, \ + not surfaced as InternalError" + ); + assert_eq!(processing_result.invalid_unpaid_count(), 0); + assert_eq!(processing_result.valid_count(), 0); + // Pre-fix the result would have been an InternalError — assert none + // of the execution results are internal errors. + for result in processing_result.execution_results() { + assert!( + !matches!(result, StateTransitionExecutionResult::InternalError(_)), + "delete must not surface as InternalError; got {:?}", + result + ); + } + } + #[tokio::test] async fn test_document_delete_on_document_type_that_is_not_mutable_and_can_be_deleted() { run_document_delete_on_document_type_that_is_not_mutable_and_can_be_deleted_at_protocol_version( diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json b/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json new file mode 100644 index 00000000000..f3db87fdc40 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json @@ -0,0 +1,29 @@ +{ + "$formatVersion": "1", + "id": "4Bqs6itzfoDXzmgQibYZQABbqYsXmawVf7SKe3mKDQVe", + "ownerId": "2b994p95akyNFKtkDnDvBRUotDbkH54MHwGbhQLr5gcU", + "version": 1, + "keywords": [], + "documentSchemas": { + "note": { + "type": "object", + "documentsKeepHistory": true, + "documentsMutable": true, + "canBeDeleted": true, + "properties": { + "message": { + "type": "string", + "maxLength": 256, + "position": 0 + } + }, + "required": [ + "message" + ], + "additionalProperties": false, + "$comment": "Self-contradictory fixture: keep-history doctypes cannot be deleted at the storage layer (rs-drive returns InvalidDeletionOfDocumentThatKeepsHistory). Loaded with full_validation=false so the DPP cross-flag rule in DocumentType::try_from_schema does not reject it — exercises the rs-drive-abci delete-transition guard that turns the contradiction into a clean invalid-paid consensus error for already-deployed contracts." + } + }, + "groups": {}, + "tokens": {} +} From 0dfa1b7c3b7509092d78ab55acac487eccff5156 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 17 Jun 2026 17:27:57 -0500 Subject: [PATCH 3/9] fix(drive-abci): version-gate keep-history delete guard at protocol v12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit added the documents_keep_history() check directly to advanced_structure_v0, which is the dispatched validator for ALL historical protocol versions (1-11) as well as v12. Mutating v0 would alter the consensus result of any historical block containing a delete against a keep-history doctype — pre-fix those returned ExecutionResult::InternalError (no fees, no state change); post-fix they would be classified as invalid-paid (fees deducted, state delta differs), breaking bit-for-bit chain replay at PROTOCOL_VERSION_11 and earlier. Move the check into a new advanced_structure_v1 module, restore v0 to its original behavior, extend the trait dispatcher to handle both versions, and bump document_delete_transition_structure_validation from 0 to 1 in DRIVE_ABCI_VALIDATION_VERSIONS_V8 (used by PROTOCOL_VERSION_12, the v3.1 hard fork). V1-V7 still dispatch v0 so pre-v12 chain history stays reproducible. Refs #3927 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../advanced_structure_v0/mod.rs | 26 ++------ .../advanced_structure_v1/mod.rs | 66 +++++++++++++++++++ .../document_delete_transition_action/mod.rs | 5 +- .../drive_abci_validation_versions/v8.rs | 11 +++- 4 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v1/mod.rs diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs index 00976fd685a..0a9cbfe8929 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v0/mod.rs @@ -27,33 +27,15 @@ impl DocumentDeleteTransitionActionStructureValidationV0 for DocumentDeleteTrans }; if !document_type.documents_can_be_deleted() { - return Ok(SimpleConsensusValidationResult::new_with_error( + Ok(SimpleConsensusValidationResult::new_with_error( InvalidDocumentTransitionActionError::new(format!( "documents of type {} can not be deleted", document_type_name )) .into(), - )); + )) + } else { + Ok(SimpleConsensusValidationResult::new()) } - - // rs-drive's `force_delete_document_for_contract_operations_v0` returns - // `InvalidDeletionOfDocumentThatKeepsHistory` when the doctype keeps - // history. Without this check the transition reaches execution and - // surfaces as `ExecutionResult::InternalError` (neither valid nor - // invalid-paid). Rejecting here turns the delete into a normal invalid - // (paid) consensus error so already-deployed contradictory contracts — - // which the DPP cross-flag rule in `try_from_schema` cannot - // retroactively fix — fail fast and legibly. See issue #3927. - if document_type.documents_keep_history() { - return Ok(SimpleConsensusValidationResult::new_with_error( - InvalidDocumentTransitionActionError::new(format!( - "documents of type {} keep history and therefore can not be deleted", - document_type_name - )) - .into(), - )); - } - - Ok(SimpleConsensusValidationResult::new()) } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v1/mod.rs new file mode 100644 index 00000000000..0c32b3e8dbd --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/advanced_structure_v1/mod.rs @@ -0,0 +1,66 @@ +use dpp::consensus::basic::document::{InvalidDocumentTransitionActionError, InvalidDocumentTypeError}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::validation::SimpleConsensusValidationResult; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::DocumentDeleteTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_delete_transition_action::v0::DocumentDeleteTransitionActionAccessorsV0; + +use crate::error::Error; + +pub(in crate::execution::validation::state_transition::state_transitions::batch::action_validation) trait DocumentDeleteTransitionActionStructureValidationV1 { + fn validate_structure_v1(&self) -> Result; +} + +impl DocumentDeleteTransitionActionStructureValidationV1 for DocumentDeleteTransitionAction { + /// V1 adds the `documents_keep_history()` guard alongside the V0 + /// `documents_can_be_deleted()` guard. + /// + /// Pre-V1, a delete against a keep-history doctype passed structure + /// validation, reached `force_delete_document_for_contract_operations_v0`, + /// and returned `DriveError::InvalidDeletionOfDocumentThatKeepsHistory`. + /// The batch processor reclassifies that drive-layer error as + /// `ExecutionResult::InternalError` — the transition is neither valid nor + /// invalid-paid, leaving the SDK with no clean accept/reject signal. + /// + /// Rejecting at the structure layer turns the contradiction into a normal + /// invalid (paid) consensus error. Gated behind a new validation version + /// (rather than mutating V0) so PROTOCOL_VERSION_11 and earlier chains — + /// which historically classified these deletes as InternalError — replay + /// bit-for-bit. See issue #3927. + fn validate_structure_v1(&self) -> Result { + let contract_fetch_info = self.base().data_contract_fetch_info(); + let data_contract = &contract_fetch_info.contract; + let document_type_name = self.base().document_type_name(); + + let Some(document_type) = data_contract.document_type_optional_for_name(document_type_name) + else { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTypeError::new(document_type_name.clone(), data_contract.id()) + .into(), + )); + }; + + if !document_type.documents_can_be_deleted() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "documents of type {} can not be deleted", + document_type_name + )) + .into(), + )); + } + + if document_type.documents_keep_history() { + return Ok(SimpleConsensusValidationResult::new_with_error( + InvalidDocumentTransitionActionError::new(format!( + "documents of type {} keep history and therefore can not be deleted", + document_type_name + )) + .into(), + )); + } + + Ok(SimpleConsensusValidationResult::new()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/mod.rs index cc1a6cfa0d1..35b5210125a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/action_validation/document/document_delete_transition_action/mod.rs @@ -9,9 +9,11 @@ use crate::error::execution::ExecutionError; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::batch::action_validation::document::document_delete_transition_action::state_v0::DocumentDeleteTransitionActionStateValidationV0; use crate::execution::validation::state_transition::batch::action_validation::document::document_delete_transition_action::advanced_structure_v0::DocumentDeleteTransitionActionStructureValidationV0; +use crate::execution::validation::state_transition::batch::action_validation::document::document_delete_transition_action::advanced_structure_v1::DocumentDeleteTransitionActionStructureValidationV1; use crate::platform_types::platform::PlatformStateRef; mod advanced_structure_v0; +mod advanced_structure_v1; mod state_v0; pub trait DocumentDeleteTransitionActionValidation { @@ -44,9 +46,10 @@ impl DocumentDeleteTransitionActionValidation for DocumentDeleteTransitionAction .document_delete_transition_structure_validation { 0 => self.validate_structure_v0(), + 1 => self.validate_structure_v1(), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "DocumentDeleteTransitionAction::validate_structure".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index c44d1a20c29..62895bb103a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -178,7 +178,16 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = }, is_allowed: 0, document_create_transition_structure_validation: 0, - document_delete_transition_structure_validation: 0, + // PROTOCOL_VERSION_12 (v3.1 hard fork): structure validator + // now rejects deletes against `documentsKeepHistory: true` + // doctypes as invalid (paid) consensus errors. Pre-v12 the + // delete reached `force_delete_document_for_contract_operations_v0`, + // returned `InvalidDeletionOfDocumentThatKeepsHistory`, and + // the batch processor reclassified it as + // `ExecutionResult::InternalError` (neither valid nor + // invalid-paid). Gated here so PROTOCOL_VERSION_11 chain + // history stays bit-for-bit reproducible. See issue #3927. + document_delete_transition_structure_validation: 1, document_replace_transition_structure_validation: 0, document_transfer_transition_structure_validation: 0, document_purchase_transition_structure_validation: 0, From 916b961857d909d519e5ecbafbc242d2a2329268 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 17 Jun 2026 18:35:08 -0500 Subject: [PATCH 4/9] fix(dpp): surface keep-history + canBeDeleted reject as ConsensusError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the v2 DocumentType keep-history + canBeDeleted contradiction through `consensus_or_protocol_data_contract_error` so that with the `validation` feature it returns `ProtocolError::ConsensusError` instead of bare `ProtocolError::DataContractError`. drive-abci's `transform_into_action_v0` only converts the consensus variant into a clean invalid (paid) transition with a bump action — the DataContractError variant propagated as an internal execution error in validator mode, defeating the parse-time guard's whole purpose for contract create/update. Pin the variant in the existing regression test so the classification can't silently regress. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../class_methods/try_from_schema/v2/mod.rs | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs index 6524800d83d..5321724fd63 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs @@ -1,5 +1,7 @@ use crate::data_contract::config::DataContractConfig; -use crate::data_contract::document_type::class_methods::consensus_or_protocol_value_error; +use crate::data_contract::document_type::class_methods::{ + consensus_or_protocol_data_contract_error, consensus_or_protocol_value_error, +}; use crate::data_contract::document_type::property::DocumentPropertyType; use crate::data_contract::document_type::property_names::{ DOCUMENTS_AVERAGEABLE, DOCUMENTS_COUNTABLE, DOCUMENTS_SUMMABLE, RANGE_AVERAGEABLE, @@ -317,9 +319,16 @@ impl DocumentTypeV2 { // type `note`) continue to load when re-parsed at v12+ — the drive-abci // delete-transition guard turns their deletes into normal invalid (paid) // transitions instead of internal errors at that layer. + // + // Use `consensus_or_protocol_data_contract_error` so that with the + // `validation` feature this surfaces as `ProtocolError::ConsensusError`; + // drive-abci's `transform_into_action_v0` only converts that variant + // into an invalid (paid) transition with a bump action — a bare + // `ProtocolError::DataContractError` would propagate as an internal + // execution error in validator mode. #[cfg(feature = "validation")] if full_validation && v1.documents_keep_history && v1.documents_can_be_deleted { - return Err(ProtocolError::DataContractError( + return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( "document type \"{}\" sets both `documentsKeepHistory: true` and \ `canBeDeleted: true`, but the storage layer unconditionally refuses to \ @@ -966,6 +975,14 @@ mod tests { /// reject. The parser must reject the combination at contract /// creation time so an SDK user gets a clean validation error /// instead of the delete failing as an internal error at execution. + /// + /// With the `validation` feature enabled the rejection must surface + /// as `ProtocolError::ConsensusError` (not bare + /// `ProtocolError::DataContractError`) — drive-abci's + /// `transform_into_action_v0` only turns the consensus variant into + /// a clean invalid (paid) transition with a bump action; the + /// data-contract-error variant propagates as an internal execution + /// error in validator mode. #[test] fn doctype_keep_history_with_can_be_deleted_rejected() { let schema = platform_value!({ @@ -986,11 +1003,20 @@ mod tests { result.is_err(), "documentsKeepHistory: true + canBeDeleted: true must be rejected" ); - let msg = format!("{:?}", result.unwrap_err()); + let err = result.unwrap_err(); + let msg = format!("{:?}", err); assert!( msg.contains("documentsKeepHistory") && msg.contains("canBeDeleted"), "error must reference both documentsKeepHistory and canBeDeleted; got {msg}" ); + #[cfg(feature = "validation")] + assert!( + matches!(err, ProtocolError::ConsensusError(_)), + "with `validation` feature the rejection must be ProtocolError::ConsensusError so \ + drive-abci's transform_into_action turns it into an invalid (paid) transition \ + with a bump action rather than propagating as an internal execution error; got \ + {err:?}" + ); } /// Guard against an over-broad fix: `documentsKeepHistory: true` + From ed57f33a6418816de6e5accf7d86964de0480160 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 17 Jun 2026 18:40:31 -0500 Subject: [PATCH 5/9] fix(dpp): reject keep-history deletable doctypes without validation feature --- .../document_type/class_methods/try_from_schema/v2/mod.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs index 5321724fd63..3761158a648 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs @@ -326,7 +326,6 @@ impl DocumentTypeV2 { // into an invalid (paid) transition with a bump action — a bare // `ProtocolError::DataContractError` would propagate as an internal // execution error in validator mode. - #[cfg(feature = "validation")] if full_validation && v1.documents_keep_history && v1.documents_can_be_deleted { return Err(consensus_or_protocol_data_contract_error( DataContractError::InvalidContractStructure(format!( From 078c233bf93a366543dc769561f63802cde2e9a9 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Wed, 17 Jun 2026 19:01:21 -0500 Subject: [PATCH 6/9] test(dpp,drive-abci): address CodeRabbit nits on keep-history delete tests - Add DPP test asserting documentsKeepHistory + canBeDeleted is accepted when try_from_schema runs with full_validation=false (the restore / migration / cache-warmup path), pinning the gating behavior that lets already-deployed contradictory contracts continue to load at v12+. - Pin the drive-abci keep-history delete regression test to protocol version 12 via with_initial_protocol_version + PlatformVersion::get instead of floating on current_platform_version(), so the assertion stays bit-for-bit reproducible as new protocol versions ship. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../class_methods/try_from_schema/v2/mod.rs | 48 +++++++++++++++++++ .../batch/tests/document/deletion.rs | 17 +++++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs index 3761158a648..bde00bfc554 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v2/mod.rs @@ -1018,6 +1018,54 @@ mod tests { ); } + /// `documentsKeepHistory: true` + `canBeDeleted: true` is rejected + /// ONLY when `full_validation: true`. With `full_validation: false` + /// (the restore / migration / cache-warmup path) the same schema must + /// parse cleanly so already-deployed contradictory contracts continue + /// to load at v12+ — the drive-abci delete-transition guard turns + /// their deletes into clean invalid (paid) transitions instead of + /// rejecting them as internal errors at the contract-load layer. + /// Mirrors the gating in `try_from_schema` (search for + /// `full_validation && v1.documents_keep_history`). + #[test] + fn doctype_keep_history_with_can_be_deleted_accepted_without_full_validation() { + let schema = platform_value!({ + "type": "object", + "properties": { + "label": { + "type": "string", + "maxLength": 50, + "position": 0, + }, + }, + "additionalProperties": false, + "documentsKeepHistory": true, + "canBeDeleted": true, + }); + let platform_version = PlatformVersion::latest(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("default config available on latest platform version"); + let v2 = DocumentTypeV2::try_from_schema( + Identifier::new([1; 32]), + 1, + config.version(), + "test_doc", + schema, + None, + &BTreeMap::new(), + &config, + false, + &mut vec![], + platform_version, + ) + .expect( + "documentsKeepHistory: true + canBeDeleted: true must be accepted when \ + full_validation: false so already-deployed contradictory contracts continue to load", + ); + assert!(v2.documents_keep_history); + assert!(v2.documents_can_be_deleted); + } + /// Guard against an over-broad fix: `documentsKeepHistory: true` + /// `canBeDeleted: false` is consistent (the doctype is append-only) /// and must continue to parse cleanly. Same for `documentsKeepHistory: diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index a6921ddf570..49937bf7a38 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -378,16 +378,27 @@ mod deletion_tests { /// this combination but cannot fix on-chain ones). #[tokio::test] async fn test_document_delete_on_document_type_that_keeps_history_is_rejected() { + // Pinned to protocol v12 — the keep-history delete guard is + // version-gated to v12 (validation v8 selects + // `document_delete_transition_structure_validation: 1`, which + // dispatches to `advanced_structure_v1` adding the + // `documents_keep_history()` check). Pre-fix this test ran against + // `current_platform_version()` (i.e. whatever the test rig's + // initial state landed on), so as new protocol versions ship the + // test would silently exercise a different version's validation + // path — possibly one where the guard regressed. Pinning here keeps + // the regression assertion bit-for-bit reproducible. + const PROTOCOL_VERSION: dpp::version::ProtocolVersion = 12; + let platform_version = PlatformVersion::get(PROTOCOL_VERSION) + .expect("expected platform version for protocol_version 12"); let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(PROTOCOL_VERSION) .build_with_mock_rpc() .set_initial_state_structure(); let contract_path = "tests/supporting_files/contract/note/note-contract-keep-history-and-can-be-deleted.json"; let platform_state = platform.state.load(); - let platform_version = platform_state - .current_platform_version() - .expect("expected to get current platform version"); // `full_validation: false` bypasses the DPP cross-flag check so the // intentionally-contradictory fixture loads — mirrors the From 3ed90bc97428bdd26cd4413920707b655a08466c Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 20 Jun 2026 00:58:09 -0500 Subject: [PATCH 7/9] test(rs-sdk): expect network-floor-raised default in unpinned seed test `SdkBuilder::new_mock()` builds on Mainnet, whose floor (PV_11) dominates `DEFAULT_INITIAL_PROTOCOL_VERSION` (10). Assert the actual `max(DEFAULT_INITIAL_PROTOCOL_VERSION, network floor)` the SDK seeds, not the bare constant. --- .../rs-sdk/tests/fetch/document_query_v0_v1.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index eb139437649..c5e204b58a5 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -220,14 +220,14 @@ fn encoder_dispatches_v0_via_query_settings_without_sdk() { #[test] fn sdk_builder_default_seeds_atomic_to_floor() { - // Auto-detect default: the atomic seeds to the floor - // `DEFAULT_INITIAL_PROTOCOL_VERSION`, which `version()` returns until the - // first response ratchets it upward. + // Auto-detect default: the atomic seeds to + // `max(DEFAULT_INITIAL_PROTOCOL_VERSION, network floor)`, which `version()` + // returns until the first response ratchets it upward. `new_mock()` builds + // on `Network::Mainnet`, whose floor is PROTOCOL_VERSION_11 — so the boot + // value is 11 even though `DEFAULT_INITIAL_PROTOCOL_VERSION` is 10. let sdk_default = SdkBuilder::new_mock().build().expect("mock sdk"); - assert_eq!( - sdk_default.version().protocol_version, - DEFAULT_INITIAL_PROTOCOL_VERSION - ); + let expected = DEFAULT_INITIAL_PROTOCOL_VERSION.max(dpp::version::v11::PROTOCOL_VERSION_11); + assert_eq!(sdk_default.version().protocol_version, expected); } /// PROTOCOL_VERSION_11 corresponds to Dash Platform v3.0 (testnet at the From a90cf62866443d4194ad02489003b7e044510acd Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 20 Jun 2026 01:52:05 -0500 Subject: [PATCH 8/9] test(drive): keep history update fixture deletable false --- packages/rs-drive/src/drive/contract/mod.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive/src/drive/contract/mod.rs b/packages/rs-drive/src/drive/contract/mod.rs index 7c3e5aae88e..f72cbf45762 100644 --- a/packages/rs-drive/src/drive/contract/mod.rs +++ b/packages/rs-drive/src/drive/contract/mod.rs @@ -2335,10 +2335,16 @@ mod tests { ) .expect("expected to apply contract successfully"); - // Now try to update with the same document type but documentsKeepHistory=true + // Now try to update with the same document type but documentsKeepHistory=true. + // `canBeDeleted: false` is required alongside `documentsKeepHistory: true` — + // the V2 schema parser rejects the keep-history + canBeDeleted combination + // (canBeDeleted's config default is true), so the schema must opt out of + // delete to reach the intended `ChangingDocumentTypeKeepsHistory` assertion + // at `update_contract`. let history_schema = platform_value!({ "type": "object", "documentsKeepHistory": true, + "canBeDeleted": false, "properties": { "name": { "type": "string", From 663b50ab8357f8f01c624c6f6da9b812adfc8610 Mon Sep 17 00:00:00 2001 From: PastaClaw Date: Sat, 20 Jun 2026 03:00:45 -0500 Subject: [PATCH 9/9] test(drive): make summable history fixture nondeletable --- .../document/insert/add_document_to_primary_storage/v0/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/rs-drive/src/drive/document/insert/add_document_to_primary_storage/v0/mod.rs b/packages/rs-drive/src/drive/document/insert/add_document_to_primary_storage/v0/mod.rs index 4142b12b9fd..a05c38d1fb0 100644 --- a/packages/rs-drive/src/drive/document/insert/add_document_to_primary_storage/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_document_to_primary_storage/v0/mod.rs @@ -832,6 +832,7 @@ mod keep_history_summable_e2e { "required": ["amount"], "additionalProperties": false, "documentsKeepHistory": true, + "canBeDeleted": false, "documentsSummable": "amount", }); let schemas = platform_value!({ DOCTYPE_NAME: document_schema });