diff --git a/book/src/state-transitions/validation-pipeline.md b/book/src/state-transitions/validation-pipeline.md index 75788a55011..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 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..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,20 +204,25 @@ 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 - }; + // 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. @@ -354,3 +359,328 @@ 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)); + } + + /// 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/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/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; 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