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..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,7 +2795,12 @@ mod nft_tests { 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, + "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()); @@ -2818,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 759a1f0f13f..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,7 +657,262 @@ mod replacement_tests { assert_eq!(processing_result.valid_count(), 0); - assert_eq!(processing_result.aggregated_fees().processing_fee, 41880); + 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. + /// + /// 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. + /// + /// 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; + 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 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()); + + 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, + "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) 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"); + + 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" + ); + 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..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,7 +1263,12 @@ mod transfer_tests { 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, + "PROTOCOL_VERSION_{}: processing fee must match the version-specific baseline", + protocol_version, + ); let query_sender_results = platform .drive @@ -1274,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 ef7d2427950..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 { @@ -637,7 +643,21 @@ 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. + /// + /// 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, @@ -664,26 +684,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(document_create_action) } DocumentTransition::Replace(document_replace_transition) => { - let mut 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 - 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, + return Self::failed_per_transition_action( + document_replace_transition.base(), + owner_id, validation_result.errors, - )); + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -695,14 +705,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + return Self::failed_per_transition_action( + document_replace_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_replace_transition.revision(), document_replace_transition.base().id(), @@ -710,8 +722,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + return Self::failed_per_transition_action( + document_replace_transition.base(), + owner_id, + validation_result.errors, + platform_version, + ); } } @@ -728,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| { @@ -745,14 +757,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { Ok(batched_action) } DocumentTransition::Transfer(document_transfer_transition) => { - let mut 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); + return Self::failed_per_transition_action( + document_transfer_transition.base(), + owner_id, + validation_result.errors, + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -764,14 +778,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + 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(), @@ -779,8 +795,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + return Self::failed_per_transition_action( + document_transfer_transition.base(), + owner_id, + validation_result.errors, + platform_version, + ); } } @@ -797,21 +817,19 @@ 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 mut 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); + 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()?; @@ -823,14 +841,16 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + 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(), @@ -838,8 +858,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + return Self::failed_per_transition_action( + document_update_price_transition.base(), + owner_id, + validation_result.errors, + platform_version, + ); } } @@ -856,21 +880,19 @@ 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 mut 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); + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, + validation_result.errors, + platform_version, + ); } let original_document = validation_result.into_data()?; @@ -879,27 +901,37 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { .properties() .get_optional_integer::(PRICE)? else { - result.add_error(StateError::DocumentNotForSaleError( - DocumentNotForSaleError::new(original_document.id()), - )); - return Ok(result); + 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() { - result.add_error(StateError::DocumentIncorrectPurchasePriceError( - DocumentIncorrectPurchasePriceError::new( - original_document.id(), - document_purchase_transition.price(), - listed_price, - ), - )); - return Ok(result); + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, + vec![StateError::DocumentIncorrectPurchasePriceError( + DocumentIncorrectPurchasePriceError::new( + original_document.id(), + document_purchase_transition.price(), + listed_price, + ), + ) + .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(), @@ -907,8 +939,12 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { ); if !validation_result.is_valid() { - result.merge(validation_result); - return Ok(result); + return Self::failed_per_transition_action( + document_purchase_transition.base(), + owner_id, + validation_result.errors, + platform_version, + ); } } @@ -926,11 +962,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) } } } @@ -1003,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 {