Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions book/src/state-transitions/validation-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ExecutionEvent>::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.
Expand Down Expand Up @@ -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<MockCoreRPCLike>,
dpns_contract: Arc<DataContract>,
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<MockCoreRPCLike>,
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<u64> {
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<Vec<u8>>,
platform_version: &PlatformVersion,
) -> Vec<StateTransitionExecutionResult> {
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<u8> {
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<u64> {
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);
}
}
Loading
Loading