From 5cdc306ab65292fe69377deed151f6d403aafaf7 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 7 May 2026 13:15:41 +0700 Subject: [PATCH 01/24] fix(drive-abci): bump nonce on failed batch document transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #2867. The Documents Batch transformer's per-transition handler was emitting BumpIdentityDataContractNonceAction only on `find_replaced_document_v0` failure for the Replace arm. Every other failure path (ownership-check, revision-check, plus the analogous paths in Transfer / UpdatePrice / Purchase, and Purchase's listed-price / price-mismatch checks) returned ConsensusValidationResult errors-only with no action data. The empty BatchTransitionAction that flowed up the chain still triggered fee accounting (PaidConsensusError) but the bump action's UpdateIdentityContractNonce drive op was never created — so identity_contract_nonce in state stayed at the pre-tx value. Consequence on mainnet: a user's SDK could re-broadcast the same exact serialized bytes; CheckTx FirstTimeCheck would still pass (committed state still says the nonce is unused), the proposer would include the tx in a later block, deliver_tx would fail again with the same error, and the cycle could repeat. Result: the same state-transition hash appearing in multiple blocks — exactly what mainnet ST hash 35C08D574302D32D7160E603D8159042C8606BE12FD97D952CF5FD40DB57313C did on 2026-05-04 (block 361774 idx 1 + a later block past the explorer indexer's panic point). Fix: copy the bump-emission pattern from the existing find_replaced_document_v0 failure path (lines 672-686) to all other failure sites in the Replace / Transfer / UpdatePrice / Purchase handlers. Each now returns `new_with_data_and_errors(BumpIdentityDataContractNonce(bump_action), errors)` so the contract nonce advances regardless of where the per-transition validation fails. This is a consensus-affecting change on PROTOCOL_VERSION_12 (current v3.1-dev). Validators that fail the same transition under the new behavior pay a higher fee than under the old behavior because the fee now covers the document fetch + ownership/revision check work that ran before the failure (those drive ops were already happening, just charged as 0). Three sibling tests had hard-coded fee assertions matching the old (under-charged) numbers; they're updated with comments explaining the change. Test plan - Added regression test `replayed_failed_replace_with_consumed_nonce_must_be_rejected_at_check_tx` in batch/tests/document/replacement.rs that reproduces the Techawanka.dash mainnet 35C0 shape (Create at nonce 2 → Replace at nonce 3 with mismatching revision → assert state nonce advanced AND CheckTx of identical bytes returns InvalidIdentityNonceError). RED on parent commit, GREEN with the fix. - Added decoder pin `should_decode_mainnet_35c0_documents_batch_replace_replay` in rs-dpp serialization tests confirming the mainnet wire bytes deserialize cleanly to a Batch / Replace / nonce=3 — the explorer's reported "Cannot deserialize" is its own version-skew, not a chain-bytes issue. - `cargo test -p drive-abci --lib`: 2424 passed, 0 failed (full suite). - `cargo test -p drive-abci --lib batch::tests::`: 254 passed, 0 failed. Out of scope - platform-explorer's `UNIQUE(hash)` constraint on `state_transitions` is too strict regardless of this fix; Tenderdash never guaranteed cross-block tx-hash uniqueness (failed-but-paid txs are committed by protocol design, like Ethereum reverts). Recommend `UNIQUE(block_height, hash)` upstream in pshenmic/platform-explorer. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/state_transition/serialization.rs | 70 +++++ .../batch/tests/document/nft.rs | 6 +- .../batch/tests/document/replacement.rs | 267 +++++++++++++++++- .../batch/tests/document/transfer.rs | 6 +- .../batch/transformer/v0/mod.rs | 179 +++++++++--- 5 files changed, 491 insertions(+), 37 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/serialization.rs b/packages/rs-dpp/src/state_transition/serialization.rs index d02fb91d016..25d6f8b96fb 100644 --- a/packages/rs-dpp/src/state_transition/serialization.rs +++ b/packages/rs-dpp/src/state_transition/serialization.rs @@ -106,6 +106,76 @@ mod tests { assert_eq!(identity_address, EXPECTED_IDENTITY_ADDRESS); } + #[test] + /// Mainnet state transition 35C08D574302D32D7160E603D8159042C8606BE12FD97D952CF5FD40DB57313C + /// (block 361774, idx 1, status FAIL, gas 41880). + /// + /// Pinned because: + /// - Its bytes are what platform-explorer's indexer choked on with + /// `duplicate key value violates unique constraint state_transition_hash` + /// (issue #2867 mainnet escalation, 2026-05-04). The same hash appears in + /// a later block the explorer hasn't indexed yet — the second copy is + /// what triggers the unique-key panic. + /// - The chat thread misidentified it as a credit transfer; the bytes are + /// actually a single Documents Batch Replace on a DPNS-like profile doc. + /// - Identity_contract_nonce == 3, while a sibling tx in the same block + /// (5962DA04... at idx 0) succeeded with nonce == 4. So the failure path + /// is the out-of-order in-block case, not a stale replay against committed + /// state — narrowing the regression target to the failed-Replace nonce + /// bookkeeping in batch/transformer/state. + fn should_decode_mainnet_35c0_documents_batch_replace_replay() { + use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; + use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; + use crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef; + use crate::state_transition::StateTransitionOwned; + + const EXPECTED_STATE_TRANSITION_HASH: &str = + "35C08D574302D32D7160E603D8159042C8606BE12FD97D952CF5FD40DB57313C"; + const RAW_TRANSACTION_BASE64: &str = "AgFQIl0YZ/WZdYW/CHCAmWvwN9FYctxuUS35L5Pf73IMTgEAAQABFSmQcqs+Yj0mrj560+00qfu+k75VeNPWWC4fDlzbpAwDB3Byb2ZpbGWiobSsb+8i6ioaaOgSNkSzV4dfa0EsGBCSgcFG57JxvAADAQtkaXNwbGF5TmFtZRIKdGVjaGF3YW5rYQABQSA7dB/FyiyS6BBNBLc4x+vVMVLuy5JWjuOzOuj/4fAaLgPrC7+nQDhl4LvPx+LZ4heDNq0dQA6LF97pjACbSO0a"; + const EXPECTED_OWNER_BASE58: &str = "6Pp1RFqRnpStnY8vmp5k3ypE6rFBvzPwcoguwDXbRA7F"; + const EXPECTED_NONCE: u64 = 3; + + let raw = STANDARD + .decode(RAW_TRANSACTION_BASE64) + .expect("base64 decodes"); + let state_transition = StateTransition::deserialize_from_bytes(&raw) + .expect("dpp deserializes the wire bytes"); + + assert_eq!( + &state_transition + .transaction_id() + .expect("expected transaction id") + .encode_hex_upper::(), + EXPECTED_STATE_TRANSITION_HASH + ); + + let StateTransition::Batch(batch) = &state_transition else { + panic!("expected Batch transition, got {state_transition:?}"); + }; + + assert_eq!( + batch.owner_id().to_string(Encoding::Base58), + EXPECTED_OWNER_BASE58 + ); + + assert_eq!(batch.transitions_len(), 1); + + let only = batch + .first_transition() + .expect("expected exactly one batched transition"); + + let BatchedTransitionRef::Document(document_transition) = only else { + panic!("expected document transition, got {only:?}"); + }; + + assert!( + matches!(document_transition, DocumentTransition::Replace(_)), + "expected Replace transition, got {document_transition:?}", + ); + + assert_eq!(only.identity_contract_nonce(), EXPECTED_NONCE); + } + #[test] #[cfg(feature = "random-identities")] fn identity_create_transition_ser_de() { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index ce43810cdd0..fe4926aa6f0 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -2788,7 +2788,11 @@ mod nft_tests { assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 36200); + // Fee bumped from 36200 in PROTOCOL_VERSION_12 — issue #2867 fix: the + // UpdatePrice ownership-mismatch failure path now emits a bump action + // (previously dropped, leaking nonce-replay risk). Cost covers the + // fetch+validation work that was already happening. + assert_eq!(processing_result.aggregated_fees().processing_fee, 571240); let sender_documents_sql_string = format!("select * from card where $ownerId == '{}'", identity.id()); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 759a1f0f13f..25a85daa4aa 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -651,7 +651,272 @@ mod replacement_tests { assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 41880); + // Fee bumped from the previous 41880 (bare bump-only) to 445700 in + // PROTOCOL_VERSION_12. Issue #2867 fix: the failure path now emits the + // bump after running the document fetch + validation work, so the fee + // covers that work — same drive ops, just charged correctly. + assert_eq!(processing_result.aggregated_fees().processing_fee, 445700); + } + + /// Regression test for issue #2867 (mainnet duplicate-tx escalation, 2026-05-04). + /// + /// Mainnet ST hash 35C0...313C — a Documents Batch Replace by Techawanka.dash + /// (6Pp1RFqRnpStnY8vmp5k3ypE6rFBvzPwcoguwDXbRA7F) — landed at block 361774 idx 1 + /// as FAIL with gasUsed 41880, then re-appeared in a later block, panicking the + /// explorer indexer with `duplicate key value violates unique constraint + /// state_transition_hash`. The shape: nonce 4 ran successfully *before* nonce + /// 3 in the same block (out-of-order SDK retries), so when nonce 3's Replace + /// reached deliver_tx the doc revision had already advanced. The + /// revision-bump-by-one check in + /// batch/transformer/v0/mod.rs::check_revision_is_bumped_by_one_during_replace_v0 + /// fails, and the surrounding handler (lines 712–715) returns `Ok(result)` + /// with errors-only and **no BumpIdentityDataContractNonce action** — unlike + /// the find_replaced_document_v0 path on the same enum arm (lines 672–686) + /// which DOES emit a bump. + /// + /// Consequence: the user pays the 41880 bump fee (because the empty + /// BatchTransitionAction still triggers PaidConsensusError accounting), but + /// the contract nonce IS NEVER ADVANCED in state. The exact same bytes can + /// then be re-broadcast indefinitely; each retry lands a new failed copy in + /// a new block, all sharing the same hash. + /// + /// What this test pins: + /// 1. After a Replace that fails the revision-bump-by-one check commits, + /// the stored identity_contract_nonce MUST advance past the submitted + /// nonce. (Direct invariant — fails fast on RED.) + /// 2. Re-submitting the same exact bytes through CheckTx FirstTimeCheck + /// MUST be rejected with InvalidIdentityNonceError. (Symptom-level — + /// this is what lklimek's Feb 10 2026 testnet debug log proved was + /// broken.) + /// + /// Both assertions fail on v3.1-dev today; both should pass once the bump + /// is emitted on revision/ownership-mismatch paths in batch/transformer/v0. + #[tokio::test] + async fn replayed_failed_replace_with_consumed_nonce_must_be_rejected_at_check_tx() { + use crate::execution::check_tx::CheckTxLevel; + use crate::execution::validation::state_transition::check_tx_verification::state_transition_to_execution_event_for_check_tx; + use crate::platform_types::platform::PlatformRef; + use dpp::serialization::PlatformDeserializable; + use dpp::state_transition::StateTransition; + + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + 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.5)); + + let dashpay = platform.drive.cache.system_data_contracts.load_dashpay(); + let dashpay_contract = dashpay.clone(); + + // Use the mutable `profile` doc type — same contract-and-doc-type that + // mainnet 35C0 was operating on (DPNS-like profile-replace flow). + let profile = dashpay_contract + .document_type_for_name("profile") + .expect("expected a profile document type"); + assert!(profile.documents_mutable()); + + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random document"); + // Random fillers can produce a non-URI avatarUrl that fails JSON-schema + // validation on Create. Pin it to a valid URI like the sibling tests do. + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + document.set("displayName", "Original".into()); + + // 1) Create at nonce 2 — consumes nonce 2; doc lands at revision 1. + let create_transition = BatchTransition::new_document_creation_transition_from_document( + document.clone(), + profile, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected to build create transition"); + + let create_serialized = create_transition + .serialize_to_bytes() + .expect("expected to serialize create"); + + let transaction = platform.drive.grove.start_transaction(); + let create_result = platform + .platform + .process_raw_state_transitions( + &vec![create_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process create"); + assert_eq!(create_result.valid_count(), 1); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit create"); + + let (post_create_nonce_raw, _) = platform + .drive + .fetch_identity_contract_nonce_with_fees( + identity.id().to_buffer(), + dashpay_contract.id().to_buffer(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to fetch contract nonce after create"); + let post_create_nonce = + post_create_nonce_raw.expect("contract nonce must be present after create"); + + // 2) Build a Replace at nonce 3 with a revision the chain will reject. + // Doc at revision 1 → expected revision 2 on replace. We submit + // revision 3 → check_revision_is_bumped_by_one_during_replace_v0 + // returns InvalidDocumentRevisionError(Some(1), 3). On mainnet this + // happened naturally because nonce 4 ran first and advanced the doc + // revision past what nonce 3's tx expected. + let mut altered_document = document.clone(); + altered_document.set_revision(Some(3)); + altered_document.set("displayName", "Out of order".into()); + + let replace_transition = BatchTransition::new_document_replacement_transition_from_document( + altered_document, + profile, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected to build replace transition"); + + let replace_serialized = replace_transition + .serialize_to_bytes() + .expect("expected to serialize replace"); + + let transaction = platform.drive.grove.start_transaction(); + let replace_result = platform + .platform + .process_raw_state_transitions( + &vec![replace_serialized.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process replace"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit failed replace"); + + assert_eq!( + replace_result.invalid_paid_count(), + 1, + "Replace must commit as invalid_paid (PaidConsensusError); execution_results={:?}", + replace_result.execution_results() + ); + assert_eq!(replace_result.valid_count(), 0); + + // 3) Direct invariant: the bump must have advanced the contract nonce + // in state. If the stored nonce is still post-create, the bump + // silently dropped — that is the bug. + let (post_replace_nonce_raw, _) = platform + .drive + .fetch_identity_contract_nonce_with_fees( + identity.id().to_buffer(), + dashpay_contract.id().to_buffer(), + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to fetch contract nonce after failed replace"); + let post_replace_nonce = + post_replace_nonce_raw.expect("contract nonce must be present after failed replace"); + + assert_ne!( + post_replace_nonce, post_create_nonce, + "BUG: failed Replace's bump action did not advance the contract \ + nonce. Stored nonce is still {:#x} (= post-create value), so the \ + same exact serialized bytes can be replayed forever. \ + Root cause: batch/transformer/v0/mod.rs:712-715 (and 697-700) \ + return Ok(result) with errors-only and no BumpIdentityDataContractNonce \ + action when check_revision_is_bumped_by_one_during_replace_v0 (or \ + check_ownership_of_old_replaced_document_v0) fails — unlike the \ + find_replaced_document_v0 failure path on the same arm (lines \ + 672-686) which does emit the bump.", + post_create_nonce + ); + + // 4) Symptom-level: re-submitting identical bytes through CheckTx + // FirstTimeCheck must hit the nonce check first and reject. This is + // exactly what lklimek's Feb 10 2026 testnet check_tx debug log + // showed NOT happening. + let replayed_state_transition = + StateTransition::deserialize_from_bytes(&replace_serialized) + .expect("expected to deserialize replayed transition"); + + let platform_state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &platform_state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + + let check_tx_result = state_transition_to_execution_event_for_check_tx( + &platform_ref, + replayed_state_transition, + CheckTxLevel::FirstTimeCheck, + platform_version, + ) + .expect("expected check_tx to not return an Err"); + + assert!( + !check_tx_result.is_valid(), + "CheckTx FirstTimeCheck MUST reject identical bytes after the \ + failed-Replace bump consumed the nonce — it accepted them on \ + testnet on 2026-02-10." + ); + assert!( + check_tx_result.errors.iter().any(|e| matches!( + e, + ConsensusError::StateError(StateError::InvalidIdentityNonceError(_)) + )), + "expected InvalidIdentityNonceError on replay; got {:?}", + check_tx_result.errors + ); } #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs index da2030502df..232a439fdbf 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs @@ -1256,7 +1256,11 @@ mod transfer_tests { assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 36200); + // Fee bumped from 36200 in PROTOCOL_VERSION_12 — issue #2867 fix: the + // Transfer find-replaced-document failure path now emits a bump action + // (previously dropped, leaking nonce-replay risk). Cost covers the + // fetch+validation work that was already happening. + assert_eq!(processing_result.aggregated_fees().processing_fee, 517400); let query_sender_results = platform .drive diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index ef7d2427950..9b64e33a9e3 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -664,7 +664,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(document_create_action) } DocumentTransition::Replace(document_replace_transition) => { - let mut result = ConsensusValidationResult::::new(); + let result = ConsensusValidationResult::::new(); let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); @@ -695,8 +695,19 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + // Emit a bump action so the identity_contract_nonce advances even + // when ownership doesn't match. Without this, the same exact bytes + // could be replayed forever — see issue #2867. + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_replace_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } if validate_against_state { @@ -710,8 +721,20 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + // Emit a bump action so the identity_contract_nonce advances even + // when the revision doesn't bump by one. Without this, out-of-order + // SDK retries can replay the same bytes across multiple blocks — + // see issue #2867 (mainnet 35C0…313C, 2026-05-04). + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_replace_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } } @@ -745,14 +768,24 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(batched_action) } DocumentTransition::Transfer(document_transfer_transition) => { - let mut result = ConsensusValidationResult::::new(); + let result = ConsensusValidationResult::::new(); let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - result.merge(validation_result); - return Ok(result); + // Emit a bump action so the identity_contract_nonce advances even + // when the target document is missing — see issue #2867. + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_transfer_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } let original_document = validation_result.into_data()?; @@ -764,8 +797,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_transfer_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } if validate_against_state { @@ -779,8 +820,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_transfer_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } } @@ -804,14 +853,24 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } } DocumentTransition::UpdatePrice(document_update_price_transition) => { - let mut result = ConsensusValidationResult::::new(); + let result = ConsensusValidationResult::::new(); let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - result.merge(validation_result); - return Ok(result); + // Emit a bump action so the identity_contract_nonce advances even + // when the target document is missing — see issue #2867. + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_update_price_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } let original_document = validation_result.into_data()?; @@ -823,8 +882,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_update_price_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } if validate_against_state { @@ -838,8 +905,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_update_price_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } } @@ -863,14 +938,24 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } } DocumentTransition::Purchase(document_purchase_transition) => { - let mut result = ConsensusValidationResult::::new(); + let result = ConsensusValidationResult::::new(); let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - result.merge(validation_result); - return Ok(result); + // Emit a bump action so the identity_contract_nonce advances even + // when the target document is missing — see issue #2867. + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_purchase_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } let original_document = validation_result.into_data()?; @@ -879,21 +964,39 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { .properties() .get_optional_integer::(PRICE)? else { - result.add_error(StateError::DocumentNotForSaleError( - DocumentNotForSaleError::new(original_document.id()), + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_purchase_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + vec![StateError::DocumentNotForSaleError( + DocumentNotForSaleError::new(original_document.id()), + ) + .into()], )); - return Ok(result); }; if listed_price != document_purchase_transition.price() { - result.add_error(StateError::DocumentIncorrectPurchasePriceError( - DocumentIncorrectPurchasePriceError::new( - original_document.id(), - document_purchase_transition.price(), - listed_price, - ), + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_purchase_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + vec![StateError::DocumentIncorrectPurchasePriceError( + DocumentIncorrectPurchasePriceError::new( + original_document.id(), + document_purchase_transition.price(), + listed_price, + ), + ) + .into()], )); - return Ok(result); } if validate_against_state { @@ -907,8 +1010,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_purchase_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + validation_result.errors, + )); } } From 251124817d3032b92eb98e7a2c161e72802699cf Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 7 May 2026 22:00:53 +0700 Subject: [PATCH 02/24] fix(drive-abci): close paid-with-empty-action gap in batch transformer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes part of #2867 — the architectural side. The companion PR (dashpay/platform#3608) handles the bump-on-failure side at PROTOCOL_VERSION_12; this PR fixes the deeper invariant violation that made bump-emission necessary in the first place. Root cause ---------- `ConsensusValidationResult::merge_many` and `ConsensusValidationResult::flatten` always produce `data: Some(Vec)` — even when the input was empty or every input had `data: None`. This violates the implicit contract `data.is_none() ⇔ no work done` that `process_validation_result_v0:241` keys on: if validation_result.data.is_none() { return Ok(StateTransitionExecutionResult::UnpaidConsensusError(...)); }; When per-transition validation in a Documents Batch fails for every transition (no fallback action is emitted), the per-transition results all carry `data: None`. The deprecated aggregators wrap that as `Some(empty_vec)` → `try_into_action_v0:278`'s `has_data()` check returns `true` → an empty `BatchTransitionAction { transitions: vec![] }` flows up → `process_state_transition_v0:282` `is_valid_with_data() == false` but `data.is_some()` → `map_result` produces an `ExecutionEvent::Paid` with no drive ops → `process_validation_result_v0` classifies as `PaidConsensusError`, charges the user a small fee for zero actual work, leaves the tx in the block. This is the "validating state transition for free" gap. Fix --- - New `merge_many_strict` / `flatten_strict` on `ValidationResult, E>`: identical semantics except they return `data: None` when no input contributed any data (empty input, all inputs `data: None`, or all inputs `Some(empty_vec)`). - Existing `merge_many` / `flatten` are marked `#[deprecated]`. Their behavior is unchanged and they remain in the codebase because removing them would break PROTOCOL_VERSION_11 and earlier chain reproducibility. - New `transformer/v1/mod.rs` is a near-copy of `transformer/v0/mod.rs` with all four aggregator call sites switched to the strict variants. v0 is preserved verbatim for v11 (it gets a file-level `#![allow(deprecated)]` to silence the deprecation warnings). - `batch/mod.rs` dispatches `transform_into_action == 1` to v1. - `PLATFORM_V12.batch_state_transition.transform_into_action` bumped from 0 to 1 (in `DRIVE_ABCI_VALIDATION_VERSIONS_V8`, used only by PLATFORM_V12; PLATFORM_V11's `_V7` is untouched). Effect ------ On PROTOCOL_VERSION_11 (mainnet today): unchanged. Same chain history. On PROTOCOL_VERSION_12+ (the v3.1 hard-fork target): a Documents Batch where every per-transition validation fails-without-action surfaces as `UnpaidConsensusError` instead of an empty `PaidConsensusError`. `prepare_proposal:223` removes the tx from the block. No fee charged. No replay surface (the tx never landed in the chain to begin with). Test plan --------- - `validation_result.rs` unit tests (in this PR): 9 new strict-variant tests + 2 new pinning tests for the deprecated buggy behavior. Local run: `57 passed; 0 failed`. - `batch/tests/document/{replacement,transfer,nft}.rs`: existing scenarios where the empty-action shape is reachable (immutable Replace, transfer-of-missing-doc, set-price-on-not-owned) factored into helper functions; default test (PlatformVersion::latest = v12) asserts the new `UnpaidConsensusError` shape with `processing_fee == 0`; new `*_protocol_version_11` siblings assert the historical `PaidConsensusError` + 41880/36200 fees are preserved bit-for-bit. - Full `drive-abci` test run is left to CI in this PR (the local run was time-prohibitive in a fresh worktree's empty `target/`). Out of scope ------------ Partial-batch failures (some transitions succeed, some fail-without- action) still leak the failed transitions' contract nonces — the strict aggregator only fixes the all-failed shape. PR #3608 (bump on failure) addresses the per-transition gap; together, the two PRs cover all known issue #2867 shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/validation/validation_result.rs | 245 +++- .../state_transitions/batch/mod.rs | 4 +- .../batch/state/v0/fetch_documents.rs | 5 + .../batch/tests/document/nft.rs | 61 +- .../batch/tests/document/replacement.rs | 90 +- .../batch/tests/document/transfer.rs | 61 +- .../batch/transformer/mod.rs | 6 + .../batch/transformer/v0/mod.rs | 7 + .../batch/transformer/v1/mod.rs | 1070 +++++++++++++++++ .../drive_abci_validation_versions/v8.rs | 8 +- 10 files changed, 1530 insertions(+), 27 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs diff --git a/packages/rs-dpp/src/validation/validation_result.rs b/packages/rs-dpp/src/validation/validation_result.rs index 505e65edef4..e6f0283f5fe 100644 --- a/packages/rs-dpp/src/validation/validation_result.rs +++ b/packages/rs-dpp/src/validation/validation_result.rs @@ -34,6 +34,22 @@ impl Default for ValidationResult { } impl ValidationResult, E> { + /// **Deprecated.** Always returns `data: Some(Vec<...>)` — even if no + /// input contributed any data — which violates the implicit contract + /// `data.is_none() ⇔ no work done` that downstream `process_validation_result` + /// keys on. See issue #2867 (the empty-action / "validating state + /// transition for free" bug). Use [`flatten_strict`] instead, which + /// returns `data: None` when no input contributed data. + /// + /// Preserved for `PROTOCOL_VERSION_11` and below — changing this + /// function's behavior would be a consensus-breaking change for the + /// existing chain history. + /// + /// [`flatten_strict`]: ValidationResult::flatten_strict + #[deprecated( + since = "3.1.0", + note = "use flatten_strict; flatten always returns Some(empty_vec) which violates the data-is-None ⇔ no-work invariant — see issue #2867" + )] pub fn flatten, E>>>( items: I, ) -> ValidationResult, E> { @@ -48,9 +64,58 @@ impl ValidationResult, E> { }); ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } + + /// Strict variant of [`flatten`]: returns `data: None` when no input + /// contributed any data (i.e. every input was either `data: None` or + /// `data: Some(empty_vec)`), and only returns `data: Some(...)` when + /// the aggregate Vec is non-empty. + /// + /// This restores the invariant that `data.is_none() ⇔ no work done`, + /// which downstream code (e.g. + /// `process_validation_result_v0:241`) relies on to choose between + /// `PaidConsensusError` and `UnpaidConsensusError`. Used by + /// `PROTOCOL_VERSION_12`+ to close the issue #2867 "validating state + /// transition for free" gap. + /// + /// [`flatten`]: ValidationResult::flatten + pub fn flatten_strict, E>>>( + items: I, + ) -> ValidationResult, E> { + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(mut data) = data { + aggregate_data.append(&mut data); + } + }); + if aggregate_data.is_empty() { + ValidationResult { + errors: aggregate_errors, + data: None, + } + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } + } } impl ValidationResult { + /// **Deprecated.** Always returns `data: Some(Vec<...>)` — even if no + /// input contributed any data — which violates the implicit contract + /// `data.is_none() ⇔ no work done`. See issue #2867. Use + /// [`merge_many_strict`] instead. + /// + /// Preserved for `PROTOCOL_VERSION_11` and below — changing this + /// function's behavior would be a consensus-breaking change for the + /// existing chain history. + /// + /// [`merge_many_strict`]: ValidationResult::merge_many_strict + #[deprecated( + since = "3.1.0", + note = "use merge_many_strict; merge_many always returns Some(empty_vec) which violates the data-is-None ⇔ no-work invariant — see issue #2867" + )] pub fn merge_many>>( items: I, ) -> ValidationResult, E> { @@ -65,6 +130,37 @@ impl ValidationResult { }); ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } + + /// Strict variant of [`merge_many`]: returns `data: None` when no + /// input had `Some(data)`, and only returns `data: Some(Vec<...>)` + /// when at least one input contributed data. + /// + /// This restores the `data.is_none() ⇔ no work done` invariant — see + /// issue #2867. Used by `PROTOCOL_VERSION_12`+ to close the + /// "validating state transition for free" gap. + /// + /// [`merge_many`]: ValidationResult::merge_many + pub fn merge_many_strict>>( + items: I, + ) -> ValidationResult, E> { + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(data) = data { + aggregate_data.push(data); + } + }); + if aggregate_data.is_empty() { + ValidationResult { + errors: aggregate_errors, + data: None, + } + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } + } } impl SimpleValidationResult { @@ -539,9 +635,12 @@ mod tests { assert_eq!(result.errors, vec!["bad".to_string()]); } - // -- flatten() -- + // -- flatten() (deprecated) -- + // These pin the historical buggy behavior preserved for + // PROTOCOL_VERSION_11 and below — issue #2867. #[test] + #[allow(deprecated)] fn test_flatten_merges_data_and_errors() { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); let r2: ValidationResult, String> = @@ -555,16 +654,36 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_flatten_empty_input() { let flat: ValidationResult, String> = ValidationResult::flatten(std::iter::empty()); + // Issue #2867 root cause: flatten produces Some(empty_vec) here, + // not None. Downstream code that checks `data.is_none()` is fooled + // into treating "no data" as "has data". assert_eq!(flat.data, Some(vec![])); assert!(flat.errors.is_empty()); } - // -- merge_many() -- + #[test] + #[allow(deprecated)] + fn test_flatten_all_inputs_no_data_returns_some_empty() { + // Pins the buggy v11 behavior: all inputs have data:None, but + // flatten still produces data:Some(vec![]). + let r1: ValidationResult, String> = + ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = ValidationResult::flatten(vec![r1, r2]); + assert_eq!(flat.data, Some(vec![])); + assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); + } + + // -- merge_many() (deprecated) -- #[test] + #[allow(deprecated)] fn test_merge_many_collects_data_into_vec() { let r1: ValidationResult = ValidationResult::new_with_data(1); let r2: ValidationResult = ValidationResult::new_with_data(2); @@ -576,13 +695,135 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_merge_many_empty_input() { let merged: ValidationResult, String> = ValidationResult::merge_many(std::iter::empty::>()); + // Same buggy shape: Some(empty_vec) instead of None. + assert_eq!(merged.data, Some(vec![])); + assert!(merged.errors.is_empty()); + } + + #[test] + #[allow(deprecated)] + fn test_merge_many_all_inputs_no_data_returns_some_empty() { + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); + + let merged = ValidationResult::merge_many(vec![r1, r2]); assert_eq!(merged.data, Some(vec![])); + assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); + } + + // -- flatten_strict() (issue #2867 fix) -- + // PROTOCOL_VERSION_12+ uses these. They restore the + // `data.is_none() ⇔ no work done` invariant. + + #[test] + fn test_flatten_strict_merges_non_empty_data() { + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); + let r2: ValidationResult, String> = + ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); + let r3: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = ValidationResult::flatten_strict(vec![r1, r2, r3]); + assert_eq!(flat.data, Some(vec![1, 2, 3])); + assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); + } + + #[test] + fn test_flatten_strict_empty_input_returns_none_data() { + let flat: ValidationResult, String> = + ValidationResult::flatten_strict(std::iter::empty()); + assert_eq!(flat.data, None); + assert!(flat.errors.is_empty()); + } + + #[test] + fn test_flatten_strict_all_inputs_no_data_returns_none() { + // The whole point of strict: when no input contributed data, + // return data:None — not Some(empty_vec). Downstream code + // (process_validation_result_v0:241) keys on data.is_none(). + let r1: ValidationResult, String> = + ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = ValidationResult::flatten_strict(vec![r1, r2]); + assert!(flat.data.is_none()); + assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); + } + + #[test] + fn test_flatten_strict_some_empty_some_non_empty_returns_some() { + // Mixed input: one had data:Some(empty_vec), another had + // Some(non_empty). The aggregate is non-empty, so data:Some(...). + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); + + let flat = ValidationResult::flatten_strict(vec![r1, r2]); + assert_eq!(flat.data, Some(vec![42])); + assert!(flat.errors.is_empty()); + } + + #[test] + fn test_flatten_strict_all_some_empty_returns_none() { + // All inputs had data:Some(empty_vec). The aggregate Vec is + // empty → data:None per the strict contract. + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + + let flat = ValidationResult::flatten_strict(vec![r1, r2]); + assert!(flat.data.is_none()); + assert!(flat.errors.is_empty()); + } + + // -- merge_many_strict() (issue #2867 fix) -- + + #[test] + fn test_merge_many_strict_collects_non_empty_data() { + let r1: ValidationResult = ValidationResult::new_with_data(1); + let r2: ValidationResult = ValidationResult::new_with_data(2); + let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); + + let merged = ValidationResult::merge_many_strict(vec![r1, r2, r3]); + assert_eq!(merged.data, Some(vec![1, 2])); + assert_eq!(merged.errors, vec!["e".to_string()]); + } + + #[test] + fn test_merge_many_strict_empty_input_returns_none_data() { + let merged: ValidationResult, String> = ValidationResult::merge_many_strict( + std::iter::empty::>(), + ); + assert!(merged.data.is_none()); assert!(merged.errors.is_empty()); } + #[test] + fn test_merge_many_strict_all_inputs_no_data_returns_none() { + // The bug-fixing case: all per-transition results returned + // errors-only with no action. Strict aggregator surfaces this + // as data:None so the downstream paid/unpaid switch picks unpaid. + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); + + let merged = ValidationResult::merge_many_strict(vec![r1, r2]); + assert!(merged.data.is_none()); + assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); + } + + #[test] + fn test_merge_many_strict_some_data_returns_some() { + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_data(7); + + let merged = ValidationResult::merge_many_strict(vec![r1, r2]); + assert_eq!(merged.data, Some(vec![7])); + assert_eq!(merged.errors, vec!["e1".to_string()]); + } + // -- merge_many_errors() -- #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index 8c0ba510d5e..33d67bc9cef 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -33,6 +33,7 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::batch::advanced_structure::v0::DocumentsBatchStateTransitionStructureValidationV0; use crate::execution::validation::state_transition::batch::identity_contract_nonce::v0::DocumentsBatchStateTransitionIdentityContractNonceV0; use crate::execution::validation::state_transition::batch::state::v0::DocumentsBatchStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::batch::transformer::v1::BatchTransitionActionTransformerV1; use crate::execution::validation::state_transition::processor::advanced_structure_with_state::StateTransitionStructureKnownInStateValidationV0; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::processor::identity_nonces::StateTransitionIdentityNonceValidationV0; @@ -75,9 +76,10 @@ impl StateTransitionActionTransformer for BatchTransition { .transform_into_action { 0 => self.transform_into_action_v0(&platform.into(), block_info, validation_mode, tx), + 1 => self.transform_into_action_v1(&platform.into(), block_info, validation_mode, tx), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "documents batch transition: transform_into_action".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index 79f1b87ec29..f3df36d1aab 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -69,6 +69,11 @@ pub(crate) fn fetch_documents_for_transitions( }) .collect::>>, Error>>()?; + // The deprecated non-strict aggregator is fine here: the only caller + // checks `is_valid()` (errors), not `data.is_some()`, so the + // `Some(empty_vec)` vs `None` distinction is invisible to it. See + // issue #2867 for context on the strict aggregators. + #[allow(deprecated)] let validation_result = ConsensusValidationResult::flatten(validation_results_of_documents); Ok(validation_result) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index ce43810cdd0..7ae4036d57c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -2652,10 +2652,19 @@ mod nft_tests { assert_eq!(processing_result.aggregated_fees().processing_fee, 0); } - #[tokio::test] - async fn test_document_set_price_on_not_owned_document() { - let platform_version = PlatformVersion::latest(); + /// Issue #2867 paired test (helper). Same scenario across protocol + /// versions: SetPrice on a doc owned by someone else → all-failed + /// batch (ownership mismatch). v11 = `PaidConsensusError` with empty + /// action; v12 = `UnpaidConsensusError`. + async fn run_document_set_price_on_not_owned_document_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_unpaid_consensus: bool, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected platform version"); let (mut platform, contract) = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_initial_state_structure() .with_crypto_card_game_nft(TradeMode::DirectPurchase); @@ -2782,13 +2791,30 @@ mod nft_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); - - assert_eq!(processing_result.invalid_unpaid_count(), 0); + if expected_unpaid_consensus { + assert_eq!( + processing_result.invalid_unpaid_count(), + 1, + "PROTOCOL_VERSION_{}: must surface as UnpaidConsensusError", + protocol_version, + ); + assert_eq!(processing_result.invalid_paid_count(), 0); + } else { + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "PROTOCOL_VERSION_{}: must preserve historical PaidConsensusError shape", + protocol_version, + ); + assert_eq!(processing_result.invalid_unpaid_count(), 0); + } assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 36200); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee + ); let sender_documents_sql_string = format!("select * from card where $ownerId == '{}'", identity.id()); @@ -2818,6 +2844,27 @@ mod nft_tests { ); } + /// PROTOCOL_VERSION_12+ (architectural fix active). + #[tokio::test] + async fn test_document_set_price_on_not_owned_document() { + run_document_set_price_on_not_owned_document_at_protocol_version( + PlatformVersion::latest().protocol_version, + true, // architectural fix active + 0, // no fee charged on UnpaidConsensus + ) + .await; + } + + /// PROTOCOL_VERSION_11: preserved historical buggy behavior. + #[tokio::test] + async fn test_document_set_price_on_not_owned_document_protocol_version_11() { + run_document_set_price_on_not_owned_document_at_protocol_version( + 11, false, // bug preserved + 36200, // pre-fix bump-only fee + ) + .await; + } + #[tokio::test] async fn test_document_set_price_and_purchase_with_token_costs() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 759a1f0f13f..21ad0a66a42 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -509,11 +509,32 @@ mod replacement_tests { .await; } - #[tokio::test] - async fn test_document_replace_on_document_type_that_is_not_mutable() { - let platform_version = PlatformVersion::latest(); + /// Issue #2867 — paired test: same scenario at v11 (preserved buggy + /// behavior) and v12 (architectural fix). The Replace targets an + /// immutable doc type, so per-transition validation fails with no + /// fallback action. + /// + /// On PROTOCOL_VERSION_11 (`expected_unpaid_consensus = false`): + /// the deprecated `merge_many`/`flatten` aggregators wrap the empty + /// per-transition results as `Some(empty_vec)` → empty + /// `BatchTransitionAction` → `PaidConsensusError` is recorded with a + /// 41880 bump-only fee, but the bump action's drive op is empty so + /// nothing actually advances. Tx stays in the block. + /// + /// On PROTOCOL_VERSION_12+ (`expected_unpaid_consensus = true`): + /// `flatten_strict`/`merge_many_strict` return `data: None`, downstream + /// `process_validation_result_v0:241` routes to `UnpaidConsensusError`, + /// `prepare_proposal:223` removes the tx from the block. No fee charged, + /// no chain bloat. + async fn run_document_replace_on_immutable_doc_type_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_unpaid_consensus: bool, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected platform version"); let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -645,13 +666,64 @@ mod replacement_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); - - assert_eq!(processing_result.invalid_unpaid_count(), 0); - + if expected_unpaid_consensus { + // PROTOCOL_VERSION_12+: architectural fix — strict aggregator + // surfaces the all-failed batch as data:None → + // UnpaidConsensusError → tx removed from block. + assert_eq!( + processing_result.invalid_unpaid_count(), + 1, + "PROTOCOL_VERSION_{}: must surface as UnpaidConsensusError; \ + results: {:?}", + protocol_version, + processing_result.execution_results(), + ); + assert_eq!(processing_result.invalid_paid_count(), 0); + } else { + // PROTOCOL_VERSION_11: preserved buggy behavior — empty action + // synthesised as PaidConsensusError, user charged for nothing. + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "PROTOCOL_VERSION_{}: must preserve historical \ + PaidConsensusError-with-empty-action shape; results: {:?}", + protocol_version, + processing_result.execution_results(), + ); + assert_eq!(processing_result.invalid_unpaid_count(), 0); + } assert_eq!(processing_result.valid_count(), 0); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee + ); + } - assert_eq!(processing_result.aggregated_fees().processing_fee, 41880); + /// PROTOCOL_VERSION_12+ (architectural fix active): all-failed Replace + /// batch surfaces as `UnpaidConsensusError`, no fee charged, tx will + /// be removed from the block by `prepare_proposal`. Issue #2867. + #[tokio::test] + async fn test_document_replace_on_document_type_that_is_not_mutable() { + run_document_replace_on_immutable_doc_type_at_protocol_version( + PlatformVersion::latest().protocol_version, + true, // architectural fix active + 0, // no fee charged on UnpaidConsensus + ) + .await; + } + + /// PROTOCOL_VERSION_11: preserved historical buggy behavior. Empty + /// `BatchTransitionAction` synthesised by the deprecated non-strict + /// aggregators → user pays the 41880 bump-only fee for no actual work. + /// Pinned so any accidental change to the deprecated aggregators + /// or v0 transformer surfaces here as a consensus break for v11. + #[tokio::test] + async fn test_document_replace_on_document_type_that_is_not_mutable_protocol_version_11() { + run_document_replace_on_immutable_doc_type_at_protocol_version( + 11, false, // bug preserved: PaidConsensus with empty action + 41880, // pre-fix bump-only fee + ) + .await; } #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs index da2030502df..0ae1848aee9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs @@ -1123,10 +1123,19 @@ mod transfer_tests { assert_eq!(query_receiver_results.documents().len(), 0); } - #[tokio::test] - async fn test_document_transfer_that_does_not_yet_exist() { - let platform_version = PlatformVersion::latest(); + /// Issue #2867 paired test (helper). Same scenario across protocol + /// versions: Transfer a non-existent document → all-failed batch. + /// v11 = `PaidConsensusError` with empty action (bug); v12 = + /// `UnpaidConsensusError` (architectural fix). + async fn run_document_transfer_that_does_not_yet_exist_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_unpaid_consensus: bool, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = + PlatformVersion::get(protocol_version).expect("expected platform version"); let (mut platform, contract) = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_initial_state_structure() .with_crypto_card_game_transfer_only(Transferable::Never); @@ -1250,13 +1259,30 @@ mod transfer_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); - - assert_eq!(processing_result.invalid_unpaid_count(), 0); + if expected_unpaid_consensus { + assert_eq!( + processing_result.invalid_unpaid_count(), + 1, + "PROTOCOL_VERSION_{}: must surface as UnpaidConsensusError", + protocol_version, + ); + assert_eq!(processing_result.invalid_paid_count(), 0); + } else { + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "PROTOCOL_VERSION_{}: must preserve historical PaidConsensusError shape", + protocol_version, + ); + assert_eq!(processing_result.invalid_unpaid_count(), 0); + } assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 36200); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee + ); let query_sender_results = platform .drive @@ -1274,6 +1300,27 @@ mod transfer_tests { assert_eq!(query_receiver_results.documents().len(), 0); } + /// PROTOCOL_VERSION_12+ (architectural fix active). + #[tokio::test] + async fn test_document_transfer_that_does_not_yet_exist() { + run_document_transfer_that_does_not_yet_exist_at_protocol_version( + PlatformVersion::latest().protocol_version, + true, // architectural fix active + 0, // no fee charged on UnpaidConsensus + ) + .await; + } + + /// PROTOCOL_VERSION_11: preserved historical buggy behavior. + #[tokio::test] + async fn test_document_transfer_that_does_not_yet_exist_protocol_version_11() { + run_document_transfer_that_does_not_yet_exist_at_protocol_version( + 11, false, // bug preserved + 36200, // pre-fix bump-only fee + ) + .await; + } + #[tokio::test] async fn test_document_delete_after_transfer() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs index 9a1925de7fc..126c9e689cc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs @@ -1 +1,7 @@ pub(crate) mod v0; +/// v1 of the batch transformer fixes issue #2867: when per-transition +/// validation produces no action, we should not synthesise an empty paid +/// action via merge_many/flatten — instead the transition becomes +/// UnpaidConsensusError so prepare_proposal removes it from the block. +/// v0 is preserved for older platform versions (≤ PROTOCOL_VERSION_11). +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index ef7d2427950..a9eadef7e6c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -1,3 +1,10 @@ +// v0 uses the now-deprecated `ConsensusValidationResult::flatten` / +// `merge_many` aggregators. This is intentional: changing them to the +// strict variants would alter PROTOCOL_VERSION_11 (and earlier) chain +// behavior. v1 of this transformer (used by PROTOCOL_VERSION_12+) uses +// the strict variants. See issue #2867. +#![allow(deprecated)] + use std::collections::btree_map::Entry; use std::collections::BTreeMap; use std::sync::Arc; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs new file mode 100644 index 00000000000..2e3d9987266 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs @@ -0,0 +1,1070 @@ +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; +use std::sync::Arc; + +use crate::error::Error; +use crate::platform_types::platform::PlatformStateRef; +use dpp::consensus::basic::document::{DataContractNotPresentError, InvalidDocumentTypeError}; +use dpp::consensus::basic::BasicError; + +use dpp::consensus::state::document::document_not_found_error::DocumentNotFoundError; +use dpp::consensus::state::document::document_owner_id_mismatch_error::DocumentOwnerIdMismatchError; + +use dpp::consensus::state::document::invalid_document_revision_error::InvalidDocumentRevisionError; +use dpp::consensus::state::state_error::StateError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; + +use dpp::block::block_info::BlockInfo; +use dpp::consensus::state::document::document_incorrect_purchase_price_error::DocumentIncorrectPurchasePriceError; +use dpp::consensus::state::document::document_not_for_sale_error::DocumentNotForSaleError; +use dpp::document::property_names::PRICE; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::fee::Credits; +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::prelude::{Revision, UserFeeIncrease}; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::{consensus::ConsensusError, prelude::Identifier, validation::ConsensusValidationResult}; +use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; +use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::v0_methods::DocumentPurchaseTransitionV0Methods; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionOwned}; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionAction; +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_replace_transition_action::DocumentReplaceTransitionAction; +use drive::state_transition_action::batch::BatchTransitionAction; +use drive::state_transition_action::batch::v0::BatchTransitionActionV0; + +use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::fetch_documents_for_transitions_knowing_contract_and_document_type; +use dpp::version::PlatformVersion; +use drive::grovedb::TransactionArg; + +use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::document_transition::{DocumentTransition, DocumentTransitionV0Methods}; +use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; +use dpp::state_transition::batch_transition::batched_transition::token_transition::{TokenTransition, TokenTransitionV0Methods}; +use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; +use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; +use drive::drive::contract::DataContractFetchInfo; +use drive::drive::Drive; +use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionAction; +use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_burn_transition_action::TokenBurnTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_config_update_transition_action::TokenConfigUpdateTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_emergency_action_transition_action::TokenEmergencyActionTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_freeze_transition_action::TokenFreezeTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_mint_transition_action::TokenMintTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_claim_transition_action::TokenClaimTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_transition_action::TokenDirectPurchaseTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_set_price_for_direct_purchase_transition_action::TokenSetPriceForDirectPurchaseTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::TokenTransferTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::token_unfreeze_transition_action::TokenUnfreezeTransitionAction; +use drive::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; +use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; +use crate::platform_types::platform_state::PlatformStateV0Methods; + +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait BatchTransitionTransformerV1 +{ + fn try_into_action_v1( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + full_validation: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + ) -> Result, Error>; +} + +trait BatchTransitionInternalTransformerV1 { + #[allow(clippy::too_many_arguments)] + fn transform_document_transitions_within_contract_v1( + platform: &PlatformStateRef, + block_info: &BlockInfo, + full_validation: bool, + data_contract_id: &Identifier, + owner_id: Identifier, + document_transitions: &BTreeMap<&String, Vec<&DocumentTransition>>, + user_fee_increase: UserFeeIncrease, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error>; + #[allow(clippy::too_many_arguments)] + fn transform_document_transitions_within_document_type_v1( + platform: &PlatformStateRef, + block_info: &BlockInfo, + full_validation: bool, + data_contract_fetch_info: Arc, + document_type_name: &str, + owner_id: Identifier, + document_transitions: &[&DocumentTransition], + user_fee_increase: UserFeeIncrease, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error>; + #[allow(clippy::too_many_arguments)] + fn transform_token_transitions_within_contract_v1( + platform: &PlatformStateRef, + data_contract_id: &Identifier, + block_info: &BlockInfo, + validate_against_state: bool, + owner_id: Identifier, + token_transitions: &[&TokenTransition], + user_fee_increase: UserFeeIncrease, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result>, Error>; + #[allow(clippy::too_many_arguments)] + /// Transfer token transition + fn transform_token_transition_v1( + drive: &Drive, + transaction: TransactionArg, + block_info: &BlockInfo, + validate_against_state: bool, + data_contract_fetch_info: Arc, + transition: &TokenTransition, + owner_id: Identifier, + user_fee_increase: UserFeeIncrease, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; + /// The data contract can be of multiple difference versions + #[allow(clippy::too_many_arguments)] + fn transform_document_transition_v1( + drive: &Drive, + transaction: TransactionArg, + full_validation: bool, + block_info: &BlockInfo, + data_contract_fetch_info: Arc, + transition: &DocumentTransition, + replaced_documents: &[Document], + user_fee_increase: UserFeeIncrease, + owner_id: Identifier, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; + fn find_replaced_document_v1<'a>( + document_transition: &'a DocumentTransition, + fetched_documents: &'a [Document], + ) -> ConsensusValidationResult<&'a Document>; + fn check_ownership_of_old_replaced_document_v1( + document_id: Identifier, + fetched_document: &Document, + owner_id: &Identifier, + ) -> SimpleConsensusValidationResult; + fn check_revision_is_bumped_by_one_during_replace_v1( + transition_revision: Revision, + document_id: Identifier, + original_document: &Document, + ) -> SimpleConsensusValidationResult; +} + +impl BatchTransitionTransformerV1 for BatchTransition { + fn try_into_action_v1( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + validate_against_state: bool, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + ) -> Result, Error> { + let owner_id = self.owner_id(); + let user_fee_increase = self.user_fee_increase(); + let platform_version = platform.state.current_platform_version()?; + let mut document_transitions_by_contracts_and_types: BTreeMap< + &Identifier, + BTreeMap<&String, Vec<&DocumentTransition>>, + > = BTreeMap::new(); + + let mut token_transitions_by_contracts: BTreeMap<&Identifier, Vec<&TokenTransition>> = + BTreeMap::new(); + + // We want to validate by contract, and then for each document type within a contract + for transition in self.transitions_iter() { + match transition { + BatchedTransitionRef::Document(document_transition) => { + let document_type = document_transition.base().document_type_name(); + let data_contract_id = document_transition.base().data_contract_id_ref(); + + match document_transitions_by_contracts_and_types.entry(data_contract_id) { + Entry::Vacant(v) => { + v.insert(BTreeMap::from([(document_type, vec![document_transition])])); + } + Entry::Occupied(mut transitions_by_types_in_contract) => { + match transitions_by_types_in_contract + .get_mut() + .entry(document_type) + { + Entry::Vacant(v) => { + v.insert(vec![document_transition]); + } + Entry::Occupied(mut o) => o.get_mut().push(document_transition), + } + } + } + } + BatchedTransitionRef::Token(token_transition) => { + let data_contract_id = token_transition.base().data_contract_id_ref(); + + match token_transitions_by_contracts.entry(data_contract_id) { + Entry::Vacant(v) => { + v.insert(vec![token_transition]); + } + Entry::Occupied(mut transitions_by_tokens_in_contract) => { + transitions_by_tokens_in_contract + .get_mut() + .push(token_transition) + } + } + } + } + } + + let validation_result_documents = document_transitions_by_contracts_and_types + .iter() + .map( + |(data_contract_id, document_transitions_by_document_type)| { + Self::transform_document_transitions_within_contract_v1( + platform, + block_info, + validate_against_state, + data_contract_id, + owner_id, + document_transitions_by_document_type, + user_fee_increase, + execution_context, + transaction, + platform_version, + ) + }, + ) + .collect::>>, Error>>( + )?; + + let mut validation_result_tokens = token_transitions_by_contracts + .iter() + .map(|(data_contract_id, token_transitions)| { + Self::transform_token_transitions_within_contract_v1( + platform, + data_contract_id, + block_info, + validate_against_state, + owner_id, + token_transitions, + user_fee_increase, + transaction, + execution_context, + platform_version, + ) + }) + .collect::>>, Error>>( + )?; + + let mut validation_results = validation_result_documents; + + validation_results.append(&mut validation_result_tokens); + + // Issue #2867: use the strict aggregator so an all-failed batch + // surfaces as data:None — the downstream + // process_validation_result_v0:241 then routes to UnpaidConsensusError + // instead of synthesising a paid empty BatchTransitionAction. + let validation_result = ConsensusValidationResult::flatten_strict(validation_results); + + if validation_result.has_data() { + let (transitions, errors) = validation_result.into_data_and_errors()?; + let batch_transition_action = BatchTransitionActionV0 { + owner_id, + transitions, + user_fee_increase, + } + .into(); + Ok(ConsensusValidationResult::new_with_data_and_errors( + batch_transition_action, + errors, + )) + } else { + Ok(ConsensusValidationResult::new_with_errors( + validation_result.errors, + )) + } + } +} + +impl BatchTransitionInternalTransformerV1 for BatchTransition { + fn transform_token_transitions_within_contract_v1( + platform: &PlatformStateRef, + data_contract_id: &Identifier, + block_info: &BlockInfo, + validate_against_state: bool, + owner_id: Identifier, + token_transitions: &[&TokenTransition], + user_fee_increase: UserFeeIncrease, + transaction: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + let drive = platform.drive; + // Data Contract must exist + let Some(data_contract_fetch_info) = drive + .get_contract_with_fetch_info_and_fee( + data_contract_id.to_buffer(), + None, + false, + transaction, + platform_version, + )? + .1 + else { + return Ok(ConsensusValidationResult::new_with_error( + BasicError::DataContractNotPresentError(DataContractNotPresentError::new( + *data_contract_id, + )) + .into(), + )); + }; + + let validation_result = token_transitions + .iter() + .map(|token_transition| { + Self::transform_token_transition_v1( + platform.drive, + transaction, + block_info, + validate_against_state, + data_contract_fetch_info.clone(), + token_transition, + owner_id, + user_fee_increase, + execution_context, + platform_version, + ) + }) + .collect::>, Error>>()?; + // Issue #2867: strict variant returns data:None when no token + // transition produced an action. + let validation_result = ConsensusValidationResult::merge_many_strict(validation_result); + Ok(validation_result) + } + fn transform_document_transitions_within_contract_v1( + platform: &PlatformStateRef, + block_info: &BlockInfo, + validate_against_state: bool, + data_contract_id: &Identifier, + owner_id: Identifier, + document_transitions: &BTreeMap<&String, Vec<&DocumentTransition>>, + user_fee_increase: UserFeeIncrease, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + let drive = platform.drive; + // Data Contract must exist + let Some(data_contract_fetch_info) = drive + .get_contract_with_fetch_info_and_fee( + data_contract_id.0 .0, + None, + false, + transaction, + platform_version, + )? + .1 + else { + return Ok(ConsensusValidationResult::new_with_error( + BasicError::DataContractNotPresentError(DataContractNotPresentError::new( + *data_contract_id, + )) + .into(), + )); + }; + + let validation_result = document_transitions + .iter() + .map(|(document_type_name, document_transitions)| { + Self::transform_document_transitions_within_document_type_v1( + platform, + block_info, + validate_against_state, + data_contract_fetch_info.clone(), + document_type_name, + owner_id, + document_transitions, + user_fee_increase, + execution_context, + transaction, + platform_version, + ) + }) + .collect::>>, Error>>( + )?; + // Issue #2867: strict variant. + Ok(ConsensusValidationResult::flatten_strict(validation_result)) + } + + fn transform_document_transitions_within_document_type_v1( + platform: &PlatformStateRef, + block_info: &BlockInfo, + validate_against_state: bool, + data_contract_fetch_info: Arc, + document_type_name: &str, + owner_id: Identifier, + document_transitions: &[&DocumentTransition], + user_fee_increase: UserFeeIncrease, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result>, Error> { + // We use temporary execution context without dry run, + // because despite the dryRun, we need to get the + // data contract to proceed with following logic + // let tmp_execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version)?; + // + // execution_context.add_operations(tmp_execution_context.operations_slice()); + + let dry_run = false; //maybe reenable + + let data_contract = &data_contract_fetch_info.contract; + + let Some(document_type) = data_contract.document_type_optional_for_name(document_type_name) + else { + return Ok(ConsensusValidationResult::new_with_error( + InvalidDocumentTypeError::new(document_type_name.to_owned(), data_contract.id()) + .into(), + )); + }; + + let replace_and_transfer_transitions = document_transitions + .iter() + .filter(|transition| { + matches!( + transition, + DocumentTransition::Replace(_) + | DocumentTransition::Transfer(_) + | DocumentTransition::Purchase(_) + | DocumentTransition::UpdatePrice(_) + ) + }) + .copied() + .collect::>(); + + // We fetch documents only for replace and transfer transitions + // since we need them to create transition actions + // Below we also perform state validation for replace and transfer transitions only + // other transitions are validated in their validate_state functions + // TODO: Think more about this architecture + let fetched_documents_validation_result = + fetch_documents_for_transitions_knowing_contract_and_document_type( + platform.drive, + data_contract, + document_type, + replace_and_transfer_transitions.as_slice(), + transaction, + platform_version, + )?; + + if !fetched_documents_validation_result.is_valid() { + return Ok(ConsensusValidationResult::new_with_errors( + fetched_documents_validation_result.errors, + )); + } + + let replaced_documents = fetched_documents_validation_result.into_data()?; + + Ok(if !dry_run { + let document_transition_actions_validation_result = document_transitions + .iter() + .map(|transition| { + // we validate every transition in this document type + Self::transform_document_transition_v1( + platform.drive, + transaction, + validate_against_state, + block_info, + data_contract_fetch_info.clone(), + transition, + &replaced_documents, + user_fee_increase, + owner_id, + execution_context, + platform_version, + ) + }) + .collect::>, Error>>( + )?; + + // Issue #2867: strict variant returns data:None when no + // per-transition validation produced an action — propagates + // through to UnpaidConsensusError downstream. + let result = ConsensusValidationResult::merge_many_strict( + document_transition_actions_validation_result, + ); + + if !result.is_valid() { + return Ok(result); + } + result + } else { + ConsensusValidationResult::default() + }) + } + + /// The data contract can be of multiple difference versions + fn transform_token_transition_v1( + drive: &Drive, + transaction: TransactionArg, + block_info: &BlockInfo, + validate_against_state: bool, + data_contract_fetch_info: Arc, + transition: &TokenTransition, + owner_id: Identifier, + user_fee_increase: UserFeeIncrease, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let approximate_for_costs = !validate_against_state; + match transition { + TokenTransition::Burn(token_burn_transition) => { + let (batched_action, fee_result) = TokenBurnTransitionAction::try_from_borrowed_token_burn_transition_with_contract_lookup(drive, owner_id, token_burn_transition, approximate_for_costs, transaction, block_info,user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::Mint(token_mint_transition) => { + let (batched_action, fee_result) = TokenMintTransitionAction::try_from_borrowed_token_mint_transition_with_contract_lookup(drive, owner_id, token_mint_transition, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::Transfer(token_transfer_transition) => { + let (token_transfer_action, fee_result) = TokenTransferTransitionAction::try_from_borrowed_token_transfer_transition_with_contract_lookup(drive, owner_id, token_transfer_transition, approximate_for_costs, transaction, block_info, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + let batched_action = BatchedTransitionAction::TokenAction( + TokenTransitionAction::TransferAction(token_transfer_action), + ); + Ok(batched_action.into()) + } + TokenTransition::Freeze(token_freeze_transition) => { + let (batched_action, fee_result) = TokenFreezeTransitionAction::try_from_borrowed_token_freeze_transition_with_contract_lookup(drive, owner_id, token_freeze_transition, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::Unfreeze(token_unfreeze_transition) => { + let (batched_action, fee_result) = TokenUnfreezeTransitionAction::try_from_borrowed_token_unfreeze_transition_with_contract_lookup(drive, owner_id, token_unfreeze_transition, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::DestroyFrozenFunds(destroy_frozen_funds) => { + let (batched_action, fee_result) = TokenDestroyFrozenFundsTransitionAction::try_from_borrowed_token_destroy_frozen_funds_transition_with_contract_lookup(drive, owner_id, destroy_frozen_funds, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::EmergencyAction(emergency_action) => { + let (batched_action, fee_result) = TokenEmergencyActionTransitionAction::try_from_borrowed_token_emergency_action_transition_with_contract_lookup(drive, owner_id, emergency_action, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::ConfigUpdate(token_config_update) => { + let (batched_action, fee_result) = TokenConfigUpdateTransitionAction::try_from_borrowed_token_config_update_transition_with_contract_lookup(drive, owner_id, token_config_update, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::Claim(claim) => { + let (batched_action, fee_result) = TokenClaimTransitionAction::try_from_borrowed_token_claim_transition_with_contract_lookup(drive, owner_id, claim, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::DirectPurchase(direct_purchase) => { + let (batched_action, fee_result) = TokenDirectPurchaseTransitionAction::try_from_borrowed_token_direct_purchase_transition_with_contract_lookup(drive, owner_id, direct_purchase, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + TokenTransition::SetPriceForDirectPurchase(set_price_for_direct_purchase) => { + let (batched_action, fee_result) = TokenSetPriceForDirectPurchaseTransitionAction::try_from_borrowed_token_set_price_for_direct_purchase_transition_with_contract_lookup(drive, owner_id, set_price_for_direct_purchase, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + } + } + + /// The data contract can be of multiple difference versions + fn transform_document_transition_v1<'a>( + drive: &Drive, + transaction: TransactionArg, + validate_against_state: bool, + block_info: &BlockInfo, + data_contract_fetch_info: Arc, + transition: &DocumentTransition, + replaced_documents: &[Document], + user_fee_increase: UserFeeIncrease, + owner_id: Identifier, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match transition { + DocumentTransition::Create(document_create_transition) => { + let (document_create_action, fee_result) = DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( + drive, owner_id, transaction, + document_create_transition, block_info, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + }, platform_version)?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + Ok(document_create_action) + } + DocumentTransition::Replace(document_replace_transition) => { + let mut result = ConsensusValidationResult::::new(); + + let validation_result = + Self::find_replaced_document_v1(transition, replaced_documents); + + if !validation_result.is_valid_with_data() { + // We can set the user fee increase to 0 here because it is decided by the Documents Batch instead + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_replace_transition.base(), + owner_id, + 0, + ); + let batched_action = + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action); + + return Ok(ConsensusValidationResult::new_with_data_and_errors( + batched_action, + validation_result.errors, + )); + } + + let original_document = validation_result.into_data()?; + + let validation_result = Self::check_ownership_of_old_replaced_document_v1( + document_replace_transition.base().id(), + original_document, + &owner_id, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + + if validate_against_state { + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened + let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( + document_replace_transition.revision(), + document_replace_transition.base().id(), + original_document, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + } + + let (document_replace_action, fee_result) = + DocumentReplaceTransitionAction::try_from_borrowed_document_replace_transition( + document_replace_transition, + owner_id, + original_document, + block_info, + user_fee_increase, + |_identifier| Ok(data_contract_fetch_info.clone()), + )?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if result.is_valid() { + Ok(document_replace_action) + } else { + Ok(result) + } + } + DocumentTransition::Delete(document_delete_transition) => { + let (batched_action, fee_result) = DocumentDeleteTransitionAction::try_from_document_borrowed_delete_transition_with_contract_lookup(document_delete_transition, owner_id, user_fee_increase, |_identifier| { + Ok(data_contract_fetch_info.clone()) + })?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + Ok(batched_action) + } + DocumentTransition::Transfer(document_transfer_transition) => { + let mut result = ConsensusValidationResult::::new(); + + let validation_result = + Self::find_replaced_document_v1(transition, replaced_documents); + + if !validation_result.is_valid_with_data() { + result.merge(validation_result); + return Ok(result); + } + + let original_document = validation_result.into_data()?; + + let validation_result = Self::check_ownership_of_old_replaced_document_v1( + document_transfer_transition.base().id(), + original_document, + &owner_id, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + + if validate_against_state { + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened + let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( + document_transfer_transition.revision(), + document_transfer_transition.base().id(), + original_document, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + } + + let (document_transfer_action, fee_result) = + DocumentTransferTransitionAction::try_from_borrowed_document_transfer_transition( + document_transfer_transition, + owner_id, + original_document.clone(), //todo: remove clone + block_info, + user_fee_increase, + |_identifier| Ok(data_contract_fetch_info.clone()), + )?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if result.is_valid() { + Ok(document_transfer_action) + } else { + Ok(result) + } + } + DocumentTransition::UpdatePrice(document_update_price_transition) => { + let mut result = ConsensusValidationResult::::new(); + + let validation_result = + Self::find_replaced_document_v1(transition, replaced_documents); + + if !validation_result.is_valid_with_data() { + result.merge(validation_result); + return Ok(result); + } + + let original_document = validation_result.into_data()?; + + let validation_result = Self::check_ownership_of_old_replaced_document_v1( + document_update_price_transition.base().id(), + original_document, + &owner_id, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + + if validate_against_state { + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened + let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( + document_update_price_transition.revision(), + document_update_price_transition.base().id(), + original_document, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + } + + let (document_update_price_action, fee_result) = + DocumentUpdatePriceTransitionAction::try_from_borrowed_document_update_price_transition( + document_update_price_transition, + owner_id, + original_document.clone(), //todo: find a way to not have to use cloning + block_info, + user_fee_increase, + |_identifier| Ok(data_contract_fetch_info.clone()), + )?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if result.is_valid() { + Ok(document_update_price_action) + } else { + Ok(result) + } + } + DocumentTransition::Purchase(document_purchase_transition) => { + let mut result = ConsensusValidationResult::::new(); + + let validation_result = + Self::find_replaced_document_v1(transition, replaced_documents); + + if !validation_result.is_valid_with_data() { + result.merge(validation_result); + return Ok(result); + } + + let original_document = validation_result.into_data()?; + + let Some(listed_price) = original_document + .properties() + .get_optional_integer::(PRICE)? + else { + result.add_error(StateError::DocumentNotForSaleError( + DocumentNotForSaleError::new(original_document.id()), + )); + return Ok(result); + }; + + if listed_price != document_purchase_transition.price() { + result.add_error(StateError::DocumentIncorrectPurchasePriceError( + DocumentIncorrectPurchasePriceError::new( + original_document.id(), + document_purchase_transition.price(), + listed_price, + ), + )); + return Ok(result); + } + + if validate_against_state { + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened + let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( + document_purchase_transition.revision(), + document_purchase_transition.base().id(), + original_document, + ); + + if !validation_result.is_valid() { + result.merge(validation_result); + return Ok(result); + } + } + + let (document_purchase_action, fee_result) = + DocumentPurchaseTransitionAction::try_from_borrowed_document_purchase_transition( + document_purchase_transition, + owner_id, + original_document.clone(), //todo: find a way to not have to use cloning + owner_id, + block_info, + user_fee_increase, + |_identifier| Ok(data_contract_fetch_info.clone()), + )?; + + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); + + if result.is_valid() { + Ok(document_purchase_action) + } else { + Ok(result) + } + } + } + } + + fn find_replaced_document_v1<'a>( + document_transition: &'a DocumentTransition, + fetched_documents: &'a [Document], + ) -> ConsensusValidationResult<&'a Document> { + let maybe_fetched_document = fetched_documents + .iter() + .find(|d| d.id() == document_transition.base().id()); + + if let Some(document) = maybe_fetched_document { + ConsensusValidationResult::new_with_data(document) + } else { + ConsensusValidationResult::new_with_error(ConsensusError::StateError( + StateError::DocumentNotFoundError(DocumentNotFoundError::new( + document_transition.base().id(), + )), + )) + } + } + + fn check_ownership_of_old_replaced_document_v1( + document_id: Identifier, + fetched_document: &Document, + owner_id: &Identifier, + ) -> SimpleConsensusValidationResult { + let mut result = SimpleConsensusValidationResult::default(); + if fetched_document.owner_id() != owner_id { + result.add_error(ConsensusError::StateError( + StateError::DocumentOwnerIdMismatchError(DocumentOwnerIdMismatchError::new( + document_id, + owner_id.to_owned(), + fetched_document.owner_id(), + )), + )); + } + result + } + fn check_revision_is_bumped_by_one_during_replace_v1( + transition_revision: Revision, + document_id: Identifier, + original_document: &Document, + ) -> SimpleConsensusValidationResult { + let mut result = SimpleConsensusValidationResult::default(); + + // If there was no previous revision this means that the document_type is not update-able + // However this should have been caught earlier + let Some(previous_revision) = original_document.revision() else { + result.add_error(ConsensusError::StateError( + StateError::InvalidDocumentRevisionError(InvalidDocumentRevisionError::new( + document_id, + None, + transition_revision, + )), + )); + return result; + }; + // no need to check bounds here, because it would be impossible to hit the end on a u64 + let expected_revision = previous_revision + 1; + if transition_revision != expected_revision { + result.add_error(ConsensusError::StateError( + StateError::InvalidDocumentRevisionError(InvalidDocumentRevisionError::new( + document_id, + Some(previous_revision), + transition_revision, + )), + )) + } + result + } +} + +/// Public wrapper used by `BatchTransition::transform_into_action` dispatcher +/// when `platform_version.…batch_state_transition.transform_into_action == 1`. +/// Mirrors `transform_into_action_v0` in `batch/state/v0/mod.rs:323` but +/// routes through the v1 transformer, which uses +/// [`ConsensusValidationResult::flatten_strict`] / +/// [`ConsensusValidationResult::merge_many_strict`] in place of the +/// deprecated non-strict aggregators. This closes issue #2867's +/// "validating state transition for free" gap: when no per-transition +/// validation contributes an action, the result carries `data: None` and +/// downstream `process_validation_result_v0:241` routes to +/// `UnpaidConsensusError` (tx removed from block by prepare_proposal) +/// instead of synthesising a paid empty `BatchTransitionAction`. +pub(in crate::execution::validation::state_transition::state_transitions::batch) trait BatchTransitionActionTransformerV1 +{ + fn transform_into_action_v1( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + validation_mode: crate::execution::validation::state_transition::ValidationMode, + tx: TransactionArg, + ) -> Result< + ConsensusValidationResult, + Error, + >; +} + +impl BatchTransitionActionTransformerV1 for BatchTransition { + fn transform_into_action_v1( + &self, + platform: &PlatformStateRef, + block_info: &BlockInfo, + validation_mode: crate::execution::validation::state_transition::ValidationMode, + tx: TransactionArg, + ) -> Result< + ConsensusValidationResult, + Error, + > { + let platform_version = platform.state.current_platform_version()?; + + let mut execution_context = + ::default_for_platform_version(platform_version)?; + + let validation_result = self.try_into_action_v1( + platform, + block_info, + validation_mode.should_validate_batch_valid_against_state(), + tx, + &mut execution_context, + )?; + + Ok(validation_result.map(Into::into)) + } +} 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 db9c4505047..f9c324084c8 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 @@ -110,7 +110,13 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = advanced_structure: 0, state: 0, revision: 0, - transform_into_action: 0, + // Issue #2867: route to v1 of the transformer so empty-action + // failure paths become UnpaidConsensusError (tx removed from + // block) instead of being synthesised into a paid empty + // BatchTransitionAction by the merge_many/flatten Some(empty) + // shape. v0 stays for PROTOCOL_VERSION_11 so mainnet history + // is preserved bit-for-bit. + transform_into_action: 1, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { From 46cc656d0dce3bac49a291a5cd98e01b796f4ddd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 7 May 2026 22:24:20 +0700 Subject: [PATCH 03/24] =?UTF-8?q?refactor(dpp,drive-abci):=20rename=20stri?= =?UTF-8?q?ct=20aggregators=20to=20canonical;=20legacy=20=E2=86=92=20=5For?= =?UTF-8?q?=5Fempty=5Fvec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Followup on the previous commit's naming. The corrected aggregator behavior (returning data:None when no input contributed) is the intended semantic and gets the canonical names `merge_many` / `flatten`. The legacy `Some(empty_vec)`-on-no-data behavior is renamed to `merge_many_or_empty_vec` / `flatten_or_empty_vec` and kept in the codebase only for PROTOCOL_VERSION_11 chain reproducibility. This biases the codebase toward correctness: any future caller typing the canonical name gets the right semantics. The legacy variants are clearly documented as v11-compat only. - `validation_result.rs`: rename methods, swap doc comments, swap test assertions (the canonical-named tests now assert the corrected behavior; the `_or_empty_vec` tests pin the legacy v11 shape). - `transformer/v0/mod.rs`: 4 call sites migrated to `_or_empty_vec`. Remove the file-level `#![allow(deprecated)]` (nothing is deprecated anymore). - `fetch_documents.rs`: 1 call site migrated to `_or_empty_vec`. - `transformer/v1/mod.rs`: 4 call sites use canonical names. - `v8.rs`: comment updated to reference `_or_empty_vec`. - Doc comments updated throughout. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/validation/validation_result.rs | 287 +++++++++--------- .../batch/state/v0/fetch_documents.rs | 13 +- .../batch/transformer/v0/mod.rs | 25 +- .../batch/transformer/v1/mod.rs | 28 +- .../drive_abci_validation_versions/v8.rs | 9 +- 5 files changed, 178 insertions(+), 184 deletions(-) diff --git a/packages/rs-dpp/src/validation/validation_result.rs b/packages/rs-dpp/src/validation/validation_result.rs index e6f0283f5fe..a9f6c90a34a 100644 --- a/packages/rs-dpp/src/validation/validation_result.rs +++ b/packages/rs-dpp/src/validation/validation_result.rs @@ -34,22 +34,21 @@ impl Default for ValidationResult { } impl ValidationResult, E> { - /// **Deprecated.** Always returns `data: Some(Vec<...>)` — even if no - /// input contributed any data — which violates the implicit contract - /// `data.is_none() ⇔ no work done` that downstream `process_validation_result` - /// keys on. See issue #2867 (the empty-action / "validating state - /// transition for free" bug). Use [`flatten_strict`] instead, which - /// returns `data: None` when no input contributed data. + /// Aggregate a list of `ValidationResult, E>` into a single + /// result. Returns `data: None` when no input contributed any data + /// (i.e. every input was either `data: None` or `data: Some(empty_vec)`), + /// and `data: Some(merged_vec)` when at least one input contributed + /// non-empty data. /// - /// Preserved for `PROTOCOL_VERSION_11` and below — changing this - /// function's behavior would be a consensus-breaking change for the - /// existing chain history. + /// This honors the invariant `data.is_none() ⇔ no work done`, which + /// downstream code (e.g. `process_validation_result_v0:241`) relies on + /// to choose between `PaidConsensusError` and `UnpaidConsensusError`. /// - /// [`flatten_strict`]: ValidationResult::flatten_strict - #[deprecated( - since = "3.1.0", - note = "use flatten_strict; flatten always returns Some(empty_vec) which violates the data-is-None ⇔ no-work invariant — see issue #2867" - )] + /// For the legacy variant that always returns `data: Some(...)` even + /// when no input contributed data (preserved for `PROTOCOL_VERSION_11` + /// chain reproducibility), see [`flatten_or_empty_vec`]. + /// + /// [`flatten_or_empty_vec`]: ValidationResult::flatten_or_empty_vec pub fn flatten, E>>>( items: I, ) -> ValidationResult, E> { @@ -62,23 +61,29 @@ impl ValidationResult, E> { aggregate_data.append(&mut data); } }); - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + if aggregate_data.is_empty() { + ValidationResult { + errors: aggregate_errors, + data: None, + } + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } } - /// Strict variant of [`flatten`]: returns `data: None` when no input - /// contributed any data (i.e. every input was either `data: None` or - /// `data: Some(empty_vec)`), and only returns `data: Some(...)` when - /// the aggregate Vec is non-empty. + /// Legacy variant of [`flatten`] that always returns `data: Some(Vec<...>)` — + /// including `Some(empty_vec)` when no input contributed any data. /// - /// This restores the invariant that `data.is_none() ⇔ no work done`, - /// which downstream code (e.g. - /// `process_validation_result_v0:241`) relies on to choose between - /// `PaidConsensusError` and `UnpaidConsensusError`. Used by - /// `PROTOCOL_VERSION_12`+ to close the issue #2867 "validating state - /// transition for free" gap. + /// Preserved for `PROTOCOL_VERSION_11` and below: the + /// `Some(empty_vec)`-on-no-data behavior is part of the existing chain + /// history, and changing it would be a consensus-breaking change for + /// already-finalized blocks. New code should prefer [`flatten`], which + /// honors `data.is_none() ⇔ no work done`. + /// + /// See issue #2867 for context. /// /// [`flatten`]: ValidationResult::flatten - pub fn flatten_strict, E>>>( + pub fn flatten_or_empty_vec, E>>>( items: I, ) -> ValidationResult, E> { let mut aggregate_errors = vec![]; @@ -90,32 +95,24 @@ impl ValidationResult, E> { aggregate_data.append(&mut data); } }); - if aggregate_data.is_empty() { - ValidationResult { - errors: aggregate_errors, - data: None, - } - } else { - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) - } + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } } impl ValidationResult { - /// **Deprecated.** Always returns `data: Some(Vec<...>)` — even if no - /// input contributed any data — which violates the implicit contract - /// `data.is_none() ⇔ no work done`. See issue #2867. Use - /// [`merge_many_strict`] instead. + /// Aggregate a list of `ValidationResult` into a + /// `ValidationResult, E>`. Returns `data: None` when no + /// input had `data: Some(_)`, and `data: Some(Vec)` when at + /// least one input contributed data. + /// + /// This honors the invariant `data.is_none() ⇔ no work done` — see + /// [`flatten`] for context. /// - /// Preserved for `PROTOCOL_VERSION_11` and below — changing this - /// function's behavior would be a consensus-breaking change for the - /// existing chain history. + /// For the legacy variant that always returns `data: Some(...)` even + /// when no input contributed data, see [`merge_many_or_empty_vec`]. /// - /// [`merge_many_strict`]: ValidationResult::merge_many_strict - #[deprecated( - since = "3.1.0", - note = "use merge_many_strict; merge_many always returns Some(empty_vec) which violates the data-is-None ⇔ no-work invariant — see issue #2867" - )] + /// [`flatten`]: ValidationResult::flatten + /// [`merge_many_or_empty_vec`]: ValidationResult::merge_many_or_empty_vec pub fn merge_many>>( items: I, ) -> ValidationResult, E> { @@ -128,19 +125,29 @@ impl ValidationResult { aggregate_data.push(data); } }); - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + if aggregate_data.is_empty() { + ValidationResult { + errors: aggregate_errors, + data: None, + } + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } } - /// Strict variant of [`merge_many`]: returns `data: None` when no - /// input had `Some(data)`, and only returns `data: Some(Vec<...>)` - /// when at least one input contributed data. + /// Legacy variant of [`merge_many`] that always returns + /// `data: Some(Vec<...>)` — including `Some(empty_vec)` when no input + /// had `data: Some(_)`. /// - /// This restores the `data.is_none() ⇔ no work done` invariant — see - /// issue #2867. Used by `PROTOCOL_VERSION_12`+ to close the - /// "validating state transition for free" gap. + /// Preserved for `PROTOCOL_VERSION_11` and below: the + /// `Some(empty_vec)`-on-no-data behavior is part of the existing chain + /// history, and changing it would be a consensus-breaking change. + /// New code should prefer [`merge_many`]. + /// + /// See issue #2867 for context. /// /// [`merge_many`]: ValidationResult::merge_many - pub fn merge_many_strict>>( + pub fn merge_many_or_empty_vec>>( items: I, ) -> ValidationResult, E> { let mut aggregate_errors = vec![]; @@ -152,14 +159,7 @@ impl ValidationResult { aggregate_data.push(data); } }); - if aggregate_data.is_empty() { - ValidationResult { - errors: aggregate_errors, - data: None, - } - } else { - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) - } + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } } @@ -635,13 +635,10 @@ mod tests { assert_eq!(result.errors, vec!["bad".to_string()]); } - // -- flatten() (deprecated) -- - // These pin the historical buggy behavior preserved for - // PROTOCOL_VERSION_11 and below — issue #2867. + // -- flatten() (canonical, honors data.is_none() ⇔ no work done) -- #[test] - #[allow(deprecated)] - fn test_flatten_merges_data_and_errors() { + fn test_flatten_merges_non_empty_data() { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); let r2: ValidationResult, String> = ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); @@ -654,37 +651,57 @@ mod tests { } #[test] - #[allow(deprecated)] - fn test_flatten_empty_input() { + fn test_flatten_empty_input_returns_none_data() { let flat: ValidationResult, String> = ValidationResult::flatten(std::iter::empty()); - // Issue #2867 root cause: flatten produces Some(empty_vec) here, - // not None. Downstream code that checks `data.is_none()` is fooled - // into treating "no data" as "has data". - assert_eq!(flat.data, Some(vec![])); + assert_eq!(flat.data, None); assert!(flat.errors.is_empty()); } #[test] - #[allow(deprecated)] - fn test_flatten_all_inputs_no_data_returns_some_empty() { - // Pins the buggy v11 behavior: all inputs have data:None, but - // flatten still produces data:Some(vec![]). + fn test_flatten_all_inputs_no_data_returns_none() { + // When no input contributed data, return data:None — not + // Some(empty_vec). Downstream code + // (process_validation_result_v0:241) keys on data.is_none() to + // route to UnpaidConsensusError. let r1: ValidationResult, String> = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); let flat = ValidationResult::flatten(vec![r1, r2]); - assert_eq!(flat.data, Some(vec![])); + assert!(flat.data.is_none()); assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); } - // -- merge_many() (deprecated) -- + #[test] + fn test_flatten_some_empty_some_non_empty_returns_some() { + // Mixed input: one had data:Some(empty_vec), another had + // Some(non_empty). The aggregate is non-empty → data:Some(...). + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); + + let flat = ValidationResult::flatten(vec![r1, r2]); + assert_eq!(flat.data, Some(vec![42])); + assert!(flat.errors.is_empty()); + } #[test] - #[allow(deprecated)] - fn test_merge_many_collects_data_into_vec() { + fn test_flatten_all_some_empty_returns_none() { + // All inputs had data:Some(empty_vec). The aggregate Vec is + // empty → data:None. + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + + let flat = ValidationResult::flatten(vec![r1, r2]); + assert!(flat.data.is_none()); + assert!(flat.errors.is_empty()); + } + + // -- merge_many() (canonical) -- + + #[test] + fn test_merge_many_collects_non_empty_data() { let r1: ValidationResult = ValidationResult::new_with_data(1); let r2: ValidationResult = ValidationResult::new_with_data(2); let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); @@ -695,135 +712,103 @@ mod tests { } #[test] - #[allow(deprecated)] - fn test_merge_many_empty_input() { + fn test_merge_many_empty_input_returns_none_data() { let merged: ValidationResult, String> = ValidationResult::merge_many(std::iter::empty::>()); - // Same buggy shape: Some(empty_vec) instead of None. - assert_eq!(merged.data, Some(vec![])); + assert!(merged.data.is_none()); assert!(merged.errors.is_empty()); } #[test] - #[allow(deprecated)] - fn test_merge_many_all_inputs_no_data_returns_some_empty() { + fn test_merge_many_all_inputs_no_data_returns_none() { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); let merged = ValidationResult::merge_many(vec![r1, r2]); - assert_eq!(merged.data, Some(vec![])); + assert!(merged.data.is_none()); assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); } - // -- flatten_strict() (issue #2867 fix) -- - // PROTOCOL_VERSION_12+ uses these. They restore the - // `data.is_none() ⇔ no work done` invariant. + #[test] + fn test_merge_many_some_data_returns_some() { + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_data(7); + + let merged = ValidationResult::merge_many(vec![r1, r2]); + assert_eq!(merged.data, Some(vec![7])); + assert_eq!(merged.errors, vec!["e1".to_string()]); + } + + // -- flatten_or_empty_vec() / merge_many_or_empty_vec() -- + // These pin the legacy `Some(empty_vec)`-on-no-data behavior preserved + // for PROTOCOL_VERSION_11 and below. #[test] - fn test_flatten_strict_merges_non_empty_data() { + fn test_flatten_or_empty_vec_merges_data_and_errors() { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); let r2: ValidationResult, String> = ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); let r3: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = ValidationResult::flatten_strict(vec![r1, r2, r3]); + let flat = ValidationResult::flatten_or_empty_vec(vec![r1, r2, r3]); assert_eq!(flat.data, Some(vec![1, 2, 3])); assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); } #[test] - fn test_flatten_strict_empty_input_returns_none_data() { + fn test_flatten_or_empty_vec_empty_input_returns_some_empty() { let flat: ValidationResult, String> = - ValidationResult::flatten_strict(std::iter::empty()); - assert_eq!(flat.data, None); + ValidationResult::flatten_or_empty_vec(std::iter::empty()); + // Legacy v11 behavior: Some(empty_vec), not None. + assert_eq!(flat.data, Some(vec![])); assert!(flat.errors.is_empty()); } #[test] - fn test_flatten_strict_all_inputs_no_data_returns_none() { - // The whole point of strict: when no input contributed data, - // return data:None — not Some(empty_vec). Downstream code - // (process_validation_result_v0:241) keys on data.is_none(). + fn test_flatten_or_empty_vec_all_inputs_no_data_returns_some_empty() { let r1: ValidationResult, String> = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = ValidationResult::flatten_strict(vec![r1, r2]); - assert!(flat.data.is_none()); + let flat = ValidationResult::flatten_or_empty_vec(vec![r1, r2]); + assert_eq!(flat.data, Some(vec![])); assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); } #[test] - fn test_flatten_strict_some_empty_some_non_empty_returns_some() { - // Mixed input: one had data:Some(empty_vec), another had - // Some(non_empty). The aggregate is non-empty, so data:Some(...). - let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); - - let flat = ValidationResult::flatten_strict(vec![r1, r2]); - assert_eq!(flat.data, Some(vec![42])); - assert!(flat.errors.is_empty()); - } - - #[test] - fn test_flatten_strict_all_some_empty_returns_none() { - // All inputs had data:Some(empty_vec). The aggregate Vec is - // empty → data:None per the strict contract. - let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - - let flat = ValidationResult::flatten_strict(vec![r1, r2]); - assert!(flat.data.is_none()); - assert!(flat.errors.is_empty()); - } - - // -- merge_many_strict() (issue #2867 fix) -- - - #[test] - fn test_merge_many_strict_collects_non_empty_data() { + fn test_merge_many_or_empty_vec_collects_data_into_vec() { let r1: ValidationResult = ValidationResult::new_with_data(1); let r2: ValidationResult = ValidationResult::new_with_data(2); let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - let merged = ValidationResult::merge_many_strict(vec![r1, r2, r3]); + let merged = ValidationResult::merge_many_or_empty_vec(vec![r1, r2, r3]); assert_eq!(merged.data, Some(vec![1, 2])); assert_eq!(merged.errors, vec!["e".to_string()]); } #[test] - fn test_merge_many_strict_empty_input_returns_none_data() { - let merged: ValidationResult, String> = ValidationResult::merge_many_strict( - std::iter::empty::>(), - ); - assert!(merged.data.is_none()); + fn test_merge_many_or_empty_vec_empty_input_returns_some_empty() { + let merged: ValidationResult, String> = + ValidationResult::merge_many_or_empty_vec(std::iter::empty::< + ValidationResult, + >()); + // Legacy v11 behavior: Some(empty_vec), not None. + assert_eq!(merged.data, Some(vec![])); assert!(merged.errors.is_empty()); } #[test] - fn test_merge_many_strict_all_inputs_no_data_returns_none() { - // The bug-fixing case: all per-transition results returned - // errors-only with no action. Strict aggregator surfaces this - // as data:None so the downstream paid/unpaid switch picks unpaid. + fn test_merge_many_or_empty_vec_all_inputs_no_data_returns_some_empty() { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - let merged = ValidationResult::merge_many_strict(vec![r1, r2]); - assert!(merged.data.is_none()); + let merged = ValidationResult::merge_many_or_empty_vec(vec![r1, r2]); + assert_eq!(merged.data, Some(vec![])); assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); } - #[test] - fn test_merge_many_strict_some_data_returns_some() { - let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); - let r2: ValidationResult = ValidationResult::new_with_data(7); - - let merged = ValidationResult::merge_many_strict(vec![r1, r2]); - assert_eq!(merged.data, Some(vec![7])); - assert_eq!(merged.errors, vec!["e1".to_string()]); - } - // -- merge_many_errors() -- #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index f3df36d1aab..c1a6e9b9163 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -69,12 +69,13 @@ pub(crate) fn fetch_documents_for_transitions( }) .collect::>>, Error>>()?; - // The deprecated non-strict aggregator is fine here: the only caller - // checks `is_valid()` (errors), not `data.is_some()`, so the - // `Some(empty_vec)` vs `None` distinction is invisible to it. See - // issue #2867 for context on the strict aggregators. - #[allow(deprecated)] - let validation_result = ConsensusValidationResult::flatten(validation_results_of_documents); + // The legacy `_or_empty_vec` aggregator is used here because this + // helper is shared with v0 of the batch transformer (which preserves + // PROTOCOL_VERSION_11 chain history). The only caller checks + // `is_valid()` (errors), not `data.is_some()`, so the + // `Some(empty_vec)` vs `None` distinction is invisible to it. + let validation_result = + ConsensusValidationResult::flatten_or_empty_vec(validation_results_of_documents); Ok(validation_result) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index a9eadef7e6c..45fb2f704c9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -1,9 +1,11 @@ -// v0 uses the now-deprecated `ConsensusValidationResult::flatten` / -// `merge_many` aggregators. This is intentional: changing them to the -// strict variants would alter PROTOCOL_VERSION_11 (and earlier) chain -// behavior. v1 of this transformer (used by PROTOCOL_VERSION_12+) uses -// the strict variants. See issue #2867. -#![allow(deprecated)] +// v0 of the batch transformer uses the legacy +// `ConsensusValidationResult::flatten_or_empty_vec` / +// `merge_many_or_empty_vec` aggregators that always return +// `data: Some(Vec)` — including `Some(empty_vec)` when no input +// contributed any data. This is intentional and load-bearing for +// PROTOCOL_VERSION_11 chain reproducibility. v1 (used from +// PROTOCOL_VERSION_12 onwards) uses the canonical `flatten` / `merge_many` +// which return `data: None` in that case. See issue #2867. use std::collections::btree_map::Entry; use std::collections::BTreeMap; @@ -280,7 +282,7 @@ impl BatchTransitionTransformerV0 for BatchTransition { validation_results.append(&mut validation_result_tokens); - let validation_result = ConsensusValidationResult::flatten(validation_results); + let validation_result = ConsensusValidationResult::flatten_or_empty_vec(validation_results); if validation_result.has_data() { let (transitions, errors) = validation_result.into_data_and_errors()?; @@ -352,7 +354,8 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ) }) .collect::>, Error>>()?; - let validation_result = ConsensusValidationResult::merge_many(validation_result); + let validation_result = + ConsensusValidationResult::merge_many_or_empty_vec(validation_result); Ok(validation_result) } fn transform_document_transitions_within_contract_v0( @@ -406,7 +409,9 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { }) .collect::>>, Error>>( )?; - Ok(ConsensusValidationResult::flatten(validation_result)) + Ok(ConsensusValidationResult::flatten_or_empty_vec( + validation_result, + )) } fn transform_document_transitions_within_document_type_v0( @@ -500,7 +505,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { .collect::>, Error>>( )?; - let result = ConsensusValidationResult::merge_many( + let result = ConsensusValidationResult::merge_many_or_empty_vec( document_transition_actions_validation_result, ); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs index 2e3d9987266..b08c693bfd8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs @@ -277,7 +277,7 @@ impl BatchTransitionTransformerV1 for BatchTransition { // surfaces as data:None — the downstream // process_validation_result_v0:241 then routes to UnpaidConsensusError // instead of synthesising a paid empty BatchTransitionAction. - let validation_result = ConsensusValidationResult::flatten_strict(validation_results); + let validation_result = ConsensusValidationResult::flatten(validation_results); if validation_result.has_data() { let (transitions, errors) = validation_result.into_data_and_errors()?; @@ -351,7 +351,7 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { .collect::>, Error>>()?; // Issue #2867: strict variant returns data:None when no token // transition produced an action. - let validation_result = ConsensusValidationResult::merge_many_strict(validation_result); + let validation_result = ConsensusValidationResult::merge_many(validation_result); Ok(validation_result) } fn transform_document_transitions_within_contract_v1( @@ -406,7 +406,7 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { .collect::>>, Error>>( )?; // Issue #2867: strict variant. - Ok(ConsensusValidationResult::flatten_strict(validation_result)) + Ok(ConsensusValidationResult::flatten(validation_result)) } fn transform_document_transitions_within_document_type_v1( @@ -503,7 +503,7 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { // Issue #2867: strict variant returns data:None when no // per-transition validation produced an action — propagates // through to UnpaidConsensusError downstream. - let result = ConsensusValidationResult::merge_many_strict( + let result = ConsensusValidationResult::merge_many( document_transition_actions_validation_result, ); @@ -1018,15 +1018,17 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { /// Public wrapper used by `BatchTransition::transform_into_action` dispatcher /// when `platform_version.…batch_state_transition.transform_into_action == 1`. /// Mirrors `transform_into_action_v0` in `batch/state/v0/mod.rs:323` but -/// routes through the v1 transformer, which uses -/// [`ConsensusValidationResult::flatten_strict`] / -/// [`ConsensusValidationResult::merge_many_strict`] in place of the -/// deprecated non-strict aggregators. This closes issue #2867's -/// "validating state transition for free" gap: when no per-transition -/// validation contributes an action, the result carries `data: None` and -/// downstream `process_validation_result_v0:241` routes to -/// `UnpaidConsensusError` (tx removed from block by prepare_proposal) -/// instead of synthesising a paid empty `BatchTransitionAction`. +/// routes through the v1 transformer, which uses the canonical +/// [`ConsensusValidationResult::flatten`] / +/// [`ConsensusValidationResult::merge_many`] aggregators (returning +/// `data: None` when no input contributed). v0 keeps using the legacy +/// `_or_empty_vec` variants for PROTOCOL_VERSION_11 chain reproducibility. +/// +/// This closes issue #2867: when no per-transition validation contributes +/// an action, the result carries `data: None` and downstream +/// `process_validation_result_v0:241` routes to `UnpaidConsensusError` +/// (tx removed from block by `prepare_proposal:223`) instead of +/// synthesising a paid empty `BatchTransitionAction`. pub(in crate::execution::validation::state_transition::state_transitions::batch) trait BatchTransitionActionTransformerV1 { fn transform_into_action_v1( 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 f9c324084c8..936b0cd4051 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 @@ -112,10 +112,11 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = revision: 0, // Issue #2867: route to v1 of the transformer so empty-action // failure paths become UnpaidConsensusError (tx removed from - // block) instead of being synthesised into a paid empty - // BatchTransitionAction by the merge_many/flatten Some(empty) - // shape. v0 stays for PROTOCOL_VERSION_11 so mainnet history - // is preserved bit-for-bit. + // block by prepare_proposal) instead of being synthesised + // into a paid empty BatchTransitionAction by the legacy + // `_or_empty_vec` aggregators. v0 stays for + // PROTOCOL_VERSION_11 so mainnet history is preserved + // bit-for-bit. transform_into_action: 1, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, From ce5c375e22c459954a5240030eb6f1b76cd6a94a Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Thu, 7 May 2026 23:23:04 +0700 Subject: [PATCH 04/24] chore: deprecate _or_empty_vec aggregators, simplify None construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark `merge_many_or_empty_vec` and `flatten_or_empty_vec` with `#[deprecated]` so new code is steered toward the canonical `merge_many` / `flatten` (which honor `data.is_none() ⇔ no work done`). The legacy methods stay in the codebase strictly for PROTOCOL_VERSION_11 chain reproducibility — the deprecation note spells this out. - Suppress the warning at the legitimate v11-compat call sites: `#![allow(deprecated)]` at the top of `transformer/v0/mod.rs` (the whole v0 transformer is dedicated to v11), `#[allow(deprecated)]` on the single call in `fetch_documents.rs` and on each legacy- pinning test in `validation_result.rs`. - Replace the verbose `ValidationResult { errors, data: None }` struct literal in the canonical `flatten` / `merge_many` with the existing `new_with_errors` constructor. - Document the issue #2867 batch-transformer version bump in v8.rs alongside the existing basic_structure note. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/validation/validation_result.rs | 28 +++++++++++++------ .../batch/state/v0/fetch_documents.rs | 4 ++- .../batch/transformer/v0/mod.rs | 5 ++++ .../drive_abci_validation_versions/v8.rs | 8 ++++++ 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/rs-dpp/src/validation/validation_result.rs b/packages/rs-dpp/src/validation/validation_result.rs index a9f6c90a34a..1cfa3f869b3 100644 --- a/packages/rs-dpp/src/validation/validation_result.rs +++ b/packages/rs-dpp/src/validation/validation_result.rs @@ -62,10 +62,7 @@ impl ValidationResult, E> { } }); if aggregate_data.is_empty() { - ValidationResult { - errors: aggregate_errors, - data: None, - } + ValidationResult::new_with_errors(aggregate_errors) } else { ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } @@ -83,6 +80,10 @@ impl ValidationResult, E> { /// See issue #2867 for context. /// /// [`flatten`]: ValidationResult::flatten + #[deprecated( + since = "3.1.0", + note = "use `flatten` instead unless you specifically need PROTOCOL_VERSION_11 chain reproducibility (only the v0 batch transformer should need this) — issue #2867" + )] pub fn flatten_or_empty_vec, E>>>( items: I, ) -> ValidationResult, E> { @@ -126,10 +127,7 @@ impl ValidationResult { } }); if aggregate_data.is_empty() { - ValidationResult { - errors: aggregate_errors, - data: None, - } + ValidationResult::new_with_errors(aggregate_errors) } else { ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } @@ -147,6 +145,10 @@ impl ValidationResult { /// See issue #2867 for context. /// /// [`merge_many`]: ValidationResult::merge_many + #[deprecated( + since = "3.1.0", + note = "use `merge_many` instead unless you specifically need PROTOCOL_VERSION_11 chain reproducibility (only the v0 batch transformer should need this) — issue #2867" + )] pub fn merge_many_or_empty_vec>>( items: I, ) -> ValidationResult, E> { @@ -741,9 +743,12 @@ mod tests { // -- flatten_or_empty_vec() / merge_many_or_empty_vec() -- // These pin the legacy `Some(empty_vec)`-on-no-data behavior preserved - // for PROTOCOL_VERSION_11 and below. + // for PROTOCOL_VERSION_11 and below. Both methods are `#[deprecated]` + // to steer new code away from them; we suppress the warnings on the + // tests that intentionally exercise them. #[test] + #[allow(deprecated)] fn test_flatten_or_empty_vec_merges_data_and_errors() { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); let r2: ValidationResult, String> = @@ -757,6 +762,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_flatten_or_empty_vec_empty_input_returns_some_empty() { let flat: ValidationResult, String> = ValidationResult::flatten_or_empty_vec(std::iter::empty()); @@ -766,6 +772,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_flatten_or_empty_vec_all_inputs_no_data_returns_some_empty() { let r1: ValidationResult, String> = ValidationResult::new_with_error("e1".to_string()); @@ -778,6 +785,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_merge_many_or_empty_vec_collects_data_into_vec() { let r1: ValidationResult = ValidationResult::new_with_data(1); let r2: ValidationResult = ValidationResult::new_with_data(2); @@ -789,6 +797,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_merge_many_or_empty_vec_empty_input_returns_some_empty() { let merged: ValidationResult, String> = ValidationResult::merge_many_or_empty_vec(std::iter::empty::< @@ -800,6 +809,7 @@ mod tests { } #[test] + #[allow(deprecated)] fn test_merge_many_or_empty_vec_all_inputs_no_data_returns_some_empty() { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index c1a6e9b9163..fb524a24cac 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -73,7 +73,9 @@ pub(crate) fn fetch_documents_for_transitions( // helper is shared with v0 of the batch transformer (which preserves // PROTOCOL_VERSION_11 chain history). The only caller checks // `is_valid()` (errors), not `data.is_some()`, so the - // `Some(empty_vec)` vs `None` distinction is invisible to it. + // `Some(empty_vec)` vs `None` distinction is invisible to it. Issue + // #2867. + #[allow(deprecated)] let validation_result = ConsensusValidationResult::flatten_or_empty_vec(validation_results_of_documents); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 45fb2f704c9..f3632d83d67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -6,6 +6,11 @@ // PROTOCOL_VERSION_11 chain reproducibility. v1 (used from // PROTOCOL_VERSION_12 onwards) uses the canonical `flatten` / `merge_many` // which return `data: None` in that case. See issue #2867. +// +// Those legacy aggregators are `#[deprecated]` to steer new code away +// from them; we suppress the warnings here because v0 specifically +// needs them. +#![allow(deprecated)] use std::collections::btree_map::Entry; use std::collections::BTreeMap; 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 936b0cd4051..78a5c4b0f47 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 @@ -9,6 +9,14 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // Bump basic_structure to v1 for contract create and update state transitions. // v1 adds config min_version enforcement: since protocol version 12, V0 config is no longer // accepted because it lacks sized_integer_types support. +// +// Bump batch_state_transition.transform_into_action to v1 (issue #2867): +// v1 of the batch transformer uses the canonical `flatten` / `merge_many` +// aggregators which return `data: None` when no input contributed — +// closing the "validating state transition for free" gap where an +// all-failed Documents Batch was being recorded as PaidConsensusError +// with an empty action and the same exact bytes could be replayed +// across blocks. v0 stays for PROTOCOL_VERSION_11 chain reproducibility. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = DriveAbciValidationVersions { state_transitions: DriveAbciStateTransitionValidationVersions { From fdecf17dcea6fd84e62e7af8ae8a2392051b6039 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 00:01:13 +0700 Subject: [PATCH 05/24] fix(drive-abci): bump nonce of failed transitions in best-effort batches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the partial-failed-batch replay surface (issue #2867) without abandoning successful transitions. When a batch contains a mix of successful and failed per-transition results, the previous v1 design left the failed transitions' contract nonces unbumped, so identical bytes could be re-broadcast forever (CheckTx only sees committed state). Atomic-batch (drop everything) was an option but unnecessarily discards successful work. Approach: at the per-transition aggregation site in v1, after each per-transition handler returns, inject a `BumpIdentityDataContractNonce` action when the result is errors-only (data:None). This is a ~10-line change in two places (document and token aggregation branches) and centralises what would otherwise be 12+ inline bump emissions across the per-transition handlers. Why bumping only the failed transitions is sufficient: CheckTx's `validate_identity_contract_nonces_v0` iterates *every* transition's nonce against committed state and rejects on the first failure. Once the failing transition's nonce is consumed, re-broadcasting identical bytes fails CheckTx — even though the successful transitions' nonces remain unchanged. The user retries with a fresh nonce for the failed transition; the previously successful transitions stay untouched. Composes with the existing v1 strict aggregators (`flatten` / `merge_many`): after injection, every per-transition failure carries data:Some(bump), so the strict aggregators only return data:None in edge cases (empty batch). The all-failed case becomes `PaidConsensusError` with all bumps applied — closing the same replay loop that affected mainnet 35C0…313C. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../batch/transformer/v1/mod.rs | 60 +++++++++++++++---- 1 file changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs index b08c693bfd8..6c725467c87 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs @@ -335,7 +335,7 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { let validation_result = token_transitions .iter() .map(|token_transition| { - Self::transform_token_transition_v1( + let mut result = Self::transform_token_transition_v1( platform.drive, transaction, block_info, @@ -346,11 +346,31 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { user_fee_increase, execution_context, platform_version, - ) + )?; + // Issue #2867: if the per-transition handler failed without + // emitting any action (data:None + errors), inject a + // bump-only action so the transition's contract nonce + // always advances. Closes the replay surface where a + // failed transition's nonce was being left unbumped — the + // user could re-broadcast identical bytes indefinitely. + // CheckTx's per-transition nonce check rejects re-broadcasts + // once any transition's nonce is consumed, so this works + // even when only the failing transition is bumped. + if !result.is_valid() && result.data.is_none() { + let bump = + BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( + token_transition.base(), + owner_id, + 0, + ); + result = ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump), + std::mem::take(&mut result.errors), + ); + } + Ok(result) }) .collect::>, Error>>()?; - // Issue #2867: strict variant returns data:None when no token - // transition produced an action. let validation_result = ConsensusValidationResult::merge_many(validation_result); Ok(validation_result) } @@ -482,8 +502,7 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { let document_transition_actions_validation_result = document_transitions .iter() .map(|transition| { - // we validate every transition in this document type - Self::transform_document_transition_v1( + let mut result = Self::transform_document_transition_v1( platform.drive, transaction, validate_against_state, @@ -495,14 +514,35 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { owner_id, execution_context, platform_version, - ) + )?; + // Issue #2867: if the per-transition handler failed + // without emitting any action (data:None + errors), + // inject a bump-only action so the transition's + // contract nonce always advances. Closes the replay + // surface where a failed transition's nonce was being + // left unbumped — the user could re-broadcast identical + // bytes indefinitely. CheckTx's per-transition nonce + // check rejects re-broadcasts once any transition's + // nonce is consumed, so injecting a bump only on the + // failing transitions is sufficient (successful + // transitions' real actions advance their own nonces). + if !result.is_valid() && result.data.is_none() { + let bump = BumpIdentityDataContractNonceAction:: + from_borrowed_document_base_transition( + transition.base(), + owner_id, + 0, + ); + result = ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump), + std::mem::take(&mut result.errors), + ); + } + Ok(result) }) .collect::>, Error>>( )?; - // Issue #2867: strict variant returns data:None when no - // per-transition validation produced an action — propagates - // through to UnpaidConsensusError downstream. let result = ConsensusValidationResult::merge_many( document_transition_actions_validation_result, ); From 4c7aa6221ff411fce967f94fc841a4cb2a56dd51 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 00:09:33 +0700 Subject: [PATCH 06/24] Revert "fix(drive-abci): bump nonce of failed transitions in best-effort batches" This reverts commit fdecf17dcea6fd84e62e7af8ae8a2392051b6039. --- .../batch/transformer/v1/mod.rs | 60 ++++--------------- 1 file changed, 10 insertions(+), 50 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs index 6c725467c87..b08c693bfd8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs @@ -335,7 +335,7 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { let validation_result = token_transitions .iter() .map(|token_transition| { - let mut result = Self::transform_token_transition_v1( + Self::transform_token_transition_v1( platform.drive, transaction, block_info, @@ -346,31 +346,11 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { user_fee_increase, execution_context, platform_version, - )?; - // Issue #2867: if the per-transition handler failed without - // emitting any action (data:None + errors), inject a - // bump-only action so the transition's contract nonce - // always advances. Closes the replay surface where a - // failed transition's nonce was being left unbumped — the - // user could re-broadcast identical bytes indefinitely. - // CheckTx's per-transition nonce check rejects re-broadcasts - // once any transition's nonce is consumed, so this works - // even when only the failing transition is bumped. - if !result.is_valid() && result.data.is_none() { - let bump = - BumpIdentityDataContractNonceAction::from_borrowed_token_base_transition( - token_transition.base(), - owner_id, - 0, - ); - result = ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump), - std::mem::take(&mut result.errors), - ); - } - Ok(result) + ) }) .collect::>, Error>>()?; + // Issue #2867: strict variant returns data:None when no token + // transition produced an action. let validation_result = ConsensusValidationResult::merge_many(validation_result); Ok(validation_result) } @@ -502,7 +482,8 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { let document_transition_actions_validation_result = document_transitions .iter() .map(|transition| { - let mut result = Self::transform_document_transition_v1( + // we validate every transition in this document type + Self::transform_document_transition_v1( platform.drive, transaction, validate_against_state, @@ -514,35 +495,14 @@ impl BatchTransitionInternalTransformerV1 for BatchTransition { owner_id, execution_context, platform_version, - )?; - // Issue #2867: if the per-transition handler failed - // without emitting any action (data:None + errors), - // inject a bump-only action so the transition's - // contract nonce always advances. Closes the replay - // surface where a failed transition's nonce was being - // left unbumped — the user could re-broadcast identical - // bytes indefinitely. CheckTx's per-transition nonce - // check rejects re-broadcasts once any transition's - // nonce is consumed, so injecting a bump only on the - // failing transitions is sufficient (successful - // transitions' real actions advance their own nonces). - if !result.is_valid() && result.data.is_none() { - let bump = BumpIdentityDataContractNonceAction:: - from_borrowed_document_base_transition( - transition.base(), - owner_id, - 0, - ); - result = ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump), - std::mem::take(&mut result.errors), - ); - } - Ok(result) + ) }) .collect::>, Error>>( )?; + // Issue #2867: strict variant returns data:None when no + // per-transition validation produced an action — propagates + // through to UnpaidConsensusError downstream. let result = ConsensusValidationResult::merge_many( document_transition_actions_validation_result, ); From 99cc0ab4576186d4e1cde112128ca90138a27435 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 01:15:58 +0700 Subject: [PATCH 07/24] refactor(dpp,drive-abci): version flatten/merge_many instead of the batch transformer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback on PR #3616: rather than versioning the ~1100-line batch transformer to switch aggregator behavior at PROTOCOL_VERSION_12, version `ConsensusValidationResult::flatten` / `merge_many` themselves. The semantic change for issue #2867 is at the aggregator layer (return `data: None` when no input contributed any data, instead of the legacy `Some(empty_vec)`), so the version gate now lives where the actual behavior change is — the transformer is single-version and just calls the facade with `platform_version`. - New `validation_result/{mod.rs,v0.rs,v1.rs}` — facade dispatches via `platform_version.dpp.validation.validation_result.{flatten, merge_many}` - `DPP_VALIDATION_VERSIONS_V3` (PROTOCOL_VERSION_12) bumps both fields to 1 - `transformer/v1/mod.rs` deleted; `batch/mod.rs` dispatcher reverted - Legacy `_or_empty_vec` aggregators removed (only the v0 internal module retains that behavior, gated by platform_version) - Net: +414 / −743 vs the previous +1700 / −44 shape of this PR Also adds a TODO at `system_limits/v1.rs::max_transitions_in_documents_batch` (currently 1) noting the broader batch-pipeline issues that must be fixed before lifting the cap: non-atomic application of partial-success batches, and unclear nonce-bump semantics for mixed success/failure batches (see issue #2867 and PR #3608). --- .../mod.rs} | 272 ++--- .../src/validation/validation_result/v0.rs | 50 + .../src/validation/validation_result/v1.rs | 59 + .../state_transitions/batch/mod.rs | 4 +- .../batch/state/v0/fetch_documents.rs | 13 +- .../batch/transformer/mod.rs | 6 - .../batch/transformer/v0/mod.rs | 34 +- .../batch/transformer/v1/mod.rs | 1072 ----------------- .../dpp_validation_versions/mod.rs | 16 + .../dpp_validation_versions/v1.rs | 6 +- .../dpp_validation_versions/v2.rs | 6 +- .../dpp_validation_versions/v3.rs | 12 +- .../drive_abci_validation_versions/v8.rs | 25 +- .../src/version/system_limits/v1.rs | 13 + 14 files changed, 307 insertions(+), 1281 deletions(-) rename packages/rs-dpp/src/validation/{validation_result.rs => validation_result/mod.rs} (76%) create mode 100644 packages/rs-dpp/src/validation/validation_result/v0.rs create mode 100644 packages/rs-dpp/src/validation/validation_result/v1.rs delete mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs diff --git a/packages/rs-dpp/src/validation/validation_result.rs b/packages/rs-dpp/src/validation/validation_result/mod.rs similarity index 76% rename from packages/rs-dpp/src/validation/validation_result.rs rename to packages/rs-dpp/src/validation/validation_result/mod.rs index 1cfa3f869b3..a4801b985ee 100644 --- a/packages/rs-dpp/src/validation/validation_result.rs +++ b/packages/rs-dpp/src/validation/validation_result/mod.rs @@ -1,7 +1,11 @@ use crate::errors::consensus::ConsensusError; +use crate::version::PlatformVersion; use crate::ProtocolError; use std::fmt::Debug; +mod v0; +mod v1; + #[macro_export] macro_rules! check_validation_result_with_data { ($result:expr) => { @@ -35,134 +39,63 @@ impl Default for ValidationResult { impl ValidationResult, E> { /// Aggregate a list of `ValidationResult, E>` into a single - /// result. Returns `data: None` when no input contributed any data - /// (i.e. every input was either `data: None` or `data: Some(empty_vec)`), - /// and `data: Some(merged_vec)` when at least one input contributed - /// non-empty data. - /// - /// This honors the invariant `data.is_none() ⇔ no work done`, which - /// downstream code (e.g. `process_validation_result_v0:241`) relies on - /// to choose between `PaidConsensusError` and `UnpaidConsensusError`. + /// result. Dispatches to the version selected by `platform_version`: /// - /// For the legacy variant that always returns `data: Some(...)` even - /// when no input contributed data (preserved for `PROTOCOL_VERSION_11` - /// chain reproducibility), see [`flatten_or_empty_vec`]. + /// - **v0** (`PROTOCOL_VERSION_11` and below): always returns + /// `data: Some(Vec<...>)`, including `Some(empty_vec)` when no input + /// contributed any data. Preserved for chain reproducibility. + /// - **v1** (`PROTOCOL_VERSION_12`+): returns `data: None` when no input + /// contributed any data. Honors the invariant + /// `data.is_none() ⇔ no work done`, which downstream code (e.g. + /// `process_validation_result_v0:241`) relies on to choose between + /// `PaidConsensusError` and `UnpaidConsensusError`. /// - /// [`flatten_or_empty_vec`]: ValidationResult::flatten_or_empty_vec + /// See issue #2867 for context on the v0 → v1 change. pub fn flatten, E>>>( items: I, - ) -> ValidationResult, E> { - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(mut data) = data { - aggregate_data.append(&mut data); - } - }); - if aggregate_data.is_empty() { - ValidationResult::new_with_errors(aggregate_errors) - } else { - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + platform_version: &PlatformVersion, + ) -> Result, E>, ProtocolError> { + match platform_version.dpp.validation.validation_result.flatten { + 0 => Ok(v0::flatten(items)), + 1 => Ok(v1::flatten(items)), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ValidationResult::flatten".to_string(), + known_versions: vec![0, 1], + received: version, + }), } } - - /// Legacy variant of [`flatten`] that always returns `data: Some(Vec<...>)` — - /// including `Some(empty_vec)` when no input contributed any data. - /// - /// Preserved for `PROTOCOL_VERSION_11` and below: the - /// `Some(empty_vec)`-on-no-data behavior is part of the existing chain - /// history, and changing it would be a consensus-breaking change for - /// already-finalized blocks. New code should prefer [`flatten`], which - /// honors `data.is_none() ⇔ no work done`. - /// - /// See issue #2867 for context. - /// - /// [`flatten`]: ValidationResult::flatten - #[deprecated( - since = "3.1.0", - note = "use `flatten` instead unless you specifically need PROTOCOL_VERSION_11 chain reproducibility (only the v0 batch transformer should need this) — issue #2867" - )] - pub fn flatten_or_empty_vec, E>>>( - items: I, - ) -> ValidationResult, E> { - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(mut data) = data { - aggregate_data.append(&mut data); - } - }); - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) - } } impl ValidationResult { /// Aggregate a list of `ValidationResult` into a - /// `ValidationResult, E>`. Returns `data: None` when no - /// input had `data: Some(_)`, and `data: Some(Vec)` when at - /// least one input contributed data. + /// `ValidationResult, E>`. Dispatches to the version selected + /// by `platform_version`: /// - /// This honors the invariant `data.is_none() ⇔ no work done` — see - /// [`flatten`] for context. + /// - **v0** (`PROTOCOL_VERSION_11` and below): always returns + /// `data: Some(Vec<...>)`, including `Some(empty_vec)` when no input + /// contributed any data. Preserved for chain reproducibility. + /// - **v1** (`PROTOCOL_VERSION_12`+): returns `data: None` when no input + /// contributed any data. See [`flatten`] for the invariant this + /// restores. /// - /// For the legacy variant that always returns `data: Some(...)` even - /// when no input contributed data, see [`merge_many_or_empty_vec`]. + /// See issue #2867 for context on the v0 → v1 change. /// /// [`flatten`]: ValidationResult::flatten - /// [`merge_many_or_empty_vec`]: ValidationResult::merge_many_or_empty_vec pub fn merge_many>>( items: I, - ) -> ValidationResult, E> { - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(data) = data { - aggregate_data.push(data); - } - }); - if aggregate_data.is_empty() { - ValidationResult::new_with_errors(aggregate_errors) - } else { - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + platform_version: &PlatformVersion, + ) -> Result, E>, ProtocolError> { + match platform_version.dpp.validation.validation_result.merge_many { + 0 => Ok(v0::merge_many(items)), + 1 => Ok(v1::merge_many(items)), + version => Err(ProtocolError::UnknownVersionMismatch { + method: "ValidationResult::merge_many".to_string(), + known_versions: vec![0, 1], + received: version, + }), } } - - /// Legacy variant of [`merge_many`] that always returns - /// `data: Some(Vec<...>)` — including `Some(empty_vec)` when no input - /// had `data: Some(_)`. - /// - /// Preserved for `PROTOCOL_VERSION_11` and below: the - /// `Some(empty_vec)`-on-no-data behavior is part of the existing chain - /// history, and changing it would be a consensus-breaking change. - /// New code should prefer [`merge_many`]. - /// - /// See issue #2867 for context. - /// - /// [`merge_many`]: ValidationResult::merge_many - #[deprecated( - since = "3.1.0", - note = "use `merge_many` instead unless you specifically need PROTOCOL_VERSION_11 chain reproducibility (only the v0 batch transformer should need this) — issue #2867" - )] - pub fn merge_many_or_empty_vec>>( - items: I, - ) -> ValidationResult, E> { - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(data) = data { - aggregate_data.push(data); - } - }); - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) - } } impl SimpleValidationResult { @@ -637,31 +570,31 @@ mod tests { assert_eq!(result.errors, vec!["bad".to_string()]); } - // -- flatten() (canonical, honors data.is_none() ⇔ no work done) -- + // -- v1::flatten() (canonical, honors data.is_none() ⇔ no work done) -- #[test] - fn test_flatten_merges_non_empty_data() { + fn test_v1_flatten_merges_non_empty_data() { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); let r2: ValidationResult, String> = ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); let r3: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = ValidationResult::flatten(vec![r1, r2, r3]); + let flat = v1::flatten(vec![r1, r2, r3]); assert_eq!(flat.data, Some(vec![1, 2, 3])); assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); } #[test] - fn test_flatten_empty_input_returns_none_data() { + fn test_v1_flatten_empty_input_returns_none_data() { let flat: ValidationResult, String> = - ValidationResult::flatten(std::iter::empty()); + v1::flatten(std::iter::empty::, String>>()); assert_eq!(flat.data, None); assert!(flat.errors.is_empty()); } #[test] - fn test_flatten_all_inputs_no_data_returns_none() { + fn test_v1_flatten_all_inputs_no_data_returns_none() { // When no input contributed data, return data:None — not // Some(empty_vec). Downstream code // (process_validation_result_v0:241) keys on data.is_none() to @@ -671,154 +604,185 @@ mod tests { let r2: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = ValidationResult::flatten(vec![r1, r2]); + let flat = v1::flatten(vec![r1, r2]); assert!(flat.data.is_none()); assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); } #[test] - fn test_flatten_some_empty_some_non_empty_returns_some() { + fn test_v1_flatten_some_empty_some_non_empty_returns_some() { // Mixed input: one had data:Some(empty_vec), another had // Some(non_empty). The aggregate is non-empty → data:Some(...). let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); - let flat = ValidationResult::flatten(vec![r1, r2]); + let flat = v1::flatten(vec![r1, r2]); assert_eq!(flat.data, Some(vec![42])); assert!(flat.errors.is_empty()); } #[test] - fn test_flatten_all_some_empty_returns_none() { + fn test_v1_flatten_all_some_empty_returns_none() { // All inputs had data:Some(empty_vec). The aggregate Vec is // empty → data:None. let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - let flat = ValidationResult::flatten(vec![r1, r2]); + let flat = v1::flatten(vec![r1, r2]); assert!(flat.data.is_none()); assert!(flat.errors.is_empty()); } - // -- merge_many() (canonical) -- + // -- v1::merge_many() (canonical) -- #[test] - fn test_merge_many_collects_non_empty_data() { + fn test_v1_merge_many_collects_non_empty_data() { let r1: ValidationResult = ValidationResult::new_with_data(1); let r2: ValidationResult = ValidationResult::new_with_data(2); let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - let merged = ValidationResult::merge_many(vec![r1, r2, r3]); + let merged = v1::merge_many(vec![r1, r2, r3]); assert_eq!(merged.data, Some(vec![1, 2])); assert_eq!(merged.errors, vec!["e".to_string()]); } #[test] - fn test_merge_many_empty_input_returns_none_data() { + fn test_v1_merge_many_empty_input_returns_none_data() { let merged: ValidationResult, String> = - ValidationResult::merge_many(std::iter::empty::>()); + v1::merge_many(std::iter::empty::>()); assert!(merged.data.is_none()); assert!(merged.errors.is_empty()); } #[test] - fn test_merge_many_all_inputs_no_data_returns_none() { + fn test_v1_merge_many_all_inputs_no_data_returns_none() { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - let merged = ValidationResult::merge_many(vec![r1, r2]); + let merged = v1::merge_many(vec![r1, r2]); assert!(merged.data.is_none()); assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); } #[test] - fn test_merge_many_some_data_returns_some() { + fn test_v1_merge_many_some_data_returns_some() { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_data(7); - let merged = ValidationResult::merge_many(vec![r1, r2]); + let merged = v1::merge_many(vec![r1, r2]); assert_eq!(merged.data, Some(vec![7])); assert_eq!(merged.errors, vec!["e1".to_string()]); } - // -- flatten_or_empty_vec() / merge_many_or_empty_vec() -- + // -- v0::flatten() / v0::merge_many() -- // These pin the legacy `Some(empty_vec)`-on-no-data behavior preserved - // for PROTOCOL_VERSION_11 and below. Both methods are `#[deprecated]` - // to steer new code away from them; we suppress the warnings on the - // tests that intentionally exercise them. + // for PROTOCOL_VERSION_11 and below. #[test] - #[allow(deprecated)] - fn test_flatten_or_empty_vec_merges_data_and_errors() { + fn test_v0_flatten_merges_data_and_errors() { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); let r2: ValidationResult, String> = ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); let r3: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = ValidationResult::flatten_or_empty_vec(vec![r1, r2, r3]); + let flat = v0::flatten(vec![r1, r2, r3]); assert_eq!(flat.data, Some(vec![1, 2, 3])); assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); } #[test] - #[allow(deprecated)] - fn test_flatten_or_empty_vec_empty_input_returns_some_empty() { + fn test_v0_flatten_empty_input_returns_some_empty() { let flat: ValidationResult, String> = - ValidationResult::flatten_or_empty_vec(std::iter::empty()); + v0::flatten(std::iter::empty::, String>>()); // Legacy v11 behavior: Some(empty_vec), not None. assert_eq!(flat.data, Some(vec![])); assert!(flat.errors.is_empty()); } #[test] - #[allow(deprecated)] - fn test_flatten_or_empty_vec_all_inputs_no_data_returns_some_empty() { + fn test_v0_flatten_all_inputs_no_data_returns_some_empty() { let r1: ValidationResult, String> = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = ValidationResult::flatten_or_empty_vec(vec![r1, r2]); + let flat = v0::flatten(vec![r1, r2]); assert_eq!(flat.data, Some(vec![])); assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); } #[test] - #[allow(deprecated)] - fn test_merge_many_or_empty_vec_collects_data_into_vec() { + fn test_v0_merge_many_collects_data_into_vec() { let r1: ValidationResult = ValidationResult::new_with_data(1); let r2: ValidationResult = ValidationResult::new_with_data(2); let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - let merged = ValidationResult::merge_many_or_empty_vec(vec![r1, r2, r3]); + let merged = v0::merge_many(vec![r1, r2, r3]); assert_eq!(merged.data, Some(vec![1, 2])); assert_eq!(merged.errors, vec!["e".to_string()]); } #[test] - #[allow(deprecated)] - fn test_merge_many_or_empty_vec_empty_input_returns_some_empty() { + fn test_v0_merge_many_empty_input_returns_some_empty() { let merged: ValidationResult, String> = - ValidationResult::merge_many_or_empty_vec(std::iter::empty::< - ValidationResult, - >()); + v0::merge_many(std::iter::empty::>()); // Legacy v11 behavior: Some(empty_vec), not None. assert_eq!(merged.data, Some(vec![])); assert!(merged.errors.is_empty()); } #[test] - #[allow(deprecated)] - fn test_merge_many_or_empty_vec_all_inputs_no_data_returns_some_empty() { + fn test_v0_merge_many_all_inputs_no_data_returns_some_empty() { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - let merged = ValidationResult::merge_many_or_empty_vec(vec![r1, r2]); + let merged = v0::merge_many(vec![r1, r2]); assert_eq!(merged.data, Some(vec![])); assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); } + // -- facade dispatch (flatten / merge_many take platform_version) -- + // + // These verify the version field on PlatformVersion correctly steers the + // facade to v0 vs v1 semantics. + + #[test] + fn test_facade_flatten_v0_returns_some_empty_on_no_data() { + // PROTOCOL_VERSION_11 maps to dpp.validation.validation_result.flatten = 0 + let pv = PlatformVersion::get(11).expect("v11 exists"); + let r1: ValidationResult, ConsensusError> = + ValidationResult::new_with_errors(vec![]); + let flat = ValidationResult::flatten(vec![r1], pv).expect("dispatch ok"); + assert_eq!(flat.data, Some(vec![])); + } + + #[test] + fn test_facade_flatten_v1_returns_none_on_no_data() { + // PROTOCOL_VERSION_12 maps to dpp.validation.validation_result.flatten = 1 + let pv = PlatformVersion::get(12).expect("v12 exists"); + let r1: ValidationResult, ConsensusError> = + ValidationResult::new_with_errors(vec![]); + let flat = ValidationResult::flatten(vec![r1], pv).expect("dispatch ok"); + assert!(flat.data.is_none()); + } + + #[test] + fn test_facade_merge_many_v0_returns_some_empty_on_no_data() { + let pv = PlatformVersion::get(11).expect("v11 exists"); + let r1: ValidationResult = ValidationResult::new_with_errors(vec![]); + let merged = ValidationResult::merge_many(vec![r1], pv).expect("dispatch ok"); + assert_eq!(merged.data, Some(vec![])); + } + + #[test] + fn test_facade_merge_many_v1_returns_none_on_no_data() { + let pv = PlatformVersion::get(12).expect("v12 exists"); + let r1: ValidationResult = ValidationResult::new_with_errors(vec![]); + let merged = ValidationResult::merge_many(vec![r1], pv).expect("dispatch ok"); + assert!(merged.data.is_none()); + } + // -- merge_many_errors() -- #[test] diff --git a/packages/rs-dpp/src/validation/validation_result/v0.rs b/packages/rs-dpp/src/validation/validation_result/v0.rs new file mode 100644 index 00000000000..142f885fbe9 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/v0.rs @@ -0,0 +1,50 @@ +//! v0 of [`flatten`] / [`merge_many`]. +//! +//! Legacy aggregator semantics: always return `data: Some(Vec<...>)`, +//! including `Some(empty_vec)` when no input contributed any data. +//! +//! Preserved for `PROTOCOL_VERSION_11` and below — the +//! `Some(empty_vec)`-on-no-data behavior is part of the existing chain +//! history, and changing it would be a consensus-breaking change for +//! already-finalized blocks. New code should let the facade dispatch to v1. +//! +//! See issue #2867 for context. + +use super::ValidationResult; +use std::fmt::Debug; + +pub(super) fn flatten(items: I) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator, E>>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(mut data) = data { + aggregate_data.append(&mut data); + } + }); + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) +} + +pub(super) fn merge_many(items: I) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(data) = data { + aggregate_data.push(data); + } + }); + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) +} diff --git a/packages/rs-dpp/src/validation/validation_result/v1.rs b/packages/rs-dpp/src/validation/validation_result/v1.rs new file mode 100644 index 00000000000..dbee0aca41a --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/v1.rs @@ -0,0 +1,59 @@ +//! v1 of [`flatten`] / [`merge_many`]. +//! +//! Canonical aggregator semantics: return `data: None` when no input +//! contributed any data (i.e. every input was either `data: None` or +//! `data: Some(empty_vec)`), and `data: Some(merged_vec)` when at least one +//! input contributed non-empty data. +//! +//! This honors the invariant `data.is_none() ⇔ no work done`, which +//! downstream code (e.g. `process_validation_result_v0:241`) relies on to +//! choose between `PaidConsensusError` and `UnpaidConsensusError`. +//! +//! See issue #2867 for context. + +use super::ValidationResult; +use std::fmt::Debug; + +pub(super) fn flatten(items: I) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator, E>>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(mut data) = data { + aggregate_data.append(&mut data); + } + }); + if aggregate_data.is_empty() { + ValidationResult::new_with_errors(aggregate_errors) + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } +} + +pub(super) fn merge_many(items: I) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(data) = data { + aggregate_data.push(data); + } + }); + if aggregate_data.is_empty() { + ValidationResult::new_with_errors(aggregate_errors) + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs index 33d67bc9cef..8c0ba510d5e 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/mod.rs @@ -33,7 +33,6 @@ use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::batch::advanced_structure::v0::DocumentsBatchStateTransitionStructureValidationV0; use crate::execution::validation::state_transition::batch::identity_contract_nonce::v0::DocumentsBatchStateTransitionIdentityContractNonceV0; use crate::execution::validation::state_transition::batch::state::v0::DocumentsBatchStateTransitionStateValidationV0; -use crate::execution::validation::state_transition::batch::transformer::v1::BatchTransitionActionTransformerV1; use crate::execution::validation::state_transition::processor::advanced_structure_with_state::StateTransitionStructureKnownInStateValidationV0; use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; use crate::execution::validation::state_transition::processor::identity_nonces::StateTransitionIdentityNonceValidationV0; @@ -76,10 +75,9 @@ impl StateTransitionActionTransformer for BatchTransition { .transform_into_action { 0 => self.transform_into_action_v0(&platform.into(), block_info, validation_mode, tx), - 1 => self.transform_into_action_v1(&platform.into(), block_info, validation_mode, tx), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "documents batch transition: transform_into_action".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index fb524a24cac..a33d59d206a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -69,15 +69,10 @@ pub(crate) fn fetch_documents_for_transitions( }) .collect::>>, Error>>()?; - // The legacy `_or_empty_vec` aggregator is used here because this - // helper is shared with v0 of the batch transformer (which preserves - // PROTOCOL_VERSION_11 chain history). The only caller checks - // `is_valid()` (errors), not `data.is_some()`, so the - // `Some(empty_vec)` vs `None` distinction is invisible to it. Issue - // #2867. - #[allow(deprecated)] - let validation_result = - ConsensusValidationResult::flatten_or_empty_vec(validation_results_of_documents); + let validation_result = ConsensusValidationResult::flatten( + validation_results_of_documents, + platform_version, + )?; Ok(validation_result) } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs index 126c9e689cc..9a1925de7fc 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/mod.rs @@ -1,7 +1 @@ pub(crate) mod v0; -/// v1 of the batch transformer fixes issue #2867: when per-transition -/// validation produces no action, we should not synthesise an empty paid -/// action via merge_many/flatten — instead the transition becomes -/// UnpaidConsensusError so prepare_proposal removes it from the block. -/// v0 is preserved for older platform versions (≤ PROTOCOL_VERSION_11). -pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index f3632d83d67..b933610e423 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -1,16 +1,9 @@ -// v0 of the batch transformer uses the legacy -// `ConsensusValidationResult::flatten_or_empty_vec` / -// `merge_many_or_empty_vec` aggregators that always return -// `data: Some(Vec)` — including `Some(empty_vec)` when no input -// contributed any data. This is intentional and load-bearing for -// PROTOCOL_VERSION_11 chain reproducibility. v1 (used from -// PROTOCOL_VERSION_12 onwards) uses the canonical `flatten` / `merge_many` -// which return `data: None` in that case. See issue #2867. -// -// Those legacy aggregators are `#[deprecated]` to steer new code away -// from them; we suppress the warnings here because v0 specifically -// needs them. -#![allow(deprecated)] +// The aggregator helpers `ConsensusValidationResult::flatten` / +// `merge_many` are versioned via `dpp.validation.validation_result` on +// `PlatformVersion`. v0 (PROTOCOL_VERSION_11 and below) preserves the +// legacy `Some(empty_vec)`-on-no-data behavior for chain reproducibility; +// v1 (PROTOCOL_VERSION_12+) returns `data: None` in that case so an +// all-failed batch flows down the unpaid path. See issue #2867. use std::collections::btree_map::Entry; use std::collections::BTreeMap; @@ -287,7 +280,8 @@ impl BatchTransitionTransformerV0 for BatchTransition { validation_results.append(&mut validation_result_tokens); - let validation_result = ConsensusValidationResult::flatten_or_empty_vec(validation_results); + let validation_result = + ConsensusValidationResult::flatten(validation_results, platform_version)?; if validation_result.has_data() { let (transitions, errors) = validation_result.into_data_and_errors()?; @@ -360,7 +354,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { }) .collect::>, Error>>()?; let validation_result = - ConsensusValidationResult::merge_many_or_empty_vec(validation_result); + ConsensusValidationResult::merge_many(validation_result, platform_version)?; Ok(validation_result) } fn transform_document_transitions_within_contract_v0( @@ -414,9 +408,10 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { }) .collect::>>, Error>>( )?; - Ok(ConsensusValidationResult::flatten_or_empty_vec( + Ok(ConsensusValidationResult::flatten( validation_result, - )) + platform_version, + )?) } fn transform_document_transitions_within_document_type_v0( @@ -510,9 +505,10 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { .collect::>, Error>>( )?; - let result = ConsensusValidationResult::merge_many_or_empty_vec( + let result = ConsensusValidationResult::merge_many( document_transition_actions_validation_result, - ); + platform_version, + )?; if !result.is_valid() { return Ok(result); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs deleted file mode 100644 index b08c693bfd8..00000000000 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v1/mod.rs +++ /dev/null @@ -1,1072 +0,0 @@ -use std::collections::btree_map::Entry; -use std::collections::BTreeMap; -use std::sync::Arc; - -use crate::error::Error; -use crate::platform_types::platform::PlatformStateRef; -use dpp::consensus::basic::document::{DataContractNotPresentError, InvalidDocumentTypeError}; -use dpp::consensus::basic::BasicError; - -use dpp::consensus::state::document::document_not_found_error::DocumentNotFoundError; -use dpp::consensus::state::document::document_owner_id_mismatch_error::DocumentOwnerIdMismatchError; - -use dpp::consensus::state::document::invalid_document_revision_error::InvalidDocumentRevisionError; -use dpp::consensus::state::state_error::StateError; -use dpp::data_contract::accessors::v0::DataContractV0Getters; - -use dpp::block::block_info::BlockInfo; -use dpp::consensus::state::document::document_incorrect_purchase_price_error::DocumentIncorrectPurchasePriceError; -use dpp::consensus::state::document::document_not_for_sale_error::DocumentNotForSaleError; -use dpp::document::property_names::PRICE; -use dpp::document::{Document, DocumentV0Getters}; -use dpp::fee::Credits; -use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; -use dpp::prelude::{Revision, UserFeeIncrease}; -use dpp::validation::SimpleConsensusValidationResult; -use dpp::{consensus::ConsensusError, prelude::Identifier, validation::ConsensusValidationResult}; -use dpp::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; -use dpp::state_transition::batch_transition::batched_transition::BatchedTransitionRef; -use dpp::state_transition::batch_transition::BatchTransition; -use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; -use dpp::state_transition::batch_transition::batched_transition::document_purchase_transition::v0::v0_methods::DocumentPurchaseTransitionV0Methods; -use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionOwned}; -use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionAction; -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_replace_transition_action::DocumentReplaceTransitionAction; -use drive::state_transition_action::batch::BatchTransitionAction; -use drive::state_transition_action::batch::v0::BatchTransitionActionV0; - -use crate::execution::validation::state_transition::batch::state::v0::fetch_documents::fetch_documents_for_transitions_knowing_contract_and_document_type; -use dpp::version::PlatformVersion; -use drive::grovedb::TransactionArg; - -use dpp::state_transition::batch_transition::batched_transition::document_replace_transition::v0::v0_methods::DocumentReplaceTransitionV0Methods; -use dpp::state_transition::batch_transition::batched_transition::document_transfer_transition::v0::v0_methods::DocumentTransferTransitionV0Methods; -use dpp::state_transition::batch_transition::batched_transition::document_transition::{DocumentTransition, DocumentTransitionV0Methods}; -use dpp::state_transition::batch_transition::batched_transition::document_update_price_transition::v0::v0_methods::DocumentUpdatePriceTransitionV0Methods; -use dpp::state_transition::batch_transition::batched_transition::token_transition::{TokenTransition, TokenTransitionV0Methods}; -use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; -use dpp::state_transition::batch_transition::token_base_transition::v0::v0_methods::TokenBaseTransitionV0Methods; -use drive::drive::contract::DataContractFetchInfo; -use drive::drive::Drive; -use drive::state_transition_action::batch::batched_transition::BatchedTransitionAction; -use drive::state_transition_action::batch::batched_transition::document_transition::document_purchase_transition_action::DocumentPurchaseTransitionAction; -use drive::state_transition_action::batch::batched_transition::document_transition::document_transfer_transition_action::DocumentTransferTransitionAction; -use drive::state_transition_action::batch::batched_transition::document_transition::document_update_price_transition_action::DocumentUpdatePriceTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_burn_transition_action::TokenBurnTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_config_update_transition_action::TokenConfigUpdateTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_destroy_frozen_funds_transition_action::TokenDestroyFrozenFundsTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_emergency_action_transition_action::TokenEmergencyActionTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_freeze_transition_action::TokenFreezeTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_mint_transition_action::TokenMintTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_claim_transition_action::TokenClaimTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_direct_purchase_transition_action::TokenDirectPurchaseTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_set_price_for_direct_purchase_transition_action::TokenSetPriceForDirectPurchaseTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_transfer_transition_action::TokenTransferTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::token_unfreeze_transition_action::TokenUnfreezeTransitionAction; -use drive::state_transition_action::batch::batched_transition::token_transition::TokenTransitionAction; -use drive::state_transition_action::system::bump_identity_data_contract_nonce_action::BumpIdentityDataContractNonceAction; -use crate::execution::types::execution_operation::ValidationOperation; -use crate::execution::types::state_transition_execution_context::{StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0}; -use crate::platform_types::platform_state::PlatformStateV0Methods; - -pub(in crate::execution::validation::state_transition::state_transitions::batch) trait BatchTransitionTransformerV1 -{ - fn try_into_action_v1( - &self, - platform: &PlatformStateRef, - block_info: &BlockInfo, - full_validation: bool, - transaction: TransactionArg, - execution_context: &mut StateTransitionExecutionContext, - ) -> Result, Error>; -} - -trait BatchTransitionInternalTransformerV1 { - #[allow(clippy::too_many_arguments)] - fn transform_document_transitions_within_contract_v1( - platform: &PlatformStateRef, - block_info: &BlockInfo, - full_validation: bool, - data_contract_id: &Identifier, - owner_id: Identifier, - document_transitions: &BTreeMap<&String, Vec<&DocumentTransition>>, - user_fee_increase: UserFeeIncrease, - execution_context: &mut StateTransitionExecutionContext, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result>, Error>; - #[allow(clippy::too_many_arguments)] - fn transform_document_transitions_within_document_type_v1( - platform: &PlatformStateRef, - block_info: &BlockInfo, - full_validation: bool, - data_contract_fetch_info: Arc, - document_type_name: &str, - owner_id: Identifier, - document_transitions: &[&DocumentTransition], - user_fee_increase: UserFeeIncrease, - execution_context: &mut StateTransitionExecutionContext, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result>, Error>; - #[allow(clippy::too_many_arguments)] - fn transform_token_transitions_within_contract_v1( - platform: &PlatformStateRef, - data_contract_id: &Identifier, - block_info: &BlockInfo, - validate_against_state: bool, - owner_id: Identifier, - token_transitions: &[&TokenTransition], - user_fee_increase: UserFeeIncrease, - transaction: TransactionArg, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result>, Error>; - #[allow(clippy::too_many_arguments)] - /// Transfer token transition - fn transform_token_transition_v1( - drive: &Drive, - transaction: TransactionArg, - block_info: &BlockInfo, - validate_against_state: bool, - data_contract_fetch_info: Arc, - transition: &TokenTransition, - owner_id: Identifier, - user_fee_increase: UserFeeIncrease, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result, Error>; - /// The data contract can be of multiple difference versions - #[allow(clippy::too_many_arguments)] - fn transform_document_transition_v1( - drive: &Drive, - transaction: TransactionArg, - full_validation: bool, - block_info: &BlockInfo, - data_contract_fetch_info: Arc, - transition: &DocumentTransition, - replaced_documents: &[Document], - user_fee_increase: UserFeeIncrease, - owner_id: Identifier, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result, Error>; - fn find_replaced_document_v1<'a>( - document_transition: &'a DocumentTransition, - fetched_documents: &'a [Document], - ) -> ConsensusValidationResult<&'a Document>; - fn check_ownership_of_old_replaced_document_v1( - document_id: Identifier, - fetched_document: &Document, - owner_id: &Identifier, - ) -> SimpleConsensusValidationResult; - fn check_revision_is_bumped_by_one_during_replace_v1( - transition_revision: Revision, - document_id: Identifier, - original_document: &Document, - ) -> SimpleConsensusValidationResult; -} - -impl BatchTransitionTransformerV1 for BatchTransition { - fn try_into_action_v1( - &self, - platform: &PlatformStateRef, - block_info: &BlockInfo, - validate_against_state: bool, - transaction: TransactionArg, - execution_context: &mut StateTransitionExecutionContext, - ) -> Result, Error> { - let owner_id = self.owner_id(); - let user_fee_increase = self.user_fee_increase(); - let platform_version = platform.state.current_platform_version()?; - let mut document_transitions_by_contracts_and_types: BTreeMap< - &Identifier, - BTreeMap<&String, Vec<&DocumentTransition>>, - > = BTreeMap::new(); - - let mut token_transitions_by_contracts: BTreeMap<&Identifier, Vec<&TokenTransition>> = - BTreeMap::new(); - - // We want to validate by contract, and then for each document type within a contract - for transition in self.transitions_iter() { - match transition { - BatchedTransitionRef::Document(document_transition) => { - let document_type = document_transition.base().document_type_name(); - let data_contract_id = document_transition.base().data_contract_id_ref(); - - match document_transitions_by_contracts_and_types.entry(data_contract_id) { - Entry::Vacant(v) => { - v.insert(BTreeMap::from([(document_type, vec![document_transition])])); - } - Entry::Occupied(mut transitions_by_types_in_contract) => { - match transitions_by_types_in_contract - .get_mut() - .entry(document_type) - { - Entry::Vacant(v) => { - v.insert(vec![document_transition]); - } - Entry::Occupied(mut o) => o.get_mut().push(document_transition), - } - } - } - } - BatchedTransitionRef::Token(token_transition) => { - let data_contract_id = token_transition.base().data_contract_id_ref(); - - match token_transitions_by_contracts.entry(data_contract_id) { - Entry::Vacant(v) => { - v.insert(vec![token_transition]); - } - Entry::Occupied(mut transitions_by_tokens_in_contract) => { - transitions_by_tokens_in_contract - .get_mut() - .push(token_transition) - } - } - } - } - } - - let validation_result_documents = document_transitions_by_contracts_and_types - .iter() - .map( - |(data_contract_id, document_transitions_by_document_type)| { - Self::transform_document_transitions_within_contract_v1( - platform, - block_info, - validate_against_state, - data_contract_id, - owner_id, - document_transitions_by_document_type, - user_fee_increase, - execution_context, - transaction, - platform_version, - ) - }, - ) - .collect::>>, Error>>( - )?; - - let mut validation_result_tokens = token_transitions_by_contracts - .iter() - .map(|(data_contract_id, token_transitions)| { - Self::transform_token_transitions_within_contract_v1( - platform, - data_contract_id, - block_info, - validate_against_state, - owner_id, - token_transitions, - user_fee_increase, - transaction, - execution_context, - platform_version, - ) - }) - .collect::>>, Error>>( - )?; - - let mut validation_results = validation_result_documents; - - validation_results.append(&mut validation_result_tokens); - - // Issue #2867: use the strict aggregator so an all-failed batch - // surfaces as data:None — the downstream - // process_validation_result_v0:241 then routes to UnpaidConsensusError - // instead of synthesising a paid empty BatchTransitionAction. - let validation_result = ConsensusValidationResult::flatten(validation_results); - - if validation_result.has_data() { - let (transitions, errors) = validation_result.into_data_and_errors()?; - let batch_transition_action = BatchTransitionActionV0 { - owner_id, - transitions, - user_fee_increase, - } - .into(); - Ok(ConsensusValidationResult::new_with_data_and_errors( - batch_transition_action, - errors, - )) - } else { - Ok(ConsensusValidationResult::new_with_errors( - validation_result.errors, - )) - } - } -} - -impl BatchTransitionInternalTransformerV1 for BatchTransition { - fn transform_token_transitions_within_contract_v1( - platform: &PlatformStateRef, - data_contract_id: &Identifier, - block_info: &BlockInfo, - validate_against_state: bool, - owner_id: Identifier, - token_transitions: &[&TokenTransition], - user_fee_increase: UserFeeIncrease, - transaction: TransactionArg, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result>, Error> { - let drive = platform.drive; - // Data Contract must exist - let Some(data_contract_fetch_info) = drive - .get_contract_with_fetch_info_and_fee( - data_contract_id.to_buffer(), - None, - false, - transaction, - platform_version, - )? - .1 - else { - return Ok(ConsensusValidationResult::new_with_error( - BasicError::DataContractNotPresentError(DataContractNotPresentError::new( - *data_contract_id, - )) - .into(), - )); - }; - - let validation_result = token_transitions - .iter() - .map(|token_transition| { - Self::transform_token_transition_v1( - platform.drive, - transaction, - block_info, - validate_against_state, - data_contract_fetch_info.clone(), - token_transition, - owner_id, - user_fee_increase, - execution_context, - platform_version, - ) - }) - .collect::>, Error>>()?; - // Issue #2867: strict variant returns data:None when no token - // transition produced an action. - let validation_result = ConsensusValidationResult::merge_many(validation_result); - Ok(validation_result) - } - fn transform_document_transitions_within_contract_v1( - platform: &PlatformStateRef, - block_info: &BlockInfo, - validate_against_state: bool, - data_contract_id: &Identifier, - owner_id: Identifier, - document_transitions: &BTreeMap<&String, Vec<&DocumentTransition>>, - user_fee_increase: UserFeeIncrease, - execution_context: &mut StateTransitionExecutionContext, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result>, Error> { - let drive = platform.drive; - // Data Contract must exist - let Some(data_contract_fetch_info) = drive - .get_contract_with_fetch_info_and_fee( - data_contract_id.0 .0, - None, - false, - transaction, - platform_version, - )? - .1 - else { - return Ok(ConsensusValidationResult::new_with_error( - BasicError::DataContractNotPresentError(DataContractNotPresentError::new( - *data_contract_id, - )) - .into(), - )); - }; - - let validation_result = document_transitions - .iter() - .map(|(document_type_name, document_transitions)| { - Self::transform_document_transitions_within_document_type_v1( - platform, - block_info, - validate_against_state, - data_contract_fetch_info.clone(), - document_type_name, - owner_id, - document_transitions, - user_fee_increase, - execution_context, - transaction, - platform_version, - ) - }) - .collect::>>, Error>>( - )?; - // Issue #2867: strict variant. - Ok(ConsensusValidationResult::flatten(validation_result)) - } - - fn transform_document_transitions_within_document_type_v1( - platform: &PlatformStateRef, - block_info: &BlockInfo, - validate_against_state: bool, - data_contract_fetch_info: Arc, - document_type_name: &str, - owner_id: Identifier, - document_transitions: &[&DocumentTransition], - user_fee_increase: UserFeeIncrease, - execution_context: &mut StateTransitionExecutionContext, - transaction: TransactionArg, - platform_version: &PlatformVersion, - ) -> Result>, Error> { - // We use temporary execution context without dry run, - // because despite the dryRun, we need to get the - // data contract to proceed with following logic - // let tmp_execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version)?; - // - // execution_context.add_operations(tmp_execution_context.operations_slice()); - - let dry_run = false; //maybe reenable - - let data_contract = &data_contract_fetch_info.contract; - - let Some(document_type) = data_contract.document_type_optional_for_name(document_type_name) - else { - return Ok(ConsensusValidationResult::new_with_error( - InvalidDocumentTypeError::new(document_type_name.to_owned(), data_contract.id()) - .into(), - )); - }; - - let replace_and_transfer_transitions = document_transitions - .iter() - .filter(|transition| { - matches!( - transition, - DocumentTransition::Replace(_) - | DocumentTransition::Transfer(_) - | DocumentTransition::Purchase(_) - | DocumentTransition::UpdatePrice(_) - ) - }) - .copied() - .collect::>(); - - // We fetch documents only for replace and transfer transitions - // since we need them to create transition actions - // Below we also perform state validation for replace and transfer transitions only - // other transitions are validated in their validate_state functions - // TODO: Think more about this architecture - let fetched_documents_validation_result = - fetch_documents_for_transitions_knowing_contract_and_document_type( - platform.drive, - data_contract, - document_type, - replace_and_transfer_transitions.as_slice(), - transaction, - platform_version, - )?; - - if !fetched_documents_validation_result.is_valid() { - return Ok(ConsensusValidationResult::new_with_errors( - fetched_documents_validation_result.errors, - )); - } - - let replaced_documents = fetched_documents_validation_result.into_data()?; - - Ok(if !dry_run { - let document_transition_actions_validation_result = document_transitions - .iter() - .map(|transition| { - // we validate every transition in this document type - Self::transform_document_transition_v1( - platform.drive, - transaction, - validate_against_state, - block_info, - data_contract_fetch_info.clone(), - transition, - &replaced_documents, - user_fee_increase, - owner_id, - execution_context, - platform_version, - ) - }) - .collect::>, Error>>( - )?; - - // Issue #2867: strict variant returns data:None when no - // per-transition validation produced an action — propagates - // through to UnpaidConsensusError downstream. - let result = ConsensusValidationResult::merge_many( - document_transition_actions_validation_result, - ); - - if !result.is_valid() { - return Ok(result); - } - result - } else { - ConsensusValidationResult::default() - }) - } - - /// The data contract can be of multiple difference versions - fn transform_token_transition_v1( - drive: &Drive, - transaction: TransactionArg, - block_info: &BlockInfo, - validate_against_state: bool, - data_contract_fetch_info: Arc, - transition: &TokenTransition, - owner_id: Identifier, - user_fee_increase: UserFeeIncrease, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result, Error> { - let approximate_for_costs = !validate_against_state; - match transition { - TokenTransition::Burn(token_burn_transition) => { - let (batched_action, fee_result) = TokenBurnTransitionAction::try_from_borrowed_token_burn_transition_with_contract_lookup(drive, owner_id, token_burn_transition, approximate_for_costs, transaction, block_info,user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::Mint(token_mint_transition) => { - let (batched_action, fee_result) = TokenMintTransitionAction::try_from_borrowed_token_mint_transition_with_contract_lookup(drive, owner_id, token_mint_transition, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::Transfer(token_transfer_transition) => { - let (token_transfer_action, fee_result) = TokenTransferTransitionAction::try_from_borrowed_token_transfer_transition_with_contract_lookup(drive, owner_id, token_transfer_transition, approximate_for_costs, transaction, block_info, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - let batched_action = BatchedTransitionAction::TokenAction( - TokenTransitionAction::TransferAction(token_transfer_action), - ); - Ok(batched_action.into()) - } - TokenTransition::Freeze(token_freeze_transition) => { - let (batched_action, fee_result) = TokenFreezeTransitionAction::try_from_borrowed_token_freeze_transition_with_contract_lookup(drive, owner_id, token_freeze_transition, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::Unfreeze(token_unfreeze_transition) => { - let (batched_action, fee_result) = TokenUnfreezeTransitionAction::try_from_borrowed_token_unfreeze_transition_with_contract_lookup(drive, owner_id, token_unfreeze_transition, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::DestroyFrozenFunds(destroy_frozen_funds) => { - let (batched_action, fee_result) = TokenDestroyFrozenFundsTransitionAction::try_from_borrowed_token_destroy_frozen_funds_transition_with_contract_lookup(drive, owner_id, destroy_frozen_funds, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::EmergencyAction(emergency_action) => { - let (batched_action, fee_result) = TokenEmergencyActionTransitionAction::try_from_borrowed_token_emergency_action_transition_with_contract_lookup(drive, owner_id, emergency_action, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::ConfigUpdate(token_config_update) => { - let (batched_action, fee_result) = TokenConfigUpdateTransitionAction::try_from_borrowed_token_config_update_transition_with_contract_lookup(drive, owner_id, token_config_update, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::Claim(claim) => { - let (batched_action, fee_result) = TokenClaimTransitionAction::try_from_borrowed_token_claim_transition_with_contract_lookup(drive, owner_id, claim, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::DirectPurchase(direct_purchase) => { - let (batched_action, fee_result) = TokenDirectPurchaseTransitionAction::try_from_borrowed_token_direct_purchase_transition_with_contract_lookup(drive, owner_id, direct_purchase, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - TokenTransition::SetPriceForDirectPurchase(set_price_for_direct_purchase) => { - let (batched_action, fee_result) = TokenSetPriceForDirectPurchaseTransitionAction::try_from_borrowed_token_set_price_for_direct_purchase_transition_with_contract_lookup(drive, owner_id, set_price_for_direct_purchase, approximate_for_costs, transaction, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - } - } - - /// The data contract can be of multiple difference versions - fn transform_document_transition_v1<'a>( - drive: &Drive, - transaction: TransactionArg, - validate_against_state: bool, - block_info: &BlockInfo, - data_contract_fetch_info: Arc, - transition: &DocumentTransition, - replaced_documents: &[Document], - user_fee_increase: UserFeeIncrease, - owner_id: Identifier, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result, Error> { - match transition { - DocumentTransition::Create(document_create_transition) => { - let (document_create_action, fee_result) = DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( - drive, owner_id, transaction, - document_create_transition, block_info, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - }, platform_version)?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - Ok(document_create_action) - } - DocumentTransition::Replace(document_replace_transition) => { - let mut result = ConsensusValidationResult::::new(); - - let validation_result = - Self::find_replaced_document_v1(transition, replaced_documents); - - if !validation_result.is_valid_with_data() { - // We can set the user fee increase to 0 here because it is decided by the Documents Batch instead - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_replace_transition.base(), - owner_id, - 0, - ); - let batched_action = - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action); - - return Ok(ConsensusValidationResult::new_with_data_and_errors( - batched_action, - validation_result.errors, - )); - } - - let original_document = validation_result.into_data()?; - - let validation_result = Self::check_ownership_of_old_replaced_document_v1( - document_replace_transition.base().id(), - original_document, - &owner_id, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - - if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened - let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( - document_replace_transition.revision(), - document_replace_transition.base().id(), - original_document, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - } - - let (document_replace_action, fee_result) = - DocumentReplaceTransitionAction::try_from_borrowed_document_replace_transition( - document_replace_transition, - owner_id, - original_document, - block_info, - user_fee_increase, - |_identifier| Ok(data_contract_fetch_info.clone()), - )?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - if result.is_valid() { - Ok(document_replace_action) - } else { - Ok(result) - } - } - DocumentTransition::Delete(document_delete_transition) => { - let (batched_action, fee_result) = DocumentDeleteTransitionAction::try_from_document_borrowed_delete_transition_with_contract_lookup(document_delete_transition, owner_id, user_fee_increase, |_identifier| { - Ok(data_contract_fetch_info.clone()) - })?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - Ok(batched_action) - } - DocumentTransition::Transfer(document_transfer_transition) => { - let mut result = ConsensusValidationResult::::new(); - - let validation_result = - Self::find_replaced_document_v1(transition, replaced_documents); - - if !validation_result.is_valid_with_data() { - result.merge(validation_result); - return Ok(result); - } - - let original_document = validation_result.into_data()?; - - let validation_result = Self::check_ownership_of_old_replaced_document_v1( - document_transfer_transition.base().id(), - original_document, - &owner_id, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - - if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened - let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( - document_transfer_transition.revision(), - document_transfer_transition.base().id(), - original_document, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - } - - let (document_transfer_action, fee_result) = - DocumentTransferTransitionAction::try_from_borrowed_document_transfer_transition( - document_transfer_transition, - owner_id, - original_document.clone(), //todo: remove clone - block_info, - user_fee_increase, - |_identifier| Ok(data_contract_fetch_info.clone()), - )?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - if result.is_valid() { - Ok(document_transfer_action) - } else { - Ok(result) - } - } - DocumentTransition::UpdatePrice(document_update_price_transition) => { - let mut result = ConsensusValidationResult::::new(); - - let validation_result = - Self::find_replaced_document_v1(transition, replaced_documents); - - if !validation_result.is_valid_with_data() { - result.merge(validation_result); - return Ok(result); - } - - let original_document = validation_result.into_data()?; - - let validation_result = Self::check_ownership_of_old_replaced_document_v1( - document_update_price_transition.base().id(), - original_document, - &owner_id, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - - if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened - let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( - document_update_price_transition.revision(), - document_update_price_transition.base().id(), - original_document, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - } - - let (document_update_price_action, fee_result) = - DocumentUpdatePriceTransitionAction::try_from_borrowed_document_update_price_transition( - document_update_price_transition, - owner_id, - original_document.clone(), //todo: find a way to not have to use cloning - block_info, - user_fee_increase, - |_identifier| Ok(data_contract_fetch_info.clone()), - )?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - if result.is_valid() { - Ok(document_update_price_action) - } else { - Ok(result) - } - } - DocumentTransition::Purchase(document_purchase_transition) => { - let mut result = ConsensusValidationResult::::new(); - - let validation_result = - Self::find_replaced_document_v1(transition, replaced_documents); - - if !validation_result.is_valid_with_data() { - result.merge(validation_result); - return Ok(result); - } - - let original_document = validation_result.into_data()?; - - let Some(listed_price) = original_document - .properties() - .get_optional_integer::(PRICE)? - else { - result.add_error(StateError::DocumentNotForSaleError( - DocumentNotForSaleError::new(original_document.id()), - )); - return Ok(result); - }; - - if listed_price != document_purchase_transition.price() { - result.add_error(StateError::DocumentIncorrectPurchasePriceError( - DocumentIncorrectPurchasePriceError::new( - original_document.id(), - document_purchase_transition.price(), - listed_price, - ), - )); - return Ok(result); - } - - if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened - let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v1( - document_purchase_transition.revision(), - document_purchase_transition.base().id(), - original_document, - ); - - if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); - } - } - - let (document_purchase_action, fee_result) = - DocumentPurchaseTransitionAction::try_from_borrowed_document_purchase_transition( - document_purchase_transition, - owner_id, - original_document.clone(), //todo: find a way to not have to use cloning - owner_id, - block_info, - user_fee_increase, - |_identifier| Ok(data_contract_fetch_info.clone()), - )?; - - execution_context - .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - - if result.is_valid() { - Ok(document_purchase_action) - } else { - Ok(result) - } - } - } - } - - fn find_replaced_document_v1<'a>( - document_transition: &'a DocumentTransition, - fetched_documents: &'a [Document], - ) -> ConsensusValidationResult<&'a Document> { - let maybe_fetched_document = fetched_documents - .iter() - .find(|d| d.id() == document_transition.base().id()); - - if let Some(document) = maybe_fetched_document { - ConsensusValidationResult::new_with_data(document) - } else { - ConsensusValidationResult::new_with_error(ConsensusError::StateError( - StateError::DocumentNotFoundError(DocumentNotFoundError::new( - document_transition.base().id(), - )), - )) - } - } - - fn check_ownership_of_old_replaced_document_v1( - document_id: Identifier, - fetched_document: &Document, - owner_id: &Identifier, - ) -> SimpleConsensusValidationResult { - let mut result = SimpleConsensusValidationResult::default(); - if fetched_document.owner_id() != owner_id { - result.add_error(ConsensusError::StateError( - StateError::DocumentOwnerIdMismatchError(DocumentOwnerIdMismatchError::new( - document_id, - owner_id.to_owned(), - fetched_document.owner_id(), - )), - )); - } - result - } - fn check_revision_is_bumped_by_one_during_replace_v1( - transition_revision: Revision, - document_id: Identifier, - original_document: &Document, - ) -> SimpleConsensusValidationResult { - let mut result = SimpleConsensusValidationResult::default(); - - // If there was no previous revision this means that the document_type is not update-able - // However this should have been caught earlier - let Some(previous_revision) = original_document.revision() else { - result.add_error(ConsensusError::StateError( - StateError::InvalidDocumentRevisionError(InvalidDocumentRevisionError::new( - document_id, - None, - transition_revision, - )), - )); - return result; - }; - // no need to check bounds here, because it would be impossible to hit the end on a u64 - let expected_revision = previous_revision + 1; - if transition_revision != expected_revision { - result.add_error(ConsensusError::StateError( - StateError::InvalidDocumentRevisionError(InvalidDocumentRevisionError::new( - document_id, - Some(previous_revision), - transition_revision, - )), - )) - } - result - } -} - -/// Public wrapper used by `BatchTransition::transform_into_action` dispatcher -/// when `platform_version.…batch_state_transition.transform_into_action == 1`. -/// Mirrors `transform_into_action_v0` in `batch/state/v0/mod.rs:323` but -/// routes through the v1 transformer, which uses the canonical -/// [`ConsensusValidationResult::flatten`] / -/// [`ConsensusValidationResult::merge_many`] aggregators (returning -/// `data: None` when no input contributed). v0 keeps using the legacy -/// `_or_empty_vec` variants for PROTOCOL_VERSION_11 chain reproducibility. -/// -/// This closes issue #2867: when no per-transition validation contributes -/// an action, the result carries `data: None` and downstream -/// `process_validation_result_v0:241` routes to `UnpaidConsensusError` -/// (tx removed from block by `prepare_proposal:223`) instead of -/// synthesising a paid empty `BatchTransitionAction`. -pub(in crate::execution::validation::state_transition::state_transitions::batch) trait BatchTransitionActionTransformerV1 -{ - fn transform_into_action_v1( - &self, - platform: &PlatformStateRef, - block_info: &BlockInfo, - validation_mode: crate::execution::validation::state_transition::ValidationMode, - tx: TransactionArg, - ) -> Result< - ConsensusValidationResult, - Error, - >; -} - -impl BatchTransitionActionTransformerV1 for BatchTransition { - fn transform_into_action_v1( - &self, - platform: &PlatformStateRef, - block_info: &BlockInfo, - validation_mode: crate::execution::validation::state_transition::ValidationMode, - tx: TransactionArg, - ) -> Result< - ConsensusValidationResult, - Error, - > { - let platform_version = platform.state.current_platform_version()?; - - let mut execution_context = - ::default_for_platform_version(platform_version)?; - - let validation_result = self.try_into_action_v1( - platform, - block_info, - validation_mode.should_validate_batch_valid_against_state(), - tx, - &mut execution_context, - )?; - - Ok(validation_result.map(Into::into)) - } -} diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs index d77c94722e3..a8572c27be2 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/mod.rs @@ -10,6 +10,22 @@ pub struct DPPValidationVersions { pub data_contract: DataContractValidationVersions, pub document_type: DocumentTypeValidationVersions, pub voting: VotingValidationVersions, + pub validation_result: ValidationResultMethodVersions, +} + +/// Versions of the aggregator methods on +/// [`crate::validation::ValidationResult`] (`flatten`, `merge_many`). +/// +/// Issue #2867: in v0 the aggregators returned `Some(empty_vec)` when no +/// per-item input contributed any data, which caused +/// `validating-state-transition-for-free` — empty-action batches were treated +/// as paid (and stayed in the block) instead of unpaid (removed in +/// prepare_proposal). v1 returns `None` in that case so the result correctly +/// flows down the unpaid path. +#[derive(Clone, Debug, Default)] +pub struct ValidationResultMethodVersions { + pub flatten: FeatureVersion, + pub merge_many: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs index 61e85ffea12..093687b24ad 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v1.rs @@ -1,6 +1,6 @@ use crate::version::dpp_versions::dpp_validation_versions::{ DPPValidationVersions, DataContractValidationVersions, DocumentTypeValidationVersions, - JsonSchemaValidatorVersions, VotingValidationVersions, + JsonSchemaValidatorVersions, ValidationResultMethodVersions, VotingValidationVersions, }; pub const DPP_VALIDATION_VERSIONS_V1: DPPValidationVersions = DPPValidationVersions { @@ -31,4 +31,8 @@ pub const DPP_VALIDATION_VERSIONS_V1: DPPValidationVersions = DPPValidationVersi allow_other_contenders_time_testing_ms: 604_800_000, // 1 week in ms for v1 (changes in v2) votes_allowed_per_masternode: 5, }, + validation_result: ValidationResultMethodVersions { + flatten: 0, + merge_many: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs index ad370c2a999..1e795f018a7 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v2.rs @@ -1,6 +1,6 @@ use crate::version::dpp_versions::dpp_validation_versions::{ DPPValidationVersions, DataContractValidationVersions, DocumentTypeValidationVersions, - JsonSchemaValidatorVersions, VotingValidationVersions, + JsonSchemaValidatorVersions, ValidationResultMethodVersions, VotingValidationVersions, }; pub const DPP_VALIDATION_VERSIONS_V2: DPPValidationVersions = DPPValidationVersions { @@ -31,4 +31,8 @@ pub const DPP_VALIDATION_VERSIONS_V2: DPPValidationVersions = DPPValidationVersi allow_other_contenders_time_testing_ms: 2_700_000, //45 minutes votes_allowed_per_masternode: 5, }, + validation_result: ValidationResultMethodVersions { + flatten: 0, + merge_many: 0, + }, }; diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs index e5621fd5851..d2d4795d6fe 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_validation_versions/v3.rs @@ -1,6 +1,6 @@ use crate::version::dpp_versions::dpp_validation_versions::{ DPPValidationVersions, DataContractValidationVersions, DocumentTypeValidationVersions, - JsonSchemaValidatorVersions, VotingValidationVersions, + JsonSchemaValidatorVersions, ValidationResultMethodVersions, VotingValidationVersions, }; pub const DPP_VALIDATION_VERSIONS_V3: DPPValidationVersions = DPPValidationVersions { @@ -32,4 +32,14 @@ pub const DPP_VALIDATION_VERSIONS_V3: DPPValidationVersions = DPPValidationVersi allow_other_contenders_time_testing_ms: 2_700_000, //45 minutes votes_allowed_per_masternode: 5, }, + // Issue #2867: bump aggregator methods to v1 — `flatten` / `merge_many` + // now return `data: None` when no input contributed any data, instead of + // the legacy `Some(empty_vec)`. Closes the + // "validating-state-transition-for-free" gap where an all-failed + // documents batch was being recorded as PaidConsensusError with an empty + // action and the same exact bytes could be replayed across blocks. + validation_result: ValidationResultMethodVersions { + flatten: 1, + merge_many: 1, + }, }; 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 78a5c4b0f47..c9cced0a0e6 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 @@ -10,13 +10,15 @@ use crate::version::drive_abci_versions::drive_abci_validation_versions::{ // v1 adds config min_version enforcement: since protocol version 12, V0 config is no longer // accepted because it lacks sized_integer_types support. // -// Bump batch_state_transition.transform_into_action to v1 (issue #2867): -// v1 of the batch transformer uses the canonical `flatten` / `merge_many` -// aggregators which return `data: None` when no input contributed — -// closing the "validating state transition for free" gap where an -// all-failed Documents Batch was being recorded as PaidConsensusError -// with an empty action and the same exact bytes could be replayed -// across blocks. v0 stays for PROTOCOL_VERSION_11 chain reproducibility. +// Issue #2867 ("validating state transition for free") is fixed at the +// aggregator layer instead — see +// `dpp_versions::dpp_validation_versions::v3::DPP_VALIDATION_VERSIONS_V3`, +// which bumps `validation_result.flatten` and `merge_many` from v0 to v1 +// for PROTOCOL_VERSION_12. This keeps the batch transformer single-version +// while changing the underlying aggregator semantics so empty-action +// failure paths become UnpaidConsensusError (tx removed from block by +// prepare_proposal) instead of being synthesised into a paid empty +// BatchTransitionAction. pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = DriveAbciValidationVersions { state_transitions: DriveAbciStateTransitionValidationVersions { @@ -118,14 +120,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = advanced_structure: 0, state: 0, revision: 0, - // Issue #2867: route to v1 of the transformer so empty-action - // failure paths become UnpaidConsensusError (tx removed from - // block by prepare_proposal) instead of being synthesised - // into a paid empty BatchTransitionAction by the legacy - // `_or_empty_vec` aggregators. v0 stays for - // PROTOCOL_VERSION_11 so mainnet history is preserved - // bit-for-bit. - transform_into_action: 1, + transform_into_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 3421ba58076..5f3873336cb 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -4,6 +4,19 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5120, //5 KiB max_state_transition_size: 20480, //20 KiB + // TODO: this is currently capped at 1 because the batch state-transition + // pipeline has known correctness issues with multi-transition batches: + // - It is not atomic: when one transition errors, earlier successful + // transitions inside the same batch are still applied to state. + // - Nonce-bump semantics for mixed success/failure batches are not + // well-defined: it is unclear whether to bump the nonce for the + // failed transition only, for all transitions, or for none — and the + // transformer/dispatch code does not consistently express any of + // those policies (see issue #2867 and PR #3608). + // Before lifting this cap above 1, the whole batch validation + + // transformer + nonce-bump path must be reviewed and the atomicity / + // nonce semantics fixed. Pulling the cap higher today would expose + // those bugs to mainnet traffic. max_transitions_in_documents_batch: 1, withdrawal_transactions_per_block_limit: 4, retry_signing_expired_withdrawal_documents_per_block_limit: 1, From 9a7d3505668ada4ee4a47a6d24d476acd5f3b8c0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 01:28:32 +0700 Subject: [PATCH 08/24] refactor(dpp): organise validation_result aggregator versions per codebase convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit landed v0/v1 as flat sibling files (`validation_result/v{0,1}.rs`). The codebase convention for versioned methods is `/v{N}/mod.rs` (matching e.g. drive-abci's `transform_into_action/v0/mod.rs`). Restructure to: validation_result/ mod.rs — facade dispatch on platform_version flatten/ mod.rs — declares v0/v1 submodules v0/mod.rs — flatten_v0 (legacy Some(empty_vec)) v1/mod.rs — flatten_v1 (canonical None-on-empty) merge_many/ mod.rs v0/mod.rs — merge_many_v0 v1/mod.rs — merge_many_v1 No behavior change. The 61 unit tests in `validation_result/mod.rs` remain green; only the function paths they reference change (`flatten::v1::flatten_v1`, etc.). --- .../validation_result/flatten/mod.rs | 2 + .../validation_result/flatten/v0/mod.rs | 36 +++++++++++ .../validation_result/flatten/v1/mod.rs | 41 +++++++++++++ .../validation_result/merge_many/mod.rs | 2 + .../validation_result/merge_many/v0/mod.rs | 36 +++++++++++ .../validation_result/merge_many/v1/mod.rs | 40 +++++++++++++ .../src/validation/validation_result/mod.rs | 48 +++++++-------- .../src/validation/validation_result/v0.rs | 50 ---------------- .../src/validation/validation_result/v1.rs | 59 ------------------- 9 files changed, 181 insertions(+), 133 deletions(-) create mode 100644 packages/rs-dpp/src/validation/validation_result/flatten/mod.rs create mode 100644 packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs create mode 100644 packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs create mode 100644 packages/rs-dpp/src/validation/validation_result/merge_many/mod.rs create mode 100644 packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs create mode 100644 packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs delete mode 100644 packages/rs-dpp/src/validation/validation_result/v0.rs delete mode 100644 packages/rs-dpp/src/validation/validation_result/v1.rs diff --git a/packages/rs-dpp/src/validation/validation_result/flatten/mod.rs b/packages/rs-dpp/src/validation/validation_result/flatten/mod.rs new file mode 100644 index 00000000000..0bfd3c4c7b2 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/flatten/mod.rs @@ -0,0 +1,2 @@ +pub(super) mod v0; +pub(super) mod v1; diff --git a/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs b/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs new file mode 100644 index 00000000000..dc352d9f6e9 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs @@ -0,0 +1,36 @@ +//! v0 of [`ConsensusValidationResult::flatten`]. +//! +//! Legacy semantics: always returns `data: Some(Vec<...>)`, including +//! `Some(empty_vec)` when no input contributed any data. +//! +//! Preserved for `PROTOCOL_VERSION_11` and below — the +//! `Some(empty_vec)`-on-no-data behavior is part of the existing chain +//! history, and changing it would be a consensus-breaking change for +//! already-finalized blocks. New code should let the facade dispatch to v1. +//! +//! See issue #2867 for context. +//! +//! [`ConsensusValidationResult::flatten`]: crate::validation::ConsensusValidationResult::flatten + +use crate::validation::ValidationResult; +use std::fmt::Debug; + +pub(in crate::validation::validation_result) fn flatten_v0( + items: I, +) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator, E>>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(mut data) = data { + aggregate_data.append(&mut data); + } + }); + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) +} diff --git a/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs b/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs new file mode 100644 index 00000000000..ba52bb0d463 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs @@ -0,0 +1,41 @@ +//! v1 of [`ConsensusValidationResult::flatten`]. +//! +//! Canonical semantics: returns `data: None` when no input contributed any +//! data (i.e. every input was either `data: None` or `data: Some(empty_vec)`), +//! and `data: Some(merged_vec)` when at least one input contributed +//! non-empty data. +//! +//! This honors the invariant `data.is_none() ⇔ no work done`, which +//! downstream code (e.g. `process_validation_result_v0:241`) relies on to +//! choose between `PaidConsensusError` and `UnpaidConsensusError`. +//! +//! See issue #2867 for context. +//! +//! [`ConsensusValidationResult::flatten`]: crate::validation::ConsensusValidationResult::flatten + +use crate::validation::ValidationResult; +use std::fmt::Debug; + +pub(in crate::validation::validation_result) fn flatten_v1( + items: I, +) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator, E>>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(mut data) = data { + aggregate_data.append(&mut data); + } + }); + if aggregate_data.is_empty() { + ValidationResult::new_with_errors(aggregate_errors) + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } +} diff --git a/packages/rs-dpp/src/validation/validation_result/merge_many/mod.rs b/packages/rs-dpp/src/validation/validation_result/merge_many/mod.rs new file mode 100644 index 00000000000..0bfd3c4c7b2 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/merge_many/mod.rs @@ -0,0 +1,2 @@ +pub(super) mod v0; +pub(super) mod v1; diff --git a/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs b/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs new file mode 100644 index 00000000000..521c3fc3660 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs @@ -0,0 +1,36 @@ +//! v0 of [`ValidationResult::merge_many`]. +//! +//! Legacy semantics: always returns `data: Some(Vec<...>)`, including +//! `Some(empty_vec)` when no input had `data: Some(_)`. +//! +//! Preserved for `PROTOCOL_VERSION_11` and below — the +//! `Some(empty_vec)`-on-no-data behavior is part of the existing chain +//! history, and changing it would be a consensus-breaking change for +//! already-finalized blocks. New code should let the facade dispatch to v1. +//! +//! See issue #2867 for context. +//! +//! [`ValidationResult::merge_many`]: crate::validation::ValidationResult::merge_many + +use crate::validation::ValidationResult; +use std::fmt::Debug; + +pub(in crate::validation::validation_result) fn merge_many_v0( + items: I, +) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(data) = data { + aggregate_data.push(data); + } + }); + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) +} diff --git a/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs b/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs new file mode 100644 index 00000000000..b704d0f04b3 --- /dev/null +++ b/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs @@ -0,0 +1,40 @@ +//! v1 of [`ValidationResult::merge_many`]. +//! +//! Canonical semantics: returns `data: None` when no input had +//! `data: Some(_)`, and `data: Some(Vec)` when at least one input +//! contributed data. +//! +//! This honors the invariant `data.is_none() ⇔ no work done`, which +//! downstream code (e.g. `process_validation_result_v0:241`) relies on to +//! choose between `PaidConsensusError` and `UnpaidConsensusError`. +//! +//! See issue #2867 for context. +//! +//! [`ValidationResult::merge_many`]: crate::validation::ValidationResult::merge_many + +use crate::validation::ValidationResult; +use std::fmt::Debug; + +pub(in crate::validation::validation_result) fn merge_many_v1( + items: I, +) -> ValidationResult, E> +where + TData: Clone, + E: Debug, + I: IntoIterator>, +{ + let mut aggregate_errors = vec![]; + let mut aggregate_data = vec![]; + items.into_iter().for_each(|single_validation_result| { + let ValidationResult { mut errors, data } = single_validation_result; + aggregate_errors.append(&mut errors); + if let Some(data) = data { + aggregate_data.push(data); + } + }); + if aggregate_data.is_empty() { + ValidationResult::new_with_errors(aggregate_errors) + } else { + ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) + } +} diff --git a/packages/rs-dpp/src/validation/validation_result/mod.rs b/packages/rs-dpp/src/validation/validation_result/mod.rs index a4801b985ee..c8346632999 100644 --- a/packages/rs-dpp/src/validation/validation_result/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/mod.rs @@ -3,8 +3,8 @@ use crate::version::PlatformVersion; use crate::ProtocolError; use std::fmt::Debug; -mod v0; -mod v1; +mod flatten; +mod merge_many; #[macro_export] macro_rules! check_validation_result_with_data { @@ -56,8 +56,8 @@ impl ValidationResult, E> { platform_version: &PlatformVersion, ) -> Result, E>, ProtocolError> { match platform_version.dpp.validation.validation_result.flatten { - 0 => Ok(v0::flatten(items)), - 1 => Ok(v1::flatten(items)), + 0 => Ok(flatten::v0::flatten_v0(items)), + 1 => Ok(flatten::v1::flatten_v1(items)), version => Err(ProtocolError::UnknownVersionMismatch { method: "ValidationResult::flatten".to_string(), known_versions: vec![0, 1], @@ -87,8 +87,8 @@ impl ValidationResult { platform_version: &PlatformVersion, ) -> Result, E>, ProtocolError> { match platform_version.dpp.validation.validation_result.merge_many { - 0 => Ok(v0::merge_many(items)), - 1 => Ok(v1::merge_many(items)), + 0 => Ok(merge_many::v0::merge_many_v0(items)), + 1 => Ok(merge_many::v1::merge_many_v1(items)), version => Err(ProtocolError::UnknownVersionMismatch { method: "ValidationResult::merge_many".to_string(), known_versions: vec![0, 1], @@ -570,7 +570,7 @@ mod tests { assert_eq!(result.errors, vec!["bad".to_string()]); } - // -- v1::flatten() (canonical, honors data.is_none() ⇔ no work done) -- + // -- flatten::v1::flatten_v1() (canonical, honors data.is_none() ⇔ no work done) -- #[test] fn test_v1_flatten_merges_non_empty_data() { @@ -580,7 +580,7 @@ mod tests { let r3: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = v1::flatten(vec![r1, r2, r3]); + let flat = flatten::v1::flatten_v1(vec![r1, r2, r3]); assert_eq!(flat.data, Some(vec![1, 2, 3])); assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); } @@ -588,7 +588,7 @@ mod tests { #[test] fn test_v1_flatten_empty_input_returns_none_data() { let flat: ValidationResult, String> = - v1::flatten(std::iter::empty::, String>>()); + flatten::v1::flatten_v1(std::iter::empty::, String>>()); assert_eq!(flat.data, None); assert!(flat.errors.is_empty()); } @@ -604,7 +604,7 @@ mod tests { let r2: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = v1::flatten(vec![r1, r2]); + let flat = flatten::v1::flatten_v1(vec![r1, r2]); assert!(flat.data.is_none()); assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); } @@ -616,7 +616,7 @@ mod tests { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); - let flat = v1::flatten(vec![r1, r2]); + let flat = flatten::v1::flatten_v1(vec![r1, r2]); assert_eq!(flat.data, Some(vec![42])); assert!(flat.errors.is_empty()); } @@ -628,12 +628,12 @@ mod tests { let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - let flat = v1::flatten(vec![r1, r2]); + let flat = flatten::v1::flatten_v1(vec![r1, r2]); assert!(flat.data.is_none()); assert!(flat.errors.is_empty()); } - // -- v1::merge_many() (canonical) -- + // -- merge_many::v1::merge_many_v1() (canonical) -- #[test] fn test_v1_merge_many_collects_non_empty_data() { @@ -641,7 +641,7 @@ mod tests { let r2: ValidationResult = ValidationResult::new_with_data(2); let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - let merged = v1::merge_many(vec![r1, r2, r3]); + let merged = merge_many::v1::merge_many_v1(vec![r1, r2, r3]); assert_eq!(merged.data, Some(vec![1, 2])); assert_eq!(merged.errors, vec!["e".to_string()]); } @@ -649,7 +649,7 @@ mod tests { #[test] fn test_v1_merge_many_empty_input_returns_none_data() { let merged: ValidationResult, String> = - v1::merge_many(std::iter::empty::>()); + merge_many::v1::merge_many_v1(std::iter::empty::>()); assert!(merged.data.is_none()); assert!(merged.errors.is_empty()); } @@ -659,7 +659,7 @@ mod tests { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - let merged = v1::merge_many(vec![r1, r2]); + let merged = merge_many::v1::merge_many_v1(vec![r1, r2]); assert!(merged.data.is_none()); assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); } @@ -669,12 +669,12 @@ mod tests { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_data(7); - let merged = v1::merge_many(vec![r1, r2]); + let merged = merge_many::v1::merge_many_v1(vec![r1, r2]); assert_eq!(merged.data, Some(vec![7])); assert_eq!(merged.errors, vec!["e1".to_string()]); } - // -- v0::flatten() / v0::merge_many() -- + // -- flatten::v0::flatten_v0() / merge_many::v0::merge_many_v0() -- // These pin the legacy `Some(empty_vec)`-on-no-data behavior preserved // for PROTOCOL_VERSION_11 and below. @@ -686,7 +686,7 @@ mod tests { let r3: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = v0::flatten(vec![r1, r2, r3]); + let flat = flatten::v0::flatten_v0(vec![r1, r2, r3]); assert_eq!(flat.data, Some(vec![1, 2, 3])); assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); } @@ -694,7 +694,7 @@ mod tests { #[test] fn test_v0_flatten_empty_input_returns_some_empty() { let flat: ValidationResult, String> = - v0::flatten(std::iter::empty::, String>>()); + flatten::v0::flatten_v0(std::iter::empty::, String>>()); // Legacy v11 behavior: Some(empty_vec), not None. assert_eq!(flat.data, Some(vec![])); assert!(flat.errors.is_empty()); @@ -707,7 +707,7 @@ mod tests { let r2: ValidationResult, String> = ValidationResult::new_with_error("e2".to_string()); - let flat = v0::flatten(vec![r1, r2]); + let flat = flatten::v0::flatten_v0(vec![r1, r2]); assert_eq!(flat.data, Some(vec![])); assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); } @@ -718,7 +718,7 @@ mod tests { let r2: ValidationResult = ValidationResult::new_with_data(2); let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - let merged = v0::merge_many(vec![r1, r2, r3]); + let merged = merge_many::v0::merge_many_v0(vec![r1, r2, r3]); assert_eq!(merged.data, Some(vec![1, 2])); assert_eq!(merged.errors, vec!["e".to_string()]); } @@ -726,7 +726,7 @@ mod tests { #[test] fn test_v0_merge_many_empty_input_returns_some_empty() { let merged: ValidationResult, String> = - v0::merge_many(std::iter::empty::>()); + merge_many::v0::merge_many_v0(std::iter::empty::>()); // Legacy v11 behavior: Some(empty_vec), not None. assert_eq!(merged.data, Some(vec![])); assert!(merged.errors.is_empty()); @@ -737,7 +737,7 @@ mod tests { let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - let merged = v0::merge_many(vec![r1, r2]); + let merged = merge_many::v0::merge_many_v0(vec![r1, r2]); assert_eq!(merged.data, Some(vec![])); assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); } diff --git a/packages/rs-dpp/src/validation/validation_result/v0.rs b/packages/rs-dpp/src/validation/validation_result/v0.rs deleted file mode 100644 index 142f885fbe9..00000000000 --- a/packages/rs-dpp/src/validation/validation_result/v0.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! v0 of [`flatten`] / [`merge_many`]. -//! -//! Legacy aggregator semantics: always return `data: Some(Vec<...>)`, -//! including `Some(empty_vec)` when no input contributed any data. -//! -//! Preserved for `PROTOCOL_VERSION_11` and below — the -//! `Some(empty_vec)`-on-no-data behavior is part of the existing chain -//! history, and changing it would be a consensus-breaking change for -//! already-finalized blocks. New code should let the facade dispatch to v1. -//! -//! See issue #2867 for context. - -use super::ValidationResult; -use std::fmt::Debug; - -pub(super) fn flatten(items: I) -> ValidationResult, E> -where - TData: Clone, - E: Debug, - I: IntoIterator, E>>, -{ - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(mut data) = data { - aggregate_data.append(&mut data); - } - }); - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) -} - -pub(super) fn merge_many(items: I) -> ValidationResult, E> -where - TData: Clone, - E: Debug, - I: IntoIterator>, -{ - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(data) = data { - aggregate_data.push(data); - } - }); - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) -} diff --git a/packages/rs-dpp/src/validation/validation_result/v1.rs b/packages/rs-dpp/src/validation/validation_result/v1.rs deleted file mode 100644 index dbee0aca41a..00000000000 --- a/packages/rs-dpp/src/validation/validation_result/v1.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! v1 of [`flatten`] / [`merge_many`]. -//! -//! Canonical aggregator semantics: return `data: None` when no input -//! contributed any data (i.e. every input was either `data: None` or -//! `data: Some(empty_vec)`), and `data: Some(merged_vec)` when at least one -//! input contributed non-empty data. -//! -//! This honors the invariant `data.is_none() ⇔ no work done`, which -//! downstream code (e.g. `process_validation_result_v0:241`) relies on to -//! choose between `PaidConsensusError` and `UnpaidConsensusError`. -//! -//! See issue #2867 for context. - -use super::ValidationResult; -use std::fmt::Debug; - -pub(super) fn flatten(items: I) -> ValidationResult, E> -where - TData: Clone, - E: Debug, - I: IntoIterator, E>>, -{ - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(mut data) = data { - aggregate_data.append(&mut data); - } - }); - if aggregate_data.is_empty() { - ValidationResult::new_with_errors(aggregate_errors) - } else { - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) - } -} - -pub(super) fn merge_many(items: I) -> ValidationResult, E> -where - TData: Clone, - E: Debug, - I: IntoIterator>, -{ - let mut aggregate_errors = vec![]; - let mut aggregate_data = vec![]; - items.into_iter().for_each(|single_validation_result| { - let ValidationResult { mut errors, data } = single_validation_result; - aggregate_errors.append(&mut errors); - if let Some(data) = data { - aggregate_data.push(data); - } - }); - if aggregate_data.is_empty() { - ValidationResult::new_with_errors(aggregate_errors) - } else { - ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) - } -} From c37622fa0d85e7566b48f2da69a31ebd35b7cdb8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 01:47:13 +0700 Subject: [PATCH 09/24] test(dpp): co-locate validation_result aggregator tests with their version modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit landed v0/v1-specific tests in `validation_result/mod.rs::tests` alongside facade dispatch tests, which duplicated coverage (the facade tests already exercise v0/v1 behavior through dispatch). Move per-version behavior tests into their own modules: flatten/v0/mod.rs::tests — 3 tests for legacy Some(empty_vec) flatten/v1/mod.rs::tests — 5 tests for canonical None-on-empty merge_many/v0/mod.rs::tests — 3 tests for legacy Some(empty_vec) merge_many/v1/mod.rs::tests — 4 tests for canonical None-on-empty mod.rs::tests — facade dispatch tests + struct/method tests Same 61 tests, same coverage, no duplication. --- .../validation_result/flatten/v0/mod.rs | 39 ++++ .../validation_result/flatten/v1/mod.rs | 61 ++++++ .../validation_result/merge_many/v0/mod.rs | 35 ++++ .../validation_result/merge_many/v1/mod.rs | 44 +++++ .../src/validation/validation_result/mod.rs | 175 +----------------- 5 files changed, 181 insertions(+), 173 deletions(-) diff --git a/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs b/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs index dc352d9f6e9..9ba36e14e82 100644 --- a/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/flatten/v0/mod.rs @@ -34,3 +34,42 @@ where }); ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_data_and_errors() { + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); + let r2: ValidationResult, String> = + ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); + let r3: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = flatten_v0(vec![r1, r2, r3]); + assert_eq!(flat.data, Some(vec![1, 2, 3])); + assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); + } + + #[test] + fn empty_input_returns_some_empty() { + // Legacy v11 behavior: Some(empty_vec), not None. + let flat: ValidationResult, String> = + flatten_v0(std::iter::empty::, String>>()); + assert_eq!(flat.data, Some(vec![])); + assert!(flat.errors.is_empty()); + } + + #[test] + fn all_inputs_no_data_returns_some_empty() { + let r1: ValidationResult, String> = + ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = flatten_v0(vec![r1, r2]); + assert_eq!(flat.data, Some(vec![])); + assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); + } +} diff --git a/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs b/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs index ba52bb0d463..a4384ec18f6 100644 --- a/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs @@ -39,3 +39,64 @@ where ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_non_empty_data() { + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); + let r2: ValidationResult, String> = + ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); + let r3: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = flatten_v1(vec![r1, r2, r3]); + assert_eq!(flat.data, Some(vec![1, 2, 3])); + assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); + } + + #[test] + fn empty_input_returns_none() { + let flat: ValidationResult, String> = + flatten_v1(std::iter::empty::, String>>()); + assert_eq!(flat.data, None); + assert!(flat.errors.is_empty()); + } + + #[test] + fn all_inputs_no_data_returns_none() { + // Downstream code (process_validation_result_v0:241) keys on + // data.is_none() to route to UnpaidConsensusError. + let r1: ValidationResult, String> = + ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult, String> = + ValidationResult::new_with_error("e2".to_string()); + + let flat = flatten_v1(vec![r1, r2]); + assert!(flat.data.is_none()); + assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); + } + + #[test] + fn some_empty_some_non_empty_returns_some() { + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); + + let flat = flatten_v1(vec![r1, r2]); + assert_eq!(flat.data, Some(vec![42])); + assert!(flat.errors.is_empty()); + } + + #[test] + fn all_some_empty_returns_none() { + // All inputs had data:Some(empty_vec). The aggregate Vec is empty → data:None. + let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); + + let flat = flatten_v1(vec![r1, r2]); + assert!(flat.data.is_none()); + assert!(flat.errors.is_empty()); + } +} diff --git a/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs b/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs index 521c3fc3660..a1c79b68fad 100644 --- a/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/merge_many/v0/mod.rs @@ -34,3 +34,38 @@ where }); ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_data_into_vec() { + let r1: ValidationResult = ValidationResult::new_with_data(1); + let r2: ValidationResult = ValidationResult::new_with_data(2); + let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); + + let merged = merge_many_v0(vec![r1, r2, r3]); + assert_eq!(merged.data, Some(vec![1, 2])); + assert_eq!(merged.errors, vec!["e".to_string()]); + } + + #[test] + fn empty_input_returns_some_empty() { + // Legacy v11 behavior: Some(empty_vec), not None. + let merged: ValidationResult, String> = + merge_many_v0(std::iter::empty::>()); + assert_eq!(merged.data, Some(vec![])); + assert!(merged.errors.is_empty()); + } + + #[test] + fn all_inputs_no_data_returns_some_empty() { + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); + + let merged = merge_many_v0(vec![r1, r2]); + assert_eq!(merged.data, Some(vec![])); + assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); + } +} diff --git a/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs b/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs index b704d0f04b3..01df5e78c68 100644 --- a/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs @@ -38,3 +38,47 @@ where ValidationResult::new_with_data_and_errors(aggregate_data, aggregate_errors) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_non_empty_data() { + let r1: ValidationResult = ValidationResult::new_with_data(1); + let r2: ValidationResult = ValidationResult::new_with_data(2); + let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); + + let merged = merge_many_v1(vec![r1, r2, r3]); + assert_eq!(merged.data, Some(vec![1, 2])); + assert_eq!(merged.errors, vec!["e".to_string()]); + } + + #[test] + fn empty_input_returns_none() { + let merged: ValidationResult, String> = + merge_many_v1(std::iter::empty::>()); + assert!(merged.data.is_none()); + assert!(merged.errors.is_empty()); + } + + #[test] + fn all_inputs_no_data_returns_none() { + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); + + let merged = merge_many_v1(vec![r1, r2]); + assert!(merged.data.is_none()); + assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); + } + + #[test] + fn some_data_returns_some() { + let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); + let r2: ValidationResult = ValidationResult::new_with_data(7); + + let merged = merge_many_v1(vec![r1, r2]); + assert_eq!(merged.data, Some(vec![7])); + assert_eq!(merged.errors, vec!["e1".to_string()]); + } +} diff --git a/packages/rs-dpp/src/validation/validation_result/mod.rs b/packages/rs-dpp/src/validation/validation_result/mod.rs index c8346632999..636f241f909 100644 --- a/packages/rs-dpp/src/validation/validation_result/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/mod.rs @@ -570,182 +570,11 @@ mod tests { assert_eq!(result.errors, vec!["bad".to_string()]); } - // -- flatten::v1::flatten_v1() (canonical, honors data.is_none() ⇔ no work done) -- - - #[test] - fn test_v1_flatten_merges_non_empty_data() { - let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); - let r2: ValidationResult, String> = - ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); - let r3: ValidationResult, String> = - ValidationResult::new_with_error("e2".to_string()); - - let flat = flatten::v1::flatten_v1(vec![r1, r2, r3]); - assert_eq!(flat.data, Some(vec![1, 2, 3])); - assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); - } - - #[test] - fn test_v1_flatten_empty_input_returns_none_data() { - let flat: ValidationResult, String> = - flatten::v1::flatten_v1(std::iter::empty::, String>>()); - assert_eq!(flat.data, None); - assert!(flat.errors.is_empty()); - } - - #[test] - fn test_v1_flatten_all_inputs_no_data_returns_none() { - // When no input contributed data, return data:None — not - // Some(empty_vec). Downstream code - // (process_validation_result_v0:241) keys on data.is_none() to - // route to UnpaidConsensusError. - let r1: ValidationResult, String> = - ValidationResult::new_with_error("e1".to_string()); - let r2: ValidationResult, String> = - ValidationResult::new_with_error("e2".to_string()); - - let flat = flatten::v1::flatten_v1(vec![r1, r2]); - assert!(flat.data.is_none()); - assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); - } - - #[test] - fn test_v1_flatten_some_empty_some_non_empty_returns_some() { - // Mixed input: one had data:Some(empty_vec), another had - // Some(non_empty). The aggregate is non-empty → data:Some(...). - let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![42]); - - let flat = flatten::v1::flatten_v1(vec![r1, r2]); - assert_eq!(flat.data, Some(vec![42])); - assert!(flat.errors.is_empty()); - } - - #[test] - fn test_v1_flatten_all_some_empty_returns_none() { - // All inputs had data:Some(empty_vec). The aggregate Vec is - // empty → data:None. - let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - let r2: ValidationResult, String> = ValidationResult::new_with_data(vec![]); - - let flat = flatten::v1::flatten_v1(vec![r1, r2]); - assert!(flat.data.is_none()); - assert!(flat.errors.is_empty()); - } - - // -- merge_many::v1::merge_many_v1() (canonical) -- - - #[test] - fn test_v1_merge_many_collects_non_empty_data() { - let r1: ValidationResult = ValidationResult::new_with_data(1); - let r2: ValidationResult = ValidationResult::new_with_data(2); - let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - - let merged = merge_many::v1::merge_many_v1(vec![r1, r2, r3]); - assert_eq!(merged.data, Some(vec![1, 2])); - assert_eq!(merged.errors, vec!["e".to_string()]); - } - - #[test] - fn test_v1_merge_many_empty_input_returns_none_data() { - let merged: ValidationResult, String> = - merge_many::v1::merge_many_v1(std::iter::empty::>()); - assert!(merged.data.is_none()); - assert!(merged.errors.is_empty()); - } - - #[test] - fn test_v1_merge_many_all_inputs_no_data_returns_none() { - let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); - let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - - let merged = merge_many::v1::merge_many_v1(vec![r1, r2]); - assert!(merged.data.is_none()); - assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); - } - - #[test] - fn test_v1_merge_many_some_data_returns_some() { - let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); - let r2: ValidationResult = ValidationResult::new_with_data(7); - - let merged = merge_many::v1::merge_many_v1(vec![r1, r2]); - assert_eq!(merged.data, Some(vec![7])); - assert_eq!(merged.errors, vec!["e1".to_string()]); - } - - // -- flatten::v0::flatten_v0() / merge_many::v0::merge_many_v0() -- - // These pin the legacy `Some(empty_vec)`-on-no-data behavior preserved - // for PROTOCOL_VERSION_11 and below. - - #[test] - fn test_v0_flatten_merges_data_and_errors() { - let r1: ValidationResult, String> = ValidationResult::new_with_data(vec![1, 2]); - let r2: ValidationResult, String> = - ValidationResult::new_with_data_and_errors(vec![3], vec!["e".to_string()]); - let r3: ValidationResult, String> = - ValidationResult::new_with_error("e2".to_string()); - - let flat = flatten::v0::flatten_v0(vec![r1, r2, r3]); - assert_eq!(flat.data, Some(vec![1, 2, 3])); - assert_eq!(flat.errors, vec!["e".to_string(), "e2".to_string()]); - } - - #[test] - fn test_v0_flatten_empty_input_returns_some_empty() { - let flat: ValidationResult, String> = - flatten::v0::flatten_v0(std::iter::empty::, String>>()); - // Legacy v11 behavior: Some(empty_vec), not None. - assert_eq!(flat.data, Some(vec![])); - assert!(flat.errors.is_empty()); - } - - #[test] - fn test_v0_flatten_all_inputs_no_data_returns_some_empty() { - let r1: ValidationResult, String> = - ValidationResult::new_with_error("e1".to_string()); - let r2: ValidationResult, String> = - ValidationResult::new_with_error("e2".to_string()); - - let flat = flatten::v0::flatten_v0(vec![r1, r2]); - assert_eq!(flat.data, Some(vec![])); - assert_eq!(flat.errors, vec!["e1".to_string(), "e2".to_string()]); - } - - #[test] - fn test_v0_merge_many_collects_data_into_vec() { - let r1: ValidationResult = ValidationResult::new_with_data(1); - let r2: ValidationResult = ValidationResult::new_with_data(2); - let r3: ValidationResult = ValidationResult::new_with_error("e".to_string()); - - let merged = merge_many::v0::merge_many_v0(vec![r1, r2, r3]); - assert_eq!(merged.data, Some(vec![1, 2])); - assert_eq!(merged.errors, vec!["e".to_string()]); - } - - #[test] - fn test_v0_merge_many_empty_input_returns_some_empty() { - let merged: ValidationResult, String> = - merge_many::v0::merge_many_v0(std::iter::empty::>()); - // Legacy v11 behavior: Some(empty_vec), not None. - assert_eq!(merged.data, Some(vec![])); - assert!(merged.errors.is_empty()); - } - - #[test] - fn test_v0_merge_many_all_inputs_no_data_returns_some_empty() { - let r1: ValidationResult = ValidationResult::new_with_error("e1".to_string()); - let r2: ValidationResult = ValidationResult::new_with_error("e2".to_string()); - - let merged = merge_many::v0::merge_many_v0(vec![r1, r2]); - assert_eq!(merged.data, Some(vec![])); - assert_eq!(merged.errors, vec!["e1".to_string(), "e2".to_string()]); - } - // -- facade dispatch (flatten / merge_many take platform_version) -- // // These verify the version field on PlatformVersion correctly steers the - // facade to v0 vs v1 semantics. + // facade to v0 vs v1 semantics. Per-version behavior is tested in each + // version's own module (e.g. `flatten::v1::tests`). #[test] fn test_facade_flatten_v0_returns_some_empty_on_no_data() { From 1d0a02bc3e9efd713f273293946456d285075417 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 02:15:00 +0700 Subject: [PATCH 10/24] refactor(drive-abci): tighten bump-emission framing in batch transformer + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframe the change from a mainnet duplicate-tx narrative to its actual purpose: the user pays for the per-transition validation work that ran before a failure (document fetch + ownership / revision check), instead of getting it for free because the failure path emitted no action. - Drop the dead `let result = ConsensusValidationResult::new();` and `if result.is_valid() { ... } else { Ok(result) }` tails in Replace, Transfer, UpdatePrice and Purchase arms — `result` is never modified after the per-path failure handlers were rewritten to return early. - One short doc on `transform_document_transition_v0` explaining why every per-transition failure emits a bump (charge for the work, advance the nonce); strip per-site narrative comments that pointed at issue #2867 / mainnet 35C0…313C. - Slim the regression test doc: keep what it pins, drop the mainnet escalation timeline. - Slim the fee-change comments in replacement / nft / transfer to a one-liner ("covers the fetch + check that ran before the failure"). - Drop the mainnet 35C0 decoder pin in `packages/rs-dpp/src/state_transition/serialization.rs` — it was a one-off debug artifact, not a behavior pin for this PR. The architectural mainnet fix (return `data: None` from aggregators so empty-action batches become UnpaidConsensusError → tx removed from block) lives in PR #3616. This PR is the complementary "user pays for advanced-structure validation work" layer for paths that DO produce a real per-transition error inside an otherwise-valid batch. --- .../src/state_transition/serialization.rs | 70 ---------------- .../batch/tests/document/nft.rs | 7 +- .../batch/tests/document/replacement.rs | 81 ++++++------------- .../batch/tests/document/transfer.rs | 7 +- .../batch/transformer/v0/mod.rs | 65 ++++----------- 5 files changed, 45 insertions(+), 185 deletions(-) diff --git a/packages/rs-dpp/src/state_transition/serialization.rs b/packages/rs-dpp/src/state_transition/serialization.rs index 25d6f8b96fb..d02fb91d016 100644 --- a/packages/rs-dpp/src/state_transition/serialization.rs +++ b/packages/rs-dpp/src/state_transition/serialization.rs @@ -106,76 +106,6 @@ mod tests { assert_eq!(identity_address, EXPECTED_IDENTITY_ADDRESS); } - #[test] - /// Mainnet state transition 35C08D574302D32D7160E603D8159042C8606BE12FD97D952CF5FD40DB57313C - /// (block 361774, idx 1, status FAIL, gas 41880). - /// - /// Pinned because: - /// - Its bytes are what platform-explorer's indexer choked on with - /// `duplicate key value violates unique constraint state_transition_hash` - /// (issue #2867 mainnet escalation, 2026-05-04). The same hash appears in - /// a later block the explorer hasn't indexed yet — the second copy is - /// what triggers the unique-key panic. - /// - The chat thread misidentified it as a credit transfer; the bytes are - /// actually a single Documents Batch Replace on a DPNS-like profile doc. - /// - Identity_contract_nonce == 3, while a sibling tx in the same block - /// (5962DA04... at idx 0) succeeded with nonce == 4. So the failure path - /// is the out-of-order in-block case, not a stale replay against committed - /// state — narrowing the regression target to the failed-Replace nonce - /// bookkeeping in batch/transformer/state. - fn should_decode_mainnet_35c0_documents_batch_replace_replay() { - use crate::state_transition::batch_transition::accessors::DocumentsBatchTransitionAccessorsV0; - use crate::state_transition::batch_transition::batched_transition::document_transition::DocumentTransition; - use crate::state_transition::batch_transition::batched_transition::BatchedTransitionRef; - use crate::state_transition::StateTransitionOwned; - - const EXPECTED_STATE_TRANSITION_HASH: &str = - "35C08D574302D32D7160E603D8159042C8606BE12FD97D952CF5FD40DB57313C"; - const RAW_TRANSACTION_BASE64: &str = "AgFQIl0YZ/WZdYW/CHCAmWvwN9FYctxuUS35L5Pf73IMTgEAAQABFSmQcqs+Yj0mrj560+00qfu+k75VeNPWWC4fDlzbpAwDB3Byb2ZpbGWiobSsb+8i6ioaaOgSNkSzV4dfa0EsGBCSgcFG57JxvAADAQtkaXNwbGF5TmFtZRIKdGVjaGF3YW5rYQABQSA7dB/FyiyS6BBNBLc4x+vVMVLuy5JWjuOzOuj/4fAaLgPrC7+nQDhl4LvPx+LZ4heDNq0dQA6LF97pjACbSO0a"; - const EXPECTED_OWNER_BASE58: &str = "6Pp1RFqRnpStnY8vmp5k3ypE6rFBvzPwcoguwDXbRA7F"; - const EXPECTED_NONCE: u64 = 3; - - let raw = STANDARD - .decode(RAW_TRANSACTION_BASE64) - .expect("base64 decodes"); - let state_transition = StateTransition::deserialize_from_bytes(&raw) - .expect("dpp deserializes the wire bytes"); - - assert_eq!( - &state_transition - .transaction_id() - .expect("expected transaction id") - .encode_hex_upper::(), - EXPECTED_STATE_TRANSITION_HASH - ); - - let StateTransition::Batch(batch) = &state_transition else { - panic!("expected Batch transition, got {state_transition:?}"); - }; - - assert_eq!( - batch.owner_id().to_string(Encoding::Base58), - EXPECTED_OWNER_BASE58 - ); - - assert_eq!(batch.transitions_len(), 1); - - let only = batch - .first_transition() - .expect("expected exactly one batched transition"); - - let BatchedTransitionRef::Document(document_transition) = only else { - panic!("expected document transition, got {only:?}"); - }; - - assert!( - matches!(document_transition, DocumentTransition::Replace(_)), - "expected Replace transition, got {document_transition:?}", - ); - - assert_eq!(only.identity_contract_nonce(), EXPECTED_NONCE); - } - #[test] #[cfg(feature = "random-identities")] fn identity_create_transition_ser_de() { diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index fe4926aa6f0..e95b680df29 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -2788,10 +2788,9 @@ mod nft_tests { assert_eq!(processing_result.valid_count(), 0); - // Fee bumped from 36200 in PROTOCOL_VERSION_12 — issue #2867 fix: the - // UpdatePrice ownership-mismatch failure path now emits a bump action - // (previously dropped, leaking nonce-replay risk). Cost covers the - // fetch+validation work that was already happening. + // Covers the fetch + ownership check that ran before the failure. + // Previously the failure path emitted no action and this work was + // charged as 0. assert_eq!(processing_result.aggregated_fees().processing_fee, 571240); let sender_documents_sql_string = diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 25a85daa4aa..f3ce04b4937 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -651,46 +651,24 @@ mod replacement_tests { assert_eq!(processing_result.valid_count(), 0); - // Fee bumped from the previous 41880 (bare bump-only) to 445700 in - // PROTOCOL_VERSION_12. Issue #2867 fix: the failure path now emits the - // bump after running the document fetch + validation work, so the fee - // covers that work — same drive ops, just charged correctly. + // Covers the document fetch + structure validation that ran before + // the failure. Previously the failure path emitted no action and this + // work was charged as 0; now the bump covers it. assert_eq!(processing_result.aggregated_fees().processing_fee, 445700); } - /// Regression test for issue #2867 (mainnet duplicate-tx escalation, 2026-05-04). + /// Pins the bump-emission contract on Replace's revision-mismatch path. /// - /// Mainnet ST hash 35C0...313C — a Documents Batch Replace by Techawanka.dash - /// (6Pp1RFqRnpStnY8vmp5k3ypE6rFBvzPwcoguwDXbRA7F) — landed at block 361774 idx 1 - /// as FAIL with gasUsed 41880, then re-appeared in a later block, panicking the - /// explorer indexer with `duplicate key value violates unique constraint - /// state_transition_hash`. The shape: nonce 4 ran successfully *before* nonce - /// 3 in the same block (out-of-order SDK retries), so when nonce 3's Replace - /// reached deliver_tx the doc revision had already advanced. The - /// revision-bump-by-one check in - /// batch/transformer/v0/mod.rs::check_revision_is_bumped_by_one_during_replace_v0 - /// fails, and the surrounding handler (lines 712–715) returns `Ok(result)` - /// with errors-only and **no BumpIdentityDataContractNonce action** — unlike - /// the find_replaced_document_v0 path on the same enum arm (lines 672–686) - /// which DOES emit a bump. + /// Without the bump, a failed Replace returns errors-only with no action. + /// Fee accounting then charges the user (PaidConsensusError) but the + /// identity_contract_nonce in state never advances — the same exact bytes + /// can be re-broadcast indefinitely. /// - /// Consequence: the user pays the 41880 bump fee (because the empty - /// BatchTransitionAction still triggers PaidConsensusError accounting), but - /// the contract nonce IS NEVER ADVANCED in state. The exact same bytes can - /// then be re-broadcast indefinitely; each retry lands a new failed copy in - /// a new block, all sharing the same hash. - /// - /// What this test pins: - /// 1. After a Replace that fails the revision-bump-by-one check commits, - /// the stored identity_contract_nonce MUST advance past the submitted - /// nonce. (Direct invariant — fails fast on RED.) - /// 2. Re-submitting the same exact bytes through CheckTx FirstTimeCheck - /// MUST be rejected with InvalidIdentityNonceError. (Symptom-level — - /// this is what lklimek's Feb 10 2026 testnet debug log proved was - /// broken.) - /// - /// Both assertions fail on v3.1-dev today; both should pass once the bump - /// is emitted on revision/ownership-mismatch paths in batch/transformer/v0. + /// The test asserts: + /// 1. After a Replace that fails `check_revision_is_bumped_by_one`, the + /// stored contract nonce MUST advance past the submitted nonce. + /// 2. Re-submitting the same bytes through CheckTx FirstTimeCheck MUST + /// be rejected with `InvalidIdentityNonceError`. #[tokio::test] async fn replayed_failed_replace_with_consumed_nonce_must_be_rejected_at_check_tx() { use crate::execution::check_tx::CheckTxLevel; @@ -792,12 +770,10 @@ mod replacement_tests { let post_create_nonce = post_create_nonce_raw.expect("contract nonce must be present after create"); - // 2) Build a Replace at nonce 3 with a revision the chain will reject. - // Doc at revision 1 → expected revision 2 on replace. We submit - // revision 3 → check_revision_is_bumped_by_one_during_replace_v0 - // returns InvalidDocumentRevisionError(Some(1), 3). On mainnet this - // happened naturally because nonce 4 ran first and advanced the doc - // revision past what nonce 3's tx expected. + // 2) Build a Replace at nonce 3 with revision 3. Doc is at revision + // 1, so check_revision_is_bumped_by_one_during_replace_v0 returns + // InvalidDocumentRevisionError(Some(1), 3) and we hit the + // failure-with-bump path in the transformer. let mut altered_document = document.clone(); altered_document.set_revision(Some(3)); altered_document.set("displayName", "Out of order".into()); @@ -867,22 +843,14 @@ mod replacement_tests { assert_ne!( post_replace_nonce, post_create_nonce, - "BUG: failed Replace's bump action did not advance the contract \ - nonce. Stored nonce is still {:#x} (= post-create value), so the \ - same exact serialized bytes can be replayed forever. \ - Root cause: batch/transformer/v0/mod.rs:712-715 (and 697-700) \ - return Ok(result) with errors-only and no BumpIdentityDataContractNonce \ - action when check_revision_is_bumped_by_one_during_replace_v0 (or \ - check_ownership_of_old_replaced_document_v0) fails — unlike the \ - find_replaced_document_v0 failure path on the same arm (lines \ - 672-686) which does emit the bump.", + "failed Replace's bump action did not advance the contract \ + nonce — stored nonce is still {:#x} (= post-create value), so \ + the same serialized bytes can be replayed", post_create_nonce ); - // 4) Symptom-level: re-submitting identical bytes through CheckTx - // FirstTimeCheck must hit the nonce check first and reject. This is - // exactly what lklimek's Feb 10 2026 testnet check_tx debug log - // showed NOT happening. + // 4) Re-submitting identical bytes through CheckTx FirstTimeCheck must + // hit the nonce check first and reject. let replayed_state_transition = StateTransition::deserialize_from_bytes(&replace_serialized) .expect("expected to deserialize replayed transition"); @@ -905,9 +873,8 @@ mod replacement_tests { assert!( !check_tx_result.is_valid(), - "CheckTx FirstTimeCheck MUST reject identical bytes after the \ - failed-Replace bump consumed the nonce — it accepted them on \ - testnet on 2026-02-10." + "CheckTx FirstTimeCheck must reject identical bytes after the \ + failed-Replace bump consumed the nonce" ); assert!( check_tx_result.errors.iter().any(|e| matches!( diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs index 232a439fdbf..c318a921ad7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs @@ -1256,10 +1256,9 @@ mod transfer_tests { assert_eq!(processing_result.valid_count(), 0); - // Fee bumped from 36200 in PROTOCOL_VERSION_12 — issue #2867 fix: the - // Transfer find-replaced-document failure path now emits a bump action - // (previously dropped, leaking nonce-replay risk). Cost covers the - // fetch+validation work that was already happening. + // Covers the fetch + ownership check that ran before the failure. + // Previously the failure path emitted no action and this work was + // charged as 0. assert_eq!(processing_result.aggregated_fees().processing_fee, 517400); let query_sender_results = platform diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 9b64e33a9e3..fd1e5edaec1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -637,7 +637,14 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } } - /// The data contract can be of multiple difference versions + /// Per-transition handler for document arms. Each per-transition failure + /// path (ownership mismatch, revision mismatch, missing target document, + /// etc.) emits a `BumpIdentityDataContractNonce` action so the user pays + /// for the validation work that already ran (fetch + ownership/revision + /// checks) and the contract nonce advances. Without this, the failure + /// path would return errors-only with no action data, fee accounting + /// would charge 0, and the same nonce would remain available — i.e. a + /// "free advanced-structure validation" hole. fn transform_document_transition_v0<'a>( drive: &Drive, transaction: TransactionArg, @@ -664,24 +671,19 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(document_create_action) } DocumentTransition::Replace(document_replace_transition) => { - let result = ConsensusValidationResult::::new(); - let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - // We can set the user fee increase to 0 here because it is decided by the Documents Batch instead + // user_fee_increase is set on the outer Documents Batch let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_replace_transition.base(), owner_id, 0, ); - let batched_action = - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - batched_action, + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), validation_result.errors, )); } @@ -695,9 +697,6 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - // Emit a bump action so the identity_contract_nonce advances even - // when ownership doesn't match. Without this, the same exact bytes - // could be replayed forever — see issue #2867. let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_replace_transition.base(), @@ -711,9 +710,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened + // Skipped on the rerun path where the action has already been applied. let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_replace_transition.revision(), document_replace_transition.base().id(), @@ -721,10 +718,6 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - // Emit a bump action so the identity_contract_nonce advances even - // when the revision doesn't bump by one. Without this, out-of-order - // SDK retries can replay the same bytes across multiple blocks — - // see issue #2867 (mainnet 35C0…313C, 2026-05-04). let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_replace_transition.base(), @@ -751,11 +744,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - if result.is_valid() { - Ok(document_replace_action) - } else { - Ok(result) - } + Ok(document_replace_action) } DocumentTransition::Delete(document_delete_transition) => { let (batched_action, fee_result) = DocumentDeleteTransitionAction::try_from_document_borrowed_delete_transition_with_contract_lookup(document_delete_transition, owner_id, user_fee_increase, |_identifier| { @@ -768,14 +757,10 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(batched_action) } DocumentTransition::Transfer(document_transfer_transition) => { - let result = ConsensusValidationResult::::new(); - let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - // Emit a bump action so the identity_contract_nonce advances even - // when the target document is missing — see issue #2867. let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_transfer_transition.base(), @@ -846,21 +831,13 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - if result.is_valid() { - Ok(document_transfer_action) - } else { - Ok(result) - } + Ok(document_transfer_action) } DocumentTransition::UpdatePrice(document_update_price_transition) => { - let result = ConsensusValidationResult::::new(); - let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - // Emit a bump action so the identity_contract_nonce advances even - // when the target document is missing — see issue #2867. let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_update_price_transition.base(), @@ -931,21 +908,13 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - if result.is_valid() { - Ok(document_update_price_action) - } else { - Ok(result) - } + Ok(document_update_price_action) } DocumentTransition::Purchase(document_purchase_transition) => { - let result = ConsensusValidationResult::::new(); - let validation_result = Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - // Emit a bump action so the identity_contract_nonce advances even - // when the target document is missing — see issue #2867. let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_purchase_transition.base(), @@ -1037,11 +1006,7 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context .add_operation(ValidationOperation::PrecalculatedOperation(fee_result)); - if result.is_valid() { - Ok(document_purchase_action) - } else { - Ok(result) - } + Ok(document_purchase_action) } } } From f9d0ee1a436998a0924cd14bb679d71a56088548 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 02:23:28 +0700 Subject: [PATCH 11/24] style(drive-abci): cargo fmt fetch_documents.rs flatten call --- .../state_transitions/batch/state/v0/fetch_documents.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs index a33d59d206a..58583fb5455 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/state/v0/fetch_documents.rs @@ -69,10 +69,8 @@ pub(crate) fn fetch_documents_for_transitions( }) .collect::>>, Error>>()?; - let validation_result = ConsensusValidationResult::flatten( - validation_results_of_documents, - platform_version, - )?; + let validation_result = + ConsensusValidationResult::flatten(validation_results_of_documents, platform_version)?; Ok(validation_result) } From aa47a8289d115d735909b01d035c05dd93d631b1 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 02:24:12 +0700 Subject: [PATCH 12/24] style(drive-abci): cargo fmt for #2867 bump-emission cleanup --- .../batch/tests/document/replacement.rs | 27 ++++++++++--------- .../batch/transformer/v0/mod.rs | 10 ++++--- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index f3ce04b4937..75a4182d934 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -778,19 +778,20 @@ mod replacement_tests { altered_document.set_revision(Some(3)); altered_document.set("displayName", "Out of order".into()); - let replace_transition = BatchTransition::new_document_replacement_transition_from_document( - altered_document, - profile, - &key, - 3, - 0, - None, - &signer, - platform_version, - None, - ) - .await - .expect("expected to build replace transition"); + let replace_transition = + BatchTransition::new_document_replacement_transition_from_document( + altered_document, + profile, + &key, + 3, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expected to build replace transition"); let replace_serialized = replace_transition .serialize_to_bytes() diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index fd1e5edaec1..6626e1fb39f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -941,10 +941,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); return Ok(ConsensusValidationResult::new_with_data_and_errors( BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), - vec![StateError::DocumentNotForSaleError( - DocumentNotForSaleError::new(original_document.id()), - ) - .into()], + vec![ + StateError::DocumentNotForSaleError(DocumentNotForSaleError::new( + original_document.id(), + )) + .into(), + ], )); }; From be4a538372563e2ed75cefbe7f4129675b0b03ad Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 02:38:43 +0700 Subject: [PATCH 13/24] fix(drive-abci): version-gate per-transition bump emission, add v11 paired tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR #3608 review comments from QuantumExplorer. - The previous commit emitted bump actions on every per-transition failure unconditionally, which would diverge PROTOCOL_VERSION_11 chain replay (the user-paid fees and stored contract nonce both shifted). Gate the bump emission on a new `failed_per_transition_action: FeatureVersion` field on `DriveAbciDocumentsStateTransitionValidationVersions`: - v0 (PROTOCOL_VERSION_11 and below): errors-only, no action data — preserves chain history bit-for-bit. - v1 (PROTOCOL_VERSION_12+, set in DRIVE_ABCI_VALIDATION_VERSIONS_V8): emit `BumpIdentityDataContractNonce` action. - Factor the 13 per-transition failure sites in `transform_document_transition` into a single `Self::failed_per_transition_action(...)` helper that dispatches on the new field. - Add a function-level doc on `transform_document_transition_v0` noting that the `0` user_fee_increase passed into each `BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition` call is overridden by the outer Documents Batch's `user_fee_increase` when the per-transition action rolls up into the `BatchTransitionAction`, so any per-site value would be discarded. - Refactor three sibling tests into paired-version tests so the v11 baseline fees stay pinned alongside the v12 post-fix fees: - test_document_replace_on_document_type_that_is_not_mutable + _protocol_version_11 (445700 / 41880) - test_document_transfer_that_does_not_yet_exist + _protocol_version_11 (517400 / 36200) - test_document_set_price_on_not_owned_document + _protocol_version_11 (571240 / 36200) --- .../batch/tests/document/nft.rs | 41 ++- .../batch/tests/document/replacement.rs | 44 +++- .../batch/tests/document/transfer.rs | 41 ++- .../batch/transformer/v0/mod.rs | 249 +++++++++--------- .../drive_abci_validation_versions/mod.rs | 15 ++ .../drive_abci_validation_versions/v1.rs | 1 + .../drive_abci_validation_versions/v2.rs | 1 + .../drive_abci_validation_versions/v3.rs | 1 + .../drive_abci_validation_versions/v4.rs | 1 + .../drive_abci_validation_versions/v5.rs | 1 + .../drive_abci_validation_versions/v6.rs | 1 + .../drive_abci_validation_versions/v7.rs | 1 + .../drive_abci_validation_versions/v8.rs | 7 + 13 files changed, 255 insertions(+), 149 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index e95b680df29..7ee10c09c86 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -2652,10 +2652,17 @@ mod nft_tests { assert_eq!(processing_result.aggregated_fees().processing_fee, 0); } - #[tokio::test] - async fn test_document_set_price_on_not_owned_document() { - let platform_version = PlatformVersion::latest(); + /// Helper for the paired set-price-on-not-owned-document test. Same + /// scenario at PROTOCOL_VERSION_11 (legacy bump-only fee) and + /// PROTOCOL_VERSION_12 (fee covers fetch + validation work). + async fn run_document_set_price_on_not_owned_document_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let (mut platform, contract) = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_initial_state_structure() .with_crypto_card_game_nft(TradeMode::DirectPurchase); @@ -2788,10 +2795,12 @@ mod nft_tests { assert_eq!(processing_result.valid_count(), 0); - // Covers the fetch + ownership check that ran before the failure. - // Previously the failure path emitted no action and this work was - // charged as 0. - assert_eq!(processing_result.aggregated_fees().processing_fee, 571240); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee, + "PROTOCOL_VERSION_{}: processing fee must match the version-specific baseline", + protocol_version, + ); let sender_documents_sql_string = format!("select * from card where $ownerId == '{}'", identity.id()); @@ -2821,6 +2830,24 @@ mod nft_tests { ); } + /// PROTOCOL_VERSION_12+: bump emission charges the user for the fetch + + /// ownership check that ran before the failure. + #[tokio::test] + async fn test_document_set_price_on_not_owned_document() { + run_document_set_price_on_not_owned_document_at_protocol_version( + PlatformVersion::latest().protocol_version, + 571240, + ) + .await; + } + + /// PROTOCOL_VERSION_11: pre-fix bump-only fee. Pinned so v11 chain + /// history stays bit-for-bit reproducible. + #[tokio::test] + async fn test_document_set_price_on_not_owned_document_protocol_version_11() { + run_document_set_price_on_not_owned_document_at_protocol_version(11, 36200).await; + } + #[tokio::test] async fn test_document_set_price_and_purchase_with_token_costs() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index 75a4182d934..a4faef44229 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -509,11 +509,17 @@ mod replacement_tests { .await; } - #[tokio::test] - async fn test_document_replace_on_document_type_that_is_not_mutable() { - let platform_version = PlatformVersion::latest(); + /// Helper for the paired Replace-on-immutable-doc test. The same scenario + /// is exercised at PROTOCOL_VERSION_11 (legacy bump-only fee) and at + /// PROTOCOL_VERSION_12 (fee covers fetch + validation). + async fn run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -651,10 +657,32 @@ mod replacement_tests { assert_eq!(processing_result.valid_count(), 0); - // Covers the document fetch + structure validation that ran before - // the failure. Previously the failure path emitted no action and this - // work was charged as 0; now the bump covers it. - assert_eq!(processing_result.aggregated_fees().processing_fee, 445700); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee, + "PROTOCOL_VERSION_{}: processing fee must match the version-specific baseline", + protocol_version, + ); + } + + /// PROTOCOL_VERSION_12+: bump emission charges the user for the fetch + + /// structure validation that ran before the failure. + #[tokio::test] + async fn test_document_replace_on_document_type_that_is_not_mutable() { + run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version( + PlatformVersion::latest().protocol_version, + 445700, + ) + .await; + } + + /// PROTOCOL_VERSION_11: pre-fix bump-only fee (no charge for the fetch + /// + validation work). Pinned so v11 chain history stays bit-for-bit + /// reproducible. + #[tokio::test] + async fn test_document_replace_on_document_type_that_is_not_mutable_protocol_version_11() { + run_document_replace_on_document_type_that_is_not_mutable_at_protocol_version(11, 41880) + .await; } /// Pins the bump-emission contract on Replace's revision-mismatch path. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs index c318a921ad7..2df43222264 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/transfer.rs @@ -1123,10 +1123,17 @@ mod transfer_tests { assert_eq!(query_receiver_results.documents().len(), 0); } - #[tokio::test] - async fn test_document_transfer_that_does_not_yet_exist() { - let platform_version = PlatformVersion::latest(); + /// Helper for the paired transfer-of-missing-document test. Same scenario + /// at PROTOCOL_VERSION_11 (legacy bump-only fee) and PROTOCOL_VERSION_12 + /// (fee covers fetch + validation work). + async fn run_document_transfer_that_does_not_yet_exist_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let (mut platform, contract) = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_initial_state_structure() .with_crypto_card_game_transfer_only(Transferable::Never); @@ -1256,10 +1263,12 @@ mod transfer_tests { assert_eq!(processing_result.valid_count(), 0); - // Covers the fetch + ownership check that ran before the failure. - // Previously the failure path emitted no action and this work was - // charged as 0. - assert_eq!(processing_result.aggregated_fees().processing_fee, 517400); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee, + "PROTOCOL_VERSION_{}: processing fee must match the version-specific baseline", + protocol_version, + ); let query_sender_results = platform .drive @@ -1277,6 +1286,24 @@ mod transfer_tests { assert_eq!(query_receiver_results.documents().len(), 0); } + /// PROTOCOL_VERSION_12+: bump emission charges the user for the fetch + /// that ran before the failure. + #[tokio::test] + async fn test_document_transfer_that_does_not_yet_exist() { + run_document_transfer_that_does_not_yet_exist_at_protocol_version( + PlatformVersion::latest().protocol_version, + 517400, + ) + .await; + } + + /// PROTOCOL_VERSION_11: pre-fix bump-only fee. Pinned so v11 chain + /// history stays bit-for-bit reproducible. + #[tokio::test] + async fn test_document_transfer_that_does_not_yet_exist_protocol_version_11() { + run_document_transfer_that_does_not_yet_exist_at_protocol_version(11, 36200).await; + } + #[tokio::test] async fn test_document_delete_after_transfer() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 6626e1fb39f..c2803b7e5a5 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -166,6 +166,12 @@ trait BatchTransitionInternalTransformerV0 { document_id: Identifier, original_document: &Document, ) -> SimpleConsensusValidationResult; + fn failed_per_transition_action( + base_transition: &dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition, + owner_id: Identifier, + errors: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error>; } impl BatchTransitionTransformerV0 for BatchTransition { @@ -645,6 +651,13 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { /// path would return errors-only with no action data, fee accounting /// would charge 0, and the same nonce would remain available — i.e. a /// "free advanced-structure validation" hole. + /// + /// The `user_fee_increase` argument passed into each + /// `BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition` + /// call is `0` deliberately: the value gets overridden by the outer + /// Documents Batch's `user_fee_increase` when the per-transition action + /// rolls up into the `BatchTransitionAction`, so any per-site value + /// would be discarded. fn transform_document_transition_v0<'a>( drive: &Drive, transaction: TransactionArg, @@ -675,17 +688,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - // user_fee_increase is set on the outer Documents Batch - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_replace_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_replace_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -697,16 +705,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_replace_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_replace_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } if validate_against_state { @@ -718,16 +722,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_replace_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_replace_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } } @@ -761,16 +761,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_transfer_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_transfer_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -782,22 +778,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_transfer_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_transfer_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened + // Skipped on the rerun path where the action has already been applied. let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_transfer_transition.revision(), document_transfer_transition.base().id(), @@ -805,16 +795,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_transfer_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_transfer_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } } @@ -838,16 +824,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_update_price_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_update_price_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -859,22 +841,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_update_price_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_update_price_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened + // Skipped on the rerun path where the action has already been applied. let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_update_price_transition.revision(), document_update_price_transition.base().id(), @@ -882,16 +858,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_update_price_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_update_price_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } } @@ -915,16 +887,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_purchase_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -933,32 +901,23 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { .properties() .get_optional_integer::(PRICE)? else { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_purchase_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, vec![ StateError::DocumentNotForSaleError(DocumentNotForSaleError::new( original_document.id(), )) .into(), ], - )); + platform_version, + ); }; if listed_price != document_purchase_transition.price() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_purchase_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, vec![StateError::DocumentIncorrectPurchasePriceError( DocumentIncorrectPurchasePriceError::new( original_document.id(), @@ -967,13 +926,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ), ) .into()], - )); + platform_version, + ); } if validate_against_state { - //there are situations where we don't want to validate this against the state - // for example when we already applied the state transition action - // and we are just validating it happened + // Skipped on the rerun path where the action has already been applied. let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_purchase_transition.revision(), document_purchase_transition.base().id(), @@ -981,16 +939,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - let bump_action = - BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( - document_purchase_transition.base(), - owner_id, - 0, - ); - return Ok(ConsensusValidationResult::new_with_data_and_errors( - BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } } @@ -1081,4 +1035,45 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } result } + + fn failed_per_transition_action( + base_transition: &dpp::state_transition::batch_transition::document_base_transition::DocumentBaseTransition, + owner_id: Identifier, + errors: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .failed_per_transition_action + { + // PROTOCOL_VERSION_11 and below: errors-only, no action data. + 0 => Ok(ConsensusValidationResult::new_with_errors(errors)), + // PROTOCOL_VERSION_12+: emit a `BumpIdentityDataContractNonce` action + // so the user pays for the validation work that already ran. + // The `0` user_fee_increase here is overridden by the outer + // Documents Batch when this per-transition action rolls up. + 1 => { + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + base_transition, + owner_id, + 0, + ); + Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), + errors, + )) + } + version => Err(Error::Execution( + crate::error::execution::ExecutionError::UnknownVersionMismatch { + method: "documents batch transition: failed_per_transition_action".to_string(), + known_versions: vec![0, 1], + received: version, + }, + )), + } + } } diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 58e63961909..8b3b18edcae 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -127,6 +127,21 @@ pub struct DriveAbciDocumentsStateTransitionValidationVersions { pub revision: FeatureVersion, pub state: FeatureVersion, pub transform_into_action: FeatureVersion, + /// Versions the action emitted when a per-transition validation fails + /// inside [`transform_document_transition`]. + /// + /// - `0` (PROTOCOL_VERSION_11 and below): errors-only, no action data. + /// The empty action flowed through the legacy + /// `flatten` / `merge_many` aggregators as `Some(empty_vec)` and was + /// accounted as `PaidConsensusError`, but no `BumpIdentityDataContractNonce` + /// drive op was created — so the user only paid the bare-bump fee + /// and the contract nonce never advanced. + /// - `1` (PROTOCOL_VERSION_12+): emit a `BumpIdentityDataContractNonce` + /// action so the user pays for the validation work that already ran + /// (fetch + ownership/revision check) and the contract nonce advances. + /// + /// [`transform_document_transition`]: crate + pub failed_per_transition_action: FeatureVersion, pub data_triggers: DriveAbciValidationDataTriggerAndBindingVersions, pub is_allowed: FeatureVersion, pub document_create_transition_structure_validation: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index af26fae4cf0..fce75c16330 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -106,6 +106,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index 0991f5d79ab..ab2d160f2a3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -106,6 +106,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 9d62d308b13..c80ed9f6e0d 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -106,6 +106,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index 4e631bc9c37..a986d603a1a 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -109,6 +109,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index e19de80d669..bb9673de70b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -110,6 +110,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index bea326d225b..21838220e8f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -113,6 +113,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index 5549f4e7ed7..5e23882714d 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -107,6 +107,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + failed_per_transition_action: 0, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { 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 db9c4505047..03fbc32d043 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 @@ -111,6 +111,13 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = state: 0, revision: 0, transform_into_action: 0, + // PROTOCOL_VERSION_12 (v3.1 hard fork): per-transition + // failure paths in `transform_document_transition` now emit + // a `BumpIdentityDataContractNonce` action so the user pays + // for the validation work that already ran (fetch + + // ownership/revision check). v0 stays for chain + // reproducibility on PROTOCOL_VERSION_11 and below. + failed_per_transition_action: 1, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { From 583bd6d5e4ea5d16447c8834a75c774e9bf5ee25 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 17:25:58 +0700 Subject: [PATCH 14/24] test(drive-abci): expect UnpaidConsensusError for single-tx purchase failures (issue #2867) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pre-existing NFT tests asserted PaidConsensusError + invalid_paid_count=1 for single-transition batches whose lone Purchase fails on DocumentNotForSaleError or DocumentIncorrectPurchasePriceError. With the v1 `flatten` / `merge_many` aggregators introduced in this PR (PROTOCOL_VERSION_12+), an all-failed single-transition batch now returns `data: None` instead of the legacy `Some(empty_vec)`. The result flows down the unpaid path: `UnpaidConsensusError` and the tx is removed from the block by prepare_proposal. Update the assertions to reflect that. Note that PR #3608 (separate, pending) restores Paid behavior for these specific paths by emitting a BumpIdentityDataContractNonce action so the user pays for the validation work — at which point these tests will need to flip back to PaidConsensusError. For now this PR alone produces UnpaidConsensus for these cases, which matches v3.1-dev's current state without #3608's bump emission. --- .../batch/tests/document/nft.rs | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index 7ae4036d57c..d79db716421 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -1847,16 +1847,19 @@ mod nft_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); + // PROTOCOL_VERSION_12+ (issue #2867): the v1 `flatten` / `merge_many` + // aggregators return `data: None` when no per-transition input + // contributed, so a single-transition batch where the lone Purchase + // fails its price check now flows as `UnpaidConsensusError` (tx + // removed from block by prepare_proposal) instead of being recorded + // as a paid empty `BatchTransitionAction`. + assert_eq!(processing_result.invalid_unpaid_count(), 1); + assert_eq!(processing_result.invalid_paid_count(), 0); let result = processing_result.into_execution_results().remove(0); - let StateTransitionExecutionResult::PaidConsensusError { - error: consensus_error, - .. - } = result - else { - panic!("expected a paid consensus error"); + let StateTransitionExecutionResult::UnpaidConsensusError(consensus_error) = result else { + panic!("expected an unpaid consensus error"); }; assert_eq!(consensus_error.to_string(), "5rJccTdtJfg6AxSKyrptWUug3PWjveEitTTLqBn9wHdk document can not be purchased for 35000000000, it's sale price is 50000000000 (in credits)"); } @@ -2355,16 +2358,19 @@ mod nft_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); + // PROTOCOL_VERSION_12+ (issue #2867): the v1 `flatten` / `merge_many` + // aggregators return `data: None` when no per-transition input + // contributed, so a single-transition batch where the lone Purchase + // hits `DocumentNotForSaleError` now flows as `UnpaidConsensusError` + // (tx removed from block by prepare_proposal) instead of being + // recorded as a paid empty `BatchTransitionAction`. + assert_eq!(processing_result.invalid_unpaid_count(), 1); + assert_eq!(processing_result.invalid_paid_count(), 0); let result = processing_result.into_execution_results().remove(0); - let StateTransitionExecutionResult::PaidConsensusError { - error: consensus_error, - .. - } = result - else { - panic!("expected a paid consensus error"); + let StateTransitionExecutionResult::UnpaidConsensusError(consensus_error) = result else { + panic!("expected an unpaid consensus error"); }; assert_eq!( consensus_error.to_string(), From 964df5b0a08f03eaca95852bdd855cebc35accc8 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Fri, 8 May 2026 19:09:49 +0700 Subject: [PATCH 15/24] test(drive-abci): pair purchase-failure tests for v11/v12 protocol coverage Address @thepastaclaw's review on PR #3616: the purchase-failure regressions were only pinned to PlatformVersion::latest(), so the v11 chain-history branch was not covered. Pair them following the same pattern as the ownership-mismatch / transfer / replace tests: test_document_set_price_and_try_purchase_at_different_amount + ..._protocol_version_11 test_document_set_price_and_purchase_then_try_buy_back + ..._protocol_version_11 Both versions land as PaidConsensusError (legacy v0 aggregator wraps the empty per-tx action as Some(empty_vec) on v11; v1 aggregator + bump emission produces a non-empty action on v12). The error-string assertion is identical across versions; the helper takes platform_version and runs the full scenario under that protocol. --- .../batch/tests/document/nft.rs | 75 +++++++++++++++++-- 1 file changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index e76656f9f3f..5641a8e7d5d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -1662,10 +1662,18 @@ mod nft_tests { assert_eq!(buyers_balance, dash_to_credits!(0.9) - 68691480); } - #[tokio::test] - async fn test_document_set_price_and_try_purchase_at_different_amount() { - let platform_version = PlatformVersion::latest(); + /// Helper for the paired Purchase-at-wrong-price test. Same scenario at + /// PROTOCOL_VERSION_11 (legacy bump-only fee — Purchase paths don't emit + /// per-tx bumps in v0 of the transformer) and PROTOCOL_VERSION_12+ (bump + /// emission active for every per-tx failure). Both versions land as + /// PaidConsensusError; only the fee differs. + async fn run_document_set_price_and_try_purchase_at_different_amount_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let (mut platform, contract) = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_initial_state_structure() .with_crypto_card_game_nft(TradeMode::DirectPurchase); @@ -1847,7 +1855,12 @@ mod nft_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "PROTOCOL_VERSION_{}: must land as PaidConsensusError", + protocol_version, + ); let result = processing_result.into_execution_results().remove(0); @@ -1861,6 +1874,23 @@ mod nft_tests { assert_eq!(consensus_error.to_string(), "5rJccTdtJfg6AxSKyrptWUug3PWjveEitTTLqBn9wHdk document can not be purchased for 35000000000, it's sale price is 50000000000 (in credits)"); } + /// PROTOCOL_VERSION_12+: bump emission active on Purchase failure paths. + #[tokio::test] + async fn test_document_set_price_and_try_purchase_at_different_amount() { + run_document_set_price_and_try_purchase_at_different_amount_at_protocol_version( + PlatformVersion::latest().protocol_version, + ) + .await; + } + + /// PROTOCOL_VERSION_11: legacy bump-only fee (Purchase paths don't emit + /// per-tx bumps in v0 of the transformer; the empty action still flows as + /// PaidConsensusError via the legacy `Some(empty_vec)` aggregator). + #[tokio::test] + async fn test_document_set_price_and_try_purchase_at_different_amount_protocol_version_11() { + run_document_set_price_and_try_purchase_at_different_amount_at_protocol_version(11).await; + } + #[tokio::test] async fn test_document_set_price_and_purchase_from_ones_self() { let platform_version = PlatformVersion::latest(); @@ -2057,12 +2087,19 @@ mod nft_tests { assert_eq!(consensus_error.to_string(), "Document transition action on document type: card identity trying to purchase a document that is already owned by the purchaser is not supported"); } - #[tokio::test] - async fn test_document_set_price_and_purchase_then_try_buy_back() { + /// Helper for the paired Purchase-then-buy-back test. Same scenario at + /// PROTOCOL_VERSION_11 (legacy bump-only fee) and PROTOCOL_VERSION_12+ + /// (bump emission active for every per-tx failure). Both versions land + /// as PaidConsensusError; only the fee differs. + async fn run_document_set_price_and_purchase_then_try_buy_back_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + ) { // In this test we try to buy back a document after it has been sold - let platform_version = PlatformVersion::latest(); + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let (mut platform, contract) = TestPlatformBuilder::new() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_initial_state_structure() .with_crypto_card_game_nft(TradeMode::DirectPurchase); @@ -2355,7 +2392,12 @@ mod nft_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "PROTOCOL_VERSION_{}: must land as PaidConsensusError", + protocol_version, + ); let result = processing_result.into_execution_results().remove(0); @@ -2372,6 +2414,23 @@ mod nft_tests { ); } + /// PROTOCOL_VERSION_12+: bump emission active on Purchase failure paths. + #[tokio::test] + async fn test_document_set_price_and_purchase_then_try_buy_back() { + run_document_set_price_and_purchase_then_try_buy_back_at_protocol_version( + PlatformVersion::latest().protocol_version, + ) + .await; + } + + /// PROTOCOL_VERSION_11: legacy bump-only fee (Purchase paths don't emit + /// per-tx bumps in v0 of the transformer; the empty action still flows as + /// PaidConsensusError via the legacy `Some(empty_vec)` aggregator). + #[tokio::test] + async fn test_document_set_price_and_purchase_then_try_buy_back_protocol_version_11() { + run_document_set_price_and_purchase_then_try_buy_back_at_protocol_version(11).await; + } + #[tokio::test] async fn test_document_set_price_and_purchase_with_enough_credits_to_buy_but_not_enough_to_pay_for_processing( ) { From 8ab48a072f6d794e1bc0da2c36deabadacc726e0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 18:00:29 +0700 Subject: [PATCH 16/24] fix(drive-abci): preserve v11 bump on Replace's missing-target-document path @thepastaclaw flagged that the unified bump helper Self::failed_per_transition_action() was stripping the legacy bump emission on PROTOCOL_VERSION_11 for the one path that already had it pre-PR: Replace -> find_replaced_document_v0 failure. In the pre-PR v3.1-dev code this arm inlined a BumpIdentityDataContractNonceAction and returned new_with_data_and_errors(...). Under v11 the legacy aggregator lifts that to Some(vec![BumpAction]) and the bump's UpdateIdentityContractNonce drive op advances the contract nonce in state. After consolidation this path delegated to the helper, whose v0 branch returns new_with_errors(errors) (data: None). Under v11 the legacy aggregator then lifts to Some(vec![]) - empty action, no bump op - and the nonce that v11 mainnet history advanced is no longer advanced. That is a bit-for-bit divergence from PROTOCOL_VERSION_11 (state root + replay protection both shift). Fix: keep the bump inline on this one path so it emits regardless of failed_per_transition_action. The helper continues to gate the other 12 per-transition failure paths (legacy didn't bump them, so v0=no-bump correctly preserves their behavior; v1 adds the new bump). Also adds test_document_replace_that_does_not_yet_exist_protocol_version_11 to pin both branches of this path going forward. 6/6 Replace-arm tests pass locally. --- .../batch/tests/document/replacement.rs | 51 ++++++++++++++++--- .../batch/transformer/v0/mod.rs | 20 ++++++-- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index a4faef44229..8a16213a46b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -1112,11 +1112,21 @@ mod replacement_tests { assert_eq!(query_receiver_results.documents().len(), 0); } - #[tokio::test] - async fn test_document_replace_that_does_not_yet_exist() { - let platform_version = PlatformVersion::latest(); + /// Helper for the paired Replace-on-missing-document test. + /// + /// Both versions land as PaidConsensusError because the Replace + /// missing-target-document path emits a `BumpIdentityDataContractNonce` + /// action on every protocol version (it was the one legacy v0 bump + /// site, preserved to keep PROTOCOL_VERSION_11 chain replay bit-for-bit + /// reproducible). Only the fee differs. + async fn run_document_replace_that_does_not_yet_exist_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + expected_processing_fee: dpp::fee::Credits, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -1195,13 +1205,42 @@ mod replacement_tests { .unwrap() .expect("expected to commit transaction"); - assert_eq!(processing_result.invalid_paid_count(), 1); + assert_eq!( + processing_result.invalid_paid_count(), + 1, + "PROTOCOL_VERSION_{}: must land as PaidConsensusError", + protocol_version, + ); assert_eq!(processing_result.invalid_unpaid_count(), 0); assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 516040); + assert_eq!( + processing_result.aggregated_fees().processing_fee, + expected_processing_fee, + "PROTOCOL_VERSION_{}: processing fee must match the version-specific baseline", + protocol_version, + ); + } + + /// PROTOCOL_VERSION_12+ — same fee as v11 because the bump emission for + /// this specific path is unconditional (pre-existing legacy behavior). + #[tokio::test] + async fn test_document_replace_that_does_not_yet_exist() { + run_document_replace_that_does_not_yet_exist_at_protocol_version( + PlatformVersion::latest().protocol_version, + 516040, + ) + .await; + } + + /// PROTOCOL_VERSION_11 — pins the legacy fee + bump-emission behavior. + /// This is the one Replace failure path that already emitted a bump on + /// v11; the bump-emission helper must not strip it on v0. + #[tokio::test] + async fn test_document_replace_that_does_not_yet_exist_protocol_version_11() { + run_document_replace_that_does_not_yet_exist_at_protocol_version(11, 516040).await; } #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index eca802a80fd..8ddf9b65402 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -701,12 +701,22 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - return Self::failed_per_transition_action( - document_replace_transition.base(), - owner_id, + // Replace's missing-target-document path is the one + // failure site that already emitted a bump action on + // PROTOCOL_VERSION_11 (pre-PR), so it must continue to + // do so regardless of `failed_per_transition_action`. + // Routing it through the helper would drop the bump on + // v0 and diverge v11 chain replay. + let bump_action = + BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( + document_replace_transition.base(), + owner_id, + 0, + ); + return Ok(ConsensusValidationResult::new_with_data_and_errors( + BatchedTransitionAction::BumpIdentityDataContractNonce(bump_action), validation_result.errors, - platform_version, - ); + )); } let original_document = validation_result.into_data()?; From 869d257bef14e7568e1af5edd47680cc818a26f5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 18:02:20 +0700 Subject: [PATCH 17/24] =?UTF-8?q?docs(drive-abci):=20correct=20nft.rs=20he?= =?UTF-8?q?lper=20doc=20=E2=80=94=20v11=20lands=20Paid=20via=20legacy=20ag?= =?UTF-8?q?gregator,=20not=20bump?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @thepastaclaw flagged that the doc on run_document_set_price_on_not_owned_document_at_protocol_version misattributes v11's PaidConsensusError shape to bump emission. failed_per_transition_action == 0 on v11, so the helper returns new_with_errors(errors) — the empty errors-only result is then lifted to Some(empty_vec) by the legacy flatten_v0/merge_many_v0 aggregators (the v11 'bug' we're preserving for chain reproducibility), which the downstream processor reads as PaidConsensusError. Bump emission only kicks in under v12 (failed_per_transition_action: 1). Reword the doc to split the two reasons clearly so future maintainers don't get the wrong mental model. --- .../batch/tests/document/nft.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index 5641a8e7d5d..be096719e1f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -2713,11 +2713,18 @@ mod nft_tests { /// Helper for the paired set-price-on-not-owned-document test. /// - /// Same scenario at PROTOCOL_VERSION_11 (legacy bump-only fee) and - /// PROTOCOL_VERSION_12 (fee covers the fetch + ownership-mismatch - /// validation work that ran before the failure). Both versions land as - /// PaidConsensusError because the bump-emission patch makes the per-tx - /// failure produce a non-empty action on every version. + /// - **PROTOCOL_VERSION_11**: lands as `PaidConsensusError` via the + /// legacy `flatten_v0` / `merge_many_v0` aggregators lifting the + /// empty errors-only result to `Some(empty_vec)` — preserved for + /// chain reproducibility. The contract nonce is *not* actually + /// advanced (no bump action's drive op is created). + /// - **PROTOCOL_VERSION_12+**: lands as `PaidConsensusError` because + /// the per-tx failure path now emits a + /// `BumpIdentityDataContractNonce` action + /// (`failed_per_transition_action: 1`). The contract nonce + /// advances and the user pays for the fetch + ownership check. + /// + /// Only the fee differs between the two versions. async fn run_document_set_price_on_not_owned_document_at_protocol_version( protocol_version: dpp::version::ProtocolVersion, expected_processing_fee: dpp::fee::Credits, From 50addd7bf5ef75d27930256a8f0bf003c00903e2 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 18:18:05 +0700 Subject: [PATCH 18/24] docs(platform-version): drop stale PR #3608 reference from max_transitions TODO @thepastaclaw flagged that PR #3608 is no longer a live reference (consolidated into #3616 and closed). Keep just `issue #2867` as the persistent umbrella ticket for the broader batch-pipeline atomicity work the TODO describes. --- packages/rs-platform-version/src/version/system_limits/v1.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 5f3873336cb..c4237df7ca8 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -12,7 +12,7 @@ pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { // well-defined: it is unclear whether to bump the nonce for the // failed transition only, for all transitions, or for none — and the // transformer/dispatch code does not consistently express any of - // those policies (see issue #2867 and PR #3608). + // those policies (see issue #2867). // Before lifting this cap above 1, the whole batch validation + // transformer + nonce-bump path must be reviewed and the atomicity / // nonce semantics fixed. Pulling the cap higher today would expose From ed0c66a1004659b2500daab5a27f941093815fdd Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 18:26:04 +0700 Subject: [PATCH 19/24] docs(drive-abci): explain why batch transformer keeps the _v0 suffix despite version dispatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @thepastaclaw noted the _v0-suffixed transformer functions (try_into_action_v0, transform_document_transition_v0, etc.) carry internal version dispatch, breaking the codebase convention that _v0 implements v0 behavior only. Renaming the suffix would force duplicating the ~1100-line transformer body into a v1 archive (the v0 file has to stay verbatim to keep v11 chain replay reproducible) — exactly the copy-paste this PR's facade pattern was refactored to avoid. The colleague review suggested versioning the smaller flatten/merge_many helpers instead, which is why the transformer is single-file with field-level version gates. Add a module-level doc explaining the convention so future contributors don't either (a) replicate this pattern by accident in a place that doesn't warrant it, or (b) try to 'fix' the suffix and trigger the duplication. --- .../batch/transformer/v0/mod.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 8ddf9b65402..bec77ab7b5c 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -4,6 +4,22 @@ // legacy `Some(empty_vec)`-on-no-data behavior for chain reproducibility; // v1 (PROTOCOL_VERSION_12+) returns `data: None` in that case so an // all-failed batch flows down the unpaid path. See issue #2867. +// +// Note on the `_v0` suffix retained on the transformer functions in this +// file (`try_into_action_v0`, `transform_document_transition_v0`, etc.): +// these are version-1 in their behavior but keep the `_v0` suffix on +// purpose. Bumping the suffix would force duplicating the entire ~1100 +// line transformer body into a `v1/mod.rs` archive (the v0 file would +// have to stay verbatim to keep v11 chain replay reproducible) — the +// kind of copy-paste this PR was specifically refactored to avoid. +// Instead, protocol-version-dependent behavior is gated at a finer +// granularity by the version fields it consumes: +// * `dpp.validation.validation_result.flatten` +// * `dpp.validation.validation_result.merge_many` +// * `drive_abci...batch_state_transition.failed_per_transition_action` +// A future protocol bump that needs different aggregator or +// failure-action semantics should add another value to one of those +// fields rather than rename this file. use std::collections::btree_map::Entry; use std::collections::BTreeMap; From 7fb8495c38dfc384b55404b6048f960e532bb859 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 18:56:32 +0700 Subject: [PATCH 20/24] docs(dpp): document Some(empty_vec) collapse hazard in flatten_v1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @thepastaclaw flagged that flatten_v1 keys on aggregate_data.is_empty() to decide between data: None and data: Some(_), which collapses two distinct caller-side intents — truly-no-work and validated-but-produced-no-output — into the same output. For the documents-batch path under PROTOCOL_VERSION_12 this is safe (per-tx handlers always emit either an action or a bump), but the public docstring was silent about the collapse. Document the hazard explicitly on: - flatten/v1/mod.rs (the v1 implementation) - merge_many/v1/mod.rs (notes the hazard doesn't apply at that layer — TData is per-item, not Vec) - mod.rs flatten facade (the public dispatcher) - mod.rs merge_many facade (notes symmetry with flatten) Pure doc change. --- .../validation_result/flatten/v1/mod.rs | 19 +++++++++++++++++++ .../validation_result/merge_many/v1/mod.rs | 11 +++++++++++ .../src/validation/validation_result/mod.rs | 16 ++++++++++++++++ 3 files changed, 46 insertions(+) diff --git a/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs b/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs index a4384ec18f6..13a77270709 100644 --- a/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/flatten/v1/mod.rs @@ -9,6 +9,25 @@ //! downstream code (e.g. `process_validation_result_v0:241`) relies on to //! choose between `PaidConsensusError` and `UnpaidConsensusError`. //! +//! # Caller-intent ambiguity +//! +//! `flatten_v1` keys on `aggregate_data.is_empty()` to decide between +//! `data: None` and `data: Some(_)`. This collapses two distinct +//! caller-side intents into the same output: +//! +//! * **Truly no work**: every input had `data: None`. +//! * **Validated but produced no output**: every input had +//! `data: Some(empty_vec)`. +//! +//! v1 cannot distinguish those two cases at the aggregate level — both +//! end up as `data: None` and are routed to `UnpaidConsensusError` +//! downstream. For the documents-batch path under PROTOCOL_VERSION_12 this +//! is safe: every per-transition handler emits at least one action on +//! success and a bump action on failure, so no caller produces +//! `Some(empty_vec)`. A future caller that needs "validated, but no +//! actions to apply" must signal that with at least one non-empty entry, +//! not with `Some(empty_vec)`. +//! //! See issue #2867 for context. //! //! [`ConsensusValidationResult::flatten`]: crate::validation::ConsensusValidationResult::flatten diff --git a/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs b/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs index 01df5e78c68..0a4135cefae 100644 --- a/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/merge_many/v1/mod.rs @@ -8,6 +8,17 @@ //! downstream code (e.g. `process_validation_result_v0:241`) relies on to //! choose between `PaidConsensusError` and `UnpaidConsensusError`. //! +//! # Caller-intent ambiguity +//! +//! `merge_many_v1` keys on `aggregate_data.is_empty()` to decide between +//! `data: None` and `data: Some(_)`. Every `Some(_)` input contributes one +//! element to `aggregate_data`, so the only way to get `data: None` is to +//! have zero inputs with `data: Some(_)`. There is no `Some(empty_vec)` +//! input shape at this layer (the per-item `data` is `TData`, not +//! `Vec`), so the collapse hazard described for `flatten_v1` +//! doesn't apply here. The dispatcher facade ([`ValidationResult::merge_many`]) +//! shares the limitation note for symmetry. +//! //! See issue #2867 for context. //! //! [`ValidationResult::merge_many`]: crate::validation::ValidationResult::merge_many diff --git a/packages/rs-dpp/src/validation/validation_result/mod.rs b/packages/rs-dpp/src/validation/validation_result/mod.rs index 636f241f909..73714d808cf 100644 --- a/packages/rs-dpp/src/validation/validation_result/mod.rs +++ b/packages/rs-dpp/src/validation/validation_result/mod.rs @@ -50,6 +50,17 @@ impl ValidationResult, E> { /// `process_validation_result_v0:241`) relies on to choose between /// `PaidConsensusError` and `UnpaidConsensusError`. /// + /// # v1 caller-intent ambiguity + /// + /// v1 keys on `aggregate_data.is_empty()` to decide between + /// `data: None` and `data: Some(_)`, which collapses two distinct + /// caller intents into the same output: every input had `data: None` + /// (truly no work) and every input had `data: Some(empty_vec)` + /// (validated but produced no output). v1 cannot distinguish those + /// at the aggregate level — both yield `data: None` and are routed + /// to `UnpaidConsensusError` downstream. Callers that need "validated + /// but no actions" must signal that with at least one non-empty entry. + /// /// See issue #2867 for context on the v0 → v1 change. pub fn flatten, E>>>( items: I, @@ -79,6 +90,11 @@ impl ValidationResult { /// contributed any data. See [`flatten`] for the invariant this /// restores. /// + /// Unlike [`flatten`], `merge_many` operates on per-item `TData` (not + /// `Vec`), so each `Some(_)` input contributes exactly one + /// element — there is no `Some(empty_vec)`-input collapse hazard at + /// this layer. + /// /// See issue #2867 for context on the v0 → v1 change. /// /// [`flatten`]: ValidationResult::flatten From 046ccef07334c4974b55c2a8753eeaef75b01c88 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 19:12:58 +0700 Subject: [PATCH 21/24] test(drive-abci): pin tokens-always-pay invariant for batch transformer aggregator change @thepastaclaw flagged that transform_token_transitions_within_contract_v0's ConsensusValidationResult::merge_many call is silently affected by the v0->v1 aggregator change at PROTOCOL_VERSION_12 (issue #2867), but only by the implicit "tokens always emit a bump on failure" invariant inside each token sub-transformer. A future change to a token sub-transformer that drops the bump would silently route that failure to UnpaidConsensusError - tx removed from the block by prepare_proposal - which would be a state-root and replay-protection divergence with no regression signal. Two changes: - Doc note on transform_token_transitions_within_contract_v0 spelling out the invariant explicitly, with a pointer to the regression test. - Pair test_token_burn_trying_to_burn_more_than_we_have into a helper + PROTOCOL_VERSION_12+ wrapper + PROTOCOL_VERSION_11 wrapper. Both versions now exercise the all-failed single-token-transition path and assert PaidConsensusError. If a token sub-transformer ever drops the bump, both versions break (and the failure message points at the invariant). --- .../batch/tests/token/burn/mod.rs | 46 +++++++++++++++++-- .../batch/transformer/v0/mod.rs | 21 +++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs index b6ddfa1b53e..b55b7e13523 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/burn/mod.rs @@ -180,11 +180,31 @@ mod token_burn_tests { assert_eq!(token_balance, Some(0)); } - #[tokio::test] - async fn test_token_burn_trying_to_burn_more_than_we_have() { - let platform_version = PlatformVersion::latest(); + /// Pins the *tokens-always-pay* invariant for the + /// [`ConsensusValidationResult::merge_many`] aggregator change + /// (issue #2867): an all-failed single-token-transition batch must + /// continue to land as `PaidConsensusError` on every protocol + /// version, because the token sub-transformer + /// (`try_from_borrowed_token_burn_transition_with_contract_lookup`) + /// emits a `BumpIdentityDataContractNonce` action on + /// base-validation failure, so each per-token result has + /// `data: Some([bump])` and the v1 aggregator never collapses to + /// `data: None`. + /// + /// If a future change drops the bump from the token sub-transformer, + /// the v1 aggregator would route the failure to + /// `UnpaidConsensusError` and the tx would be removed from the block + /// by `prepare_proposal` — different state-root, different + /// replay-protection behavior than every prior chain. The paired + /// `_protocol_version_11` sibling pins the same invariant under v11 + /// (legacy aggregator, but the bump emission is identical). + async fn run_token_burn_trying_to_burn_more_than_we_have_at_protocol_version( + protocol_version: dpp::version::ProtocolVersion, + ) { + let platform_version = PlatformVersion::get(protocol_version) + .expect("expected platform version for the requested protocol_version"); let mut platform = TestPlatformBuilder::new() - .with_latest_protocol_version() + .with_initial_protocol_version(protocol_version) .build_with_mock_rpc() .set_genesis_state(); @@ -271,6 +291,24 @@ mod token_burn_tests { assert_eq!(token_balance, Some(100000)); // nothing was burned } + /// PROTOCOL_VERSION_12+: pins the tokens-always-pay invariant under the + /// new v1 aggregator. + #[tokio::test] + async fn test_token_burn_trying_to_burn_more_than_we_have() { + run_token_burn_trying_to_burn_more_than_we_have_at_protocol_version( + PlatformVersion::latest().protocol_version, + ) + .await; + } + + /// PROTOCOL_VERSION_11: pins the same invariant under the legacy v0 + /// aggregator (the bump emission is identical across versions for + /// tokens, so both run paths must produce PaidConsensusError). + #[tokio::test] + async fn test_token_burn_trying_to_burn_more_than_we_have_protocol_version_11() { + run_token_burn_trying_to_burn_more_than_we_have_at_protocol_version(11).await; + } + #[tokio::test] async fn test_token_burn_gives_error_if_trying_to_burn_from_not_allowed_identity() { let platform_version = PlatformVersion::latest(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index bec77ab7b5c..cadfc9b467d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -326,6 +326,27 @@ impl BatchTransitionTransformerV0 for BatchTransition { } impl BatchTransitionInternalTransformerV0 for BatchTransition { + /// Roll up the per-token-transition results via the versioned + /// [`ConsensusValidationResult::merge_many`] facade. + /// + /// The aggregator's v0→v1 change at PROTOCOL_VERSION_12 (see issue + /// #2867) also affects this token path. **Tokens-always-pay + /// invariant**: every per-token sub-transformer + /// (`try_from_borrowed_token_*_transition_with_contract_lookup`) + /// emits a `BumpIdentityDataContractNonce` action on its + /// base-validation failure path, so each per-token result carries + /// `data: Some(...)` even when validation fails. The v1 aggregator + /// therefore never collapses an all-failed token batch to + /// `data: None`, and token failures continue to land as + /// `PaidConsensusError` (tx stays in the block, user pays for the + /// validation work) under both v11 and v12. + /// + /// A future change to a token sub-transformer that drops the bump + /// emission on a failure path would silently route that failure to + /// `UnpaidConsensusError` (tx removed from the block by + /// `prepare_proposal`). See + /// `tests/token/burn/mod.rs::test_token_burn_trying_to_burn_more_than_we_have` + /// and its `_protocol_version_11` sibling for the regression pin. fn transform_token_transitions_within_contract_v0( platform: &PlatformStateRef, data_contract_id: &Identifier, From 11c87bccbd93e871dd152679f4b333d098fa934b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 11 May 2026 21:16:42 +0700 Subject: [PATCH 22/24] docs(drive-abci): address QE review comments on transformer/v0 doc accuracy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three doc fixes: 1. transform_document_transition_v0 function-level doc previously claimed "each per-transition failure path emits a Bump" — true only under v12. Rewritten to split v0 (legacy errors-only via Some(empty_vec) lifting) vs v1 (bump emission), and call out the inline Replace exception. 2. validate_against_state inline comments — my earlier condensed wording ("Skipped on the rerun path where the action has already been applied") pinned one specific case out of several (CheckTx, dry-run, etc.). Restore the original "for example" hedge across all four occurrences so the comment doesn't misrepresent the set of skip conditions. 3. failed_per_transition_action helper — make the `0` user_fee_increase placeholder explanation visible at the literal, not just in an arm comment. Phrasing matches QE's "gets reapplied later" framing. --- .../batch/transformer/v0/mod.rs | 60 +++++++++++++------ 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index cadfc9b467d..962b97f0d4a 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -693,21 +693,36 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } } - /// Per-transition handler for document arms. Each per-transition failure - /// path (ownership mismatch, revision mismatch, missing target document, - /// etc.) emits a `BumpIdentityDataContractNonce` action so the user pays - /// for the validation work that already ran (fetch + ownership/revision - /// checks) and the contract nonce advances. Without this, the failure - /// path would return errors-only with no action data, fee accounting - /// would charge 0, and the same nonce would remain available — i.e. a - /// "free advanced-structure validation" hole. + /// Per-transition handler for document arms. + /// + /// Each per-transition failure path (ownership mismatch, revision + /// mismatch, missing target document, etc.) routes through + /// [`Self::failed_per_transition_action`], whose behavior is gated + /// on the `failed_per_transition_action` version field: + /// + /// - **v0** (`PROTOCOL_VERSION_11` and below): returns errors-only + /// with no action data. The legacy `flatten_v0` / `merge_many_v0` + /// aggregators lift this to `Some(empty_vec)`, which downstream + /// code records as `PaidConsensusError` with a bump-only fee but + /// no actual `UpdateIdentityContractNonce` drive op — the + /// "free advanced-structure validation" v11 footgun. Preserved + /// here for chain reproducibility. + /// - **v1** (`PROTOCOL_VERSION_12`+): emits a + /// `BumpIdentityDataContractNonce` action so the user pays for + /// the validation work that already ran (fetch + ownership / + /// revision check) and the contract nonce advances. + /// + /// The one exception is Replace's missing-target-document path, + /// which always emits a bump inline (regardless of version) — that + /// was the one legacy v0 bump site pre-PR, kept as-is to preserve + /// v11 chain replay bit-for-bit. /// /// The `user_fee_increase` argument passed into each /// `BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition` /// call is `0` deliberately: the value gets overridden by the outer - /// Documents Batch's `user_fee_increase` when the per-transition action - /// rolls up into the `BatchTransitionAction`, so any per-site value - /// would be discarded. + /// Documents Batch's `user_fee_increase` when the per-transition + /// action rolls up into the `BatchTransitionAction`, so any + /// per-site value would be discarded. fn transform_document_transition_v0<'a>( drive: &Drive, transaction: TransactionArg, @@ -774,7 +789,9 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } if validate_against_state { - // Skipped on the rerun path where the action has already been applied. + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_replace_transition.revision(), document_replace_transition.base().id(), @@ -847,7 +864,9 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } if validate_against_state { - // Skipped on the rerun path where the action has already been applied. + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_transfer_transition.revision(), document_transfer_transition.base().id(), @@ -910,7 +929,9 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } if validate_against_state { - // Skipped on the rerun path where the action has already been applied. + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_update_price_transition.revision(), document_update_price_transition.base().id(), @@ -991,7 +1012,9 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { } if validate_against_state { - // Skipped on the rerun path where the action has already been applied. + //there are situations where we don't want to validate this against the state + // for example when we already applied the state transition action + // and we are just validating it happened let validation_result = Self::check_revision_is_bumped_by_one_during_replace_v0( document_purchase_transition.revision(), document_purchase_transition.base().id(), @@ -1113,9 +1136,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { 0 => Ok(ConsensusValidationResult::new_with_errors(errors)), // PROTOCOL_VERSION_12+: emit a `BumpIdentityDataContractNonce` action // so the user pays for the validation work that already ran. - // The `0` user_fee_increase here is overridden by the outer - // Documents Batch when this per-transition action rolls up. 1 => { + // The `0` user_fee_increase here is a placeholder. It will be + // overridden (reapplied) with the outer Documents Batch's + // `user_fee_increase` when this per-transition action rolls up + // into the `BatchTransitionAction`, so the per-site value is + // discarded — passing `0` is harmless. let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( base_transition, From 8d7cad6a36012012299251fa5e11799f0b0f8b5d Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 12 May 2026 13:37:21 +0700 Subject: [PATCH 23/24] docs(drive-abci): trim Replace inline-bump comment per QE review QuantumExplorer agreed with the proposed three-line trim of the rationale comment on Replace's missing-target-document inline bump emission. Apply it. --- .../state_transitions/batch/transformer/v0/mod.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 962b97f0d4a..b22235fdb29 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -753,12 +753,9 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Self::find_replaced_document_v0(transition, replaced_documents); if !validation_result.is_valid_with_data() { - // Replace's missing-target-document path is the one - // failure site that already emitted a bump action on - // PROTOCOL_VERSION_11 (pre-PR), so it must continue to - // do so regardless of `failed_per_transition_action`. - // Routing it through the helper would drop the bump on - // v0 and diverge v11 chain replay. + // Keep this bump emission inline (not via Self::failed_per_transition_action): + // it's the one legacy v0 bump site, and routing it through the helper would + // drop the bump on PROTOCOL_VERSION_11 and diverge v11 chain replay (#2867). let bump_action = BumpIdentityDataContractNonceAction::from_borrowed_document_base_transition( document_replace_transition.base(), From fd92ebdac2a0f844bc35d6e218a74ab69646a061 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 12 May 2026 13:40:30 +0700 Subject: [PATCH 24/24] docs(drive-abci): fix mislabeled inline comment in set_price_on_not_owned helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @thepastaclaw flagged two issues with the inline comment in run_document_set_price_on_not_owned_document_at_protocol_version: 1. It called the transition a 'Purchase failure' — wrong; the test is UpdatePrice on a not-owned doc (the assertion message right below correctly says UpdatePrice). 2. It claimed the bump-emission fix is active on every protocol version — wrong; failed_per_transition_action is 0 on v11, so the helper returns errors-only there and v11 lands Paid via the legacy flatten_v0 / merge_many_v0 Some(empty_vec) lift (the v11 footgun preserved for chain reproducibility). Only v12 emits a bump. Rewrite the comment to (a) say UpdatePrice and (b) split the v11 (legacy aggregator lift) vs v12 (bump emission) reasons, matching the helper doc. --- .../batch/tests/document/nft.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs index be096719e1f..9498b00ed25 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/nft.rs @@ -2859,10 +2859,19 @@ mod nft_tests { .unwrap() .expect("expected to commit transaction"); - // With the bump-emission fix from this PR active on every protocol - // version, the single-transition Purchase failure produces a - // BumpIdentityDataContractNonce action (non-empty), so both v11 and - // v12 land as PaidConsensusError; only the fee differs. + // UpdatePrice on a not-owned doc: the per-tx ownership check fails. + // Both protocol versions land as PaidConsensusError, but for + // different reasons: + // - v11: `failed_per_transition_action` is 0, helper returns + // errors-only; legacy `flatten_v0`/`merge_many_v0` lift to + // `Some(empty_vec)`, recorded as Paid with the bump-only fee + // (no actual nonce advance — the v11 footgun preserved for + // chain reproducibility). + // - v12: helper emits `BumpIdentityDataContractNonce`; aggregator + // wraps as `Some([bump])`; recorded as Paid; nonce advances. + // See the helper doc on + // `run_document_set_price_on_not_owned_document_at_protocol_version` + // for the full split. assert_eq!( processing_result.invalid_paid_count(), 1,