From bc057ecc4235c42b716592c630994c4be90999a5 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Sun, 4 Oct 2026 15:02:20 +0700 Subject: [PATCH 1/6] fix(drive-abci)!: charge authenticated shield proof failures Select new processor and Shield transform generations at protocol 14. Restore principal on proof failure, reserve the estimated nonce-update fee, and cap the fixed penalty by the signed payer strategy. Unfunded failures stay unpaid; shipped protocol replay and CheckTx proof admission stay unchanged. Test would have caught this in CI: RED before the fix (funded proof failure was unpaid), GREEN after. The underfunded boundary was also RED without the affordability guard (InternalError), GREEN with it. Verified 52 Shield tests, independent PrepareProposal/ProcessProposal/FinalizeBlock, 25 platform-version tests, all-feature all-target Clippy, and formatting. --- book/src/fees/shielded-fees.md | 10 + .../state_transition/processor/mod.rs | 10 +- .../state_transition/processor/v1/mod.rs | 364 +++++++++++++ .../state_transitions/shield/mod.rs | 17 +- .../state_transitions/shield/tests.rs | 504 +++++++++++++++++- .../shield/transform_into_action/mod.rs | 1 + .../shield/transform_into_action/v2/mod.rs | 181 +++++++ .../state_transition/transformer/mod.rs | 1 + .../tests/strategy_tests/test_cases/mod.rs | 1 + .../shield_paid_proof_failure_tests.rs | 179 +++++++ .../drive_abci_validation_versions/v10.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 8 + 12 files changed, 1262 insertions(+), 18 deletions(-) create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs create mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs create mode 100644 packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index d9c2c4f3be4..0f0f7703772 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -451,6 +451,16 @@ storage and processing, plus the `shielded_verification_fee` folded into process (into the pool), `surplus_amount` (to `surplus_output`, or `0`), and `fee_amount` (to the fee pools); see [Entry-Transition Fees](#entry-transition-fees-shield-shieldfromassetlock-and-shieldfromidentity). +From protocol version 14, an authenticated `Shield` whose Orchard proof fails +moves no credits into the pool. Its input nonces are consumed and its address +inputs pay the metered failure fee plus `shielded_proof_verification_failure`. +The penalty is capped at the funds reachable by the signed fee strategy after +reserving the estimated base fee. It is charged once as a fixed processing fee, +without the user's fee increase. If those funds cannot cover even the base fee, +the refusal is unpaid and consumes no nonce. Duplicate-nullifier refusals remain +unpaid. CheckTx rejects bad proofs before mempool admission; a directly proposed +funded bad proof is a paid failure that validators can accept. + ## Cryptographic Binding The fee is not just a field that the platform trusts. It is cryptographically bound diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs index 25450b2cca1..21054e8760b 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/mod.rs @@ -1,6 +1,7 @@ /// Processor traits. pub mod traits; pub(crate) mod v0; +pub(crate) mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -51,9 +52,16 @@ pub(in crate::execution) fn process_state_transition<'a, C: CoreRPCLike>( transaction, platform_version, ), + 1 => v1::process_state_transition_v1( + platform, + block_info, + state_transition, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "process_state_transition".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/processor/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs new file mode 100644 index 00000000000..bebc88c1c50 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs @@ -0,0 +1,364 @@ +use crate::error::Error; +use crate::execution::types::execution_event::ExecutionEvent; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::processor::address_balances_and_nonces::StateTransitionAddressBalancesAndNoncesValidation; +use crate::execution::validation::state_transition::processor::address_witnesses::{ + StateTransitionAddressWitnessValidationV0, StateTransitionHasAddressWitnessValidationV0, +}; +use crate::execution::validation::state_transition::processor::addresses_minimum_balance::StateTransitionAddressesMinimumBalanceValidationV0; +use crate::execution::validation::state_transition::processor::advanced_structure_with_state::StateTransitionStructureKnownInStateValidationV0; +use crate::execution::validation::state_transition::processor::advanced_structure_without_state::StateTransitionAdvancedStructureValidationV0; +use crate::execution::validation::state_transition::processor::basic_structure::StateTransitionBasicStructureValidationV0; +use crate::execution::validation::state_transition::processor::identity_balance::StateTransitionIdentityBalanceValidationV0; +use crate::execution::validation::state_transition::processor::identity_based_signature::StateTransitionIdentityBasedSignatureValidationV0; +use crate::execution::validation::state_transition::processor::identity_nonces::{ + StateTransitionHasIdentityNonceValidationV0, StateTransitionIdentityNonceValidationV0, +}; +use crate::execution::validation::state_transition::processor::is_allowed::StateTransitionIsAllowedValidationV0; +use crate::execution::validation::state_transition::processor::prefunded_specialized_balance::StateTransitionPrefundedSpecializedBalanceValidationV0; +use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; +use crate::execution::validation::state_transition::processor::traits::shielded_proof::{ + StateTransitionHasShieldedProofValidationV0, StateTransitionShieldedMinimumFeeValidationV0, + StateTransitionShieldedProofValidationV0, +}; +use crate::execution::validation::state_transition::transformer::StateTransitionSignerAwareActionTransformer; +use crate::execution::validation::state_transition::ValidationMode; +use crate::platform_types::platform::PlatformRef; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::StateTransition; +use dpp::version::{DefaultForPlatformVersion, PlatformVersion}; +use dpp::ProtocolError; +use drive::grovedb::TransactionArg; + +pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( + platform: &'a PlatformRef, + block_info: &BlockInfo, + state_transition: StateTransition, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result>, Error> { + let mut state_transition_execution_context = + StateTransitionExecutionContext::default_for_platform_version(platform_version)?; + + if state_transition.has_is_allowed_validation()? { + let result = state_transition.validate_is_allowed(platform_version)?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Only identity create does not use identity in state validation, because it doesn't yet have the identity in state + let mut maybe_identity = if state_transition.uses_identity_in_state() { + // Validating signature for identity based state transitions (all those except identity create and identity top up) + // As we already have removed identity create above, it just splits between identity top up (below - false) and + // all other state transitions (above - true) + let result = if state_transition.validates_signature_based_on_identity_info() { + state_transition.validate_identity_signed_state_transition( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + ) + } else { + // Currently only identity top up and identity top up from addresses uses this, + // We will add the cost for a balance retrieval + state_transition.retrieve_identity_info( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + ) + }?; + if !result.is_valid() { + // If the signature is not valid or if we could not retrieve identity info + // we do not have the user pay for the state transition. + // Since it is most likely not from them + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + Some(result.into_data()?) + } else { + // Currently only identity create + None + }; + + if state_transition.has_address_witness_validation(platform_version)? { + let result = state_transition.validate_address_witnesses( + &mut state_transition_execution_context, + platform_version, + )?; + if !result.is_valid() { + // If the witnesses are not valid + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Start by validating addresses if the transition has input addresses + let remaining_address_balances = if state_transition + .has_addresses_balances_and_nonces_validation() + { + // Here we validate that all input addresses have enough balance + // We also validate that nonces are bumped + let result = state_transition.validate_address_balances_and_nonces( + platform.drive, + &mut state_transition_execution_context, + transaction, + platform_version, + )?; + if !result.is_valid() { + // The nonces are not valid or there is not enough balance. The transaction is each replaying an input or there + // isn't enough balance, either way the transaction should be rejected. + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + Some(result.into_data()?) + } else { + None + }; + + // Only identity top up and identity create do not have nonces validation + if state_transition.has_identity_nonce_validation(platform_version)? { + // Validating identity contract nonce, this must happen after validating the signature + let result = state_transition.validate_identity_nonces( + &platform.into(), + platform.state.last_block_info(), + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !result.is_valid() { + // If the nonce is not valid the state transition is not paid for, most likely because + // this is just a replayed block + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Only Data contract state transitions and Masternode vote do not have basic structure validation + if state_transition.has_basic_structure_validation(platform_version) { + // We validate basic structure validation after verifying the identity, + // this is structure validation that does not require state and is already checked on check_tx + let consensus_result = + state_transition.validate_basic_structure(platform.config.network, platform_version)?; + + if !consensus_result.is_valid() { + // Basic structure validation is extremely cheap to process, because of this attacks are + // not likely. + // Often the basic structure validation is necessary for estimated costs + // Proposers should remove such transactions from the block + // Other validators should reject blocks with such transactions + return Ok( + ConsensusValidationResult::::new_with_errors( + consensus_result.errors, + ), + ); + } + } + + // For identity credit withdrawal and identity credit transfers we have a balance pre-check that includes a + // processing amount and the transfer amount. + // For other state transitions we only check a min balance for an amount set per version. + // This is not done for identity create and identity top up who don't have this check here + if state_transition.has_identity_minimum_balance_pre_check_validation() { + // Validating that we have sufficient balance for a transfer or withdrawal, + // this must happen after validating the signature + + let identity = maybe_identity + .as_mut() + .ok_or(ProtocolError::CorruptedCodeExecution( + "identity must be known to validate the balance".to_string(), + ))?; + let result = state_transition + .validate_identity_minimum_balance_pre_check(identity, platform_version)?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // For address-based state transitions that transfer or withdraw, we have a balance pre-check + // that validates addresses have enough remaining balance after the input amounts to cover fees. + if state_transition.has_addresses_minimum_balance_pre_check_validation() { + // Validating that addresses have sufficient remaining balance for fees, + // this must happen after validating the address balances and nonces + + let address_balances = + remaining_address_balances + .as_ref() + .ok_or(ProtocolError::CorruptedCodeExecution( + "address balances must be known to validate the minimum balance".to_string(), + ))?; + let result = state_transition + .validate_addresses_minimum_balance_pre_check(address_balances, platform_version)?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // A masternode vote is paid by its vote poll's prefunded specialized balance, never by the + // voter. When that fund does not exist or cannot cover the vote, nobody can be charged for + // the vote, so it is refused unpaid: proposers strip it from their block and other + // validators reject a block that carries it, exactly like a vote that fails its nonce check. + // Until 4.2 the pre-check ran here but its result was never read, and such a vote failed + // inside execution, when its cost was deducted, as an internal error; both outcomes keep the + // vote out of every block and no chain ever held one, so acting on it is not versioned. + if state_transition.uses_prefunded_specialized_balance_for_payment() { + let result = state_transition.validate_minimum_prefunded_specialized_balance_pre_check( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Validate minimum fee for shielded spending transitions (stateless, uses public value_balance). + // This is cheaper than proof verification so we check it first. + // Only applies to ShieldedTransfer/Unshield/ShieldedWithdrawal — Shield pays from address + // inputs and ShieldFromAssetLock pays from the asset lock. + if state_transition.has_shielded_minimum_fee_validation() { + let result = state_transition.validate_minimum_shielded_fee(platform_version)?; + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Shield verifies its proof in its transform so an authenticated failure can pay + // fees and consume the transparent input nonces. Pool-paid proofs stay unpaid. + if state_transition.has_shielded_proof_validation() + && !matches!(&state_transition, StateTransition::Shield(_)) + { + let result = state_transition.validate_shielded_proof(platform_version)?; + if !result.is_valid() { + return Ok(ConsensusValidationResult::::new_with_errors(result.errors)); + } + } + + // Only identity update and data contract create have advanced structure validation without state + if state_transition.has_advanced_structure_validation_without_state() { + // Currently only used for Identity Update, Data Contract Create and Identity Create From Addresses + // Next we have advanced structure validation, this is structure validation that does not require + // state but isn't checked on check_tx. If advanced structure fails identity nonces or identity + // contract nonces will be bumped + let identity = maybe_identity + .as_ref() + .ok_or(ProtocolError::CorruptedCodeExecution( + "the identity should always be known on advanced structure validation".to_string(), + ))?; + let consensus_result = state_transition.validate_advanced_structure( + identity, + &mut state_transition_execution_context, + platform_version, + )?; + + if !consensus_result.is_valid() { + return consensus_result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }); + } + } + + // Identity create, documents batch and masternode vote all have advanced structure validation with state + let action = if state_transition.has_advanced_structure_validation_with_state() { + // Currently used for identity create and documents batch + let state_transition_action_result = state_transition.transform_into_action_for_signer( + platform, + block_info, + &remaining_address_balances, + maybe_identity.as_ref(), + ValidationMode::Validator, + &mut state_transition_execution_context, + transaction, + )?; + if !state_transition_action_result.is_valid_with_data() { + return state_transition_action_result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }); + } + let action = state_transition_action_result.into_data()?; + + // Validating structure + let result = state_transition.validate_advanced_structure_from_state( + block_info, + platform.config.network, + &action, + maybe_identity.as_ref(), + &mut state_transition_execution_context, + platform_version, + )?; + if !result.is_valid() { + return result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }); + } + + Some(action) + } else { + None + }; + + // Validating state + // Only identity Top up does not validate state and instead just returns the action for topping up + let result = if state_transition.has_state_validation() { + state_transition.validate_state( + action, + platform, + ValidationMode::Validator, + block_info, + &mut state_transition_execution_context, + transaction, + )? + } else if let Some(action) = action { + ConsensusValidationResult::new_with_data(action) + } else { + state_transition.transform_into_action_for_signer( + platform, + block_info, + &remaining_address_balances, + maybe_identity.as_ref(), + ValidationMode::Validator, + &mut state_transition_execution_context, + transaction, + )? + }; + + result.map_result(|action| { + ExecutionEvent::create_from_state_transition_action( + action, + maybe_identity, + platform.state.last_committed_block_epoch_ref(), + state_transition_execution_context, + platform_version, + ) + }) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs index ec20133669c..4033422a757 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/mod.rs @@ -17,6 +17,8 @@ use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::shield::transform_into_action::v0::ShieldStateTransitionTransformIntoActionValidationV0; use crate::execution::validation::state_transition::shield::transform_into_action::v1::ShieldStateTransitionTransformIntoActionValidationV1; +use crate::execution::validation::state_transition::shield::transform_into_action::v2::ShieldStateTransitionTransformIntoActionValidationV2; +use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; @@ -30,6 +32,7 @@ pub trait StateTransitionShieldTransitionActionTransformer { platform: &PlatformRef, inputs_with_remaining_balance: BTreeMap, block_info: &BlockInfo, + validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error>; @@ -41,6 +44,7 @@ impl StateTransitionShieldTransitionActionTransformer for ShieldTransition { platform: &PlatformRef, inputs_with_remaining_balance: BTreeMap, block_info: &BlockInfo, + validation_mode: ValidationMode, execution_context: &mut StateTransitionExecutionContext, tx: TransactionArg, ) -> Result, Error> { @@ -69,9 +73,20 @@ impl StateTransitionShieldTransitionActionTransformer for ShieldTransition { execution_context, platform_version, ), + 2 => self.transform_into_action_v2( + platform.drive, + tx, + inputs_with_remaining_balance, + block_info, + validation_mode, + platform.state.last_committed_block_epoch_ref(), + platform.state.previous_fee_versions(), + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "shield transition: transform_into_action".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index b340786fd7a..e1ccadc5344 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -779,9 +779,10 @@ mod tests { // at the actual proof verification step. assert_matches!( processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] ); } @@ -1080,9 +1081,10 @@ mod tests { // Mutated value_balance changes the sighash, causing signature verification to fail. assert_matches!( processing_result.execution_results().as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] ); } @@ -1414,6 +1416,7 @@ mod tests { process_transition_and_commit, setup_platform_at_protocol_version, shielded_transfer_errors_revealing, OutputsOnlyBundle, }; + use crate::execution::validation::state_transition::ValidationMode; use crate::platform_types::platform::PlatformRef; use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; @@ -1554,6 +1557,7 @@ mod tests { &platform_ref, remaining, &BlockInfo::default(), + ValidationMode::NoValidation, &mut execution_context, None, ) @@ -2962,14 +2966,23 @@ mod tests { )], "CheckTx must refuse it for its proof" ); - assert_matches!( - process_transition(platform, transition.clone(), platform_version) - .execution_results() - .as_slice(), - [StateTransitionExecutionResult::UnpaidConsensusError( - ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) - )] - ); + let result = process_transition(platform, transition.clone(), platform_version); + if platform_version.protocol_version < 14 { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + } else { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + .. + }] + ); + } } /// Somebody funds a `Shield` from their own addresses and wraps it around a bundle proved @@ -3090,4 +3103,467 @@ mod tests { } } } + mod paid_proof_failures { + use super::*; + use crate::execution::check_tx::CheckTxLevel; + use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::validation::state_transition::processor::process_state_transition; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + check_tx_errors, has_recorded_nullifier, insert_nullifier_into_state, + process_transition_and_commit, setup_platform_at_protocol_version, + }; + use crate::platform_types::platform::PlatformRef; + use crate::platform_types::platform_state::PlatformStateV0Methods; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::block::block_info::BlockInfo; + + #[tokio::test] + async fn should_charge_a_failed_shield_proof_and_consume_its_nonce() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([91; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = create_default_signed_shield_transition(&signer, address, 1, balance).await; + let result = process_transition_and_commit(&platform, st, pv); + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("authenticated bad proof must be paid, got {other:?}"), + }; + let penalty = pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + assert!(actual >= penalty, "a funded proof failure pays the penalty"); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((1, balance - actual)) + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes"), + 0 + ); + assert!(!has_recorded_nullifier( + &platform, + &create_dummy_serialized_action().nullifier + )); + } + fn failure_fee_and_penalty( + platform: &TempPlatform, + st: &StateTransition, + ) -> (Credits, Credits) { + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let mut event = process_state_transition( + &platform_ref, + &BlockInfo::default(), + st.clone(), + Some(&transaction), + ) + .expect("prepare failure") + .data + .expect("failure event"); + let ExecutionEvent::PaidFromAddressInputs { + ref mut additional_fixed_fee_cost, + .. + } = event + else { + panic!("address-paid failure event"); + }; + let penalty = additional_fixed_fee_cost.replace(0).expect("penalty"); + let fee = platform + .platform + .validate_fees_of_event( + &event, + &BlockInfo::default(), + Some(&transaction), + PlatformVersion::latest(), + state.previous_fee_versions(), + ) + .expect("estimate failure fee") + .data + .expect("estimate") + .total_base_fee(); + (fee, penalty) + } + + async fn set_increase_and_resign( + st: &mut StateTransition, + signer: &TestAddressSigner, + increase: u16, + ) { + let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + panic!("shield"); + }; + v0.user_fee_increase = increase; + let addresses: Vec<_> = v0.inputs.keys().copied().collect(); + let bytes = st.signable_bytes().expect("signable"); + let mut witnesses = vec![]; + for address in addresses { + witnesses.push( + signer + .sign_create_witness(&address, &bytes) + .await + .expect("witness"), + ); + } + let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + panic!("shield"); + }; + v0.input_witnesses = witnesses; + } + + #[tokio::test] + async fn should_bound_failed_proof_penalties_after_reserving_the_estimated_fee() { + let pv = PlatformVersion::latest(); + let nominal = pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + for increase in [0, 100, u16::MAX] { + let mut funded = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([92; 32]); + let nonpayer = signer.add_p2pkh([96; 32]); + setup_address_with_balance(&mut funded, nonpayer, 0, dash_to_credits!(1.0)); + setup_address_with_balance(&mut funded, address, 0, dash_to_credits!(1.0)); + let inputs = BTreeMap::from([ + ( + address, + (1, pv.dpp.state_transitions.address_funds.min_input_amount), + ), + (nonpayer, (1, dash_to_credits!(1.0))), + ]); + let payer_index = inputs + .keys() + .position(|input| *input == address) + .expect("payer") as u16; + let mut st = create_signed_shield_transition( + &signer, + inputs, + vec![create_dummy_serialized_action()], + 1_000, + vec![0; 100], + [0; 64], + AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(payer_index), + ]), + ) + .await; + set_increase_and_resign(&mut st, &signer, increase).await; + let (mut base, _) = failure_fee_and_penalty(&funded, &st); + // Storage estimation includes the sum item's balance encoding. Find + // the fee with an affordable balance in the boundary's own width. + for _ in 0..4 { + setup_address_with_balance(&mut funded, address, 0, base + nominal + 1); + let (next_base, _) = failure_fee_and_penalty(&funded, &st); + if next_base == base { + break; + } + base = next_base; + } + assert_eq!(failure_fee_and_penalty(&funded, &st).0, base); + let mut actual_without_penalty = None; + for balance in [base - 1, base, base + 1, base + nominal - 1, base + nominal] { + let mut platform = setup_platform(); + setup_address_with_balance(&mut platform, address, 0, balance); + setup_address_with_balance(&mut platform, nonpayer, 0, dash_to_credits!(1.0)); + let penalty = nominal.min(balance.saturating_sub(base)); + if balance >= base { + let (estimated_base, prepared_penalty) = + failure_fee_and_penalty(&platform, &st); + assert_eq!(estimated_base, base); + assert_eq!(prepared_penalty, penalty); + } + let result = process_transition_and_commit(&platform, st.clone(), pv); + if balance < base { + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError( + StateError::AddressesNotEnoughFundsError(_) + ) + )] + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)) + ); + } else { + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("affordable failure must be paid: {other:?}"), + }; + assert!(actual <= base + penalty, "estimate must cover the charge"); + let metered_base = *actual_without_penalty.get_or_insert(actual); + assert_eq!( + actual, + metered_base + penalty, + "the fixed penalty is included exactly once and is not increased" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((1, balance - actual)) + ); + } + let expected_nonce = if balance < base { 0 } else { 1 }; + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&nonpayer, None, pv) + .expect("nonpayer"), + Some((expected_nonce, dash_to_credits!(1.0))), + "nonpayer pays no fee or principal" + ); + assert!(!has_recorded_nullifier( + &platform, + &create_dummy_serialized_action().nullifier + )); + } + } + } + + #[tokio::test] + async fn should_leave_duplicate_nullifiers_unpaid_even_with_a_bad_proof() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([97; 32]); + let initial = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, initial); + let nullifier = create_dummy_serialized_action().nullifier; + insert_nullifier_into_state(&platform, &nullifier); + let st = create_default_signed_shield_transition(&signer, address, 1, initial).await; + let result = process_transition_and_commit(&platform, st, pv); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::NullifierAlreadySpentError(error)) + )] if error.nullifier() == nullifier + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, initial)) + ); + } + + #[tokio::test] + async fn should_cover_failed_proof_fees_at_the_input_and_action_limits() { + let pv = PlatformVersion::latest(); + let initial = dash_to_credits!(1.0); + for action_count in [1, pv.system_limits.max_shielded_transition_actions as usize] { + for increase in [0, u16::MAX] { + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let mut inputs = BTreeMap::new(); + for input in 0..pv.dpp.state_transitions.max_address_inputs { + let address = signer.add_p2pkh([input as u8 + 100; 32]); + setup_address_with_balance(&mut platform, address, 0, initial); + inputs.insert(address, (1, initial)); + } + let mut st = create_signed_shield_transition( + &signer, + inputs.clone(), + (0..action_count) + .map(|i| SerializedAction { + nullifier: [i as u8; 32], + ..create_dummy_serialized_action() + }) + .collect(), + 1000, + vec![0; 100], + [0; 64], + AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + ) + .await; + set_increase_and_resign(&mut st, &signer, increase).await; + let (base, penalty) = failure_fee_and_penalty(&platform, &st); + let result = process_transition_and_commit(&platform, st, pv); + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("funded failure at the limits must pay: {other:?}"), + }; + assert!(actual <= base + penalty, "the estimate covers execution"); + for (index, address) in inputs.keys().enumerate() { + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(address, None, pv) + .expect("address"), + Some((1, initial - if index == 0 { actual } else { 0 })), + "only the signed payer funds the failure" + ); + } + } + } + } + + #[tokio::test] + async fn should_leave_shipped_protocol_proof_failures_unpaid() { + for version in [12, 13] { + let pv = PlatformVersion::get(version).expect("shipped version"); + let mut platform = setup_platform_at_protocol_version(version); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([93; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = + create_default_signed_shield_transition(&signer, address, 1, balance).await; + let result = process_transition_and_commit(&platform, st, pv); + assert_matches!( + result.execution_results().as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::StateError(StateError::InvalidShieldedProofError(_)) + )] + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)) + ); + } + } + + #[tokio::test] + async fn should_reject_failed_proofs_at_check_tx_without_consuming_a_nonce() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([94; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = create_default_signed_shield_transition(&signer, address, 1, balance).await; + assert_matches!( + check_tx_errors(&platform, &st).as_slice(), + [ConsensusError::StateError( + StateError::InvalidShieldedProofError(_) + )] + ); + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let recheck = platform + .check_tx( + &st.serialize_to_bytes().expect("serialize"), + CheckTxLevel::Recheck, + &platform_ref, + pv, + ) + .expect("recheck performs no proof work"); + assert!(recheck.is_valid()); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)) + ); + } + + #[tokio::test] + async fn should_charge_a_bad_proof_once_then_reject_its_same_block_replay() { + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([95; 32]); + let balance = dash_to_credits!(1.0); + setup_address_with_balance(&mut platform, address, 0, balance); + let st = create_default_signed_shield_transition(&signer, address, 1, balance).await; + let bytes = st.serialize_to_bytes().expect("serialize"); + let state = platform.state.load(); + let tx = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &[bytes.clone(), bytes], + &state, + &BlockInfo::default(), + &tx, + pv, + false, + None, + ) + .expect("block processing"); + assert_matches!( + result.execution_results().as_slice(), + [ + StateTransitionExecutionResult::PaidConsensusError { .. }, + StateTransitionExecutionResult::UnpaidConsensusError(_) + ] + ); + platform + .drive + .grove + .commit_transaction(tx) + .unwrap() + .expect("commit"); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address") + .expect("exists") + .0, + 1 + ); + assert!(!has_recorded_nullifier( + &platform, + &create_dummy_serialized_action().nullifier + )); + } + } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs index 008be12cc67..a013a2f7357 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/mod.rs @@ -1,2 +1,3 @@ pub(crate) mod v0; pub(crate) mod v1; +pub(super) mod v2; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs new file mode 100644 index 00000000000..417bd674378 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs @@ -0,0 +1,181 @@ +use super::v0::reallocate_inputs_for_shield_amount; +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::execution_operation::ValidationOperation; +use crate::execution::types::state_transition_execution_context::{ + StateTransitionExecutionContext, StateTransitionExecutionContextMethodsV0, +}; +use crate::execution::validation::state_transition::state_transitions::shielded_common::{ + read_pool_total_balance, reconstruct_and_verify_bundle, validate_nullifiers, FLAGS_OUTPUTS_ONLY, +}; +use crate::execution::validation::state_transition::ValidationMode; +use dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; +use dpp::block::block_info::BlockInfo; +use dpp::block::epoch::Epoch; +use dpp::consensus::state::address_funds::AddressesNotEnoughFundsError; +use dpp::consensus::state::state_error::StateError; +use dpp::fee::default_costs::CachedEpochIndexFeeVersions; +use dpp::fee::Credits; +use dpp::prelude::{AddressNonce, ConsensusValidationResult}; +use dpp::shielded::shield_extra_sighash_data; +use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::action_convert_to_operations::DriveHighLevelOperationConverter; +use drive::state_transition_action::address_funds::restore_input_spends_for_failed_transition; +use drive::state_transition_action::shielded::shield::ShieldTransitionAction; +use drive::state_transition_action::system::bump_address_input_nonces_action::{ + BumpAddressInputNoncesAction, BumpAddressInputNoncesActionV0, +}; +use drive::state_transition_action::StateTransitionAction; +use std::collections::{BTreeMap, BTreeSet}; + +pub(in crate::execution::validation::state_transition::state_transitions::shield) trait ShieldStateTransitionTransformIntoActionValidationV2 +{ + #[allow(clippy::too_many_arguments)] + fn transform_into_action_v2( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + validation_mode: ValidationMode, + epoch: &Epoch, + previous_fee_versions: &CachedEpochIndexFeeVersions, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl ShieldStateTransitionTransformIntoActionValidationV2 for ShieldTransition { + #[allow(clippy::too_many_arguments)] + fn transform_into_action_v2( + &self, + drive: &Drive, + transaction: TransactionArg, + inputs_with_remaining_balance: BTreeMap, + block_info: &BlockInfo, + validation_mode: ValidationMode, + epoch: &Epoch, + previous_fee_versions: &CachedEpochIndexFeeVersions, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let ShieldTransition::V0(transition) = self; + let mut drive_operations = vec![]; + let current_total_balance = + read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; + let nullifiers: Vec<_> = transition + .actions + .iter() + .map(|action| action.nullifier) + .collect(); + if let Some(refusal) = validate_nullifiers( + drive, + &nullifiers, + transaction, + &mut drive_operations, + platform_version, + )? { + return Ok(refusal); + } + + // CheckTx's caller verifies once under its local proof permit. Recheck and + // action-only construction do no proof work and cannot prepare paid failures. + if validation_mode == ValidationMode::Validator { + let extra_sighash_data = + shield_extra_sighash_data(&transition.inputs, platform_version)?; + if let Err(error) = reconstruct_and_verify_bundle( + &transition.actions, + FLAGS_OUTPUTS_ONLY, + -(transition.amount as i64), + &transition.anchor, + &transition.proof, + &transition.binding_signature, + &extra_sighash_data, + ) { + // These balances still reflect the full requested debits. Restore them + // before successful-path reallocation so a failure moves no principal. + let mut restored = inputs_with_remaining_balance; + restore_input_spends_for_failed_transition(&mut restored, &transition.inputs)?; + let mut bump = BumpAddressInputNoncesActionV0 { + inputs_with_remaining_balance: restored, + fee_strategy: transition.fee_strategy.clone(), + user_fee_increase: transition.user_fee_increase, + penalty_credits: 0, + }; + let operations = BumpAddressInputNoncesAction::from(bump.clone()) + .into_high_level_drive_operations(epoch, platform_version)?; + let mut estimated = drive.apply_drive_operations( + operations, + false, + block_info, + transaction, + platform_version, + Some(previous_fee_versions), + )?; + ValidationOperation::add_many_to_fee_result( + execution_context.operations_slice(), + &mut estimated, + platform_version, + )?; + estimated.apply_user_fee_increase(transition.user_fee_increase); + + let addresses: Vec<_> = bump.inputs_with_remaining_balance.values().collect(); + let mut paying_indices = BTreeSet::new(); + let mut available: Credits = 0; + for step in &transition.fee_strategy { + if let AddressFundsFeeStrategyStep::DeductFromInput(index) = step { + if paying_indices.insert(*index) { + if let Some((_, balance)) = addresses.get(*index as usize) { + available = available.checked_add(*balance).ok_or( + Error::Execution(ExecutionError::Overflow( + "shield failure fee-payer balance overflow", + )), + )?; + } + } + } + } + let penalty = platform_version + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + if available < estimated.total_base_fee() { + return Ok(ConsensusValidationResult::new_with_error( + AddressesNotEnoughFundsError::new( + bump.inputs_with_remaining_balance, + estimated.total_base_fee(), + ) + .into(), + )); + } + bump.penalty_credits = + penalty.min(available.saturating_sub(estimated.total_base_fee())); + + // Refuse before creating a paid-invalid event when its estimated fee + // cannot fit. Execution charges the fixed penalty once, without the + // user's fee increase. + return Ok(ConsensusValidationResult::new_with_data_and_errors( + StateTransitionAction::BumpAddressInputNoncesAction(bump.into()), + vec![StateError::InvalidShieldedProofError(error).into()], + )); + } + } + + let remaining = reallocate_inputs_for_shield_amount( + transition, + inputs_with_remaining_balance, + transition.amount, + )?; + Ok(ShieldTransitionAction::try_from_transition( + self, + remaining, + transition.amount, + current_total_balance, + ) + .map(|action| action.into())) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs index 680ce0c40e1..9a16b210cf8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/transformer/mod.rs @@ -336,6 +336,7 @@ impl StateTransitionActionTransformer for StateTransition { platform, remaining_address_input_balances.clone(), block_info, + validation_mode, execution_context, tx, ) diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs index 4444a8c1e65..e53f56003e3 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/mod.rs @@ -9,6 +9,7 @@ mod identity_and_document_tests; mod identity_transfer_tests; mod process_proposal_collision_tests; mod required_since_update_tests; +mod shield_paid_proof_failure_tests; // TODO: re-enable once OperationType shielded variants are implemented // mod shielded_tests; mod token_tests; diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs new file mode 100644 index 00000000000..43000433403 --- /dev/null +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs @@ -0,0 +1,179 @@ +#[cfg(test)] +mod tests { + use crate::execution::run_chain_for_strategy; + use crate::strategy::NetworkStrategy; + use dpp::address_funds::{AddressFundsFeeStrategy, AddressFundsFeeStrategyStep}; + use dpp::block::block_info::BlockInfo; + use dpp::block::epoch::Epoch; + use dpp::dash_to_credits; + use dpp::dashcore::hashes::Hash; + use dpp::identity::signer::Signer; + use dpp::serialization::Signable; + use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::shield_transition::ShieldTransition; + use dpp::state_transition::StateTransition; + use drive_abci::config::{ExecutionConfig, PlatformConfig, PlatformTestConfig}; + use drive_abci::mimic::MimicExecuteBlockOptions; + use drive_abci::platform_types::platform_state::PlatformStateV0Methods; + use drive_abci::test::helpers::setup::TestPlatformBuilder; + use platform_version::version::PlatformVersion; + use std::collections::BTreeMap; + use strategy_tests::frequency::Frequency; + use strategy_tests::operations::{Operation, OperationType}; + use strategy_tests::{IdentityInsertInfo, StartAddresses, StartIdentities, Strategy}; + + #[tokio::test] + async fn should_commit_a_paid_shield_proof_failure_after_independent_proposal_validation() { + let pv = PlatformVersion::latest(); + let config = PlatformConfig { + execution: ExecutionConfig { + verify_sum_trees: true, + ..Default::default() + }, + block_spacing_ms: 3000, + testing_configs: PlatformTestConfig::default_minimal_verifications(), + ..Default::default() + }; + let strategy = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![Operation { + op_type: OperationType::AddressFundingFromCoreAssetLock( + dash_to_credits!(1.0)..=dash_to_credits!(1.0), + ), + frequency: Frequency { + times_per_block_range: 1..2, + chance_per_block: None, + }, + }], + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: IdentityInsertInfo::default(), + identity_contract_nonce_gaps: None, + signer: None, + }, + total_hpmns: 50, + validator_quorum_count: 10, + chain_lock_quorum_count: 10, + verify_state_transition_results: false, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + let outcome = + run_chain_for_strategy(&mut platform, 5, strategy, config, 7, &mut None, &mut None) + .await; + let (&address, _) = outcome + .addresses_with_balance + .addresses_with_balance + .first_key_value() + .expect("funded address"); + let (nonce, balance) = outcome + .abci_app + .platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("funded address balance") + .expect("persisted funded address"); + let mut transition = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + inputs: BTreeMap::from([(address, (nonce + 1, balance))]), + actions: vec![SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }], + amount: 1000, + anchor: [42; 32], + proof: vec![0; 100], + binding_signature: [0; 64], + fee_strategy: AddressFundsFeeStrategy::from(vec![ + AddressFundsFeeStrategyStep::DeductFromInput(0), + ]), + user_fee_increase: 0, + input_witnesses: vec![], + })); + let witness = outcome + .signer + .sign_create_witness(&address, &transition.signable_bytes().expect("signable")) + .await + .expect("witness"); + let StateTransition::Shield(ShieldTransition::V0(ref mut shield)) = transition else { + unreachable!(); + }; + shield.input_witnesses = vec![witness]; + let state = outcome.abci_app.platform.state.load(); + let block_info = BlockInfo { + height: state.last_committed_block_height() + 1, + core_height: state.last_committed_core_height(), + time_ms: outcome.end_time_ms + 3000, + epoch: Epoch::new(outcome.end_epoch_index).expect("epoch"), + }; + drop(state); + let block = outcome + .abci_app + .mimic_execute_block( + outcome.proposers[0].pro_tx_hash().to_byte_array(), + outcome.current_quorum(), + pv.protocol_version, + block_info, + 0, + &[40902], + false, + vec![transition], + MimicExecuteBlockOptions { + dont_finalize_block: false, + rounds_before_finalization: None, + max_tx_bytes_per_block: 40000, + independent_process_proposal_verification: true, + }, + ) + .expect("paid failure must survive preparation and independent validation"); + assert_eq!( + block.state_transaction_results.len(), + 1, + "failure stays in the block" + ); + let result = &block.state_transaction_results[0].1; + assert_eq!(result.code, 40902); + let (committed_nonce, committed_balance) = outcome + .abci_app + .platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address") + .expect("persisted address"); + assert_eq!(committed_nonce, nonce + 1); + assert!(committed_balance < balance); + assert!( + balance - committed_balance + >= pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure + ); + assert_eq!( + outcome + .abci_app + .platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + outcome + .abci_app + .platform + .state + .load() + .last_committed_block_height(), + block_info.height + ); + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 74966d12af9..f4b46026b05 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -356,7 +356,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = identity_signatures: None, nonce: None, state: 0, - transform_into_action: 1, // changed: nullifier checks + transform_into_action: 2, }, shielded_transfer_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(0), @@ -449,7 +449,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = validate_address_witnesses: 0, validate_shielded_proof: 1, validate_minimum_shielded_fee: 0, - process_state_transition: 0, + process_state_transition: 1, state_transition_to_execution_event_for_check_tx: 0, penalties: PenaltyAmounts { identity_id_not_correct: 50000000, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 7dd131fdd7d..ba803a815cf 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2010,6 +2010,14 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// version must be active on a network before its Dash Core activates V24, /// since earlier versions keep failing on these masternodes. /// +/// 74. **An authenticated Shield proof failure pays from its address inputs.** +/// Processor generation 1 defers Shield proof verification to transform generation 2. +/// A failed proof restores the input principal, consumes the input nonces, and charges +/// the ordinary metered failure fee plus the configured proof-failure penalty, capped +/// by the signed fee strategy's funds after reserving the estimated base fee. A failure +/// unable to cover the base fee stays unpaid. CheckTx still refuses invalid proofs +/// under its local proof permit; shipped protocol versions keep their unpaid refusal. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's From ecdaa6317008d7d0fb99e14478f96c1343bacb69 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 5 Oct 2026 00:44:41 +0700 Subject: [PATCH 2/6] docs(drive-abci): explain shield validation generations Describe how processor v1 delegates Shield proof verification to the action transformer and how transform v2 turns affordable proof failures into nonce updates with fees and a bounded penalty while preserving principal. Comment-only change with no behavior delta. Tests omitted because no executable code changed; cargo fmt --all -- --check and git diff --check passed, and the non-documentation source is byte-identical to the parent. --- .../validation/state_transition/processor/v1/mod.rs | 5 +++++ .../shield/transform_into_action/v2/mod.rs | 7 +++++++ 2 files changed, 12 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs index bebc88c1c50..5653811d40d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs @@ -33,6 +33,11 @@ use dpp::version::{DefaultForPlatformVersion, PlatformVersion}; use dpp::ProtocolError; use drive::grovedb::TransactionArg; +/// Version 0's validation pipeline, with Shield proof verification deferred to its +/// action transformer after authentication and input balance/nonce checks. This +/// lets a funded proof failure produce a fee-paying nonce-bump action instead of +/// returning an unpaid refusal in the shared proof step. Other transitions keep +/// their existing proof-validation path. pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( platform: &'a PlatformRef, block_info: &BlockInfo, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs index 417bd674378..78ceb78cf27 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs @@ -49,6 +49,13 @@ pub(in crate::execution::validation::state_transition::state_transitions::shield } impl ShieldStateTransitionTransformIntoActionValidationV2 for ShieldTransition { + /// Version 1's nullifier checks and successful shielding, with proof verification + /// performed here during block validation so an authenticated bad proof can return + /// a paid nonce-bump action. The input principal is restored, and the fixed penalty + /// is capped by signed fee-payer funds left after reserving the estimated base fee. + /// Insufficient base-fee funds and duplicate nullifiers still produce unpaid + /// refusals; nullifiers are checked before the proof. CheckTx keeps proof + /// verification in its caller, and rechecks do not verify it again. #[allow(clippy::too_many_arguments)] fn transform_into_action_v2( &self, From 20be752fa48b5188276430af0d75bfe7b1fc7417 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 5 Oct 2026 15:16:45 +0700 Subject: [PATCH 3/6] test(drive-abci): cover shield failures paid by multiple inputs Commit an authenticated bad-proof Shield with two signed payers whose combined balances cover the estimated fee and full penalty while neither can cover the actual charge alone. Exercise both fee orders, including reverse BTreeMap order, and verify exact deductions, consumed nonces, and unchanged pool balance, notes, and nullifiers. Validation: all 8 paid-proof-failure tests passed, zero ignored; formatting and whitespace checks passed. A mutation counting only the first payer failed the prepared-penalty assertion (22,287,880 versus 50,000,000); restoring production code made the new test pass. Independent accounting, consensus, and scope reviews were clean. No production behavior changed. --- .../state_transitions/shield/tests.rs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index e1ccadc5344..77a5f23b6c9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -3239,6 +3239,103 @@ mod tests { v0.input_witnesses = witnesses; } + #[tokio::test] + async fn should_split_failed_proof_fees_across_signed_payers_in_strategy_order() { + let pv = PlatformVersion::latest(); + let nominal = pv + .drive_abci + .validation_and_processing + .penalties + .shielded_proof_verification_failure; + let balance = nominal * 3 / 4; + + for indices in [[1, 0], [0, 1]] { + let mut platform = setup_platform(); + let mut signer = TestAddressSigner::new(); + let address_a = signer.add_p2pkh([98; 32]); + let address_b = signer.add_p2pkh([99; 32]); + for address in [address_a, address_b] { + setup_address_with_balance(&mut platform, address, 0, balance); + } + let inputs = BTreeMap::from([(address_a, (1, balance)), (address_b, (1, balance))]); + let addresses: Vec<_> = inputs.keys().copied().collect(); + let first_payer = addresses[indices[0] as usize]; + let second_payer = addresses[indices[1] as usize]; + let action = create_dummy_serialized_action(); + let nullifier = action.nullifier; + let st = create_signed_shield_transition( + &signer, + inputs, + vec![action], + 1_000, + vec![0; 100], + [0; 64], + AddressFundsFeeStrategy::from( + indices + .into_iter() + .map(AddressFundsFeeStrategyStep::DeductFromInput) + .collect::>(), + ), + ) + .await; + + let (base, penalty) = failure_fee_and_penalty(&platform, &st); + assert_eq!(penalty, nominal, "both signed payers fund the full penalty"); + assert!(base + penalty <= balance * 2); + assert!( + balance < base + penalty, + "neither payer covers the estimate alone" + ); + + let result = process_transition_and_commit(&platform, st, pv); + let actual = match result.execution_results().as_slice() { + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::InvalidShieldedProofError(_)), + actual_fees, + .. + }] => actual_fees.total_base_fee(), + other => panic!("jointly funded failure must be paid: {other:?}"), + }; + assert!(actual >= penalty, "the prepared penalty is charged"); + assert!(actual <= base + penalty, "the estimate covers execution"); + assert!( + actual > balance, + "neither payer covers the actual charge alone" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&first_payer, None, pv) + .expect("first payer"), + Some((1, 0)), + "the first signed payer is exhausted and retains its consumed nonce" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&second_payer, None, pv) + .expect("second payer"), + Some((1, balance - (actual - balance))), + "the second signed payer covers only the remaining fee" + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes"), + 0 + ); + assert!(!has_recorded_nullifier(&platform, &nullifier)); + } + } + #[tokio::test] async fn should_bound_failed_proof_penalties_after_reserving_the_estimated_fee() { let pv = PlatformVersion::latest(); From 5975278f6945c655ee89f307cd8be077a6c2757b Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 5 Oct 2026 22:14:25 +0700 Subject: [PATCH 4/6] docs(platform-version): append paid Shield failure entry after merged changes --- packages/rs-platform-version/src/version/v14.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index ba803a815cf..c6f2b6bab48 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2010,7 +2010,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// version must be active on a network before its Dash Core activates V24, /// since earlier versions keep failing on these masternodes. /// -/// 74. **An authenticated Shield proof failure pays from its address inputs.** +/// 78. **An authenticated Shield proof failure pays from its address inputs.** /// Processor generation 1 defers Shield proof verification to transform generation 2. /// A failed proof restores the input principal, consumes the input nonces, and charges /// the ordinary metered failure fee plus the configured proof-failure penalty, capped From 32c92fe3d4a6fad92a316abae626eb50dd0cf3bb Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 5 Oct 2026 23:34:37 +0700 Subject: [PATCH 5/6] fix(platform)!: retire legacy shield signatures at PV14 Use a signed Shield V1 format for the bound proof domain and paid-failure policy. Refuse legacy V0 before authentication, fees, nonce changes, and proof work at activation; preserve PV12/13 bytes and behavior. Update builder, WASM, wallet activity, and DAPI proof-budget consumers. Test would have caught this in CI: RED before fix, GREEN after. An exact valid PV13 transaction previously lost 50,991,540 credits and its nonce at PV14. The unchanged PV12/13 fixtures now execute historically and are refused unpaid after activation, including CheckTx/Recheck and direct processing. A one-byte format retag fails authentication unpaid. All 55 Shield tests and 10 fresh WASM wrapper tests pass; historical wire/signable hashes are fixed regression oracles. Independent consensus, accounting, and client reviews are clean. --- book/src/fees/shielded-fees.md | 8 + book/src/versioning/feature-versions.md | 9 + .../shielded_proof_failure_budget.rs | 62 ++- packages/rs-dpp/src/state_transition/mod.rs | 7 +- .../shield_transition/accessors/mod.rs | 12 + .../shielded/shield_transition/methods/mod.rs | 22 +- .../shielded/shield_transition/mod.rs | 128 ++++- ...ate_transition_estimated_fee_validation.rs | 4 +- .../state_transition_like.rs | 11 + .../state_transition_validation.rs | 1 + .../shielded/shield_transition/v1/mod.rs | 70 +++ .../v1/state_transition_like.rs | 78 +++ .../v1/state_transition_validation.rs | 250 ++++++++++ .../shielded/shield_transition/v1/types.rs | 16 + .../shield_transition/v1/v1_methods.rs | 65 +++ .../shielded/shield_transition/v1/version.rs | 9 + .../shielded/shield_transition/version.rs | 1 + .../processor/traits/shielded_proof.rs | 57 +-- .../state_transition/processor/v1/mod.rs | 22 +- .../state_transitions/shield/tests.rs | 465 ++++++++++++++---- .../shield/transform_into_action/v0/mod.rs | 82 ++- .../shield/transform_into_action/v1/mod.rs | 12 +- .../shield/transform_into_action/v2/mod.rs | 17 +- .../shield_from_asset_lock/tests.rs | 2 +- .../shield_paid_proof_failure_tests.rs | 6 +- .../shielded/shield/transformer.rs | 12 + .../v3.rs | 6 +- .../rs-platform-version/src/version/v14.rs | 5 +- .../src/wallet/shielded/operations.rs | 10 +- .../shielded/sync/memo_roundtrip_tests.rs | 2 +- .../sync/ovk_builder_roundtrip_tests.rs | 15 +- .../shielded/sync/shield_decrypt_tests.rs | 4 +- .../src/shielded/shield_transition.rs | 127 ++--- .../tests/unit/ShieldTransition.spec.ts | 21 +- 34 files changed, 1358 insertions(+), 260 deletions(-) create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs create mode 100644 packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs diff --git a/book/src/fees/shielded-fees.md b/book/src/fees/shielded-fees.md index 0f0f7703772..4ffdf327d47 100644 --- a/book/src/fees/shielded-fees.md +++ b/book/src/fees/shielded-fees.md @@ -461,6 +461,14 @@ the refusal is unpaid and consumes no nonce. Duplicate-nullifier refusals remain unpaid. CheckTx rejects bad proofs before mempool admission; a directly proposed funded bad proof is a paid failure that validators can accept. +This policy requires Shield wire format 1, whose format tag is covered by the +address witnesses. Format 0 is accepted only at protocol versions 12 and 13; +after activation it is refused before authentication or proof verification, +without charging fees or consuming input nonces. A pending legacy Shield must +be rebuilt and re-signed against the active version. Changing its format tag +alone invalidates its address witnesses. Historical blocks retain their original +wire format and unpaid proof-failure behavior. + ## Cryptographic Binding The fee is not just a field that the platform trusts. It is cryptographically bound diff --git a/book/src/versioning/feature-versions.md b/book/src/versioning/feature-versions.md index f2395089285..5acfe12bcd3 100644 --- a/book/src/versioning/feature-versions.md +++ b/book/src/versioning/feature-versions.md @@ -51,6 +51,15 @@ This is used when a field can accept a *range* of versions -- for example, a data contract serialization format where the system can read versions 0 through 2 but writes version 2 by default. +A state transition can also retire an older signed format. Shield uses format 0 +at protocol versions 12 and 13, and format 1 from version 14: the serialization +bounds select the client format before signing, while +`StateTransition::active_version_range` enforces acceptance at untrusted +decoding. The format tag is part of the address signing bytes. Legacy Shield +bytes therefore cannot authorize the bound proof domain and paid proof-failure +policy after activation; pending transactions must be rebuilt and re-signed. +The historical format and validation generations remain available for replay. + ## Version Structs: The Middle of the Tree Between the top-level `PlatformVersion` and the leaf-level `FeatureVersion` diff --git a/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs b/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs index 375b76c7c66..6991a1eee5c 100644 --- a/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs +++ b/packages/rs-dapi/src/services/platform_service/shielded_proof_failure_budget.rs @@ -24,7 +24,7 @@ use dpp::state_transition::identity_create_from_shielded_pool_transition::Identi use dpp::state_transition::identity_top_up_from_shielded_pool_transition::IdentityTopUpFromShieldedPoolTransition; use dpp::state_transition::shield_from_asset_lock_transition::ShieldFromAssetLockTransition; use dpp::state_transition::shield_from_identity_transition::ShieldFromIdentityTransition; -use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shielded_transfer_transition::ShieldedTransferTransition; use dpp::state_transition::shielded_withdrawal_transition::ShieldedWithdrawalTransition; use dpp::state_transition::unshield_transition::UnshieldTransition; @@ -118,7 +118,7 @@ pub(super) fn orchard_action_count(state_transition_bytes: &[u8]) -> usize { return 0; }; match state_transition { - StateTransition::Shield(ShieldTransition::V0(v0)) => v0.actions.len(), + StateTransition::Shield(shield) => shield.actions().len(), StateTransition::ShieldedTransfer(ShieldedTransferTransition::V0(v0)) => v0.actions.len(), StateTransition::Unshield(UnshieldTransition::V0(v0)) => v0.actions.len(), StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) => { @@ -327,6 +327,9 @@ mod tests { use dpp::consensus::state::state_error::StateError; use dpp::serialization::PlatformSerializable; use dpp::shielded::SerializedAction; + use dpp::state_transition::shield_transition::{ + ShieldTransition, v0::ShieldTransitionV0, v1::ShieldTransitionV1, + }; use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; use std::net::Ipv6Addr; @@ -585,4 +588,59 @@ mod tests { assert_eq!(orchard_action_count(&bytes), 3); assert_eq!(orchard_action_count(&[0xff, 0x00]), 0); } + + #[test] + fn should_meter_failed_shield_broadcasts_in_both_wire_formats() { + let actions = vec![ + SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + 3 + ]; + let legacy = ShieldTransitionV0 { + inputs: Default::default(), + actions, + amount: 1, + anchor: [7; 32], + proof: vec![0; 100], + binding_signature: [0; 64], + fee_strategy: Default::default(), + user_fee_increase: 0, + input_witnesses: vec![], + }; + let bound = ShieldTransitionV1 { + inputs: legacy.inputs.clone(), + actions: legacy.actions.clone(), + amount: legacy.amount, + anchor: legacy.anchor, + proof: legacy.proof.clone(), + binding_signature: legacy.binding_signature, + fee_strategy: legacy.fee_strategy.clone(), + user_fee_increase: legacy.user_fee_increase, + input_witnesses: legacy.input_witnesses.clone(), + }; + for shield in [ShieldTransition::V0(legacy), ShieldTransition::V1(bound)] { + let bytes = StateTransition::Shield(shield) + .serialize_to_bytes() + .unwrap(); + let count = orchard_action_count(&bytes); + assert_eq!( + count, 3, + "both formats reserve their proof work before verification" + ); + let budget = budget(); + let now = Instant::now(); + broadcast(&budget, SOURCE, count, true, now).unwrap(); + assert_eq!( + broadcast(&budget, SOURCE, count, true, now), + Err(ACTION_DRAIN_INTERVAL * 2), + "a new wire format cannot bypass the source's failed-proof budget" + ); + } + } } diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index 3b84e89cbfd..a7977265f73 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -1214,8 +1214,11 @@ impl StateTransition { | StateTransition::AddressFundsTransfer(_) | StateTransition::AddressFundingFromAssetLock(_) | StateTransition::AddressCreditWithdrawal(_) => 11..=LATEST_VERSION, - StateTransition::Shield(_) - | StateTransition::ShieldedTransfer(_) + StateTransition::Shield(st) => match st { + ShieldTransition::V0(_) => 12..=13, + ShieldTransition::V1(_) => 14..=LATEST_VERSION, + }, + StateTransition::ShieldedTransfer(_) | StateTransition::Unshield(_) | StateTransition::ShieldedWithdrawal(_) => 12..=LATEST_VERSION, // From protocol version 14 the bundle must bind its kind and its asset lock, which diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs index 6cb10c58038..6b4e849dfd2 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/accessors/mod.rs @@ -10,72 +10,84 @@ impl ShieldTransitionAccessorsV0 for ShieldTransition { fn actions(&self) -> &[SerializedAction] { match self { ShieldTransition::V0(v0) => &v0.actions, + ShieldTransition::V1(v1) => &v1.actions, } } fn set_actions(&mut self, actions: Vec) { match self { ShieldTransition::V0(v0) => v0.actions = actions, + ShieldTransition::V1(v1) => v1.actions = actions, } } fn amount(&self) -> u64 { match self { ShieldTransition::V0(v0) => v0.amount, + ShieldTransition::V1(v1) => v1.amount, } } fn set_amount(&mut self, amount: u64) { match self { ShieldTransition::V0(v0) => v0.amount = amount, + ShieldTransition::V1(v1) => v1.amount = amount, } } fn anchor(&self) -> [u8; 32] { match self { ShieldTransition::V0(v0) => v0.anchor, + ShieldTransition::V1(v1) => v1.anchor, } } fn set_anchor(&mut self, anchor: [u8; 32]) { match self { ShieldTransition::V0(v0) => v0.anchor = anchor, + ShieldTransition::V1(v1) => v1.anchor = anchor, } } fn proof(&self) -> &[u8] { match self { ShieldTransition::V0(v0) => &v0.proof, + ShieldTransition::V1(v1) => &v1.proof, } } fn set_proof(&mut self, proof: Vec) { match self { ShieldTransition::V0(v0) => v0.proof = proof, + ShieldTransition::V1(v1) => v1.proof = proof, } } fn binding_signature(&self) -> [u8; 64] { match self { ShieldTransition::V0(v0) => v0.binding_signature, + ShieldTransition::V1(v1) => v1.binding_signature, } } fn set_binding_signature(&mut self, binding_signature: [u8; 64]) { match self { ShieldTransition::V0(v0) => v0.binding_signature = binding_signature, + ShieldTransition::V1(v1) => v1.binding_signature = binding_signature, } } fn fee_strategy(&self) -> &AddressFundsFeeStrategy { match self { ShieldTransition::V0(v0) => &v0.fee_strategy, + ShieldTransition::V1(v1) => &v1.fee_strategy, } } fn set_fee_strategy(&mut self, fee_strategy: AddressFundsFeeStrategy) { match self { ShieldTransition::V0(v0) => v0.fee_strategy = fee_strategy, + ShieldTransition::V1(v1) => v1.fee_strategy = fee_strategy, } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs index 219d9415b19..c68ca3af834 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/methods/mod.rs @@ -16,7 +16,10 @@ use crate::state_transition::shield_transition::ShieldTransition; #[cfg(feature = "state-transition-signing")] use crate::{ prelude::{AddressNonce, UserFeeIncrease}, - state_transition::{shield_transition::v0::ShieldTransitionV0, StateTransition}, + state_transition::{ + shield_transition::{v0::ShieldTransitionV0, v1::ShieldTransitionV1}, + StateTransition, + }, ProtocolError, }; #[cfg(feature = "state-transition-signing")] @@ -57,9 +60,24 @@ impl ShieldTransitionMethodsV0 for ShieldTransition { ) .await } + 1 => { + ShieldTransitionV1::try_from_bundle_with_signer( + inputs, + actions, + amount, + anchor, + proof, + binding_signature, + fee_strategy, + signer, + user_fee_increase, + platform_version, + ) + .await + } version => Err(ProtocolError::UnknownVersionMismatch { method: "ShieldTransition::try_from_bundle_with_signer".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, }), } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs index 123a87060bb..fa7adc3cfbb 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/mod.rs @@ -4,13 +4,17 @@ mod state_transition_estimated_fee_validation; mod state_transition_like; mod state_transition_validation; pub mod v0; +pub mod v1; mod version; use crate::state_transition::shield_transition::v0::ShieldTransitionV0; use crate::state_transition::shield_transition::v0::ShieldTransitionV0Signable; +use crate::state_transition::shield_transition::v1::{ + ShieldTransitionV1, ShieldTransitionV1Signable, +}; use crate::state_transition::StateTransitionFieldTypes; -pub type ShieldTransitionLatest = ShieldTransitionV0; +pub type ShieldTransitionLatest = ShieldTransitionV1; use crate::identity::state_transition::OptionallyAssetLockProved; #[cfg(feature = "json-conversion")] @@ -58,6 +62,9 @@ use serde::{Deserialize, Serialize}; pub enum ShieldTransition { #[cfg_attr(feature = "serde-conversion", serde(rename = "0"))] V0(ShieldTransitionV0), + /// Binds the bundle to its kind and funding addresses. Admitted only from protocol version 14. + #[cfg_attr(feature = "serde-conversion", serde(rename = "1"))] + V1(ShieldTransitionV1), } impl OptionallyAssetLockProved for ShieldTransition {} @@ -85,9 +92,16 @@ impl StateTransitionFieldTypes for ShieldTransition { pub(crate) mod json_convertible_tests { use super::*; use crate::address_funds::{AddressFundsFeeStrategyStep, AddressWitness, PlatformAddress}; + use crate::dashcore::hashes::{sha256, Hash}; + use crate::serialization::{ + JsonConvertible, PlatformDeserializableUntrusted, PlatformSerializable, Signable, + ValueConvertible, + }; use crate::shielded::SerializedAction; use crate::state_transition::shield_transition::v0::ShieldTransitionV0; + use crate::state_transition::StateTransition; use platform_value::{platform_value, BinaryData, Bytes32}; + use platform_version::version::PlatformVersion; use serde_json::json; use std::collections::BTreeMap; @@ -120,6 +134,118 @@ pub(crate) mod json_convertible_tests { }) } + fn bound_fixture() -> ShieldTransition { + let ShieldTransition::V0(v0) = fixture() else { + panic!("historical fixture"); + }; + ShieldTransition::V1(ShieldTransitionV1 { + inputs: v0.inputs, + actions: v0.actions, + amount: v0.amount, + anchor: v0.anchor, + proof: v0.proof, + binding_signature: v0.binding_signature, + fee_strategy: v0.fee_strategy, + user_fee_increase: v0.user_fee_increase, + input_witnesses: v0.input_witnesses, + }) + } + + #[test] + fn should_separate_shield_formats_at_protocol_activation() { + for (pv, format) in [ + (PlatformVersion::get(12).expect("PV12"), 0), + (PlatformVersion::get(13).expect("PV13"), 0), + (PlatformVersion::latest(), 1), + ] { + let bounds = &pv + .dpp + .state_transition_serialization_versions + .shield_state_transition; + assert_eq!( + ( + bounds.min_version, + bounds.max_version, + bounds.default_current_version + ), + (format, format, format) + ); + for shield in [fixture(), bound_fixture()] { + let supported = shield.feature_version() == format; + let transition = StateTransition::Shield(shield); + let bytes = transition.serialize_to_bytes().expect("wire bytes"); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted(&bytes) + .expect("generic decoding"), + transition + ); + assert_eq!( + StateTransition::deserialize_from_bytes_untrusted_exact_in_version(&bytes, pv) + .is_ok(), + supported + ); + } + } + } + + #[test] + fn should_include_the_shield_format_in_address_signing_bytes() { + let legacy = StateTransition::Shield(fixture()); + let bound = StateTransition::Shield(bound_fixture()); + for (before, after) in [ + ( + legacy.serialize_to_bytes().expect("legacy"), + bound.serialize_to_bytes().expect("bound"), + ), + ( + legacy.signable_bytes().expect("legacy signing bytes"), + bound.signable_bytes().expect("bound signing bytes"), + ), + ] { + assert_eq!(before.len(), after.len()); + assert_eq!( + before.iter().zip(&after).filter(|(a, b)| a != b).count(), + 1, + "identical fields must differ only in the signed format discriminant" + ); + } + } + + #[test] + fn should_round_trip_bound_shields_without_losing_the_format() { + let original = bound_fixture(); + let json = original.to_json().expect("JSON"); + assert_eq!(json["$formatVersion"], "1"); + assert_eq!( + ShieldTransition::from_json(json).expect("JSON round trip"), + original + ); + let value = original.to_object().expect("Value"); + assert_eq!( + ShieldTransition::from_object(value).expect("Value round trip"), + original + ); + } + + #[test] + fn should_preserve_historical_shield_wire_and_signable_bytes() { + let transition = StateTransition::Shield(fixture()); + let wire = transition + .serialize_to_bytes() + .expect("historical wire bytes"); + let signable = transition + .signable_bytes() + .expect("historical signing bytes"); + assert_eq!( + sha256::Hash::hash(&wire).to_string(), + "cb3bd0211bf65d650f543cd26c41e8ea8a3f65966a7d724da0dbb84c6d954171" + ); + assert_eq!( + sha256::Hash::hash(&signable).to_string(), + "e4d9d6efea0f33c18b114c9250322c190af2eec35d1478ebadb59db243a6b45a" + ); + } + #[test] fn json_round_trip_with_full_wire_shape() { use crate::serialization::JsonConvertible; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs index 06c7d5c4e33..483df436f79 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_estimated_fee_validation.rs @@ -1,5 +1,6 @@ use crate::fee::Credits; use crate::shielded::compute_shielded_verification_fee; +use crate::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use crate::state_transition::shield_transition::ShieldTransition; use crate::state_transition::StateTransitionEstimatedFeeValidation; use crate::ProtocolError; @@ -29,7 +30,6 @@ impl StateTransitionEstimatedFeeValidation for ShieldTransition { ) -> Result { // The on-wire Orchard `actions` count is what the compute fee is priced against (matching // the consensus structure-validation floor and the execution-event compute charge). - let ShieldTransition::V0(v0) = self; - compute_shielded_verification_fee(v0.actions.len(), platform_version) + compute_shielded_verification_fee(self.actions().len(), platform_version) } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs index 017a143bc2e..9a40fb7c096 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_like.rs @@ -13,24 +13,28 @@ impl StateTransitionLike for ShieldTransition { fn modified_data_ids(&self) -> Vec { match self { ShieldTransition::V0(transition) => transition.modified_data_ids(), + ShieldTransition::V1(transition) => transition.modified_data_ids(), } } fn state_transition_protocol_version(&self) -> FeatureVersion { match self { ShieldTransition::V0(_) => 0, + ShieldTransition::V1(_) => 1, } } /// returns the type of State Transition fn state_transition_type(&self) -> StateTransitionType { match self { ShieldTransition::V0(transition) => transition.state_transition_type(), + ShieldTransition::V1(transition) => transition.state_transition_type(), } } fn unique_identifiers(&self) -> Vec { match self { ShieldTransition::V0(transition) => transition.unique_identifiers(), + ShieldTransition::V1(transition) => transition.unique_identifiers(), } } } @@ -40,12 +44,14 @@ impl StateTransitionHasUserFeeIncrease for ShieldTransition { fn user_fee_increase(&self) -> UserFeeIncrease { match self { ShieldTransition::V0(transition) => transition.user_fee_increase(), + ShieldTransition::V1(transition) => transition.user_fee_increase(), } } /// set a fee multiplier fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { match self { ShieldTransition::V0(transition) => transition.set_user_fee_increase(user_fee_increase), + ShieldTransition::V1(transition) => transition.set_user_fee_increase(user_fee_increase), } } } @@ -59,6 +65,7 @@ impl StateTransitionWitnessSigned for ShieldTransition { > { match self { ShieldTransition::V0(transition) => transition.inputs(), + ShieldTransition::V1(transition) => transition.inputs(), } } @@ -70,6 +77,7 @@ impl StateTransitionWitnessSigned for ShieldTransition { > { match self { ShieldTransition::V0(transition) => transition.inputs_mut(), + ShieldTransition::V1(transition) => transition.inputs_mut(), } } @@ -82,18 +90,21 @@ impl StateTransitionWitnessSigned for ShieldTransition { ) { match self { ShieldTransition::V0(transition) => transition.set_inputs(inputs), + ShieldTransition::V1(transition) => transition.set_inputs(inputs), } } fn witnesses(&self) -> &Vec { match self { ShieldTransition::V0(transition) => transition.witnesses(), + ShieldTransition::V1(transition) => transition.witnesses(), } } fn set_witnesses(&mut self, witnesses: Vec) { match self { ShieldTransition::V0(transition) => transition.set_witnesses(witnesses), + ShieldTransition::V1(transition) => transition.set_witnesses(witnesses), } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs index 71d92296f55..83156bab69e 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/state_transition_validation.rs @@ -12,6 +12,7 @@ impl StateTransitionStructureValidation for ShieldTransition { ) -> SimpleConsensusValidationResult { match self { ShieldTransition::V0(v0) => v0.validate_structure(platform_version), + ShieldTransition::V1(v1) => v1.validate_structure(platform_version), } } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs new file mode 100644 index 00000000000..6d2f2380d91 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/mod.rs @@ -0,0 +1,70 @@ +mod state_transition_like; +mod state_transition_validation; +mod types; +#[cfg(feature = "state-transition-signing")] +pub(super) mod v1_methods; +mod version; + +use std::collections::BTreeMap; + +use crate::address_funds::{AddressFundsFeeStrategy, AddressWitness, PlatformAddress}; +use crate::fee::Credits; +use crate::prelude::{AddressNonce, UserFeeIncrease}; +use crate::shielded::SerializedAction; +use crate::ProtocolError; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, PlatformSignable, +}; +#[cfg(feature = "serde-conversion")] +use serde::{Deserialize, Serialize}; + +/// The fields of version 0, with a signed format discriminant separating the +/// funding-bound bundle domain from the unbound bundles of protocol versions 12 and 13. +/// Version 0 is refused without charging once protocol version 14 activates. +#[cfg_attr(feature = "json-conversion", crate::serialization::json_safe_fields)] +#[derive( + Debug, + Clone, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + PlatformSignable, + PartialEq, + DecodeUntrusted, +)] +#[cfg_attr( + feature = "serde-conversion", + derive(Serialize, Deserialize), + serde(rename_all = "camelCase") +)] +#[platform_serialize(unversioned)] +pub struct ShieldTransitionV1 { + /// Address inputs funding the shield (address -> nonce + max contribution). + /// The total across all inputs must cover |value_balance| + fees. + /// Excess credits remain in the source addresses. + #[cfg_attr( + feature = "json-conversion", + serde(with = "crate::address_funds::serde_helpers::address_input_map") + )] + pub inputs: BTreeMap, + /// Orchard actions (spend-output pairs) + pub actions: Vec, + /// Amount of credits being shielded (entering the shielded pool). + pub amount: u64, + /// Sinsemilla root of the note commitment tree (Orchard Anchor) + pub anchor: [u8; 32], + /// Halo2 proof bytes + pub proof: Vec, + /// RedPallas binding signature + pub binding_signature: [u8; 64], + /// Fee payment strategy + pub fee_strategy: AddressFundsFeeStrategy, + /// Fee multiplier + pub user_fee_increase: UserFeeIncrease, + /// Address witness signatures (excluded from sig hash) + #[platform_signable(exclude_from_sig_hash)] + pub input_witnesses: Vec, +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs new file mode 100644 index 00000000000..b9eaf4a61b0 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_like.rs @@ -0,0 +1,78 @@ +use crate::address_funds::{AddressWitness, PlatformAddress}; +use crate::fee::Credits; +use crate::prelude::{AddressNonce, UserFeeIncrease}; +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::shield_transition::ShieldTransition; +use crate::state_transition::StateTransitionHasUserFeeIncrease; +use crate::{ + prelude::Identifier, + state_transition::{StateTransitionLike, StateTransitionType}, +}; + +use crate::state_transition::StateTransitionType::Shield; +use crate::state_transition::{StateTransition, StateTransitionWitnessSigned}; +use crate::version::FeatureVersion; +use std::collections::BTreeMap; + +impl From for StateTransition { + fn from(value: ShieldTransitionV1) -> Self { + let shield_transition: ShieldTransition = value.into(); + shield_transition.into() + } +} + +impl StateTransitionLike for ShieldTransitionV1 { + fn state_transition_protocol_version(&self) -> FeatureVersion { + 1 + } + + /// returns the type of State Transition + fn state_transition_type(&self) -> StateTransitionType { + Shield + } + + /// Returns IDs of modified data (none for shielded transitions) + fn modified_data_ids(&self) -> Vec { + vec![] + } + + /// State transitions with the same inputs should not be allowed to overlap + fn unique_identifiers(&self) -> Vec { + self.inputs + .iter() + .map(|(key, (nonce, _))| key.base64_string_with_nonce(*nonce)) + .collect() + } +} + +impl StateTransitionHasUserFeeIncrease for ShieldTransitionV1 { + fn user_fee_increase(&self) -> UserFeeIncrease { + self.user_fee_increase + } + + fn set_user_fee_increase(&mut self, user_fee_increase: UserFeeIncrease) { + self.user_fee_increase = user_fee_increase + } +} + +impl StateTransitionWitnessSigned for ShieldTransitionV1 { + fn inputs(&self) -> &BTreeMap { + &self.inputs + } + + fn inputs_mut(&mut self) -> &mut BTreeMap { + &mut self.inputs + } + + fn set_inputs(&mut self, inputs: BTreeMap) { + self.inputs = inputs; + } + + fn witnesses(&self) -> &Vec { + &self.input_witnesses + } + + fn set_witnesses(&mut self, witnesses: Vec) { + self.input_witnesses = witnesses; + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs new file mode 100644 index 00000000000..defcdb32b4b --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/state_transition_validation.rs @@ -0,0 +1,250 @@ +use crate::address_funds::AddressFundsFeeStrategyStep; +use crate::consensus::basic::state_transition::{ + FeeStrategyDuplicateError, FeeStrategyEmptyError, FeeStrategyIndexOutOfBoundsError, + FeeStrategyTooManyStepsError, InputBelowMinimumError, InputWitnessCountMismatchError, + ShieldedInvalidValueBalanceError, TransitionNoInputsError, +}; +use crate::consensus::basic::BasicError; +use crate::shielded::compute_shielded_verification_fee; +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::state_transitions::shielded::common_validation::{ + validate_actions_count, validate_anchor_not_zero, validate_encrypted_note_sizes, + validate_proof_not_empty, +}; +use crate::state_transition::StateTransitionStructureValidation; +use crate::validation::SimpleConsensusValidationResult; +use platform_version::version::PlatformVersion; +use std::collections::HashSet; + +impl StateTransitionStructureValidation for ShieldTransitionV1 { + fn validate_structure( + &self, + platform_version: &PlatformVersion, + ) -> SimpleConsensusValidationResult { + // Actions count must be in [1, max] + let result = validate_actions_count( + &self.actions, + platform_version + .system_limits + .max_shielded_transition_actions, + ); + if !result.is_valid() { + return result; + } + + // Each action's encrypted_note must be exactly ENCRYPTED_NOTE_SIZE bytes + let result = validate_encrypted_note_sizes(&self.actions); + if !result.is_valid() { + return result; + } + + // Inputs must not be empty (shield requires address funding) + if self.inputs.is_empty() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::TransitionNoInputsError(TransitionNoInputsError::new()).into(), + ); + } + + // Input witnesses must match inputs count + if self.inputs.len() != self.input_witnesses.len() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::InputWitnessCountMismatchError(InputWitnessCountMismatchError::new( + self.inputs.len().min(u16::MAX as usize) as u16, + self.input_witnesses.len().min(u16::MAX as usize) as u16, + )) + .into(), + ); + } + + // Validate each input amount is > 0 + let min_input_amount = platform_version + .dpp + .state_transitions + .address_funds + .min_input_amount; + for (_nonce, amount) in self.inputs.values() { + if *amount < min_input_amount { + return SimpleConsensusValidationResult::new_with_error( + BasicError::InputBelowMinimumError(InputBelowMinimumError::new( + *amount, + min_input_amount, + )) + .into(), + ); + } + } + + // amount must be positive (credits flowing into pool) + if self.amount == 0 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount must be greater than zero".to_string(), + ), + ) + .into(), + ); + } + + // amount must fit in i64 (Orchard protocol uses i64 internally for value_balance) + if self.amount > i64::MAX as u64 { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount exceeds maximum allowed value".to_string(), + ), + ) + .into(), + ); + } + + // Total input amounts must cover the shield amount PLUS the shielded COMPUTE fee + // (`compute_shielded_verification_fee`: proof verification + per-action processing, NO storage + // term). This is a stateless lower bound: the real per-action storage cost is metered by + // GroveDB at execution and is unknowable here, so we deliberately do NOT add the 312-byte + // storage ESTIMATE — doing so would falsely reject otherwise-valid transitions whose actual + // metered storage is below the estimate. + // + // This is a cheap early reject. The AUTHORITATIVE funding gate is `validate_fees_of_event` + // (drive-abci), which re-checks `metered_storage + metered_processing + compute_fee` against + // the per-input balances AFTER the shield-amount reallocation. Anti-mint safety does NOT + // depend on this `+fee` term: it depends on `Σrequested >= amount` enforced by the + // reallocation plus that authoritative gate. `input_sum` here only bounds the sum of max + // contributions. + // + // `compute_shielded_verification_fee` returns a `Result`, but this validator returns a + // `SimpleConsensusValidationResult`, so we cannot `?`-propagate; we map an overflow to a + // consensus error (reachable only via pathological fee constants). + let minimum_fee = + match compute_shielded_verification_fee(self.actions.len(), platform_version) { + Ok(fee) => fee, + Err(_) => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "minimum shielded fee computation overflowed".to_string(), + ), + ) + .into(), + ); + } + }; + let required_input = match self.amount.checked_add(minimum_fee) { + Some(required) => required, + None => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "shield amount plus minimum shielded fee overflows".to_string(), + ), + ) + .into(), + ); + } + }; + let input_sum = self + .inputs + .values() + .try_fold(0u64, |acc, (_, amount)| acc.checked_add(*amount)); + match input_sum { + Some(sum) if sum >= required_input => {} + Some(sum) => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new(format!( + "total input amount ({}) is less than shield amount ({}) plus minimum shielded fee ({})", + sum, self.amount, minimum_fee + )), + ) + .into(), + ); + } + None => { + return SimpleConsensusValidationResult::new_with_error( + BasicError::ShieldedInvalidValueBalanceError( + ShieldedInvalidValueBalanceError::new( + "total input amounts overflow".to_string(), + ), + ) + .into(), + ); + } + } + + // Proof must not be empty + let result = validate_proof_not_empty(&self.proof); + if !result.is_valid() { + return result; + } + + // Anchor must not be all zeros + let result = validate_anchor_not_zero(&self.anchor); + if !result.is_valid() { + return result; + } + + // Fee strategy validation (reuse address funds patterns) + if self.fee_strategy.is_empty() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyEmptyError(FeeStrategyEmptyError::new()).into(), + ); + } + + let max_fee_strategies = platform_version + .dpp + .state_transitions + .max_address_fee_strategies as usize; + if self.fee_strategy.len() > max_fee_strategies { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyTooManyStepsError(FeeStrategyTooManyStepsError::new( + self.fee_strategy.len().min(u8::MAX as usize) as u8, + max_fee_strategies.min(u8::MAX as usize) as u8, + )) + .into(), + ); + } + + let mut seen = HashSet::with_capacity(self.fee_strategy.len()); + for step in &self.fee_strategy { + if !seen.insert(step) { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyDuplicateError(FeeStrategyDuplicateError::new()).into(), + ); + } + + // Reject structurally-unusable fee-strategy steps here — cheaply, before Orchard proof + // verification — rather than letting an out-of-range/no-op step slip through and only + // surface later as a generic `AddressesNotEnoughFundsError`. A `Shield` has transparent + // inputs but NO transparent outputs, so `DeductFromInput` must index a real input and + // `ReduceOutput` can never apply. Mirrors `AddressFundingFromAssetLock`'s validator. + match step { + AddressFundsFeeStrategyStep::DeductFromInput(index) => { + if *index as usize >= self.inputs.len() { + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyIndexOutOfBoundsError( + FeeStrategyIndexOutOfBoundsError::new( + "DeductFromInput", + *index, + self.inputs.len().min(u16::MAX as usize) as u16, + ), + ) + .into(), + ); + } + } + AddressFundsFeeStrategyStep::ReduceOutput(index) => { + // A Shield has no transparent outputs (count 0), so any `ReduceOutput` is out of + // bounds. + return SimpleConsensusValidationResult::new_with_error( + BasicError::FeeStrategyIndexOutOfBoundsError( + FeeStrategyIndexOutOfBoundsError::new("ReduceOutput", *index, 0), + ) + .into(), + ); + } + } + } + + SimpleConsensusValidationResult::new() + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs new file mode 100644 index 00000000000..39f36cb0ef0 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/types.rs @@ -0,0 +1,16 @@ +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::StateTransitionFieldTypes; + +impl StateTransitionFieldTypes for ShieldTransitionV1 { + fn signature_property_paths() -> Vec<&'static str> { + vec![] + } + + fn identifiers_property_paths() -> Vec<&'static str> { + vec![] + } + + fn binary_property_paths() -> Vec<&'static str> { + vec![] + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs new file mode 100644 index 00000000000..8ee23cf1493 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/v1_methods.rs @@ -0,0 +1,65 @@ +#[cfg(feature = "state-transition-signing")] +use std::collections::BTreeMap; + +#[cfg(feature = "state-transition-signing")] +use crate::address_funds::{AddressFundsFeeStrategy, AddressWitness, PlatformAddress}; +#[cfg(feature = "state-transition-signing")] +use crate::fee::Credits; +#[cfg(feature = "state-transition-signing")] +use crate::identity::signer::Signer; +#[cfg(feature = "state-transition-signing")] +use crate::serialization::Signable; +#[cfg(feature = "state-transition-signing")] +use crate::shielded::SerializedAction; +use crate::state_transition::shield_transition::methods::ShieldTransitionMethodsV0; +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +#[cfg(feature = "state-transition-signing")] +use crate::{ + prelude::{AddressNonce, UserFeeIncrease}, + state_transition::StateTransition, + ProtocolError, +}; +#[cfg(feature = "state-transition-signing")] +use platform_version::version::PlatformVersion; + +impl ShieldTransitionMethodsV0 for ShieldTransitionV1 { + #[cfg(feature = "state-transition-signing")] + async fn try_from_bundle_with_signer>( + inputs: BTreeMap, + actions: Vec, + amount: u64, + anchor: [u8; 32], + proof: Vec, + binding_signature: [u8; 64], + fee_strategy: AddressFundsFeeStrategy, + signer: &S, + user_fee_increase: UserFeeIncrease, + _platform_version: &PlatformVersion, + ) -> Result { + // Create the unsigned transition (empty witnesses) + let mut shield_transition = ShieldTransitionV1 { + inputs: inputs.clone(), + actions, + amount, + anchor, + proof, + binding_signature, + fee_strategy, + user_fee_increase, + input_witnesses: Vec::new(), + }; + + // Compute signable bytes (excludes input_witnesses which are marked exclude_from_sig_hash) + let state_transition: StateTransition = shield_transition.clone().into(); + let signable_bytes = state_transition.signable_bytes()?; + + // Sign each input address + let mut input_witnesses: Vec = Vec::with_capacity(inputs.len()); + for address in inputs.keys() { + input_witnesses.push(signer.sign_create_witness(address, &signable_bytes).await?); + } + shield_transition.input_witnesses = input_witnesses; + + Ok(shield_transition.into()) + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs new file mode 100644 index 00000000000..808a3f97797 --- /dev/null +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/v1/version.rs @@ -0,0 +1,9 @@ +use crate::state_transition::shield_transition::v1::ShieldTransitionV1; +use crate::state_transition::FeatureVersioned; +use crate::version::FeatureVersion; + +impl FeatureVersioned for ShieldTransitionV1 { + fn feature_version(&self) -> FeatureVersion { + 1 + } +} diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs index 8f5de0b6b7a..bb289aff5b3 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_transition/version.rs @@ -6,6 +6,7 @@ impl FeatureVersioned for ShieldTransition { fn feature_version(&self) -> FeatureVersion { match self { ShieldTransition::V0(v0) => v0.feature_version(), + ShieldTransition::V1(v1) => v1.feature_version(), } } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs index 6be3f347c16..0312c8619c1 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/traits/shielded_proof.rs @@ -17,6 +17,8 @@ use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlr use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; use dpp::serialization::{PlatformMessageSignable, Signable}; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::IdentityCreateFromShieldedPoolTransition; @@ -162,11 +164,7 @@ impl StateTransitionHasShieldedProofValidationV0 for StateTransition { fn shielded_proof_action_count(&self) -> usize { match self { - StateTransition::Shield(st) => match st { - dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { - v0.actions.len() - } - }, + StateTransition::Shield(st) => st.actions().len(), StateTransition::ShieldFromIdentity(st) => match st { ShieldFromIdentityTransition::V0(v0) => v0.actions.len(), }, @@ -744,19 +742,16 @@ fn validate_shielded_proof_v0( } let result = match state_transition { - StateTransition::Shield(st) => match st { - dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { - reconstruct_and_verify_bundle( - &v0.actions, - FLAGS_OUTPUTS_ONLY, - -(v0.amount as i64), - &v0.anchor, - v0.proof.as_slice(), - &v0.binding_signature, - &[], // No transparent fields for shield - ) - } - }, + // The accessors project the same V0 fields; its historical empty preimage is unchanged. + StateTransition::Shield(st) => reconstruct_and_verify_bundle( + st.actions(), + FLAGS_OUTPUTS_ONLY, + -(st.amount() as i64), + &st.anchor(), + st.proof(), + &st.binding_signature(), + &[], + ), StateTransition::ShieldFromIdentity(st) => match st { ShieldFromIdentityTransition::V0(v0) => reconstruct_and_verify_bundle( &v0.actions, @@ -1233,20 +1228,18 @@ fn validate_shielded_proof_v1( // The credit pool's outputs-only bundles carry no anchor, so the proved // bytes verify wherever they land. Their preimage binds the kind and the // funder, which a copy re-wrapped under someone else's funding cannot match. - StateTransition::Shield(st) => match st { - dpp::state_transition::shield_transition::ShieldTransition::V0(v0) => { - let extra_sighash_data = - shield_extra_sighash_data(&v0.inputs, platform_version)?; - reconstruct_and_verify_bundle( - &v0.actions, - FLAGS_OUTPUTS_ONLY, - -(v0.amount as i64), - &v0.anchor, - v0.proof.as_slice(), - &v0.binding_signature, - &extra_sighash_data, - ) - } + StateTransition::Shield(st) => { + let extra_sighash_data = + shield_extra_sighash_data(st.inputs(), platform_version)?; + reconstruct_and_verify_bundle( + st.actions(), + FLAGS_OUTPUTS_ONLY, + -(st.amount() as i64), + &st.anchor(), + st.proof(), + &st.binding_signature(), + &extra_sighash_data, + ) }, // CheckTx's admission check. Block processing verifies the same bundle in // `ShieldFromIdentity`'s transform, which must rebuild the same preimage. diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs index 5653811d40d..6159bc274b6 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs @@ -27,7 +27,9 @@ use crate::platform_types::platform::PlatformRef; use crate::platform_types::platform_state::PlatformStateV0Methods; use crate::rpc::core::CoreRPCLike; use dpp::block::block_info::BlockInfo; +use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::shield_transition::ShieldTransition; use dpp::state_transition::StateTransition; use dpp::version::{DefaultForPlatformVersion, PlatformVersion}; use dpp::ProtocolError; @@ -36,8 +38,8 @@ use drive::grovedb::TransactionArg; /// Version 0's validation pipeline, with Shield proof verification deferred to its /// action transformer after authentication and input balance/nonce checks. This /// lets a funded proof failure produce a fee-paying nonce-bump action instead of -/// returning an unpaid refusal in the shared proof step. Other transitions keep -/// their existing proof-validation path. +/// returning an unpaid refusal in the shared proof step. Legacy Shield formats are +/// refused before authentication; other transitions keep their existing validation path. pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( platform: &'a PlatformRef, block_info: &BlockInfo, @@ -45,6 +47,22 @@ pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( transaction: TransactionArg, platform_version: &PlatformVersion, ) -> Result>, Error> { + // A legacy signature did not authorize the bound proof domain or its failure fee. + // Refuse before authentication so direct, already-decoded callers cannot charge it. + if matches!( + &state_transition, + StateTransition::Shield(ShieldTransition::V0(_)) + ) { + return Ok(ConsensusValidationResult::new_with_error( + StateTransitionNotActiveError::new( + state_transition.name(), + platform_version.protocol_version, + *state_transition.active_version_range().end(), + ) + .into(), + )); + } + let mut state_transition_execution_context = StateTransitionExecutionContext::default_for_platform_version(platform_version)?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index 77a5f23b6c9..37cba924a03 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -23,7 +23,8 @@ mod tests { use dpp::prelude::AddressNonce; use dpp::serialization::{PlatformSerializable, Signable}; use dpp::shielded::{shield_extra_sighash_data, SerializedAction}; - use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::shield_transition::methods::ShieldTransitionMethodsV0; + use dpp::state_transition::shield_transition::v1::ShieldTransitionV1; use dpp::state_transition::shield_transition::ShieldTransition; use dpp::state_transition::StateTransition; use grovedb_commitment_tree::{ @@ -38,7 +39,7 @@ mod tests { // Helper Functions (transition-specific) // ========================================== - /// Builds a raw `ShieldTransitionV0` with dummy witnesses. Used for structure validation tests + /// Builds a raw `ShieldTransitionV1` with dummy witnesses. Used for structure validation tests /// that don't need valid signatures (the structure error is caught before or alongside witness /// validation, or inputs are empty so witness validation is vacuously true). fn create_raw_shield_transition( @@ -52,7 +53,7 @@ mod tests { ) -> StateTransition { let witnesses: Vec = (0..witness_count).map(|_| create_dummy_witness()).collect(); - StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs, actions, amount, @@ -65,7 +66,7 @@ mod tests { })) } - /// Builds a `ShieldTransitionV0` and signs it with the provided signer. + /// Builds a `ShieldTransitionV1` and signs it with the provided signer. /// The transition will have valid witnesses for all inputs. async fn create_signed_shield_transition( signer: &TestAddressSigner, @@ -77,7 +78,7 @@ mod tests { fee_strategy: AddressFundsFeeStrategy, ) -> StateTransition { // First create with empty witnesses to compute signable bytes - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount, @@ -103,7 +104,7 @@ mod tests { } // Inject witnesses - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } st @@ -240,7 +241,7 @@ mod tests { .await; // Add an extra dummy witness to cause mismatch (1 input, 2 witnesses) - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { v0.input_witnesses.push(create_dummy_witness()); } @@ -306,7 +307,7 @@ mod tests { let actions: Vec = (0..17).map(|_| create_dummy_serialized_action()).collect(); - let transition = ShieldTransitionV0 { + let transition = ShieldTransitionV1 { inputs: BTreeMap::new(), actions, amount: 1000, @@ -571,7 +572,7 @@ mod tests { .await; // Tamper the witness signature - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { if let Some(AddressWitness::P2pkh { ref mut signature }) = v0.input_witnesses.first_mut() { @@ -634,7 +635,7 @@ mod tests { .await .expect("should sign"); - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { v0.input_witnesses = vec![wrong_witness]; } @@ -844,7 +845,7 @@ mod tests { (1 as AddressNonce, shield_amount + dash_to_credits!(0.01)), ); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -868,7 +869,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -961,7 +962,7 @@ mod tests { (1 as AddressNonce, dash_to_credits!(0.5)), ); - let transition = ShieldTransitionV0 { + let transition = ShieldTransitionV1 { inputs, actions: vec![create_dummy_serialized_action()], amount: 1000, @@ -1047,7 +1048,7 @@ mod tests { ), ); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: mutated_amount, // MUTATED @@ -1071,7 +1072,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -1155,7 +1156,7 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(input_address, (1 as AddressNonce, requested_input_amount)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: forged_shield_amount, @@ -1179,7 +1180,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -1271,7 +1272,7 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(input_address, (1 as AddressNonce, input_amount)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -1295,7 +1296,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -1421,6 +1422,7 @@ mod tests { use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::block::block_info::BlockInfo; + use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; use dpp::validation::ConsensusValidationResult; use dpp::version::DefaultForPlatformVersion; use drive::state_transition_action::StateTransitionAction; @@ -1463,37 +1465,25 @@ mod tests { address: PlatformAddress, nonce: AddressNonce, bundle: &OutputsOnlyBundle, + platform_version: &PlatformVersion, ) -> StateTransition { let mut inputs = BTreeMap::new(); inputs.insert(address, (nonce, bundle.amount + dash_to_credits!(0.01))); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { - inputs: inputs.clone(), - actions: bundle.actions.clone(), - amount: bundle.amount, - anchor: bundle.anchor, - proof: bundle.proof.clone(), - binding_signature: bundle.binding_signature, - fee_strategy: AddressFundsFeeStrategy::from(vec![ - AddressFundsFeeStrategyStep::DeductFromInput(0), - ]), - user_fee_increase: 0, - input_witnesses: vec![], - })); - let signable_bytes = st.signable_bytes().expect("should compute signable bytes"); - let mut witnesses: Vec = Vec::with_capacity(inputs.len()); - for input in inputs.keys() { - witnesses.push( - signer - .sign_create_witness(input, &signable_bytes) - .await - .expect("should sign"), - ); - } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { - v0.input_witnesses = witnesses; - } - st + ShieldTransition::try_from_bundle_with_signer( + inputs, + bundle.actions.clone(), + bundle.amount, + bundle.anchor, + bundle.proof.clone(), + bundle.binding_signature, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + signer, + 0, + platform_version, + ) + .await + .expect("signed Shield in the active wire format") } /// A funded address on `platform` and the signer for it. @@ -1519,7 +1509,7 @@ mod tests { let requested = dash_to_credits!(0.1); let mut inputs = BTreeMap::new(); inputs.insert(address, (1 as AddressNonce, requested)); - let transition = ShieldTransition::V0(ShieldTransitionV0 { + let fields = ShieldTransitionV0 { inputs, actions: nullifiers .iter() @@ -1537,7 +1527,27 @@ mod tests { ]), user_fee_increase: 0, input_witnesses: vec![], - }); + }; + let transition = match platform_version + .dpp + .state_transition_serialization_versions + .shield_state_transition + .default_current_version + { + 0 => ShieldTransition::V0(fields), + 1 => ShieldTransition::V1(ShieldTransitionV1 { + inputs: fields.inputs, + actions: fields.actions, + amount: fields.amount, + anchor: fields.anchor, + proof: fields.proof, + binding_signature: fields.binding_signature, + fee_strategy: fields.fee_strategy, + user_fee_increase: fields.user_fee_increase, + input_witnesses: fields.input_witnesses, + }), + version => panic!("unsupported Shield format {version}"), + }; // The address held exactly `requested`, all of it debited by the balance check. let mut remaining = BTreeMap::new(); remaining.insert(address, (1 as AddressNonce, 0)); @@ -1571,7 +1581,7 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, platform_version); - let st = signed_shield(&signer, address, 1, &bundle).await; + let st = signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, st, platform_version); assert_matches!( @@ -1593,7 +1603,8 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, platform_version); - let first = signed_shield(&signer, address, 1, &bundle).await; + let first = + signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, first, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1604,7 +1615,8 @@ mod tests { // nullifiers. The binding does not stop this — it covers the funding addresses, not // the transition carrying them — so the repeat travels the whole block path and the // recorded nullifier is what refuses it. - let repeat = signed_shield(&signer, address, 2, &bundle).await; + let repeat = + signed_shield(&signer, address, 2, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, repeat, platform_version); let first_nullifier = bundle.nullifiers()[0]; @@ -1639,8 +1651,10 @@ mod tests { let mut platform = setup_platform(); let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, pv); - let first = signed_shield(&signer, address, 1, &bundle).await; - let repeat = signed_shield(&signer, address, 2, &bundle).await; + let first = + signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; + let repeat = + signed_shield(&signer, address, 2, &bundle, PlatformVersion::latest()).await; let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); let result = platform @@ -1694,7 +1708,7 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = bound_bundle(address, platform_version); - let st = signed_shield(&signer, address, 1, &bundle).await; + let st = signed_shield(&signer, address, 1, &bundle, PlatformVersion::latest()).await; let result = process_transition_and_commit(&platform, st, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1720,7 +1734,7 @@ mod tests { let (signer, address) = funded_address(&mut platform); let bundle = unbound_bundle(); - let first = signed_shield(&signer, address, 1, bundle).await; + let first = signed_shield(&signer, address, 1, bundle, platform_version).await; let result = process_transition_and_commit(&platform, first, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1733,7 +1747,7 @@ mod tests { ); } - let repeat = signed_shield(&signer, address, 2, bundle).await; + let repeat = signed_shield(&signer, address, 2, bundle, platform_version).await; let result = process_transition_and_commit(&platform, repeat, platform_version); assert_matches!( result.execution_results().as_slice(), @@ -1874,7 +1888,7 @@ mod tests { let mut inputs = BTreeMap::new(); inputs.insert(input_address, (1 as AddressNonce, requested)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -1898,7 +1912,7 @@ mod tests { witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -2053,7 +2067,7 @@ mod tests { inputs.insert(addr_a, (1 as AddressNonce, requested)); inputs.insert(addr_b, (1 as AddressNonce, requested)); - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut st = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount: shield_amount, @@ -2076,7 +2090,7 @@ mod tests { .expect("should sign"); witnesses.push(witness); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = st { v0.input_witnesses = witnesses; } @@ -2124,6 +2138,7 @@ mod tests { mod mainnet_halt_repro { use super::*; use crate::execution::validation::state_transition::state_transitions::test_helpers::insert_dummy_encrypted_notes; + use crate::execution::validation::state_transition::state_transitions::test_helpers::setup_platform_at_protocol_version; use dpp::block::block_info::BlockInfo; /// Note count on mainnet's shielded commitment tree around the halt. @@ -2197,43 +2212,28 @@ mod tests { signer: &TestAddressSigner, addr: PlatformAddress, declared_input: u64, + pv: &PlatformVersion, ) -> StateTransition { - let mut inputs = BTreeMap::new(); - inputs.insert(addr, (1 as AddressNonce, declared_input)); - - let mut st = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { - inputs: inputs.clone(), - actions: b.actions.clone(), - amount: b.shield_amount, - anchor: b.anchor, - proof: b.proof.clone(), - binding_signature: b.binding_sig, - fee_strategy: AddressFundsFeeStrategy::from(vec![ - AddressFundsFeeStrategyStep::DeductFromInput(0), - ]), - user_fee_increase: 0, - input_witnesses: vec![], - })); - let signable = st.signable_bytes().expect("should compute signable bytes"); - let mut witnesses: Vec = Vec::with_capacity(inputs.len()); - for a in inputs.keys() { - witnesses.push( - signer - .sign_create_witness(a, &signable) - .await - .expect("sign"), - ); - } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = st { - v0.input_witnesses = witnesses; - } - st + ShieldTransition::try_from_bundle_with_signer( + BTreeMap::from([(addr, (1, declared_input))]), + b.actions.clone(), + b.shield_amount, + b.anchor, + b.proof.clone(), + b.binding_sig, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + signer, + 0, + pv, + ) + .await + .expect("signed Shield") } /// Run the shield on a fresh platform whose input address holds exactly /// `shield_amount + headroom`, i.e. `headroom` credits are available to pay the fee. async fn run_at(headroom: u64, b: &Bundle, pv: &PlatformVersion) -> (Outcome, String) { - let mut platform = setup_platform(); + let mut platform = setup_platform_at_protocol_version(pv.protocol_version); insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); let mut signer = TestAddressSigner::new(); @@ -2242,7 +2242,7 @@ mod tests { let declared_input = b.shield_amount + headroom; setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - let st = build_signed(b, &signer, addr, declared_input).await; + let st = build_signed(b, &signer, addr, declared_input, pv).await; let result = process_transition(&platform, st, pv); match result.execution_results().first() { Some(StateTransitionExecutionResult::SuccessfulExecution { .. }) => { @@ -2361,10 +2361,7 @@ mod tests { #[tokio::test] async fn dropped_shield_must_not_mutate_state() { let pv = PlatformVersion::get(13).expect("protocol version 13 should exist"); - // `setup_platform` starts at the latest protocol version, and - // `process_state_transition` validates at the platform state's version, not at `pv`, - // so the bundle is bound the way the latest version's proof check rebuilds it. - let b = build_bundle(PlatformVersion::latest()); + let b = build_bundle(pv); // Least headroom validation lets through to execution. const CEILING: u64 = 5_000_000_000; @@ -2413,7 +2410,7 @@ mod tests { // Mid-band: accepted by validation, rejected by execution. let headroom = accepted + (executes - accepted) / 2; - let mut platform = setup_platform(); + let mut platform = setup_platform_at_protocol_version(pv.protocol_version); insert_dummy_encrypted_notes(&platform, MAINNET_NOTES); let mut signer = TestAddressSigner::new(); let addr = signer.add_p2pkh([1u8; 32]); @@ -2436,7 +2433,7 @@ mod tests { .unwrap() .expect("root hash"); - let st = build_signed(&b, &signer, addr, declared_input).await; + let st = build_signed(&b, &signer, addr, declared_input, pv).await; let bytes = st.serialize_to_bytes().expect("serialize"); let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); @@ -2529,7 +2526,7 @@ mod tests { let declared_input = b.shield_amount + headroom; setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - let st = build_signed(&b, &signer, addr, declared_input).await; + let st = build_signed(&b, &signer, addr, declared_input, pv).await; let bytes = st.serialize_to_bytes().expect("serialize"); let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); @@ -2704,7 +2701,7 @@ mod tests { let declared_input = b.shield_amount + headroom; setup_address_with_balance_and_system_credits(&mut platform, addr, 0, declared_input); - let st = build_signed(&b, &signer, addr, declared_input).await; + let st = build_signed(&b, &signer, addr, declared_input, pv).await; let bytes = st.serialize_to_bytes().expect("serialize"); let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); @@ -2909,7 +2906,7 @@ mod tests { binding_signature: [u8; 64], ) -> StateTransition { let mut transition = - StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: inputs.clone(), actions, amount, @@ -2932,7 +2929,7 @@ mod tests { .expect("should sign"), ); } - if let StateTransition::Shield(ShieldTransition::V0(ref mut v0)) = transition { + if let StateTransition::Shield(ShieldTransition::V1(ref mut v0)) = transition { v0.input_witnesses = witnesses; } transition @@ -3001,7 +2998,7 @@ mod tests { // Positive control: the builder's bundle is what both CheckTx and the block accept. assert_admitted_and_executed(&platform, &shield, platform_version); - let StateTransition::Shield(ShieldTransition::V0(proven)) = &shield else { + let StateTransition::Shield(ShieldTransition::V1(proven)) = &shield else { panic!("expected a shield transition"); }; let copy = wrap( @@ -3098,11 +3095,249 @@ mod tests { let shield = builder_made_shield(inputs.clone(), &signer, platform_version).await; assert_admitted_and_executed(&platform, &shield, platform_version); - let bound = builder_made_shield(inputs, &signer, PlatformVersion::latest()).await; - assert_refused_for_its_proof(&platform, &bound, platform_version); + let bound = + builder_made_shield(inputs.clone(), &signer, PlatformVersion::latest()).await; + let StateTransition::Shield(ShieldTransition::V1(bound)) = bound else { + panic!("bound Shield"); + }; + let historical_wrapper = ShieldTransition::try_from_bundle_with_signer( + inputs, + bound.actions, + bound.amount, + bound.anchor, + bound.proof, + bound.binding_signature, + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + platform_version, + ) + .await + .expect("historical-format wrapper signed over the bound bundle"); + assert_refused_for_its_proof(&platform, &historical_wrapper, platform_version); + } + } + } + mod legacy_format_activation { + use super::*; + use crate::execution::check_tx::CheckTxLevel; + use crate::execution::validation::state_transition::processor::process_state_transition; + use crate::execution::validation::state_transition::state_transitions::test_helpers::{ + has_recorded_nullifier, setup_platform_at_protocol_version, test_orchard_recipient, + TestOrchardProver, + }; + use crate::platform_types::platform::PlatformRef; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::block::block_info::BlockInfo; + use dpp::shielded::builder::build_shield_transition; + use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; + + fn process_bytes_and_commit( + platform: &TempPlatform, + bytes: &[u8], + pv: &PlatformVersion, + ) -> Vec { + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let result = platform + .platform + .process_raw_state_transitions( + &vec![bytes.to_vec()], + &state, + &BlockInfo::default(), + &transaction, + pv, + false, + None, + ) + .expect("process exact signed bytes"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("commit"); + result.execution_results().to_vec() + } + + #[tokio::test] + async fn should_refuse_a_pending_legacy_shield_without_charging_after_activation() { + let balance = dash_to_credits!(1.0); + let amount = 5_000; + for protocol_version in [13, 12] { + let historical = + PlatformVersion::get(protocol_version).expect("historical version"); + let mut signer = TestAddressSigner::new(); + let address = signer.add_p2pkh([114; 32]); + let transition = build_shield_transition( + &test_orchard_recipient(), + amount, + BTreeMap::from([(address, (1, balance))]), + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + &TestOrchardProver, + [0; 36], + None, + historical, + ) + .await + .expect("historical client-built Shield"); + let StateTransition::Shield(shield) = &transition else { + panic!("Shield"); + }; + let nullifiers: Vec<[u8; 32]> = shield.nullifiers(); + let bytes = transition.serialize_to_bytes().expect("serialize once"); + + let mut old_platform = setup_platform_at_protocol_version(protocol_version); + setup_address_with_balance(&mut old_platform, address, 0, balance); + assert_matches!( + process_bytes_and_commit(&old_platform, &bytes, historical).as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "the unchanged transaction must be valid before activation" + ); + assert_eq!( + old_platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], historical) + .expect("historical pool"), + amount + ); + + let pv = PlatformVersion::latest(); + let mut platform = setup_platform(); + setup_address_with_balance(&mut platform, address, 0, balance); + { + let state = platform.state.load(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + for level in [CheckTxLevel::FirstTimeCheck, CheckTxLevel::Recheck] { + let admission = platform + .check_tx(&bytes, level, &platform_ref, pv) + .expect("inactive format is a coded refusal"); + assert_matches!( + admission.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.state_transition_type() == "Shield" + && error.current_protocol_version() == pv.protocol_version + && error.required_protocol_version() == 13, + "raw CheckTx and Recheck must evict the legacy format unpaid" + ); + assert!(admission + .data + .expect("admission result") + .fee_result + .is_none()); + } + + let mut decoded = transition.clone(); + let StateTransition::Shield(ShieldTransition::V0(v0)) = &mut decoded else { + panic!("historical format"); + }; + v0.input_witnesses.clear(); + let direct = process_state_transition( + &platform_ref, + &BlockInfo::default(), + decoded, + None, + ) + .expect("already-decoded legacy format is refused"); + assert!(direct.data.is_none(), "no fee-paying action is constructed"); + assert_matches!( + direct.errors.as_slice(), + [ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error))] + if error.required_protocol_version() == 13, + "format refusal precedes authentication even for direct callers" + ); + } + let results = process_bytes_and_commit(&platform, &bytes, pv); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)), + "a pending historical Shield must not be charged after activation" + ); + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::BasicError(BasicError::StateTransitionNotActiveError(error)) + )] if error.required_protocol_version() == 13 + ); + + let StateTransition::Shield(ShieldTransition::V0(v0)) = transition.clone() else { + panic!("historical format"); + }; + let retagged = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { + inputs: v0.inputs, + actions: v0.actions, + amount: v0.amount, + anchor: v0.anchor, + proof: v0.proof, + binding_signature: v0.binding_signature, + fee_strategy: v0.fee_strategy, + user_fee_increase: v0.user_fee_increase, + input_witnesses: v0.input_witnesses, + })); + let retagged_bytes = retagged + .serialize_to_bytes() + .expect("retag without resigning"); + assert_eq!(retagged_bytes.len(), bytes.len()); + assert_eq!( + retagged_bytes + .iter() + .zip(&bytes) + .filter(|(a, b)| a != b) + .count(), + 1, + "only the signed format tag was changed" + ); + assert_matches!( + process_bytes_and_commit(&platform, &retagged_bytes, pv).as_slice(), + [StateTransitionExecutionResult::UnpaidConsensusError( + ConsensusError::SignatureError(_) + )], + "a version tag cannot upgrade old address authorization" + ); + assert_eq!( + platform + .drive + .fetch_balance_and_nonce(&address, None, pv) + .expect("address"), + Some((0, balance)), + "invalid witnesses cannot authorize a fee or consume a nonce" + ); + assert_eq!( + platform + .drive + .read_shielded_pool_total_balance(None, &mut vec![], pv) + .expect("pool"), + 0 + ); + assert_eq!( + platform + .drive + .shielded_pool_notes_count(None, &mut vec![], pv) + .expect("notes"), + 0 + ); + for nullifier in nullifiers { + assert!(!has_recorded_nullifier(&platform, &nullifier)); + } + assert_eq!( + transition.serialize_to_bytes().expect("unchanged bytes"), + bytes + ); } } } + mod paid_proof_failures { use super::*; use crate::execution::check_tx::CheckTxLevel; @@ -3218,7 +3453,7 @@ mod tests { signer: &TestAddressSigner, increase: u16, ) { - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("shield"); }; v0.user_fee_increase = increase; @@ -3233,7 +3468,7 @@ mod tests { .expect("witness"), ); } - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("shield"); }; v0.input_witnesses = witnesses; @@ -3552,8 +3787,20 @@ mod tests { let address = signer.add_p2pkh([93; 32]); let balance = dash_to_credits!(1.0); setup_address_with_balance(&mut platform, address, 0, balance); - let st = - create_default_signed_shield_transition(&signer, address, 1, balance).await; + let st = ShieldTransition::try_from_bundle_with_signer( + BTreeMap::from([(address, (1, balance))]), + vec![create_dummy_serialized_action()], + 1000, + [42; 32], + vec![0; 100], + [0; 64], + vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + &signer, + 0, + pv, + ) + .await + .expect("historical signed Shield"); let result = process_transition_and_commit(&platform, st, pv); assert_matches!( result.execution_results().as_slice(), diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs index 15322829922..0e9cb7b7938 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v0/mod.rs @@ -2,12 +2,13 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::state_transitions::shielded_common::read_pool_total_balance; -use dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; +use dpp::address_funds::{AddressFundsFeeStrategy, AddressFundsFeeStrategyStep, PlatformAddress}; use dpp::block::block_info::BlockInfo; use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; -use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; @@ -21,6 +22,9 @@ use std::collections::{BTreeMap, BTreeSet}; /// /// # Why this is needed /// +/// The input map and fee strategy are passed directly, independently of the wire +/// format. Historical V0 callers supply the same fields and keep the same allocation. +/// /// Each `ShieldTransitionV0::inputs` entry maps an address to `(nonce, requested)`, /// where `requested` is a **max contribution**, not an exact debit (see the field /// doc on `ShieldTransitionV0::inputs`: "The total across all inputs must cover @@ -75,13 +79,11 @@ use std::collections::{BTreeMap, BTreeSet}; /// invariant — it would otherwise mint credits (pool credited `shield_amount`, addresses debited /// less). pub(super) fn reallocate_inputs_for_shield_amount( - transition: &ShieldTransitionV0, + requested_inputs: &BTreeMap, + fee_strategy: &AddressFundsFeeStrategy, inputs_with_remaining_balance: BTreeMap, shield_amount: Credits, ) -> Result, Error> { - let requested_inputs = &transition.inputs; - let fee_strategy = &transition.fee_strategy; - // Identify which input addresses the fee strategy deducts the fee from. A // `DeductFromInput(index)` step refers to the address at that position in the // input set's `BTreeMap` (address) order — the SAME order the fee machinery @@ -201,8 +203,7 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { _execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { - let ShieldTransition::V0(transition_v0) = self; - let shield_amount: Credits = transition_v0.amount; + let shield_amount: Credits = self.amount(); // The shared address-balance validation debits the FULL per-input `requested` // (a max contribution), but the shielded pool only receives `shield_amount`. @@ -210,7 +211,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { // leaving the excess in the source addresses. This keeps credits conserved // (addresses lose `shield_amount` + fee, pool gains `shield_amount`). let inputs_with_remaining_balance = reallocate_inputs_for_shield_amount( - transition_v0, + self.inputs(), + self.fee_strategy(), inputs_with_remaining_balance, shield_amount, )?; @@ -241,7 +243,9 @@ impl ShieldStateTransitionTransformIntoActionValidationV0 for ShieldTransition { mod tests { use super::*; use dpp::address_funds::PlatformAddress; + use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::StateTransitionWitnessSigned; use std::collections::BTreeMap; /// Builds a minimal `ShieldTransitionV0` whose `inputs` map carries the per-input @@ -282,8 +286,13 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (1u32, actual - requested)); - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .unwrap(); let (nonce, new_remaining) = adjusted.get(&addr).copied().unwrap(); assert_eq!(nonce, 1u32, "nonce must be preserved"); @@ -352,8 +361,13 @@ mod tests { remaining.insert(addr_a, (1u32, actual_a - req_a)); // 0 remaining.insert(addr_b, (1u32, actual_b - req_b)); // 500_000 - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .unwrap(); let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); @@ -407,9 +421,13 @@ mod tests { remaining.insert(addr_b, (1u32, rem_b)); remaining.insert(addr_c, (1u32, rem_c)); - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining.clone(), shield_amount) - .unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining.clone(), + shield_amount, + ) + .unwrap(); // The SUM of (actual - adjusted) across inputs == shield_amount exactly. let actuals = [(addr_a, actual_a), (addr_b, actual_b), (addr_c, actual_c)]; @@ -473,8 +491,13 @@ mod tests { remaining.insert(addr_a, (1u32, actual_a - req_a)); remaining.insert(addr_b, (1u32, actual_b - req_b)); - let adjusted = - reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount).unwrap(); + let adjusted = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .unwrap(); let (_n_a, adj_a) = adjusted.get(&addr_a).copied().unwrap(); let (_n_b, adj_b) = adjusted.get(&addr_b).copied().unwrap(); @@ -497,7 +520,12 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (1u32, 5000)); - let result = reallocate_inputs_for_shield_amount(&transition, remaining, 1000); + let result = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + 1000, + ); assert!(result.is_err(), "missing transition input must error"); } @@ -519,8 +547,13 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (1u32, 0)); // actual - requested - let err = reallocate_inputs_for_shield_amount(&transition, remaining, shield_amount) - .expect_err("shield_amount > Σrequested must be rejected, not minted"); + let err = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + shield_amount, + ) + .expect_err("shield_amount > Σrequested must be rejected, not minted"); match err { Error::Execution(ExecutionError::CorruptedCodeExecution(msg)) => assert!( msg.contains("exceeds the sum of input contributions"), @@ -546,6 +579,11 @@ mod tests { let mut remaining = BTreeMap::new(); remaining.insert(addr, (2u32, 0)); // remaining-balance nonce = 2 (diverges) - let _ = reallocate_inputs_for_shield_amount(&transition, remaining, 500); + let _ = reallocate_inputs_for_shield_amount( + &transition.inputs, + &transition.fee_strategy, + remaining, + 500, + ); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs index 596eedfa96a..7f42713ab45 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v1/mod.rs @@ -8,7 +8,9 @@ use dpp::address_funds::PlatformAddress; use dpp::block::block_info::BlockInfo; use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransitionWitnessSigned; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; @@ -48,8 +50,7 @@ impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { _execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { - let ShieldTransition::V0(transition_v0) = self; - let shield_amount: Credits = transition_v0.amount; + let shield_amount: Credits = self.amount(); // The shared address-balance validation debits the FULL per-input `requested` // (a max contribution), but the shielded pool only receives `shield_amount`. @@ -57,7 +58,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { // leaving the excess in the source addresses. This keeps credits conserved // (addresses lose `shield_amount` + fee, pool gains `shield_amount`). let inputs_with_remaining_balance = reallocate_inputs_for_shield_amount( - transition_v0, + self.inputs(), + self.fee_strategy(), inputs_with_remaining_balance, shield_amount, )?; @@ -75,8 +77,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV1 for ShieldTransition { read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; // Validate nullifiers: intra-bundle duplicates + already recorded in state - let nullifiers: Vec<[u8; 32]> = transition_v0 - .actions + let nullifiers: Vec<[u8; 32]> = self + .actions() .iter() .map(|action| action.nullifier) .collect(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs index 78ceb78cf27..cbe72ae6fcd 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs @@ -12,6 +12,7 @@ use crate::execution::validation::state_transition::ValidationMode; use dpp::address_funds::{AddressFundsFeeStrategyStep, PlatformAddress}; use dpp::block::block_info::BlockInfo; use dpp::block::epoch::Epoch; +use dpp::consensus::basic::state_transition::StateTransitionNotActiveError; use dpp::consensus::state::address_funds::AddressesNotEnoughFundsError; use dpp::consensus::state::state_error::StateError; use dpp::fee::default_costs::CachedEpochIndexFeeVersions; @@ -19,6 +20,7 @@ use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; use dpp::shielded::shield_extra_sighash_data; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::StateTransition; use dpp::version::PlatformVersion; use drive::drive::Drive; use drive::grovedb::TransactionArg; @@ -69,7 +71,17 @@ impl ShieldStateTransitionTransformIntoActionValidationV2 for ShieldTransition { execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { - let ShieldTransition::V0(transition) = self; + let ShieldTransition::V1(transition) = self else { + let legacy = StateTransition::Shield(self.clone()); + return Ok(ConsensusValidationResult::new_with_error( + StateTransitionNotActiveError::new( + legacy.name(), + platform_version.protocol_version, + *legacy.active_version_range().end(), + ) + .into(), + )); + }; let mut drive_operations = vec![]; let current_total_balance = read_pool_total_balance(drive, transaction, &mut drive_operations, platform_version)?; @@ -173,7 +185,8 @@ impl ShieldStateTransitionTransformIntoActionValidationV2 for ShieldTransition { } let remaining = reallocate_inputs_for_shield_amount( - transition, + &transition.inputs, + &transition.fee_strategy, inputs_with_remaining_balance, transition.amount, )?; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs index 93e344e7d7a..68d156cb8e9 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield_from_asset_lock/tests.rs @@ -2717,7 +2717,7 @@ mod tests { [StateTransitionExecutionResult::SuccessfulExecution { .. }] ); - let StateTransition::Shield(ShieldTransition::V0(proven)) = &shield else { + let StateTransition::Shield(ShieldTransition::V1(proven)) = &shield else { panic!("expected a shield transition"); }; let mut rng = StdRng::seed_from_u64(42); diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs index 43000433403..a6397bbf682 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/shield_paid_proof_failure_tests.rs @@ -10,7 +10,7 @@ mod tests { use dpp::identity::signer::Signer; use dpp::serialization::Signable; use dpp::shielded::SerializedAction; - use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::shield_transition::v1::ShieldTransitionV1; use dpp::state_transition::shield_transition::ShieldTransition; use dpp::state_transition::StateTransition; use drive_abci::config::{ExecutionConfig, PlatformConfig, PlatformTestConfig}; @@ -77,7 +77,7 @@ mod tests { .fetch_balance_and_nonce(&address, None, pv) .expect("funded address balance") .expect("persisted funded address"); - let mut transition = StateTransition::Shield(ShieldTransition::V0(ShieldTransitionV0 { + let mut transition = StateTransition::Shield(ShieldTransition::V1(ShieldTransitionV1 { inputs: BTreeMap::from([(address, (nonce + 1, balance))]), actions: vec![SerializedAction { nullifier: [1; 32], @@ -102,7 +102,7 @@ mod tests { .sign_create_witness(&address, &transition.signable_bytes().expect("signable")) .await .expect("witness"); - let StateTransition::Shield(ShieldTransition::V0(ref mut shield)) = transition else { + let StateTransition::Shield(ShieldTransition::V1(ref mut shield)) = transition else { unreachable!(); }; shield.input_witnesses = vec![witness]; diff --git a/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs b/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs index ca619df9af9..9edd19daf82 100644 --- a/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs +++ b/packages/rs-drive/src/state_transition_action/shielded/shield/transformer.rs @@ -1,5 +1,6 @@ use crate::state_transition_action::shielded::shield::v0::ShieldTransitionActionV0; use crate::state_transition_action::shielded::shield::ShieldTransitionAction; +use crate::state_transition_action::shielded::ShieldedActionNote; use dpp::address_funds::PlatformAddress; use dpp::fee::Credits; use dpp::prelude::{AddressNonce, ConsensusValidationResult}; @@ -24,6 +25,17 @@ impl ShieldTransitionAction { ); result.map(|action| action.into()) } + ShieldTransition::V1(v1) => ConsensusValidationResult::new_with_data( + ShieldTransitionActionV0 { + inputs_with_remaining_balance, + shield_amount, + notes: v1.actions.iter().map(ShieldedActionNote::from).collect(), + fee_strategy: v1.fee_strategy.clone(), + user_fee_increase: v1.user_fee_increase, + current_total_balance, + } + .into(), + ), } } } diff --git a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs index 4a437149a7c..2ad8740e170 100644 --- a/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_serialization_versions/v3.rs @@ -169,9 +169,9 @@ pub const STATE_TRANSITION_SERIALIZATION_VERSIONS_V3: DPPStateTransitionSerializ default_current_version: 0, }, shield_state_transition: FeatureVersionBounds { - min_version: 0, - max_version: 0, - default_current_version: 0, + min_version: 1, + max_version: 1, + default_current_version: 1, }, shielded_transfer_state_transition: FeatureVersionBounds { min_version: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index c6f2b6bab48..97d98f2a125 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -2017,6 +2017,9 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// by the signed fee strategy's funds after reserving the estimated base fee. A failure /// unable to cover the base fee stays unpaid. CheckTx still refuses invalid proofs /// under its local proof permit; shipped protocol versions keep their unpaid refusal. +/// Shield wire format 1 signs the new authorization boundary. Format 0 remains valid +/// only at versions 12 and 13; after activation it is refused before authentication, +/// unpaid and without consuming input nonces, including when evicted by Recheck. /// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by @@ -2111,7 +2114,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { dpp: DPPVersion { costs: DPP_COSTS_VERSIONS_V1, validation: DPP_VALIDATION_VERSIONS_V5, // changed: validate_config_update 2 admits the contract moderation declaration of config V2 - state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire; ShieldFromAssetLock moves to version 1 alone; the ContractUserModeration transition + state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V3, // changed: the indexOnly delete-by-values kind (documentIndexOnlyDelete) joins the wire; Shield and ShieldFromAssetLock move to version 1 alone; the ContractUserModeration transition state_transition_conversion_versions: STATE_TRANSITION_CONVERSION_VERSIONS_V2, state_transition_method_versions: STATE_TRANSITION_METHOD_VERSIONS_V2, // changed: public keys in creation may carry a budget or an expiry state_transitions: STATE_TRANSITION_VERSIONS_V4, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index e7509b85389..0c217431adc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -264,12 +264,10 @@ pub(super) async fn queue_shielded_activity( /// Returns `&[]` for any non-shielded variant (none reach the recorder). /// Each shielded variant's `actions()` comes from its own accessor / /// methods trait, so the relevant traits are imported locally. -fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] { - // `actions()` is on each type's accessor trait; Shield exposes `actions` - // only as a public field on its V0 struct, so match down to that - // variant for it. +pub(super) fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] { + // Each accessor retains the actions across the supported wire formats. use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; - use dpp::state_transition::shield_transition::ShieldTransition; + use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shielded_transfer_transition::accessors::ShieldedTransferTransitionAccessorsV0; use dpp::state_transition::shielded_withdrawal_transition::accessors::ShieldedWithdrawalTransitionAccessorsV0; use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::accessors::IdentityCreateFromShieldedPoolTransitionAccessorsV0; @@ -278,7 +276,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] use dpp::state_transition::unshield_transition::accessors::UnshieldTransitionAccessorsV0; match st { - StateTransition::Shield(ShieldTransition::V0(v0)) => &v0.actions, + StateTransition::Shield(t) => t.actions(), StateTransition::ShieldedTransfer(t) => t.actions(), StateTransition::Unshield(t) => t.actions(), StateTransition::ShieldFromAssetLock(t) => t.actions(), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs index 6cdb39536f8..6726fc678b6 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/memo_roundtrip_tests.rs @@ -203,7 +203,7 @@ async fn shield_memo_round_trips_through_ivk_decryption() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs index ffef155a4aa..ad51d2ea9c8 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/ovk_builder_roundtrip_tests.rs @@ -40,6 +40,7 @@ use drive_proof_verifier::types::ShieldedEncryptedNote; use grovedb_commitment_tree::ExtractedNoteCommitment; use crate::wallet::shielded::keys::OrchardKeySet; +use crate::wallet::shielded::operations::shielded_actions; use crate::wallet::shielded::prover::CachedOrchardProver; use crate::wallet::shielded::store::{InMemoryShieldedStore, ShieldedStore, SubwalletId}; @@ -108,7 +109,7 @@ async fn shield_built_note_ovk_recovers_and_persists_as_outgoing() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; @@ -233,12 +234,14 @@ async fn live_recorder_builds_entry_from_real_shield_bundle() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { - panic!("expected a Shield state transition"); - }; + assert!(matches!( + &st, + StateTransition::Shield(ShieldTransition::V1(_)) + )); + let actions = shielded_actions(&st); let views = keys.viewing_keys(); - let cmxs = visible_output_cmxs(&v0.actions, &views); + let cmxs = visible_output_cmxs(actions, &views); assert!( !cmxs.is_empty(), "recorder must recover the wallet-visible output cmx from a real bundle" @@ -253,7 +256,7 @@ async fn live_recorder_builds_entry_from_real_shield_bundle() { fee: None, counterparty: None, memo: None, - actions: &v0.actions, + actions, spent_notes: &[], }, ); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs index fa9dafc2297..77f34ea0e67 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/shield_decrypt_tests.rs @@ -110,7 +110,7 @@ async fn shield_built_note_is_trial_decryptable_by_own_ivk() { .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; @@ -198,7 +198,7 @@ async fn shield_to_external_recipient_decrypts_for_recipient_and_recovers_for_se .await .expect("shield transition build should succeed"); - let StateTransition::Shield(ShieldTransition::V0(v0)) = st else { + let StateTransition::Shield(ShieldTransition::V1(v0)) = st else { panic!("expected a Shield state transition"); }; let wires: Vec = v0 diff --git a/packages/wasm-dpp2/src/shielded/shield_transition.rs b/packages/wasm-dpp2/src/shielded/shield_transition.rs index e2c347e13ca..1acaa940df9 100644 --- a/packages/wasm-dpp2/src/shielded/shield_transition.rs +++ b/packages/wasm-dpp2/src/shielded/shield_transition.rs @@ -7,13 +7,18 @@ use crate::platform_address::{ use crate::shielded::address_witness::{AddressWitnessWasm, input_witnesses_from_js_options}; use crate::shielded::orchard_action::{SerializedOrchardActionWasm, actions_from_js_options}; use crate::utils::try_vec_to_fixed_bytes; -use crate::utils::{try_from_options_optional_with, try_to_u16}; +use crate::utils::{try_from_options_optional, try_from_options_optional_with, try_to_u16}; +use crate::version::PlatformVersionWasm; use crate::{impl_wasm_conversions_inner, impl_wasm_type_info}; use dpp::prelude::UserFeeIncrease; use dpp::serialization::{PlatformDeserializableUntrusted, PlatformSerializable}; use dpp::state_transition::shield_transition::ShieldTransition; +use dpp::state_transition::shield_transition::accessors::ShieldTransitionAccessorsV0; use dpp::state_transition::shield_transition::v0::ShieldTransitionV0; +use dpp::state_transition::shield_transition::v1::ShieldTransitionV1; use dpp::state_transition::{StateTransition, StateTransitionLike}; +use dpp::state_transition::{StateTransitionHasUserFeeIncrease, StateTransitionWitnessSigned}; +use dpp::version::PlatformVersion; use serde::{Deserialize, Serialize}; use wasm_bindgen::prelude::*; @@ -33,6 +38,7 @@ export interface ShieldTransitionOptions { feeStrategy?: FeeStrategyStep[]; userFeeIncrease?: number; inputWitnesses: AddressWitness[]; + platformVersion?: PlatformVersion; } /** @@ -127,6 +133,11 @@ impl ShieldTransitionWasm { })? .unwrap_or(0); + let platform_version: PlatformVersion = + try_from_options_optional::(js_opts, "platformVersion")? + .map(Into::into) + .unwrap_or_else(|| PlatformVersionWasm::default().into()); + // Extract simple fields via serde (consumes options) let fields: ShieldTransitionSimpleFields = serde_wasm_bindgen::from_value(options.into()) @@ -139,112 +150,118 @@ impl ShieldTransitionWasm { let inputs_map = crate::platform_address::inputs_to_btree_map(inputs)?; let fee_strategy = fee_strategy_from_steps_or_default(fee_strategy); - Ok(ShieldTransitionWasm(ShieldTransition::V0( - ShieldTransitionV0 { + let actions = actions.into_iter().map(Into::into).collect(); + let input_witnesses = input_witnesses.into_iter().map(Into::into).collect(); + let transition = match platform_version + .dpp + .state_transition_serialization_versions + .shield_state_transition + .default_current_version + { + 0 => ShieldTransition::V0(ShieldTransitionV0 { inputs: inputs_map, - actions: actions.into_iter().map(Into::into).collect(), + actions, amount: fields.amount, anchor, proof: fields.proof, binding_signature, fee_strategy, user_fee_increase, - input_witnesses: input_witnesses.into_iter().map(Into::into).collect(), - }, - ))) + input_witnesses, + }), + 1 => ShieldTransition::V1(ShieldTransitionV1 { + inputs: inputs_map, + actions, + amount: fields.amount, + anchor, + proof: fields.proof, + binding_signature, + fee_strategy, + user_fee_increase, + input_witnesses, + }), + version => { + return Err(WasmDppError::invalid_argument(format!( + "unknown ShieldTransition version {version}" + ))); + } + }; + Ok(ShieldTransitionWasm(transition)) } /// Returns the input addresses funding the shield (with their nonces and amounts). #[wasm_bindgen(getter = "inputs")] pub fn inputs(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .inputs - .iter() - .map(|(address, (nonce, amount))| { - PlatformAddressInputWasm::new(*address, *nonce, *amount) - }) - .collect(), - } + self.0 + .inputs() + .iter() + .map(|(address, (nonce, amount))| { + PlatformAddressInputWasm::new(*address, *nonce, *amount) + }) + .collect() } /// Returns the serialized Orchard actions. #[wasm_bindgen(getter = "actions")] pub fn actions(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .actions - .iter() - .cloned() - .map(SerializedOrchardActionWasm::from) - .collect(), - } + self.0 + .actions() + .iter() + .cloned() + .map(SerializedOrchardActionWasm::from) + .collect() } /// Returns the shield amount (credits entering the pool). #[wasm_bindgen(getter = "amount")] pub fn amount(&self) -> u64 { - match &self.0 { - ShieldTransition::V0(v0) => v0.amount, - } + self.0.amount() } /// Returns the anchor (32-byte Merkle root). #[wasm_bindgen(getter = "anchor")] pub fn anchor(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0.anchor.to_vec(), - } + self.0.anchor().to_vec() } /// Returns the Halo2 proof bytes. #[wasm_bindgen(getter = "proof")] pub fn proof(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0.proof.clone(), - } + self.0.proof().to_vec() } /// Returns the RedPallas binding signature (64 bytes). #[wasm_bindgen(getter = "bindingSignature")] pub fn binding_signature(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0.binding_signature.to_vec(), - } + self.0.binding_signature().to_vec() } /// Returns the fee strategy steps. #[wasm_bindgen(getter = "feeStrategy")] pub fn fee_strategy(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .fee_strategy - .iter() - .cloned() - .map(FeeStrategyStepWasm::from) - .collect(), - } + self.0 + .fee_strategy() + .iter() + .cloned() + .map(FeeStrategyStepWasm::from) + .collect() } /// Returns the user fee increase multiplier. #[wasm_bindgen(getter = "userFeeIncrease")] pub fn user_fee_increase(&self) -> u16 { - match &self.0 { - ShieldTransition::V0(v0) => v0.user_fee_increase, - } + self.0.user_fee_increase() } /// Returns the input witnesses (signatures authorising each input). #[wasm_bindgen(getter = "inputWitnesses")] pub fn input_witnesses(&self) -> Vec { - match &self.0 { - ShieldTransition::V0(v0) => v0 - .input_witnesses - .iter() - .cloned() - .map(AddressWitnessWasm::from) - .collect(), - } + self.0 + .witnesses() + .iter() + .cloned() + .map(AddressWitnessWasm::from) + .collect() } #[wasm_bindgen(js_name = getModifiedDataIds)] diff --git a/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts b/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts index 00784f26ebb..1e05ce31d32 100644 --- a/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts +++ b/packages/wasm-dpp2/tests/unit/ShieldTransition.spec.ts @@ -16,7 +16,7 @@ describe('ShieldTransition', () => { 0x00, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, ]); - function createTransition() { + function createTransition(protocolVersion?: number) { const inputAddr = wasm.PlatformAddress.fromBytes(addrBytes); const input = new wasm.PlatformAddressInput(inputAddr, 0, BigInt(100_000)); const witness = wasm.AddressWitness.p2pkh(new Uint8Array(65)); @@ -29,10 +29,29 @@ describe('ShieldTransition', () => { proof: ZERO_PROOF, bindingSignature: ZERO_BINDING_SIG, inputWitnesses: [witness], + ...(protocolVersion === undefined ? {} : { + platformVersion: new wasm.PlatformVersion(protocolVersion), + }), }); } describe('constructor()', () => { + it('should select the signed format for explicit historical and current protocols', () => { + for (const [protocolVersion, formatVersion] of [[13, '0'], [14, '1']] as const) { + const transition = createTransition(protocolVersion); + expect(transition.toJSON().$formatVersion).to.equal(formatVersion); + const bytes = transition.toBytes(); + expect(Buffer.from(wasm.ShieldTransition.fromBytes(bytes).toBytes())) + .to.deep.equal(Buffer.from(bytes)); + expect(transition.toObject().inputWitnesses[0].signature) + .to.deep.equal(new Uint8Array(65)); + } + }); + + it('should default to the current platform format', () => { + expect(createTransition().toJSON().$formatVersion).to.equal('1'); + }); + it('should construct with required fields', () => { const t = createTransition(); expect(t).to.be.an.instanceof(wasm.ShieldTransition); From 15f4719a510730c643ab8fc9f23ce760283f0ec0 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Tue, 6 Oct 2026 07:04:11 +0700 Subject: [PATCH 6/6] fix(platform): include validation reads in Shield failure fees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meter the pool-balance and nullifier reads in the PV14 failed-proof branch before reserving the complete base fee and capping its fixed penalty. Keep the same operation in the execution context for the committed charge. Successful Shield keeps its existing flat compute fee; legacy and nullifier refusals remain unpaid. Strengthen the affordability oracle with independently priced Drive reads, exact single inclusion and a nonce/authentication/read estimate assembled before the ordinary fee increase. Pin successful fee composition too. Test would have caught this in CI: ✖ before fix (0 read-fee operations, expected 1), ✔ after fix on the same regression. All 55 Shield tests and the independent proposal/finalization regression passed, zero ignored. Formatting and three independent code reviews are clean. Addresses the validation-read accounting review on #5262; fixes #5181. --- .../state_transitions/shield/tests.rs | 138 +++++++++++++++++- .../shield/transform_into_action/v2/mod.rs | 13 ++ 2 files changed, 150 insertions(+), 1 deletion(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs index 37cba924a03..d80c354ff6d 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/tests.rs @@ -751,6 +751,12 @@ mod tests { mod proof_verification { use super::*; + use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::types::execution_operation::ValidationOperation; + use crate::execution::validation::state_transition::processor::process_state_transition; + use crate::platform_types::platform::PlatformRef; + use dpp::block::block_info::BlockInfo; + use dpp::shielded::compute_shielded_verification_fee; #[tokio::test] async fn test_invalid_proof_returns_shielded_proof_error() { @@ -888,6 +894,51 @@ mod tests { ); } + { + let state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let platform_ref = PlatformRef { + drive: &platform.drive, + state: &state, + config: &platform.config, + core_rpc: &platform.core_rpc, + }; + let event = process_state_transition( + &platform_ref, + &BlockInfo::default(), + st.clone(), + Some(&transaction), + ) + .expect("prepare successful shield") + .data + .expect("successful shield event"); + let ExecutionEvent::PaidFromAddressInputs { + execution_operations, + additional_fixed_fee_cost, + .. + } = event + else { + panic!("address-paid shield event"); + }; + let StateTransition::Shield(ShieldTransition::V1(shield)) = &st else { + panic!("shield"); + }; + assert_eq!( + additional_fixed_fee_cost, + Some( + compute_shielded_verification_fee(shield.actions.len(), platform_version) + .expect("flat shield compute fee") + ) + ); + assert!( + execution_operations.iter().all(|operation| !matches!( + operation, + ValidationOperation::PrecalculatedOperation(_) + )), + "successful shields cover validation reads in their flat compute fee" + ); + } + let processing_result = process_transition(&platform, st, platform_version); assert_matches!( @@ -3342,6 +3393,7 @@ mod tests { use super::*; use crate::execution::check_tx::CheckTxLevel; use crate::execution::types::execution_event::ExecutionEvent; + use crate::execution::types::execution_operation::ValidationOperation; use crate::execution::validation::state_transition::processor::process_state_transition; use crate::execution::validation::state_transition::state_transitions::test_helpers::{ check_tx_errors, has_recorded_nullifier, insert_nullifier_into_state, @@ -3352,6 +3404,7 @@ mod tests { use crate::rpc::core::MockCoreRPCLike; use crate::test::helpers::setup::TempPlatform; use dpp::block::block_info::BlockInfo; + use drive::drive::Drive; #[tokio::test] async fn should_charge_a_failed_shield_proof_and_consume_its_nonce() { @@ -3409,6 +3462,37 @@ mod tests { ) -> (Credits, Credits) { let state = platform.state.load(); let transaction = platform.drive.grove.start_transaction(); + let pv = PlatformVersion::latest(); + let block_info = BlockInfo::default(); + let StateTransition::Shield(ShieldTransition::V1(shield)) = st else { + panic!("shield"); + }; + let mut read_operations = vec![]; + platform + .drive + .read_shielded_pool_total_balance(Some(&transaction), &mut read_operations, pv) + .expect("pool read"); + for action in &shield.actions { + assert!(!platform + .drive + .has_nullifier( + &action.nullifier, + Some(&transaction), + &mut read_operations, + pv, + ) + .expect("nullifier read")); + } + let read_fee = Drive::calculate_fee( + None, + Some(read_operations), + &block_info.epoch, + platform.drive.config.epochs_per_era, + pv, + None, + ) + .expect("independently price validation reads"); + assert!(read_fee.processing_fee > 0); let platform_ref = PlatformRef { drive: &platform.drive, state: &state, @@ -3426,12 +3510,63 @@ mod tests { .expect("failure event"); let ExecutionEvent::PaidFromAddressInputs { ref mut additional_fixed_fee_cost, + ref operations, + ref execution_operations, + user_fee_increase, .. } = event else { panic!("address-paid failure event"); }; let penalty = additional_fixed_fee_cost.replace(0).expect("penalty"); + assert_eq!( + execution_operations + .iter() + .filter(|operation| matches!( + operation, + ValidationOperation::PrecalculatedOperation(fee) if fee == &read_fee + )) + .count(), + 1, + "a failed proof must retain its pool and nullifier read costs exactly once" + ); + let authentication_operations = execution_operations + .iter() + .filter(|operation| { + !matches!( + operation, + ValidationOperation::PrecalculatedOperation(fee) if fee == &read_fee + ) + }) + .cloned() + .collect::>(); + let mut without_reads = platform + .drive + .apply_drive_operations( + operations.clone(), + false, + &block_info, + Some(&transaction), + pv, + Some(state.previous_fee_versions()), + ) + .expect("price nonce updates"); + ValidationOperation::add_many_to_fee_result( + &authentication_operations, + &mut without_reads, + pv, + ) + .expect("price authentication"); + let mut complete = without_reads.clone(); + complete + .checked_add_assign(read_fee) + .expect("include independently measured reads"); + complete.apply_user_fee_increase(user_fee_increase); + without_reads.apply_user_fee_increase(user_fee_increase); + assert!( + without_reads.total_base_fee() < complete.total_base_fee(), + "one credit below the complete fee still covers the old incomplete estimate" + ); let fee = platform .platform .validate_fees_of_event( @@ -3445,6 +3580,7 @@ mod tests { .data .expect("estimate") .total_base_fee(); + assert_eq!(fee, complete.total_base_fee()); (fee, penalty) } @@ -3572,7 +3708,7 @@ mod tests { } #[tokio::test] - async fn should_bound_failed_proof_penalties_after_reserving_the_estimated_fee() { + async fn should_reserve_validation_reads_before_capping_failed_proof_penalties() { let pv = PlatformVersion::latest(); let nominal = pv .drive_abci diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs index cbe72ae6fcd..63253baf2c4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/shield/transform_into_action/v2/mod.rs @@ -114,6 +114,19 @@ impl ShieldStateTransitionTransformIntoActionValidationV2 for ShieldTransition { &transition.binding_signature, &extra_sighash_data, ) { + // A nonce-only failure has no Shield compute fee covering these reads. + // Retain them as ordinary metered work before reserving the base fee. + let state_read_fee = Drive::calculate_fee( + None, + Some(drive_operations), + &block_info.epoch, + drive.config.epochs_per_era, + platform_version, + None, + )?; + execution_context + .add_operation(ValidationOperation::PrecalculatedOperation(state_read_fee)); + // These balances still reflect the full requested debits. Restore them // before successful-path reallocation so a failure moves no principal. let mut restored = inputs_with_remaining_balance;