From 790d7e6dc40597057afac0843c7894f8a3915d73 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 03:58:22 +0700 Subject: [PATCH 1/4] fix(drive-abci): refuse a masternode vote on an unfunded poll as an unpaid consensus error A masternode vote is paid by its vote poll's prefunded specialized balance. The processor ran the pre-check on that fund but never read its result, so a vote on a poll whose fund is missing, or too small, reached execution and failed there when the vote's cost was deducted, as an InternalError logged at error level, instead of the PrefundedSpecializedBalanceNotFoundError or PrefundedSpecializedBalanceInsufficientError the pre-check produces. check_tx never runs the pre-check and estimates the vote's fee statelessly, so it admits such a vote at both levels; the vote was only ever caught by the proposer, which strips any InternalError result from its block, and by validators, which reject a block carrying one. That does not change. Protocol version 14 (DRIVE_ABCI_VALIDATION_VERSIONS_V10): * process_state_transition 1 returns the pre-check's errors as an unpaid consensus error. The fund is checked after state validation, once the poll is known to be open: settling a poll deletes its fund, so a check ahead of state validation would report a missing fund for every late vote and hide the poll's status. * masternode_vote_state_transition_balance_pre_check 1 requires the fund to cover the single vote cost the vote deducts (10_000_000 credits); v0 required only the vote's minimum fee (100_000 credits), so a fund between the two passed the pre-check and the vote still failed inside execution. Blocks are unaffected under either generation: proposers strip the vote and validators reject a block that carries it, so replay is identical. Co-Authored-By: Claude Fable 5.1 --- .../state-transitions/validation-pipeline.md | 11 + .../state_transition/processor/mod.rs | 10 +- .../state_transition/processor/v1/mod.rs | 697 ++++++++++++++++++ .../masternode_vote/balance/mod.rs | 10 +- .../masternode_vote/balance/v1/mod.rs | 89 +++ .../drive_abci_validation_versions/v10.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 16 +- 7 files changed, 831 insertions(+), 6 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/masternode_vote/balance/v1/mod.rs diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index 75788a55011..d688a04e384 100644 --- a/book/src/state-transitions/validation-pipeline.md +++ b/book/src/state-transitions/validation-pipeline.md @@ -317,6 +317,17 @@ Not all transitions need state validation. `IdentityTopUp`, `IdentityCreditWithd `AddressFundsTransfer`, and several others skip it -- their validation is fully covered by the earlier stages. +A `MasternodeVote` is paid by its vote poll's prefunded specialized balance, not by +the voter, so its balance check comes here rather than in stage 7. From protocol +version 14 (`process_state_transition` v1), once state validation has found the poll +and seen it open, the processor checks that fund: a vote on a poll with no fund, or +one below the vote fee, is refused unpaid with +`PrefundedSpecializedBalanceNotFoundError` or +`PrefundedSpecializedBalanceInsufficientError`. The fund is checked after the poll's +status because settling a poll deletes its fund; a vote that arrives late is told the +poll's status, not that its fund is missing. Earlier versions ran the same pre-check +but ignored its result, and such a vote failed inside execution as an internal error. + ## ConsensusValidationResult: How Errors Accumulate Throughout the pipeline, errors are communicated through `ConsensusValidationResult`, 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..c2bbba21e7a --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs @@ -0,0 +1,697 @@ +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; + +/// v1 (protocol version 14) = v0 with the masternode vote's prefunded balance pre-check acted +/// upon: a vote whose poll has no fund, or one below the single vote cost, is refused as an +/// unpaid consensus error after state validation, where v0 ignored the pre-check's result and +/// let the vote fail inside execution as an internal error. Every other step is identical to v0. +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, 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)); + } + } + + // 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)); + } + } + + // Verify ZK proof for shielded transitions (stateless, like signature verification). + if state_transition.has_shielded_proof_validation() { + 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, + )? + }; + + // 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. v0 ran this pre-check but ignored its result, and such a vote failed inside + // execution, when its cost was deducted, as an internal error. + // + // The fund is checked last, once state validation has found the poll and seen it open. + // Settling a poll deletes its fund, so a check ahead of state validation would report a + // missing fund for every vote that arrives after the poll ended and hide the poll's real + // status from the voter. + if result.is_valid() && state_transition.uses_prefunded_specialized_balance_for_payment() { + let fund_result = state_transition + .validate_minimum_prefunded_specialized_balance_pre_check( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !fund_result.is_valid() { + return Ok( + ConsensusValidationResult::::new_with_errors(fund_result.errors), + ); + } + } + + 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, + ) + }) +} + +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, setup_masternode_voting_identity, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use assert_matches::assert_matches; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::identifier::Identifier; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::platform_value::Value; + use dpp::prelude::DataContract; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; + use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; + use dpp::util::strings::convert_to_homograph_safe_chars; + use dpp::version::PlatformVersion; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use dpp::voting::vote_polls::VotePoll; + use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; + use dpp::voting::votes::resource_vote::ResourceVote; + use dpp::voting::votes::Vote; + use simple_signer::signer::SimpleSigner; + use std::sync::Arc; + + const NAME: &str = "quantum"; + + /// A DPNS name contest whose vote poll's fund the test then tampers with, and one + /// masternode ready to vote on it. + struct Contest { + platform: TempPlatform, + dpns_contract: Arc, + contender: Identity, + fund_id: Identifier, + voter: Voter, + } + + struct Voter { + pro_tx_hash: Identifier, + identity: Identity, + signer: SimpleSigner, + voting_key: IdentityPublicKey, + } + + fn vote_poll(dpns_contract: &DataContract) -> ContestedDocumentResourceVotePoll { + vote_poll_for(dpns_contract, NAME) + } + + fn vote_poll_for( + dpns_contract: &DataContract, + name: &str, + ) -> ContestedDocumentResourceVotePoll { + ContestedDocumentResourceVotePoll { + contract_id: dpns_contract.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(convert_to_homograph_safe_chars(name)), + ], + } + } + + async fn contest_at(platform_version: &PlatformVersion) -> Contest { + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + NAME, + platform_version, + ) + .await; + let fund_id = vote_poll(&dpns_contract) + .specialized_balance_id() + .expect("expected the poll's prefunded balance id"); + let (pro_tx_hash, identity, signer, voting_key) = + setup_masternode_voting_identity(&mut platform, 29, platform_version); + Contest { + platform, + dpns_contract, + contender, + fund_id, + voter: Voter { + pro_tx_hash, + identity, + signer, + voting_key, + }, + } + } + + impl Contest { + fn fund(&self, platform_version: &PlatformVersion) -> Option { + self.platform + .drive + .fetch_prefunded_specialized_balance( + self.fund_id.to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch the poll's fund") + } + + /// Deletes the poll's fund, as settling the poll does + fn remove_fund(&self, platform_version: &PlatformVersion) { + self.platform + .drive + .empty_prefunded_specialized_balance(self.fund_id, true, None, platform_version) + .expect("expected to remove the poll's fund"); + } + + /// Leaves the poll's fund at `credits` + fn set_fund(&self, credits: u64, platform_version: &PlatformVersion) { + self.remove_fund(platform_version); + self.platform + .drive + .add_prefunded_specialized_balance(self.fund_id, credits, None, platform_version) + .expect("expected to fund the poll"); + assert_eq!(self.fund(platform_version), Some(credits)); + } + + /// Casts the voter's vote for the contender through block processing, as a proposer + /// or a validator would, and returns how the block treated it + async fn vote( + &mut self, + platform_version: &PlatformVersion, + ) -> StateTransitionExecutionResult { + self.vote_on(NAME, platform_version).await + } + + /// The same vote on the poll of another name + async fn vote_on( + &mut self, + name: &str, + platform_version: &PlatformVersion, + ) -> StateTransitionExecutionResult { + let serialized_transition = self.serialized_vote(name, platform_version).await; + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &[serialized_transition], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the vote"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results().remove(0) + } + + /// The voter's signed vote for the contender on the poll of `name`, as broadcast + async fn serialized_vote(&self, name: &str, platform_version: &PlatformVersion) -> Vec { + let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(vote_poll_for( + &self.dpns_contract, + name, + )), + resource_vote_choice: ResourceVoteChoice::TowardsIdentity(self.contender.id()), + })); + MasternodeVoteTransition::try_from_vote_with_signer( + vote, + &self.voter.signer, + self.voter.pro_tx_hash, + &self.voter.voting_key, + 1, + platform_version, + None, + ) + .await + .expect("expected to make the vote") + .serialize_to_bytes() + .expect("expected to serialize the vote") + } + + /// The voter's identity nonce: 0 until an executed vote bumps it + fn voter_nonce(&self, platform_version: &PlatformVersion) -> Option { + self.platform + .drive + .fetch_identity_nonce( + self.voter.identity.id().to_buffer(), + true, + None, + platform_version, + ) + .expect("expected to fetch the voter's nonce") + } + } + + #[tokio::test] + async fn should_refuse_a_vote_unpaid_when_the_poll_has_no_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + contest.remove_fund(platform_version); + + let result = contest.vote(platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(error) + )) if *error.balance_id() == contest.fund_id + ); + // Nothing of the vote reached the state + assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), None); + } + + fn single_vote_cost(platform_version: &PlatformVersion) -> u64 { + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost + } + + /// The fund is one credit short of the single vote cost that executing the vote deducts. + /// It still covers the vote's minimum fee, the smaller amount the v0 pre-check required, + /// so under v0 the vote passed the pre-check and failed inside execution. + #[tokio::test] + async fn should_refuse_a_vote_unpaid_when_the_poll_fund_is_below_the_single_vote_cost() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + assert!( + single_vote_cost + > platform_version + .fee_version + .state_transition_min_fees + .masternode_vote, + "the fund must satisfy the v0 threshold for this test to pin the v1 one" + ); + contest.set_fund(single_vote_cost - 1, platform_version); + + let result = contest.vote(platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceInsufficientError(error) + )) if *error.balance_id() == contest.fund_id + && error.balance() == single_vote_cost - 1 + && error.required_balance() == single_vote_cost + ); + assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), Some(single_vote_cost - 1)); + } + + #[tokio::test] + async fn should_accept_a_vote_when_the_poll_fund_covers_exactly_the_single_vote_cost() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + contest.set_fund(single_vote_cost, platform_version); + + let result = contest.vote(platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(contest.voter_nonce(platform_version), Some(1)); + assert_eq!(contest.fund(platform_version), Some(0)); + } + + /// A poll that never opened has no fund either; the voter is told about the poll, which is + /// what state validation checks, not about the fund, which is checked after it. + #[tokio::test] + async fn should_report_a_missing_poll_rather_than_its_missing_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + + let result = contest.vote_on("nowhere", platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::VotePollNotFoundError(_) + )) + ); + assert_eq!(contest.voter_nonce(platform_version), Some(0)); + } + + /// v0, selected by every protocol version before 14, ignores the pre-check: the vote fails + /// inside execution instead, as an internal error. A block never carries the vote under + /// either generation, so the two agree on every block. + #[tokio::test] + async fn should_fail_a_vote_on_an_unfunded_poll_inside_execution_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let mut contest = contest_at(platform_version).await; + contest.remove_fund(platform_version); + + let result = contest.vote(platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::InternalError(message) + if message.contains("prefunded specialized balance does not exist") + ); + assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), None); + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs index 61677c3aed7..2c215ebb516 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs @@ -2,6 +2,7 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::masternode_vote::balance::v0::MasternodeVoteTransitionBalanceValidationV0; +use crate::execution::validation::state_transition::masternode_vote::balance::v1::MasternodeVoteTransitionBalanceValidationV1; use crate::execution::validation::state_transition::processor::prefunded_specialized_balance::StateTransitionPrefundedSpecializedBalanceValidationV0; use dpp::fee::Credits; use dpp::prefunded_specialized_balance::PrefundedSpecializedBalanceIdentifier; @@ -13,6 +14,7 @@ use drive::grovedb::TransactionArg; use std::collections::BTreeMap; pub(crate) mod v0; +pub(crate) mod v1; impl StateTransitionPrefundedSpecializedBalanceValidationV0 for MasternodeVoteTransition { fn validate_minimum_prefunded_specialized_balance_pre_check( @@ -37,9 +39,15 @@ impl StateTransitionPrefundedSpecializedBalanceValidationV0 for MasternodeVoteTr execution_context, platform_version, ), + 1 => self.validate_advanced_minimum_balance_pre_check_v1( + drive, + tx, + execution_context, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "masternode vote transition: validate_balance".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs new file mode 100644 index 00000000000..74c65abcf41 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs @@ -0,0 +1,89 @@ +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 dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_insufficient_error::PrefundedSpecializedBalanceInsufficientError; +use dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_not_found_error::PrefundedSpecializedBalanceNotFoundError; +use dpp::fee::Credits; +use dpp::prefunded_specialized_balance::PrefundedSpecializedBalanceIdentifier; +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; +use std::collections::BTreeMap; + +pub(super) trait MasternodeVoteTransitionBalanceValidationV1 { + fn validate_advanced_minimum_balance_pre_check_v1( + &self, + drive: &Drive, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result< + ConsensusValidationResult>, + Error, + >; +} + +impl MasternodeVoteTransitionBalanceValidationV1 for MasternodeVoteTransition { + /// v1 (protocol version 14) requires the poll's fund to cover the single vote cost, which is + /// what executing the vote deducts from it. v0 required only the vote's minimum fee, which + /// is smaller, so a fund between the two passed the pre-check and the vote then failed + /// inside execution. + fn validate_advanced_minimum_balance_pre_check_v1( + &self, + drive: &Drive, + tx: TransactionArg, + execution_context: &mut StateTransitionExecutionContext, + platform_version: &PlatformVersion, + ) -> Result< + ConsensusValidationResult>, + Error, + > { + execution_context.add_operation(ValidationOperation::RetrievePrefundedSpecializedBalance); + + let vote = self.vote(); + + let balance_id = vote.specialized_balance_id()?.ok_or(Error::Execution( + ExecutionError::CorruptedCodeExecution( + "In this version there should always be a specialized balance id", + ), + ))?; + let maybe_balance = drive.fetch_prefunded_specialized_balance( + balance_id.to_buffer(), + tx, + platform_version, + )?; + + let Some(balance) = maybe_balance else { + // If there is no balance we are voting on something that either was never created or has finished + return Ok(ConsensusValidationResult::new_with_error( + PrefundedSpecializedBalanceNotFoundError::new(balance_id).into(), + )); + }; + + let single_vote_cost = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost; + + if balance < single_vote_cost { + return Ok(ConsensusValidationResult::new_with_error( + PrefundedSpecializedBalanceInsufficientError::new( + balance_id, + balance, + single_vote_cost, + ) + .into(), + )); + } + + Ok(ConsensusValidationResult::new_with_data(BTreeMap::from([ + (balance_id, balance), + ]))) + } +} 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 87db84be22e..fba157a2297 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 @@ -106,7 +106,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, - masternode_vote_state_transition_balance_pre_check: 0, + masternode_vote_state_transition_balance_pre_check: 1, // changed: the poll's fund must cover the single vote cost the vote deducts, not only the vote's minimum fee contract_create_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(2), // changed: rejects `requiredSince` other than 1 on a newly created contract — the annotation must name the version the change arrives with, and a fresh contract is version 1 advanced_structure: Some(1), @@ -377,7 +377,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, // changed: a masternode vote on a poll whose fund is missing or below the single vote cost is refused unpaid 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 64f89f6b189..e517dd99c24 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -200,7 +200,19 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// reference on every replace (a dead one must be repointed or cleared), /// and lets an `immutable` one be cleared once its target is deleted. /// v13 keeps the v9 table and therefore keeps accepting all of these, so -/// replay of pre-upgrade blocks is unchanged. +/// replay of pre-upgrade blocks is unchanged. The same table bumps +/// `process_state_transition` 0 → 1: the processor now acts on the +/// masternode vote's prefunded balance pre-check, after state validation, +/// refusing a vote whose poll has no fund, or one below the single vote +/// cost, as an unpaid `PrefundedSpecializedBalanceNotFoundError` / +/// `PrefundedSpecializedBalanceInsufficientError`; v0 ran the pre-check +/// but ignored its result, and such a vote failed inside execution as an +/// internal error. It also bumps +/// `masternode_vote_state_transition_balance_pre_check` 0 → 1, which +/// requires the fund to cover the single vote cost the vote deducts, where +/// v0 required only the vote's minimum fee, a smaller amount. Under either +/// generation such a vote never enters a block (proposers strip it, +/// validators reject a block carrying it), so blocks replay identically. /// * `DOCUMENT_VERSIONS_V4` bumps `document_serialization_version` to /// default 3: documents are stamped with the contract version their bytes /// conform to (a varint after the format prefix), enabling the @@ -579,7 +591,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; process_state_transition 1 refuses a vote on an unfunded poll unpaid withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, From 7aff030077904dd7bc1918535e56ef2afd035d0d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 06:12:23 +0700 Subject: [PATCH 2/4] fix(drive-abci): review fix-ups for the unfunded vote refusal * The book names the single vote cost, not the vote fee, as the threshold. * The DPNS vote transition builder and vote poll move into the shared test helpers (`serialized_dpns_name_vote`, `dpns_name_vote_poll`), used by `perform_vote` and by the processor v1 tests, which drop their own copy and their single-use wrappers. * A test casts two votes in one block on a fund that covers one: the second is refused for the credits the first took, through the block transaction. * Comments state the cost of checking the fund after state validation and why a vote's validation returns its errors without an action. Co-Authored-By: Claude Fable 5.1 --- .../state-transitions/validation-pipeline.md | 2 +- .../state_transition/processor/v1/mod.rs | 183 ++++++++++-------- .../state_transition/state_transitions/mod.rs | 76 +++++--- 3 files changed, 158 insertions(+), 103 deletions(-) diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index d688a04e384..975fd4d9deb 100644 --- a/book/src/state-transitions/validation-pipeline.md +++ b/book/src/state-transitions/validation-pipeline.md @@ -321,7 +321,7 @@ A `MasternodeVote` is paid by its vote poll's prefunded specialized balance, not the voter, so its balance check comes here rather than in stage 7. From protocol version 14 (`process_state_transition` v1), once state validation has found the poll and seen it open, the processor checks that fund: a vote on a poll with no fund, or -one below the vote fee, is refused unpaid with +one below the single vote cost, is refused unpaid with `PrefundedSpecializedBalanceNotFoundError` or `PrefundedSpecializedBalanceInsufficientError`. The fund is checked after the poll's status because settling a poll deletes its fund; a vote that arrives late is told the 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 c2bbba21e7a..18ca4c03b69 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 @@ -343,7 +343,9 @@ pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( // The fund is checked last, once state validation has found the poll and seen it open. // Settling a poll deletes its fund, so a check ahead of state validation would report a // missing fund for every vote that arrives after the poll ended and hide the poll's real - // status from the voter. + // status from the voter. The price of that order is the transform's and state validation's + // reads, spent before an unpaid refusal where v0's position spent one balance read; the + // signature check, which dominates, is spent on every refused vote either way. if result.is_valid() && state_transition.uses_prefunded_specialized_balance_for_payment() { let fund_result = state_transition .validate_minimum_prefunded_specialized_balance_pre_check( @@ -360,6 +362,11 @@ pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( } } + // A result that carries errors together with an action becomes a paid-invalid event. For a + // masternode vote that event is a `PaidFixedCost` with errors, which execution does not pay + // and the block reports as an internal error, so a vote's transform and state validation + // return their errors without an action, and any later generation of them must keep doing + // so. result.map_result(|action| { ExecutionEvent::create_from_state_transition_action( action, @@ -374,7 +381,8 @@ pub(super) fn process_state_transition_v1<'a, C: CoreRPCLike>( #[cfg(test)] mod tests { use crate::execution::validation::state_transition::state_transitions::tests::{ - create_dpns_identity_name_contest, setup_masternode_voting_identity, + create_dpns_identity_name_contest, dpns_name_vote_poll, serialized_dpns_name_vote, + setup_masternode_voting_identity, }; use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; use crate::rpc::core::MockCoreRPCLike; @@ -383,23 +391,12 @@ mod tests { use dpp::block::block_info::BlockInfo; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; - use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::identifier::Identifier; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::{Identity, IdentityPublicKey}; - use dpp::platform_value::Value; use dpp::prelude::DataContract; - use dpp::serialization::PlatformSerializable; - use dpp::state_transition::masternode_vote_transition::methods::MasternodeVoteTransitionMethodsV0; - use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; - use dpp::util::strings::convert_to_homograph_safe_chars; use dpp::version::PlatformVersion; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; - use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; - use dpp::voting::vote_polls::VotePoll; - use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; - use dpp::voting::votes::resource_vote::ResourceVote; - use dpp::voting::votes::Vote; use simple_signer::signer::SimpleSigner; use std::sync::Arc; @@ -422,25 +419,6 @@ mod tests { voting_key: IdentityPublicKey, } - fn vote_poll(dpns_contract: &DataContract) -> ContestedDocumentResourceVotePoll { - vote_poll_for(dpns_contract, NAME) - } - - fn vote_poll_for( - dpns_contract: &DataContract, - name: &str, - ) -> ContestedDocumentResourceVotePoll { - ContestedDocumentResourceVotePoll { - contract_id: dpns_contract.id(), - document_type_name: "domain".to_string(), - index_name: "parentNameAndLabel".to_string(), - index_values: vec![ - Value::Text("dash".to_string()), - Value::Text(convert_to_homograph_safe_chars(name)), - ], - } - } - async fn contest_at(platform_version: &PlatformVersion) -> Contest { let mut platform = TestPlatformBuilder::new() .with_initial_protocol_version(platform_version.protocol_version) @@ -455,22 +433,33 @@ mod tests { platform_version, ) .await; - let fund_id = vote_poll(&dpns_contract) + let fund_id = dpns_name_vote_poll(&dpns_contract, NAME) .specialized_balance_id() .expect("expected the poll's prefunded balance id"); - let (pro_tx_hash, identity, signer, voting_key) = - setup_masternode_voting_identity(&mut platform, 29, platform_version); + let voter = Voter::new(&mut platform, 29, platform_version); Contest { platform, dpns_contract, contender, fund_id, - voter: Voter { + voter, + } + } + + impl Voter { + fn new( + platform: &mut TempPlatform, + seed: u64, + platform_version: &PlatformVersion, + ) -> Self { + let (pro_tx_hash, identity, signer, voting_key) = + setup_masternode_voting_identity(platform, seed, platform_version); + Voter { pro_tx_hash, identity, signer, voting_key, - }, + } } } @@ -504,29 +493,33 @@ mod tests { assert_eq!(self.fund(platform_version), Some(credits)); } - /// Casts the voter's vote for the contender through block processing, as a proposer - /// or a validator would, and returns how the block treated it - async fn vote( + /// Casts the voter's vote for the contender on the poll of `name` through block + /// processing, as a proposer or a validator would, and returns how the block treated it + async fn vote_on( &mut self, + name: &str, platform_version: &PlatformVersion, ) -> StateTransitionExecutionResult { - self.vote_on(NAME, platform_version).await + let serialized_transition = self + .serialized_vote_by(&self.voter, name, platform_version) + .await; + self.process_block(vec![serialized_transition], platform_version) + .remove(0) } - /// The same vote on the poll of another name - async fn vote_on( + /// Processes the transitions as one block and returns how the block treated each + fn process_block( &mut self, - name: &str, + serialized_transitions: Vec>, platform_version: &PlatformVersion, - ) -> StateTransitionExecutionResult { - let serialized_transition = self.serialized_vote(name, platform_version).await; + ) -> Vec { let platform_state = self.platform.state.load(); let transaction = self.platform.drive.grove.start_transaction(); let processing_result = self .platform .platform .process_raw_state_transitions( - &[serialized_transition], + &serialized_transitions, &platform_state, &BlockInfo::default(), &transaction, @@ -534,46 +527,42 @@ mod tests { false, None, ) - .expect("expected to process the vote"); + .expect("expected to process the votes"); self.platform .drive .grove .commit_transaction(transaction) .unwrap() .expect("expected to commit the transaction"); - processing_result.into_execution_results().remove(0) + processing_result.into_execution_results() } - /// The voter's signed vote for the contender on the poll of `name`, as broadcast - async fn serialized_vote(&self, name: &str, platform_version: &PlatformVersion) -> Vec { - let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: VotePoll::ContestedDocumentResourceVotePoll(vote_poll_for( - &self.dpns_contract, - name, - )), - resource_vote_choice: ResourceVoteChoice::TowardsIdentity(self.contender.id()), - })); - MasternodeVoteTransition::try_from_vote_with_signer( - vote, - &self.voter.signer, - self.voter.pro_tx_hash, - &self.voter.voting_key, + /// `voter`'s signed vote for the contender on the poll of `name`, as broadcast + async fn serialized_vote_by( + &self, + voter: &Voter, + name: &str, + platform_version: &PlatformVersion, + ) -> Vec { + serialized_dpns_name_vote( + &self.dpns_contract, + ResourceVoteChoice::TowardsIdentity(self.contender.id()), + name, + &voter.signer, + voter.pro_tx_hash, + &voter.voting_key, 1, platform_version, - None, ) .await - .expect("expected to make the vote") - .serialize_to_bytes() - .expect("expected to serialize the vote") } - /// The voter's identity nonce: 0 until an executed vote bumps it - fn voter_nonce(&self, platform_version: &PlatformVersion) -> Option { + /// `voter`'s identity nonce: 0 until an executed vote bumps it + fn nonce_of(&self, voter: &Voter, platform_version: &PlatformVersion) -> Option { self.platform .drive .fetch_identity_nonce( - self.voter.identity.id().to_buffer(), + voter.identity.id().to_buffer(), true, None, platform_version, @@ -588,7 +577,7 @@ mod tests { let mut contest = contest_at(platform_version).await; contest.remove_fund(platform_version); - let result = contest.vote(platform_version).await; + let result = contest.vote_on(NAME, platform_version).await; assert_matches!( result, @@ -597,7 +586,7 @@ mod tests { )) if *error.balance_id() == contest.fund_id ); // Nothing of the vote reached the state - assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); assert_eq!(contest.fund(platform_version), None); } @@ -626,7 +615,7 @@ mod tests { ); contest.set_fund(single_vote_cost - 1, platform_version); - let result = contest.vote(platform_version).await; + let result = contest.vote_on(NAME, platform_version).await; assert_matches!( result, @@ -636,7 +625,7 @@ mod tests { && error.balance() == single_vote_cost - 1 && error.required_balance() == single_vote_cost ); - assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); assert_eq!(contest.fund(platform_version), Some(single_vote_cost - 1)); } @@ -647,13 +636,49 @@ mod tests { let single_vote_cost = single_vote_cost(platform_version); contest.set_fund(single_vote_cost, platform_version); - let result = contest.vote(platform_version).await; + let result = contest.vote_on(NAME, platform_version).await; assert_matches!( result, StateTransitionExecutionResult::SuccessfulExecution { .. } ); - assert_eq!(contest.voter_nonce(platform_version), Some(1)); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); + assert_eq!(contest.fund(platform_version), Some(0)); + } + + /// The fund is read through the block's transaction: a fund that covers exactly one vote + /// lets the first vote of a block in, and the second, whose pre-check sees that deduction, + /// is refused for the credits the first one took. + #[tokio::test] + async fn should_refuse_the_second_vote_of_a_block_once_the_first_took_the_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + contest.set_fund(single_vote_cost, platform_version); + let second_voter = Voter::new(&mut contest.platform, 31, platform_version); + let first_vote = contest + .serialized_vote_by(&contest.voter, NAME, platform_version) + .await; + let second_vote = contest + .serialized_vote_by(&second_voter, NAME, platform_version) + .await; + + let results = contest.process_block(vec![first_vote, second_vote], platform_version); + + assert_matches!( + results[0], + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + &results[1], + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceInsufficientError(error) + )) if *error.balance_id() == contest.fund_id + && error.balance() == 0 + && error.required_balance() == single_vote_cost + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); + assert_eq!(contest.nonce_of(&second_voter, platform_version), Some(0)); assert_eq!(contest.fund(platform_version), Some(0)); } @@ -672,7 +697,7 @@ mod tests { StateError::VotePollNotFoundError(_) )) ); - assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); } /// v0, selected by every protocol version before 14, ignores the pre-check: the vote fails @@ -684,14 +709,14 @@ mod tests { let mut contest = contest_at(platform_version).await; contest.remove_fund(platform_version); - let result = contest.vote(platform_version).await; + let result = contest.vote_on(NAME, platform_version).await; assert_matches!( result, StateTransitionExecutionResult::InternalError(message) if message.contains("prefunded specialized balance does not exist") ); - assert_eq!(contest.voter_nonce(platform_version), Some(0)); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); assert_eq!(contest.fund(platform_version), None); } } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs index 0747a162852..f9fea613216 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/mod.rs @@ -2188,10 +2188,25 @@ pub(in crate::execution) mod tests { assert_eq!(second_contender.vote_tally(), Some(0)); } + /// The vote poll of the DPNS name contest on `name` + pub(in crate::execution) fn dpns_name_vote_poll( + dpns_contract: &DataContract, + name: &str, + ) -> ContestedDocumentResourceVotePoll { + ContestedDocumentResourceVotePoll { + contract_id: dpns_contract.id(), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![ + Value::Text("dash".to_string()), + Value::Text(convert_to_homograph_safe_chars(name)), + ], + } + } + + /// A masternode's signed vote on the DPNS name contest on `name`, serialized as broadcast #[allow(clippy::too_many_arguments)] - pub(in crate::execution) async fn perform_vote( - platform: &mut TempPlatform, - platform_state: &Guard>, + pub(in crate::execution) async fn serialized_dpns_name_vote( dpns_contract: &DataContract, resource_vote_choice: ResourceVoteChoice, name: &str, @@ -2199,27 +2214,17 @@ pub(in crate::execution) mod tests { pro_tx_hash: Identifier, voting_key: &IdentityPublicKey, nonce: IdentityNonce, - expect_error: Option<&str>, platform_version: &PlatformVersion, - ) { - // Let's vote for contender 1 - + ) -> Vec { let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { - vote_poll: VotePoll::ContestedDocumentResourceVotePoll( - ContestedDocumentResourceVotePoll { - contract_id: dpns_contract.id(), - document_type_name: "domain".to_string(), - index_name: "parentNameAndLabel".to_string(), - index_values: vec![ - Value::Text("dash".to_string()), - Value::Text(convert_to_homograph_safe_chars(name)), - ], - }, - ), + vote_poll: VotePoll::ContestedDocumentResourceVotePoll(dpns_name_vote_poll( + dpns_contract, + name, + )), resource_vote_choice, })); - let masternode_vote_transition = MasternodeVoteTransition::try_from_vote_with_signer( + MasternodeVoteTransition::try_from_vote_with_signer( vote, signer, pro_tx_hash, @@ -2229,11 +2234,36 @@ pub(in crate::execution) mod tests { None, ) .await - .expect("expected to make transition vote"); + .expect("expected to make transition vote") + .serialize_to_bytes() + .expect("expected to serialize the masternode vote") + } - let masternode_vote_serialized_transition = masternode_vote_transition - .serialize_to_bytes() - .expect("expected documents batch serialized state transition"); + #[allow(clippy::too_many_arguments)] + pub(in crate::execution) async fn perform_vote( + platform: &mut TempPlatform, + platform_state: &Guard>, + dpns_contract: &DataContract, + resource_vote_choice: ResourceVoteChoice, + name: &str, + signer: &SimpleSigner, + pro_tx_hash: Identifier, + voting_key: &IdentityPublicKey, + nonce: IdentityNonce, + expect_error: Option<&str>, + platform_version: &PlatformVersion, + ) { + let masternode_vote_serialized_transition = serialized_dpns_name_vote( + dpns_contract, + resource_vote_choice, + name, + signer, + pro_tx_hash, + voting_key, + nonce, + platform_version, + ) + .await; // CheckTx root-invariance guard (devnet paloma h788): `check_tx` asserts under // cfg(test) that it never mutates committed grovedb state, so every valid vote From 4ad499478066429cc31a69e542fbc11f884d5cc8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 07:42:29 +0700 Subject: [PATCH 3/4] refactor(drive-abci): act on the vote's fund pre-check in place instead of a new processor generation Both outcomes keep the vote out of every block, so no block on any chain can hold such a vote and nothing needs a version gate: the processor v0 returns the pre-check's errors unpaid after state validation, the pre-check v0 requires the single vote cost, the tests live under v0, and the version tables and the v14 changelog are untouched. The protocol version 13 test now shows the same refusal there. Co-Authored-By: Claude Fable 5.1 --- .../state-transitions/validation-pipeline.md | 10 +- .../state_transition/processor/mod.rs | 10 +- .../state_transition/processor/v0/mod.rs | 394 +++++++++- .../state_transition/processor/v1/mod.rs | 722 ------------------ .../masternode_vote/balance/mod.rs | 10 +- .../masternode_vote/balance/v0/mod.rs | 13 +- .../masternode_vote/balance/v1/mod.rs | 89 --- .../drive_abci_validation_versions/v10.rs | 4 +- .../rs-platform-version/src/version/v14.rs | 16 +- 9 files changed, 398 insertions(+), 870 deletions(-) delete mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs delete mode 100644 packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index 975fd4d9deb..8b2a59997ac 100644 --- a/book/src/state-transitions/validation-pipeline.md +++ b/book/src/state-transitions/validation-pipeline.md @@ -318,15 +318,13 @@ Not all transitions need state validation. `IdentityTopUp`, `IdentityCreditWithd by the earlier stages. A `MasternodeVote` is paid by its vote poll's prefunded specialized balance, not by -the voter, so its balance check comes here rather than in stage 7. From protocol -version 14 (`process_state_transition` v1), once state validation has found the poll -and seen it open, the processor checks that fund: a vote on a poll with no fund, or -one below the single vote cost, is refused unpaid with +the voter, so its balance check comes here rather than in stage 7. Once state +validation has found the poll and seen it open, the processor checks that fund: a vote +on a poll with no fund, or one below the single vote cost, is refused unpaid with `PrefundedSpecializedBalanceNotFoundError` or `PrefundedSpecializedBalanceInsufficientError`. The fund is checked after the poll's status because settling a poll deletes its fund; a vote that arrives late is told the -poll's status, not that its fund is missing. Earlier versions ran the same pre-check -but ignored its result, and such a vote failed inside execution as an internal error. +poll's status, not that its fund is missing. ## ConsensusValidationResult: How Errors Accumulate 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 21054e8760b..25450b2cca1 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,7 +1,6 @@ /// Processor traits. pub mod traits; pub(crate) mod v0; -pub(crate) mod v1; use crate::error::execution::ExecutionError; use crate::error::Error; @@ -52,16 +51,9 @@ 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, 1], + known_versions: vec![0], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index 2fcad7be280..fdebbefb175 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -204,21 +204,6 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( } } - // The prefunded_balances are currently not used as we would only use them for a masternode vote - // however the masternode vote acts as a free operation, as it is paid for - let _prefunded_balances = if state_transition.uses_prefunded_specialized_balance_for_payment() { - Some( - state_transition.validate_minimum_prefunded_specialized_balance_pre_check( - platform.drive, - transaction, - &mut state_transition_execution_context, - platform_version, - )?, - ) - } else { - None - }; - // 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 @@ -344,6 +329,42 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( )? }; + // 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 but its result was ignored, 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, so no block can hold one and acting on the pre-check is + // not versioned. + // + // The fund is checked last, once state validation has found the poll and seen it open. + // Settling a poll deletes its fund, so a check ahead of state validation would report a + // missing fund for every vote that arrives after the poll ended and hide the poll's real + // status from the voter. The price of that order is the transform's and state validation's + // reads, spent before an unpaid refusal where a check ahead of them would spend one balance + // read; the signature check, which dominates, is spent on every refused vote either way. + if result.is_valid() && state_transition.uses_prefunded_specialized_balance_for_payment() { + let fund_result = state_transition + .validate_minimum_prefunded_specialized_balance_pre_check( + platform.drive, + transaction, + &mut state_transition_execution_context, + platform_version, + )?; + + if !fund_result.is_valid() { + return Ok( + ConsensusValidationResult::::new_with_errors(fund_result.errors), + ); + } + } + + // A result that carries errors together with an action becomes a paid-invalid event. For a + // masternode vote that event is a `PaidFixedCost` with errors, which execution does not pay + // and the block reports as an internal error, so a vote's transform and state validation + // return their errors without an action, and any later generation of them must keep doing + // so. result.map_result(|action| { ExecutionEvent::create_from_state_transition_action( action, @@ -354,3 +375,346 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( ) }) } + +#[cfg(test)] +mod tests { + use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, dpns_name_vote_poll, serialized_dpns_name_vote, + setup_masternode_voting_identity, + }; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; + use assert_matches::assert_matches; + use dpp::block::block_info::BlockInfo; + use dpp::consensus::state::state_error::StateError; + use dpp::consensus::ConsensusError; + use dpp::identifier::Identifier; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::{Identity, IdentityPublicKey}; + use dpp::prelude::DataContract; + use dpp::version::PlatformVersion; + use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use simple_signer::signer::SimpleSigner; + use std::sync::Arc; + + const NAME: &str = "quantum"; + + /// A DPNS name contest whose vote poll's fund the test then tampers with, and one + /// masternode ready to vote on it. + struct Contest { + platform: TempPlatform, + dpns_contract: Arc, + contender: Identity, + fund_id: Identifier, + voter: Voter, + } + + struct Voter { + pro_tx_hash: Identifier, + identity: Identity, + signer: SimpleSigner, + voting_key: IdentityPublicKey, + } + + async fn contest_at(platform_version: &PlatformVersion) -> Contest { + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(platform_version.protocol_version) + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender, _, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + NAME, + platform_version, + ) + .await; + let fund_id = dpns_name_vote_poll(&dpns_contract, NAME) + .specialized_balance_id() + .expect("expected the poll's prefunded balance id"); + let voter = Voter::new(&mut platform, 29, platform_version); + Contest { + platform, + dpns_contract, + contender, + fund_id, + voter, + } + } + + impl Voter { + fn new( + platform: &mut TempPlatform, + seed: u64, + platform_version: &PlatformVersion, + ) -> Self { + let (pro_tx_hash, identity, signer, voting_key) = + setup_masternode_voting_identity(platform, seed, platform_version); + Voter { + pro_tx_hash, + identity, + signer, + voting_key, + } + } + } + + impl Contest { + fn fund(&self, platform_version: &PlatformVersion) -> Option { + self.platform + .drive + .fetch_prefunded_specialized_balance( + self.fund_id.to_buffer(), + None, + platform_version, + ) + .expect("expected to fetch the poll's fund") + } + + /// Deletes the poll's fund, as settling the poll does + fn remove_fund(&self, platform_version: &PlatformVersion) { + self.platform + .drive + .empty_prefunded_specialized_balance(self.fund_id, true, None, platform_version) + .expect("expected to remove the poll's fund"); + } + + /// Leaves the poll's fund at `credits` + fn set_fund(&self, credits: u64, platform_version: &PlatformVersion) { + self.remove_fund(platform_version); + self.platform + .drive + .add_prefunded_specialized_balance(self.fund_id, credits, None, platform_version) + .expect("expected to fund the poll"); + assert_eq!(self.fund(platform_version), Some(credits)); + } + + /// Casts the voter's vote for the contender on the poll of `name` through block + /// processing, as a proposer or a validator would, and returns how the block treated it + async fn vote_on( + &mut self, + name: &str, + platform_version: &PlatformVersion, + ) -> StateTransitionExecutionResult { + let serialized_transition = self + .serialized_vote_by(&self.voter, name, platform_version) + .await; + self.process_block(vec![serialized_transition], platform_version) + .remove(0) + } + + /// Processes the transitions as one block and returns how the block treated each + fn process_block( + &mut self, + serialized_transitions: Vec>, + platform_version: &PlatformVersion, + ) -> Vec { + let platform_state = self.platform.state.load(); + let transaction = self.platform.drive.grove.start_transaction(); + let processing_result = self + .platform + .platform + .process_raw_state_transitions( + &serialized_transitions, + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process the votes"); + self.platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the transaction"); + processing_result.into_execution_results() + } + + /// `voter`'s signed vote for the contender on the poll of `name`, as broadcast + async fn serialized_vote_by( + &self, + voter: &Voter, + name: &str, + platform_version: &PlatformVersion, + ) -> Vec { + serialized_dpns_name_vote( + &self.dpns_contract, + ResourceVoteChoice::TowardsIdentity(self.contender.id()), + name, + &voter.signer, + voter.pro_tx_hash, + &voter.voting_key, + 1, + platform_version, + ) + .await + } + + /// `voter`'s identity nonce: 0 until an executed vote bumps it + fn nonce_of(&self, voter: &Voter, platform_version: &PlatformVersion) -> Option { + self.platform + .drive + .fetch_identity_nonce( + voter.identity.id().to_buffer(), + true, + None, + platform_version, + ) + .expect("expected to fetch the voter's nonce") + } + } + + #[tokio::test] + async fn should_refuse_a_vote_unpaid_when_the_poll_has_no_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + contest.remove_fund(platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(error) + )) if *error.balance_id() == contest.fund_id + ); + // Nothing of the vote reached the state + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), None); + } + + fn single_vote_cost(platform_version: &PlatformVersion) -> u64 { + platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost + } + + /// The fund is one credit short of the single vote cost that executing the vote deducts. + /// It still covers the vote's minimum fee, the smaller amount the v0 pre-check required, + /// so under v0 the vote passed the pre-check and failed inside execution. + #[tokio::test] + async fn should_refuse_a_vote_unpaid_when_the_poll_fund_is_below_the_single_vote_cost() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + assert!( + single_vote_cost + > platform_version + .fee_version + .state_transition_min_fees + .masternode_vote, + "the fund must satisfy the v0 threshold for this test to pin the v1 one" + ); + contest.set_fund(single_vote_cost - 1, platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceInsufficientError(error) + )) if *error.balance_id() == contest.fund_id + && error.balance() == single_vote_cost - 1 + && error.required_balance() == single_vote_cost + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), Some(single_vote_cost - 1)); + } + + #[tokio::test] + async fn should_accept_a_vote_when_the_poll_fund_covers_exactly_the_single_vote_cost() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + contest.set_fund(single_vote_cost, platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); + assert_eq!(contest.fund(platform_version), Some(0)); + } + + /// The fund is read through the block's transaction: a fund that covers exactly one vote + /// lets the first vote of a block in, and the second, whose pre-check sees that deduction, + /// is refused for the credits the first one took. + #[tokio::test] + async fn should_refuse_the_second_vote_of_a_block_once_the_first_took_the_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + let single_vote_cost = single_vote_cost(platform_version); + contest.set_fund(single_vote_cost, platform_version); + let second_voter = Voter::new(&mut contest.platform, 31, platform_version); + let first_vote = contest + .serialized_vote_by(&contest.voter, NAME, platform_version) + .await; + let second_vote = contest + .serialized_vote_by(&second_voter, NAME, platform_version) + .await; + + let results = contest.process_block(vec![first_vote, second_vote], platform_version); + + assert_matches!( + results[0], + StateTransitionExecutionResult::SuccessfulExecution { .. } + ); + assert_matches!( + &results[1], + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceInsufficientError(error) + )) if *error.balance_id() == contest.fund_id + && error.balance() == 0 + && error.required_balance() == single_vote_cost + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); + assert_eq!(contest.nonce_of(&second_voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), Some(0)); + } + + /// A poll that never opened has no fund either; the voter is told about the poll, which is + /// what state validation checks, not about the fund, which is checked after it. + #[tokio::test] + async fn should_report_a_missing_poll_rather_than_its_missing_fund() { + let platform_version = PlatformVersion::latest(); + let mut contest = contest_at(platform_version).await; + + let result = contest.vote_on("nowhere", platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::VotePollNotFoundError(_) + )) + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + } + + /// The refusal is not versioned: a chain still on protocol version 13 refuses the vote the + /// same way, and it never entered a block there either. + #[tokio::test] + async fn should_refuse_a_vote_the_same_way_at_protocol_version_13() { + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let mut contest = contest_at(platform_version).await; + contest.remove_fund(platform_version); + + let result = contest.vote_on(NAME, platform_version).await; + + assert_matches!( + result, + StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( + StateError::PrefundedSpecializedBalanceNotFoundError(error) + )) if *error.balance_id() == contest.fund_id + ); + assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); + assert_eq!(contest.fund(platform_version), None); + } +} 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 deleted file mode 100644 index 18ca4c03b69..00000000000 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v1/mod.rs +++ /dev/null @@ -1,722 +0,0 @@ -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; - -/// v1 (protocol version 14) = v0 with the masternode vote's prefunded balance pre-check acted -/// upon: a vote whose poll has no fund, or one below the single vote cost, is refused as an -/// unpaid consensus error after state validation, where v0 ignored the pre-check's result and -/// let the vote fail inside execution as an internal error. Every other step is identical to v0. -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, 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)); - } - } - - // 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)); - } - } - - // Verify ZK proof for shielded transitions (stateless, like signature verification). - if state_transition.has_shielded_proof_validation() { - 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, - )? - }; - - // 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. v0 ran this pre-check but ignored its result, and such a vote failed inside - // execution, when its cost was deducted, as an internal error. - // - // The fund is checked last, once state validation has found the poll and seen it open. - // Settling a poll deletes its fund, so a check ahead of state validation would report a - // missing fund for every vote that arrives after the poll ended and hide the poll's real - // status from the voter. The price of that order is the transform's and state validation's - // reads, spent before an unpaid refusal where v0's position spent one balance read; the - // signature check, which dominates, is spent on every refused vote either way. - if result.is_valid() && state_transition.uses_prefunded_specialized_balance_for_payment() { - let fund_result = state_transition - .validate_minimum_prefunded_specialized_balance_pre_check( - platform.drive, - transaction, - &mut state_transition_execution_context, - platform_version, - )?; - - if !fund_result.is_valid() { - return Ok( - ConsensusValidationResult::::new_with_errors(fund_result.errors), - ); - } - } - - // A result that carries errors together with an action becomes a paid-invalid event. For a - // masternode vote that event is a `PaidFixedCost` with errors, which execution does not pay - // and the block reports as an internal error, so a vote's transform and state validation - // return their errors without an action, and any later generation of them must keep doing - // so. - 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, - ) - }) -} - -#[cfg(test)] -mod tests { - use crate::execution::validation::state_transition::state_transitions::tests::{ - create_dpns_identity_name_contest, dpns_name_vote_poll, serialized_dpns_name_vote, - setup_masternode_voting_identity, - }; - use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; - use crate::rpc::core::MockCoreRPCLike; - use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; - use assert_matches::assert_matches; - use dpp::block::block_info::BlockInfo; - use dpp::consensus::state::state_error::StateError; - use dpp::consensus::ConsensusError; - use dpp::identifier::Identifier; - use dpp::identity::accessors::IdentityGettersV0; - use dpp::identity::{Identity, IdentityPublicKey}; - use dpp::prelude::DataContract; - use dpp::version::PlatformVersion; - use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; - use simple_signer::signer::SimpleSigner; - use std::sync::Arc; - - const NAME: &str = "quantum"; - - /// A DPNS name contest whose vote poll's fund the test then tampers with, and one - /// masternode ready to vote on it. - struct Contest { - platform: TempPlatform, - dpns_contract: Arc, - contender: Identity, - fund_id: Identifier, - voter: Voter, - } - - struct Voter { - pro_tx_hash: Identifier, - identity: Identity, - signer: SimpleSigner, - voting_key: IdentityPublicKey, - } - - async fn contest_at(platform_version: &PlatformVersion) -> Contest { - let mut platform = TestPlatformBuilder::new() - .with_initial_protocol_version(platform_version.protocol_version) - .build_with_mock_rpc() - .set_genesis_state(); - let platform_state = platform.state.load(); - let (contender, _, dpns_contract) = create_dpns_identity_name_contest( - &mut platform, - &platform_state, - 7, - NAME, - platform_version, - ) - .await; - let fund_id = dpns_name_vote_poll(&dpns_contract, NAME) - .specialized_balance_id() - .expect("expected the poll's prefunded balance id"); - let voter = Voter::new(&mut platform, 29, platform_version); - Contest { - platform, - dpns_contract, - contender, - fund_id, - voter, - } - } - - impl Voter { - fn new( - platform: &mut TempPlatform, - seed: u64, - platform_version: &PlatformVersion, - ) -> Self { - let (pro_tx_hash, identity, signer, voting_key) = - setup_masternode_voting_identity(platform, seed, platform_version); - Voter { - pro_tx_hash, - identity, - signer, - voting_key, - } - } - } - - impl Contest { - fn fund(&self, platform_version: &PlatformVersion) -> Option { - self.platform - .drive - .fetch_prefunded_specialized_balance( - self.fund_id.to_buffer(), - None, - platform_version, - ) - .expect("expected to fetch the poll's fund") - } - - /// Deletes the poll's fund, as settling the poll does - fn remove_fund(&self, platform_version: &PlatformVersion) { - self.platform - .drive - .empty_prefunded_specialized_balance(self.fund_id, true, None, platform_version) - .expect("expected to remove the poll's fund"); - } - - /// Leaves the poll's fund at `credits` - fn set_fund(&self, credits: u64, platform_version: &PlatformVersion) { - self.remove_fund(platform_version); - self.platform - .drive - .add_prefunded_specialized_balance(self.fund_id, credits, None, platform_version) - .expect("expected to fund the poll"); - assert_eq!(self.fund(platform_version), Some(credits)); - } - - /// Casts the voter's vote for the contender on the poll of `name` through block - /// processing, as a proposer or a validator would, and returns how the block treated it - async fn vote_on( - &mut self, - name: &str, - platform_version: &PlatformVersion, - ) -> StateTransitionExecutionResult { - let serialized_transition = self - .serialized_vote_by(&self.voter, name, platform_version) - .await; - self.process_block(vec![serialized_transition], platform_version) - .remove(0) - } - - /// Processes the transitions as one block and returns how the block treated each - fn process_block( - &mut self, - serialized_transitions: Vec>, - platform_version: &PlatformVersion, - ) -> Vec { - let platform_state = self.platform.state.load(); - let transaction = self.platform.drive.grove.start_transaction(); - let processing_result = self - .platform - .platform - .process_raw_state_transitions( - &serialized_transitions, - &platform_state, - &BlockInfo::default(), - &transaction, - platform_version, - false, - None, - ) - .expect("expected to process the votes"); - self.platform - .drive - .grove - .commit_transaction(transaction) - .unwrap() - .expect("expected to commit the transaction"); - processing_result.into_execution_results() - } - - /// `voter`'s signed vote for the contender on the poll of `name`, as broadcast - async fn serialized_vote_by( - &self, - voter: &Voter, - name: &str, - platform_version: &PlatformVersion, - ) -> Vec { - serialized_dpns_name_vote( - &self.dpns_contract, - ResourceVoteChoice::TowardsIdentity(self.contender.id()), - name, - &voter.signer, - voter.pro_tx_hash, - &voter.voting_key, - 1, - platform_version, - ) - .await - } - - /// `voter`'s identity nonce: 0 until an executed vote bumps it - fn nonce_of(&self, voter: &Voter, platform_version: &PlatformVersion) -> Option { - self.platform - .drive - .fetch_identity_nonce( - voter.identity.id().to_buffer(), - true, - None, - platform_version, - ) - .expect("expected to fetch the voter's nonce") - } - } - - #[tokio::test] - async fn should_refuse_a_vote_unpaid_when_the_poll_has_no_fund() { - let platform_version = PlatformVersion::latest(); - let mut contest = contest_at(platform_version).await; - contest.remove_fund(platform_version); - - let result = contest.vote_on(NAME, platform_version).await; - - assert_matches!( - result, - StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( - StateError::PrefundedSpecializedBalanceNotFoundError(error) - )) if *error.balance_id() == contest.fund_id - ); - // Nothing of the vote reached the state - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); - assert_eq!(contest.fund(platform_version), None); - } - - fn single_vote_cost(platform_version: &PlatformVersion) -> u64 { - platform_version - .fee_version - .vote_resolution_fund_fees - .contested_document_single_vote_cost - } - - /// The fund is one credit short of the single vote cost that executing the vote deducts. - /// It still covers the vote's minimum fee, the smaller amount the v0 pre-check required, - /// so under v0 the vote passed the pre-check and failed inside execution. - #[tokio::test] - async fn should_refuse_a_vote_unpaid_when_the_poll_fund_is_below_the_single_vote_cost() { - let platform_version = PlatformVersion::latest(); - let mut contest = contest_at(platform_version).await; - let single_vote_cost = single_vote_cost(platform_version); - assert!( - single_vote_cost - > platform_version - .fee_version - .state_transition_min_fees - .masternode_vote, - "the fund must satisfy the v0 threshold for this test to pin the v1 one" - ); - contest.set_fund(single_vote_cost - 1, platform_version); - - let result = contest.vote_on(NAME, platform_version).await; - - assert_matches!( - result, - StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( - StateError::PrefundedSpecializedBalanceInsufficientError(error) - )) if *error.balance_id() == contest.fund_id - && error.balance() == single_vote_cost - 1 - && error.required_balance() == single_vote_cost - ); - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); - assert_eq!(contest.fund(platform_version), Some(single_vote_cost - 1)); - } - - #[tokio::test] - async fn should_accept_a_vote_when_the_poll_fund_covers_exactly_the_single_vote_cost() { - let platform_version = PlatformVersion::latest(); - let mut contest = contest_at(platform_version).await; - let single_vote_cost = single_vote_cost(platform_version); - contest.set_fund(single_vote_cost, platform_version); - - let result = contest.vote_on(NAME, platform_version).await; - - assert_matches!( - result, - StateTransitionExecutionResult::SuccessfulExecution { .. } - ); - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); - assert_eq!(contest.fund(platform_version), Some(0)); - } - - /// The fund is read through the block's transaction: a fund that covers exactly one vote - /// lets the first vote of a block in, and the second, whose pre-check sees that deduction, - /// is refused for the credits the first one took. - #[tokio::test] - async fn should_refuse_the_second_vote_of_a_block_once_the_first_took_the_fund() { - let platform_version = PlatformVersion::latest(); - let mut contest = contest_at(platform_version).await; - let single_vote_cost = single_vote_cost(platform_version); - contest.set_fund(single_vote_cost, platform_version); - let second_voter = Voter::new(&mut contest.platform, 31, platform_version); - let first_vote = contest - .serialized_vote_by(&contest.voter, NAME, platform_version) - .await; - let second_vote = contest - .serialized_vote_by(&second_voter, NAME, platform_version) - .await; - - let results = contest.process_block(vec![first_vote, second_vote], platform_version); - - assert_matches!( - results[0], - StateTransitionExecutionResult::SuccessfulExecution { .. } - ); - assert_matches!( - &results[1], - StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( - StateError::PrefundedSpecializedBalanceInsufficientError(error) - )) if *error.balance_id() == contest.fund_id - && error.balance() == 0 - && error.required_balance() == single_vote_cost - ); - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(1)); - assert_eq!(contest.nonce_of(&second_voter, platform_version), Some(0)); - assert_eq!(contest.fund(platform_version), Some(0)); - } - - /// A poll that never opened has no fund either; the voter is told about the poll, which is - /// what state validation checks, not about the fund, which is checked after it. - #[tokio::test] - async fn should_report_a_missing_poll_rather_than_its_missing_fund() { - let platform_version = PlatformVersion::latest(); - let mut contest = contest_at(platform_version).await; - - let result = contest.vote_on("nowhere", platform_version).await; - - assert_matches!( - result, - StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( - StateError::VotePollNotFoundError(_) - )) - ); - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); - } - - /// v0, selected by every protocol version before 14, ignores the pre-check: the vote fails - /// inside execution instead, as an internal error. A block never carries the vote under - /// either generation, so the two agree on every block. - #[tokio::test] - async fn should_fail_a_vote_on_an_unfunded_poll_inside_execution_before_protocol_version_14() { - let platform_version = PlatformVersion::get(13).expect("protocol version 13"); - let mut contest = contest_at(platform_version).await; - contest.remove_fund(platform_version); - - let result = contest.vote_on(NAME, platform_version).await; - - assert_matches!( - result, - StateTransitionExecutionResult::InternalError(message) - if message.contains("prefunded specialized balance does not exist") - ); - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); - assert_eq!(contest.fund(platform_version), None); - } -} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs index 2c215ebb516..61677c3aed7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/mod.rs @@ -2,7 +2,6 @@ use crate::error::execution::ExecutionError; use crate::error::Error; use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; use crate::execution::validation::state_transition::masternode_vote::balance::v0::MasternodeVoteTransitionBalanceValidationV0; -use crate::execution::validation::state_transition::masternode_vote::balance::v1::MasternodeVoteTransitionBalanceValidationV1; use crate::execution::validation::state_transition::processor::prefunded_specialized_balance::StateTransitionPrefundedSpecializedBalanceValidationV0; use dpp::fee::Credits; use dpp::prefunded_specialized_balance::PrefundedSpecializedBalanceIdentifier; @@ -14,7 +13,6 @@ use drive::grovedb::TransactionArg; use std::collections::BTreeMap; pub(crate) mod v0; -pub(crate) mod v1; impl StateTransitionPrefundedSpecializedBalanceValidationV0 for MasternodeVoteTransition { fn validate_minimum_prefunded_specialized_balance_pre_check( @@ -39,15 +37,9 @@ impl StateTransitionPrefundedSpecializedBalanceValidationV0 for MasternodeVoteTr execution_context, platform_version, ), - 1 => self.validate_advanced_minimum_balance_pre_check_v1( - drive, - tx, - execution_context, - platform_version, - ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "masternode vote transition: validate_balance".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0], received: version, })), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs index a3098272012..b3e93ea69ce 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v0/mod.rs @@ -7,7 +7,6 @@ use dpp::prefunded_specialized_balance::PrefundedSpecializedBalanceIdentifier; use dpp::prelude::ConsensusValidationResult; use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; -use dpp::state_transition::StateTransitionEstimatedFeeValidation; use crate::error::execution::ExecutionError; use crate::execution::types::execution_operation::ValidationOperation; @@ -64,14 +63,20 @@ impl MasternodeVoteTransitionBalanceValidationV0 for MasternodeVoteTransition { )); }; - let required_fee = self.calculate_min_required_fee(platform_version)?; + // What executing the vote deducts from the fund. Until 4.2 the vote's minimum fee was + // required here instead, a smaller amount, so a fund between the two passed this check + // and the vote failed inside execution. + let single_vote_cost = platform_version + .fee_version + .vote_resolution_fund_fees + .contested_document_single_vote_cost; - if balance < required_fee { + if balance < single_vote_cost { return Ok(ConsensusValidationResult::new_with_error( PrefundedSpecializedBalanceInsufficientError::new( balance_id, balance, - required_fee, + single_vote_cost, ) .into(), )); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs deleted file mode 100644 index 74c65abcf41..00000000000 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/balance/v1/mod.rs +++ /dev/null @@ -1,89 +0,0 @@ -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 dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_insufficient_error::PrefundedSpecializedBalanceInsufficientError; -use dpp::consensus::state::prefunded_specialized_balances::prefunded_specialized_balance_not_found_error::PrefundedSpecializedBalanceNotFoundError; -use dpp::fee::Credits; -use dpp::prefunded_specialized_balance::PrefundedSpecializedBalanceIdentifier; -use dpp::prelude::ConsensusValidationResult; -use dpp::state_transition::masternode_vote_transition::accessors::MasternodeVoteTransitionAccessorsV0; -use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; -use dpp::version::PlatformVersion; -use drive::drive::Drive; -use drive::grovedb::TransactionArg; -use std::collections::BTreeMap; - -pub(super) trait MasternodeVoteTransitionBalanceValidationV1 { - fn validate_advanced_minimum_balance_pre_check_v1( - &self, - drive: &Drive, - tx: TransactionArg, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result< - ConsensusValidationResult>, - Error, - >; -} - -impl MasternodeVoteTransitionBalanceValidationV1 for MasternodeVoteTransition { - /// v1 (protocol version 14) requires the poll's fund to cover the single vote cost, which is - /// what executing the vote deducts from it. v0 required only the vote's minimum fee, which - /// is smaller, so a fund between the two passed the pre-check and the vote then failed - /// inside execution. - fn validate_advanced_minimum_balance_pre_check_v1( - &self, - drive: &Drive, - tx: TransactionArg, - execution_context: &mut StateTransitionExecutionContext, - platform_version: &PlatformVersion, - ) -> Result< - ConsensusValidationResult>, - Error, - > { - execution_context.add_operation(ValidationOperation::RetrievePrefundedSpecializedBalance); - - let vote = self.vote(); - - let balance_id = vote.specialized_balance_id()?.ok_or(Error::Execution( - ExecutionError::CorruptedCodeExecution( - "In this version there should always be a specialized balance id", - ), - ))?; - let maybe_balance = drive.fetch_prefunded_specialized_balance( - balance_id.to_buffer(), - tx, - platform_version, - )?; - - let Some(balance) = maybe_balance else { - // If there is no balance we are voting on something that either was never created or has finished - return Ok(ConsensusValidationResult::new_with_error( - PrefundedSpecializedBalanceNotFoundError::new(balance_id).into(), - )); - }; - - let single_vote_cost = platform_version - .fee_version - .vote_resolution_fund_fees - .contested_document_single_vote_cost; - - if balance < single_vote_cost { - return Ok(ConsensusValidationResult::new_with_error( - PrefundedSpecializedBalanceInsufficientError::new( - balance_id, - balance, - single_vote_cost, - ) - .into(), - )); - } - - Ok(ConsensusValidationResult::new_with_data(BTreeMap::from([ - (balance_id, balance), - ]))) - } -} 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 fba157a2297..87db84be22e 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 @@ -106,7 +106,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = state: 0, transform_into_action: 0, }, - masternode_vote_state_transition_balance_pre_check: 1, // changed: the poll's fund must cover the single vote cost the vote deducts, not only the vote's minimum fee + masternode_vote_state_transition_balance_pre_check: 0, contract_create_state_transition: DriveAbciStateTransitionValidationVersion { basic_structure: Some(2), // changed: rejects `requiredSince` other than 1 on a newly created contract — the annotation must name the version the change arrives with, and a fresh contract is version 1 advanced_structure: Some(1), @@ -377,7 +377,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: 1, // changed: a masternode vote on a poll whose fund is missing or below the single vote cost is refused unpaid + process_state_transition: 0, 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 e517dd99c24..64f89f6b189 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -200,19 +200,7 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// reference on every replace (a dead one must be repointed or cleared), /// and lets an `immutable` one be cleared once its target is deleted. /// v13 keeps the v9 table and therefore keeps accepting all of these, so -/// replay of pre-upgrade blocks is unchanged. The same table bumps -/// `process_state_transition` 0 → 1: the processor now acts on the -/// masternode vote's prefunded balance pre-check, after state validation, -/// refusing a vote whose poll has no fund, or one below the single vote -/// cost, as an unpaid `PrefundedSpecializedBalanceNotFoundError` / -/// `PrefundedSpecializedBalanceInsufficientError`; v0 ran the pre-check -/// but ignored its result, and such a vote failed inside execution as an -/// internal error. It also bumps -/// `masternode_vote_state_transition_balance_pre_check` 0 → 1, which -/// requires the fund to cover the single vote cost the vote deducts, where -/// v0 required only the vote's minimum fee, a smaller amount. Under either -/// generation such a vote never enters a block (proposers strip it, -/// validators reject a block carrying it), so blocks replay identically. +/// replay of pre-upgrade blocks is unchanged. /// * `DOCUMENT_VERSIONS_V4` bumps `document_serialization_version` to /// default 3: documents are stamped with the contract version their bytes /// conform to (a varint after the format prefix), enabling the @@ -591,7 +579,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V2, // changed: saved platform state structure 1 keeps masternodes and validator sets as one aux entry each methods: DRIVE_ABCI_METHOD_VERSIONS_V10, // changed: records the per-block total credits history for the daily withdrawal limit - validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate; process_state_transition 1 refuses a vote on an unfunded poll unpaid + validation_and_processing: DRIVE_ABCI_VALIDATION_VERSIONS_V10, // changed: contested-index cross-check + refersTo document reference validation; the ContractUserModeration gates and the batch transformer's contract_moderation_gate withdrawal_constants: DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V3, // changed: prune bound for the total credits history query: DRIVE_ABCI_QUERY_VERSIONS_V3, // changed: ranked + boolean-HAVING routing gate; the v1 handler also resolves IN_TIME_RANGE from committed block time checkpoints: DRIVE_ABCI_CHECKPOINT_PARAMETERS_V1, From f8461788406a114103825c09c28276b8ca35b9df Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 22 Sep 2026 08:30:26 +0700 Subject: [PATCH 4/4] refactor(drive-abci): act on the vote's fund pre-check where it already ran The pre-check stays at its original place in the processor and its result is read there, instead of being moved after state validation. A vote that reaches a block after its poll settled is therefore refused for the deleted pot rather than for the poll's status; the two late-vote tests expect that now. Voters never see the difference: check_tx validates the poll's status first and refuses a late vote at broadcast with the status error. Co-Authored-By: Claude Fable 5.1 --- .../state-transitions/validation-pipeline.md | 15 ++-- .../state_transition/processor/v0/mod.rs | 74 +++++-------------- .../state_transitions/masternode_vote/mod.rs | 10 ++- 3 files changed, 34 insertions(+), 65 deletions(-) diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index 8b2a59997ac..499beab5ec5 100644 --- a/book/src/state-transitions/validation-pipeline.md +++ b/book/src/state-transitions/validation-pipeline.md @@ -231,6 +231,12 @@ if state_transition.has_identity_minimum_balance_pre_check_validation() { } ``` +A `MasternodeVote` is paid by its vote poll's prefunded specialized balance, not by +the voter, so its pre-check is on that pot: a vote on a poll whose pot does not exist, +or holds less than the single vote cost, is refused unpaid with +`PrefundedSpecializedBalanceNotFoundError` or +`PrefundedSpecializedBalanceInsufficientError`. + ## Stage 8: Advanced Structure Validation (without State) Some transitions need structural validation that goes beyond basic checks but does not @@ -317,15 +323,6 @@ Not all transitions need state validation. `IdentityTopUp`, `IdentityCreditWithd `AddressFundsTransfer`, and several others skip it -- their validation is fully covered by the earlier stages. -A `MasternodeVote` is paid by its vote poll's prefunded specialized balance, not by -the voter, so its balance check comes here rather than in stage 7. Once state -validation has found the poll and seen it open, the processor checks that fund: a vote -on a poll with no fund, or one below the single vote cost, is refused unpaid with -`PrefundedSpecializedBalanceNotFoundError` or -`PrefundedSpecializedBalanceInsufficientError`. The fund is checked after the poll's -status because settling a poll deletes its fund; a vote that arrives late is told the -poll's status, not that its fund is missing. - ## ConsensusValidationResult: How Errors Accumulate Throughout the pipeline, errors are communicated through `ConsensusValidationResult`, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs index fdebbefb175..13b1e85e620 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/processor/v0/mod.rs @@ -204,6 +204,26 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( } } + // 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 @@ -329,42 +349,6 @@ pub(super) fn process_state_transition_v0<'a, C: CoreRPCLike>( )? }; - // 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 but its result was ignored, 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, so no block can hold one and acting on the pre-check is - // not versioned. - // - // The fund is checked last, once state validation has found the poll and seen it open. - // Settling a poll deletes its fund, so a check ahead of state validation would report a - // missing fund for every vote that arrives after the poll ended and hide the poll's real - // status from the voter. The price of that order is the transform's and state validation's - // reads, spent before an unpaid refusal where a check ahead of them would spend one balance - // read; the signature check, which dominates, is spent on every refused vote either way. - if result.is_valid() && state_transition.uses_prefunded_specialized_balance_for_payment() { - let fund_result = state_transition - .validate_minimum_prefunded_specialized_balance_pre_check( - platform.drive, - transaction, - &mut state_transition_execution_context, - platform_version, - )?; - - if !fund_result.is_valid() { - return Ok( - ConsensusValidationResult::::new_with_errors(fund_result.errors), - ); - } - } - - // A result that carries errors together with an action becomes a paid-invalid event. For a - // masternode vote that event is a `PaidFixedCost` with errors, which execution does not pay - // and the block reports as an internal error, so a vote's transform and state validation - // return their errors without an action, and any later generation of them must keep doing - // so. result.map_result(|action| { ExecutionEvent::create_from_state_transition_action( action, @@ -680,24 +664,6 @@ mod tests { assert_eq!(contest.fund(platform_version), Some(0)); } - /// A poll that never opened has no fund either; the voter is told about the poll, which is - /// what state validation checks, not about the fund, which is checked after it. - #[tokio::test] - async fn should_report_a_missing_poll_rather_than_its_missing_fund() { - let platform_version = PlatformVersion::latest(); - let mut contest = contest_at(platform_version).await; - - let result = contest.vote_on("nowhere", platform_version).await; - - assert_matches!( - result, - StateTransitionExecutionResult::UnpaidConsensusError(ConsensusError::StateError( - StateError::VotePollNotFoundError(_) - )) - ); - assert_eq!(contest.nonce_of(&contest.voter, platform_version), Some(0)); - } - /// The refusal is not versioned: a chain still on protocol version 13 refuses the vote the /// same way, and it never entered a block there either. #[tokio::test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs index 4991e63e9b9..61bf13f8a27 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs @@ -8616,7 +8616,10 @@ mod tests { pro_tx_hash, &voting_key, 2, - Some("VotePoll ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }) not available for voting: Awarded(BjNejy4r9QAvLHpQ9Yq6yRMgNymeGZ46d48fJxJbMrfW)"), + // Settling the poll deleted its pot, and a block checks the pot before the + // poll's status; check_tx, which validates the poll's status first, is what + // tells a late voter the poll is over. + Some("Did not find a specialized balance with id: 8cLBdc35uovu4yHKuMKy4JWYhRFVUPSnnm8KTJJaaJw4"), platform_version, ) .await; @@ -8811,7 +8814,10 @@ mod tests { pro_tx_hash, &voting_key, 2, - Some("VotePoll ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { contract_id: GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec, document_type_name: domain, index_name: parentNameAndLabel, index_values: [string dash, string quantum] }) not available for voting: Locked"), + // Settling the poll deleted its pot, and a block checks the pot before the + // poll's status; check_tx, which validates the poll's status first, is what + // tells a late voter the poll is over. + Some("Did not find a specialized balance with id: 8cLBdc35uovu4yHKuMKy4JWYhRFVUPSnnm8KTJJaaJw4"), platform_version, ) .await;