From 6de0c1ac2cde85317f85032664bb95c32590113c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 05:45:41 +0300 Subject: [PATCH 1/3] fix(drive): prove a decision proposal create where the proposal is stored The prover and the client verifier both looked for a create's document in the contest area whenever the create stated a fund. A decision proposal (protocol version 15) states the decision proposal fund, but it opens a yes/no poll and is stored as any document is, so the proof of a proposal create looked in the wrong place and a client waiting for the result could not find the proposal it had just created. `SingleDocumentDriveQueryContestedStatus::of_document_transition` now decides where a transition's document is stored, for both: contested for a create stating a fund, except a decision proposal. Only creates of the decision proposals contract change, and that contract exists from protocol version 15 only. Co-Authored-By: Claude Opus 5.5 --- .../prove/prove_state_transition/v0/mod.rs | 14 +-- .../src/query/single_document_drive_query.rs | 86 +++++++++++++++++++ .../v0/mod.rs | 14 +-- 3 files changed, 92 insertions(+), 22 deletions(-) diff --git a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs index 25223a7e885..0d543b2550b 100644 --- a/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs +++ b/packages/rs-drive/src/prove/prove_state_transition/v0/mod.rs @@ -193,17 +193,9 @@ impl Drive { )? } else { let contested_status = - if let DocumentTransition::Create(create_transition) = - document_transition - { - if create_transition.prefunded_voting_balance().is_some() { - SingleDocumentDriveQueryContestedStatus::Contested - } else { - SingleDocumentDriveQueryContestedStatus::NotContested - } - } else { - SingleDocumentDriveQueryContestedStatus::NotContested - }; + SingleDocumentDriveQueryContestedStatus::of_document_transition( + document_transition, + ); let query = SingleDocumentDriveQuery { contract_id: document_transition diff --git a/packages/rs-drive/src/query/single_document_drive_query.rs b/packages/rs-drive/src/query/single_document_drive_query.rs index dfc8477d62d..1bee60799c3 100644 --- a/packages/rs-drive/src/query/single_document_drive_query.rs +++ b/packages/rs-drive/src/query/single_document_drive_query.rs @@ -5,6 +5,11 @@ use crate::drive::votes; use crate::error::query::QuerySyntaxError; use crate::error::Error; use crate::query::Query; +use dpp::decision_proposal::is_decision_proposal; +use dpp::state_transition::batch_transition::batched_transition::document_transition::{ + DocumentTransition, DocumentTransitionV0Methods, +}; +use dpp::state_transition::batch_transition::document_create_transition::v0::v0_methods::DocumentCreateTransitionV0Methods; use grovedb::{PathQuery, SizedQuery}; use platform_version::version::PlatformVersion; use platform_version::TryFromPlatformVersioned; @@ -24,6 +29,28 @@ pub enum SingleDocumentDriveQueryContestedStatus { Contested = 2, } +impl SingleDocumentDriveQueryContestedStatus { + /// Where the document of `document_transition` is stored once the transition executes, + /// which is where its proof looks. A create stating a fund opens a contest, and its + /// document waits in the contest's area. A decision proposal (protocol version 15) states a + /// fund too, but the fund pays the yes/no poll it opens, and the proposal is stored as any + /// document is. + pub fn of_document_transition(document_transition: &DocumentTransition) -> Self { + match document_transition { + DocumentTransition::Create(create_transition) + if create_transition.prefunded_voting_balance().is_some() + && !is_decision_proposal( + &document_transition.data_contract_id(), + document_transition.document_type_name(), + ) => + { + SingleDocumentDriveQueryContestedStatus::Contested + } + _ => SingleDocumentDriveQueryContestedStatus::NotContested, + } + } +} + impl TryFrom for SingleDocumentDriveQueryContestedStatus { type Error = Error; @@ -156,3 +183,62 @@ impl TryFromPlatformVersioned for PathQuery { value.construct_path_query(platform_version) } } + +#[cfg(test)] +mod tests { + use super::*; + use dpp::decision_proposal::{ + DECISION_PROPOSALS_CONTRACT_ID, DECISION_PROPOSAL_FUND_NAME, PROPOSAL_DOCUMENT_TYPE_NAME, + }; + use dpp::fee::Credits; + use dpp::identifier::Identifier; + use dpp::state_transition::batch_transition::document_base_transition::document_base_transition_trait::DocumentBaseTransitionAccessors; + use dpp::state_transition::batch_transition::document_base_transition::v0::v0_methods::DocumentBaseTransitionV0Methods; + use dpp::state_transition::batch_transition::document_create_transition::DocumentCreateTransition; + + fn create( + contract_id: Identifier, + document_type_name: &str, + stated_fund: Option<(String, Credits)>, + ) -> DocumentTransition { + let mut create = DocumentCreateTransition::default(); + create.base_mut().set_data_contract_id(contract_id); + create + .base_mut() + .set_document_type_name(document_type_name.to_string()); + *create.prefunded_voting_balances_mut() = stated_fund; + DocumentTransition::Create(create) + } + + /// A create stating a contest fund is looked for in the contest's area, while a decision + /// proposal, which states the decision proposal fund, is stored and looked for as any + /// document + #[test] + fn should_look_for_a_decision_proposal_among_documents_and_a_contested_create_in_its_contest() { + let contract_id = Identifier::new([7; 32]); + assert_eq!( + SingleDocumentDriveQueryContestedStatus::of_document_transition(&create( + contract_id, + "domain", + Some(("parentNameAndLabel".to_string(), 20_000_000_000)), + )), + SingleDocumentDriveQueryContestedStatus::Contested + ); + assert_eq!( + SingleDocumentDriveQueryContestedStatus::of_document_transition(&create( + contract_id, + "domain", + None, + )), + SingleDocumentDriveQueryContestedStatus::NotContested + ); + assert_eq!( + SingleDocumentDriveQueryContestedStatus::of_document_transition(&create( + DECISION_PROPOSALS_CONTRACT_ID, + PROPOSAL_DOCUMENT_TYPE_NAME, + Some((DECISION_PROPOSAL_FUND_NAME.to_string(), 50_000_000_000)), + )), + SingleDocumentDriveQueryContestedStatus::NotContested + ); + } +} diff --git a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs index 7fe3c52e038..babb58350b0 100644 --- a/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs +++ b/packages/rs-drive/src/verify/state_transition/verify_state_transition_was_executed_with_proof/v0/mod.rs @@ -411,17 +411,9 @@ impl Drive { } let contested_status = - if let DocumentTransition::Create(create_transition) = - document_transition - { - if create_transition.prefunded_voting_balance().is_some() { - SingleDocumentDriveQueryContestedStatus::Contested - } else { - SingleDocumentDriveQueryContestedStatus::NotContested - } - } else { - SingleDocumentDriveQueryContestedStatus::NotContested - }; + SingleDocumentDriveQueryContestedStatus::of_document_transition( + document_transition, + ); let query = SingleDocumentDriveQuery { contract_id: document_transition.data_contract_id().into_buffer(), From c2f78cb1c9515ee0ab2292a28088e10954452814 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 05:45:41 +0300 Subject: [PATCH 2/3] fix(drive): describe kept document revisions with their owner's storage flags A document of a type keeping history stores each revision with the document's storage flags, the owner's when the owner paid. The structure description allowed a revision no flags only, so the conformance check of a chain holding such documents failed. Decision proposals keep their history and are the first strategy fixture to write them. Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive/grovedb-structure.json | 5 +++++ packages/rs-drive/src/drive/document/structure.rs | 1 + 2 files changed, 6 insertions(+) diff --git a/packages/rs-drive/grovedb-structure.json b/packages/rs-drive/grovedb-structure.json index 0361210cca1..1be6220b5ba 100644 --- a/packages/rs-drive/grovedb-structure.json +++ b/packages/rs-drive/grovedb-structure.json @@ -2989,6 +2989,11 @@ "kinds": [ "Item" ], + "flags": [ + "EpochOwned", + "None" + ], + "flags_note": "The owner is the owner of the document, who is refunded when it is deleted. Documents the system writes, and documents of a type declaring a `ttl`, carry no flags.", "value": "serialized document", "since": 1, "presence": "always", diff --git a/packages/rs-drive/src/drive/document/structure.rs b/packages/rs-drive/src/drive/document/structure.rs index 4e56f331350..63a024f5594 100644 --- a/packages/rs-drive/src/drive/document/structure.rs +++ b/packages/rs-drive/src/drive/document/structure.rs @@ -106,6 +106,7 @@ pub(crate) fn document_type() -> StructureNode { u64 big endian with the sign bit flipped", ) .kind(ElementKind::Item) + .flags(&[FlagsKind::EpochOwned, FlagsKind::None], DOCUMENT_FLAGS) .value("serialized document") .describe("The document as it was at that time."), ]), From a66e85c4228644af751577916eda56fba64e5773 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sat, 31 Oct 2026 05:45:41 +0300 Subject: [PATCH 3/3] test(drive-abci): decision proposals beside a DPNS contest in a running chain A strategy test at protocol version 15: four identities open eight proposals in one block, three ending on the end date of a DPNS contest opened the block before, the others one to four weeks out. Masternodes answer every proposal and vote on the contest for four weeks of daily blocks, the strategy verifying the proof of every create and vote. It checks that each proposal opens its poll with exactly the decision proposal fund, filed under its end date beside the contest; that due polls close in end date order, at most `maximum_vote_polls_to_process` a block, the contest's included (so the proposal due half a day after the contest waits a block); that each result follows from the poll's rule and tallies, with polls passing, failing on the answers and failing short of turnout; that the rest of each fund is spent and the end date index emptied; and that the proposals stay. Strategies gain `OperationType::YesNoVote`, appended last so saved strategies still decode. It answers a yes/no poll with weighted answers, skipping an answer the masternode already gave, one past its allowance, a second answer in the same block (the proof is checked against the state the block leaves) and any vote once the poll is due. The masternode vote signing the contested votes used is shared with it. Co-Authored-By: Claude Opus 5.5 --- .../tests/strategy_tests/strategy.rs | 144 +++- .../strategy_tests/test_cases/voting_tests.rs | 802 +++++++++++++++++- packages/strategy-tests/src/operations.rs | 72 +- 3 files changed, 979 insertions(+), 39 deletions(-) diff --git a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs index 04f09907282..b70548c4938 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/strategy.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/strategy.rs @@ -36,7 +36,7 @@ use strategy_tests::operations::FinalizeBlockOperation::IdentityAddKeys; use strategy_tests::operations::{ AmountRange, DocumentAction, DocumentOp, ExtraKeys, FinalizeBlockOperation, IdentityUpdateOp, MaybeOutputAmount, OperationType, OutputCountRange, TokenOp, - UseExistingAddressesAsOutputChance, + UseExistingAddressesAsOutputChance, YesNoVoteOp, }; use strategy_tests::KeyMaps; @@ -105,12 +105,16 @@ use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; use dpp::tokens::calculate_token_id; use dpp::tokens::token_event::TokenEvent; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; 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::yes_no_vote::v0::YesNoVoteV0; +use dpp::voting::votes::yes_no_vote::YesNoVote; use dpp::voting::votes::Vote; use dpp::withdrawal::Pooling; use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; +use drive::drive::votes::PreviousVoteCount; use drive::query::DriveDocumentQuery; use drive_abci::abci::app::FullAbciApplication; use drive_abci::config::PlatformConfig; @@ -1711,17 +1715,6 @@ impl NetworkStrategy { continue; } - let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: 0, - purpose: Purpose::VOTING, - security_level: SecurityLevel::MEDIUM, - contract_bounds: None, - key_type: KeyType::ECDSA_HASH160, - read_only: false, - data: voting_address.to_vec().into(), - disabled_at: None, - }); - let vote = Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { vote_poll: VotePoll::ContestedDocumentResourceVotePoll( resource_vote_op.resolved_vote_poll.clone().into(), @@ -1729,24 +1722,86 @@ impl NetworkStrategy { resource_vote_choice, })); - let identity_nonce = - identity_nonce_counter.entry(voting_identifier).or_default(); - *identity_nonce += 1; + let state_transition = signed_masternode_vote( + vote, + pro_tx_hash_bytes, + voting_address, + signer, + identity_nonce_counter, + platform_version, + ) + .await; - let state_transition = - MasternodeVoteTransition::try_from_vote_with_signer( - vote, - signer, + vote_poll_votes.insert(voting_identifier, resource_vote_choice); + + operations.push(state_transition); + } + } + OperationType::YesNoVote(yes_no_vote_op) + if block_info.time_ms < yes_no_vote_op.end_date => + { + let YesNoVoteOp { vote_poll_id, .. } = yes_no_vote_op; + let state = maybe_state.get_or_insert(platform.state.load()); + let full_masternode_list = state.full_masternode_list(); + let votes_allowed_per_masternode = platform_version + .dpp + .validation + .voting + .votes_allowed_per_masternode; + // A masternode answers the poll at most once a block: each vote's + // proof is checked against the state the block leaves, which holds only + // the masternode's last answer + let mut answered_in_this_block: HashSet<[u8; 32]> = HashSet::new(); + for _ in 0..count { + let rand_index = rng.gen_range(0..full_masternode_list.len()); + let (pro_tx_hash, masternode_list_item) = + full_masternode_list.iter().nth(rand_index).unwrap(); + let pro_tx_hash_bytes: [u8; 32] = pro_tx_hash.to_byte_array(); + if answered_in_this_block.contains(&pro_tx_hash_bytes) { + continue; + } + + let vote_choice = yes_no_vote_op.choose_weighted_choice(rng); + + // The same answer again, or one past the masternode's allowance, + // would be refused + let previous_answer: Option<( + YesNoAbstainVoteChoice, + PreviousVoteCount, + )> = platform + .drive + .fetch_identity_yes_no_vote( Identifier::from(pro_tx_hash_bytes), - &identity_public_key, - *identity_nonce, - platform_version, + *vote_poll_id, None, + &mut vec![], + platform_version, ) - .await - .expect("expected to make a masternode vote transition"); + .expect("expected to read the masternode's answer"); + if let Some((previous_choice, previous_count)) = previous_answer { + if previous_choice == vote_choice + || previous_count >= votes_allowed_per_masternode + { + continue; + } + } - vote_poll_votes.insert(voting_identifier, resource_vote_choice); + let vote = Vote::YesNoVote(YesNoVote::V0(YesNoVoteV0 { + vote_poll_id: *vote_poll_id, + vote_choice, + })); + + let state_transition = signed_masternode_vote( + vote, + pro_tx_hash_bytes, + masternode_list_item.state.voting_address, + signer, + identity_nonce_counter, + platform_version, + ) + .await; + + answered_in_this_block.insert(pro_tx_hash_bytes); operations.push(state_transition); } @@ -3146,6 +3201,45 @@ pub struct ChainExecutionParameters { pub current_addresses_with_balance: AddressesWithBalance, } +/// `vote` signed with the voting key of the masternode `pro_tx_hash`, at the masternode's next +/// identity nonce +async fn signed_masternode_vote( + vote: Vote, + pro_tx_hash: [u8; 32], + voting_address: [u8; 20], + signer: &SimpleSigner, + identity_nonce_counter: &mut BTreeMap, + platform_version: &PlatformVersion, +) -> StateTransition { + let voting_identifier = Identifier::create_voter_identifier(&pro_tx_hash, &voting_address); + + let identity_public_key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::VOTING, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: voting_address.to_vec().into(), + disabled_at: None, + }); + + let identity_nonce = identity_nonce_counter.entry(voting_identifier).or_default(); + *identity_nonce += 1; + + MasternodeVoteTransition::try_from_vote_with_signer( + vote, + signer, + Identifier::from(pro_tx_hash), + &identity_public_key, + *identity_nonce, + platform_version, + None, + ) + .await + .expect("expected to make a masternode vote transition") +} + fn create_signed_instant_asset_lock_proofs_for_identities( identities: Vec, balance_range: &RangeInclusive, diff --git a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs index 7110d4a74d0..75280ba7927 100644 --- a/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs +++ b/packages/rs-drive-abci/tests/strategy_tests/test_cases/voting_tests.rs @@ -16,7 +16,7 @@ mod tests { use rand::prelude::StdRng; use rand::SeedableRng; use simple_signer::signer::SimpleSigner; - use std::collections::BTreeMap; + use std::collections::{BTreeMap, BTreeSet}; use assert_matches::assert_matches; use dapi_grpc::platform::v0::{get_contested_resource_vote_state_request, get_contested_resource_vote_state_response, GetContestedResourceVoteStateRequest}; use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; @@ -38,7 +38,22 @@ mod tests { use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; use drive_abci::platform_types::platform_state::PlatformStateV0Methods; use strategy_tests::frequency::Frequency; - use strategy_tests::operations::{DocumentAction, DocumentOp, Operation, OperationType, ResourceVoteOp, VoteAction}; + use strategy_tests::operations::{DocumentAction, DocumentOp, Operation, OperationType, ResourceVoteOp, VoteAction, YesNoVoteOp}; + use dpp::dashcore_rpc::dashcore_rpc_json::MasternodeType; + use dpp::decision_proposal::{decision_proposal_required_fund, DecisionProposalVotePoll, PROPOSAL_DOCUMENT_TYPE_NAME}; + use dpp::document::DocumentV0Getters; + use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; + use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::ContestedDocumentVotePollStoredInfoV0Getters; + use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; + use dpp::voting::vote_info_storage::yes_no_vote_poll_stored_info::YesNoVotePollStatus; + use dpp::voting::vote_polls::yes_no_vote_poll::VotingPower; + use dpp::voting::vote_polls::VotePoll; + use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; + use drive::query::vote_polls_by_end_date_query::VotePollsByEndDateDriveQuery; + use drive::query::DriveDocumentQuery; + use drive_abci::platform_types::platform::Platform; + use drive_abci::rpc::core::MockCoreRPCLike; + use dpp::block::block_info::BlockInfo; use strategy_tests::transitions::create_state_transitions_for_identities; use strategy_tests::{StartIdentities, StartAddresses, Strategy}; use crate::addresses_with_balance::AddressesWithBalance; @@ -2791,4 +2806,787 @@ mod tests { // We see that there is 39_780_000_000 to distribute assert_eq!(processing_fees, 39_780_000_000); } + + /// A decision proposal the chain test below opens: its rule, its end date and how the + /// masternodes answer it + struct ProposalPlan { + title: &'static str, + pass_percent: u8, + minimum_turnout_percent: u8, + end_date: u64, + answers: Vec<(YesNoAbstainVoteChoice, u8)>, + } + + /// Every poll of the end date index from `start` to `end`, both included (the whole index + /// when `None`), by end date + fn vote_polls_by_end_date( + platform: &Platform, + range: Option<(u64, u64)>, + platform_version: &PlatformVersion, + ) -> BTreeMap> { + VotePollsByEndDateDriveQuery { + start_time: range.map(|(start, _)| (start, true)), + end_time: range.map(|(_, end)| (end, true)), + limit: Some(100), + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected to read the end date index") + } + + /// Decision proposals (protocol version 15) in a running chain. Four identities open eight + /// proposals in one block, three of them ending on the very end date of a DPNS contest + /// opened the block before, the others one to four weeks out. Masternodes then answer every + /// proposal and vote on the contest for four weeks of daily blocks. + /// + /// Each proposal opens its poll funded with exactly the decision proposal fund, filed under + /// its end date beside the contest. Due polls close in end date order as soon as a block has + /// room, a block closing at most `maximum_vote_polls_to_process` of them, the contest's + /// included: the contest and the three proposals sharing its end date take two daily blocks, + /// and the proposal due half a day later waits a block behind them. Each result follows from + /// the poll's rule and its tallies: some pass, some fail on the answers and some fail short + /// of their turnout. The rest of each fund is spent, the end date index is emptied, and the + /// proposals stay. The strategy verifies the proof of every vote and create as it runs. + #[stack_size(STACK_SIZE)] + #[test] + async fn run_chain_with_decision_proposals_closing_beside_a_dpns_contest() { + const WEEK_MS: u64 = 604_800_000; + const DAY_MS: u64 = 86_400_000; + + let config = PlatformConfig { + testing_configs: PlatformTestConfig::default_minimal_verifications(), + chain_lock: ChainLockConfig::default_100_67(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + use_document_triggers: false, + ..Default::default() + }, + block_spacing_ms: 3000, + ..Default::default() + }; + let mut platform = TestPlatformBuilder::new() + .with_config(config.clone()) + .build_with_mock_rpc(); + + let platform_version = PlatformVersion::latest(); + + let mut rng = StdRng::seed_from_u64(5679); + + let mut simple_signer = SimpleSigner::default(); + + // Two identities contest a DPNS name and four make two proposals each + let mut contenders = Vec::new(); + let mut proposers = Vec::new(); + for index in 0..6 { + let (identity, keys) = Identity::random_identity_with_main_keys_with_private_key::< + Vec<_>, + >(2, &mut rng, platform_version) + .unwrap(); + simple_signer.add_identity_public_keys(keys); + if index < 2 { + contenders.push(identity); + } else { + proposers.push(identity); + } + } + + let mut start_identities: Vec<(Identity, Option)> = + create_state_transitions_for_identities( + contenders.iter_mut(), + &(dash_to_duffs!(1)..=dash_to_duffs!(1)), + &simple_signer, + &mut rng, + platform_version, + ) + .await + .into_iter() + .map(|(identity, transition)| (identity, Some(transition))) + .collect(); + // Two proposals cost two funds of 0.5 Dash and their fees + start_identities.extend( + create_state_transitions_for_identities( + proposers.iter_mut(), + &(dash_to_duffs!(2)..=dash_to_duffs!(2)), + &simple_signer, + &mut rng, + platform_version, + ) + .await + .into_iter() + .map(|(identity, transition)| (identity, Some(transition))), + ); + + let contender_ids: Vec = start_identities[..2] + .iter() + .map(|(identity, _)| identity.id()) + .collect(); + let proposer_ids: Vec = start_identities[2..] + .iter() + .map(|(identity, _)| identity.id()) + .collect(); + + let dpns_contract = platform + .drive + .cache + .system_data_contracts + .load_dpns(platform_version) + .expect("expected the dpns system contract") + .as_ref() + .clone(); + + let decision_proposals_contract = platform + .drive + .cache + .system_data_contracts + .load_decision_proposals(platform_version) + .expect("expected the decision proposals system contract") + .as_ref() + .clone(); + let proposal_type = decision_proposals_contract + .document_type_for_name(PROPOSAL_DOCUMENT_TYPE_NAME) + .expect("expected the proposal document type") + .to_owned_document_type(); + + let (preorder_op_1, domain_op_1) = dpns_preorder_and_domain_ops( + &dpns_contract, + contender_ids[0], + "quantum", + [1u8; 32], + platform_version, + ); + let (preorder_op_2, domain_op_2) = dpns_preorder_and_domain_ops( + &dpns_contract, + contender_ids[1], + "quantum", + [2u8; 32], + platform_version, + ); + + let strategy = NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations: vec![once_per_block(preorder_op_1), once_per_block(preorder_op_2)], + start_identities: StartIdentities { + hard_coded: start_identities, + ..Default::default() + }, + start_addresses: StartAddresses::default(), + identity_inserts: Default::default(), + identity_contract_nonce_gaps: None, + signer: Some(simple_signer), + }, + total_hpmns: 100, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: None, + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + ..Default::default() + }; + + let mut voting_signer = Some(SimpleSigner::default()); + + // The first block creates the identities and the second the preorders + let outcome = run_chain_for_strategy( + &mut platform, + 2, + strategy, + config.clone(), + 15, + &mut voting_signer, + &mut None, + ) + .await; + + // The contested names reveal the preorders in the third block, opening the contest + let outcome = continue_chain_for_one_block( + outcome, + vec![once_per_block(domain_op_1), once_per_block(domain_op_2)], + config.clone(), + ) + .await; + + let opened_polls = + vote_polls_by_end_date(outcome.abci_app.platform, None, platform_version); + assert_eq!( + opened_polls.len(), + 1, + "expected only the contest to be open" + ); + let contest_end_date = *opened_polls + .keys() + .next() + .expect("expected the contest's end date"); + + // The proposals are created in the fourth block, at the time the run ended on + let proposals_block_time = outcome.end_time_ms; + + let passing = vec![ + (YesNoAbstainVoteChoice::Yes, 6), + (YesNoAbstainVoteChoice::No, 2), + (YesNoAbstainVoteChoice::Abstain, 1), + ]; + let failing = vec![ + (YesNoAbstainVoteChoice::Yes, 1), + (YesNoAbstainVoteChoice::No, 3), + (YesNoAbstainVoteChoice::Abstain, 1), + ]; + let even = vec![ + (YesNoAbstainVoteChoice::Yes, 1), + (YesNoAbstainVoteChoice::No, 1), + (YesNoAbstainVoteChoice::Abstain, 1), + ]; + let plans = vec![ + ProposalPlan { + title: "Contest day, simple majority", + pass_percent: 50, + minimum_turnout_percent: 0, + end_date: contest_end_date, + answers: passing.clone(), + }, + ProposalPlan { + title: "Contest day, four fifths", + pass_percent: 80, + minimum_turnout_percent: 0, + end_date: contest_end_date, + answers: passing.clone(), + }, + ProposalPlan { + title: "Contest day, half the network", + pass_percent: 50, + minimum_turnout_percent: 50, + end_date: contest_end_date, + answers: passing.clone(), + }, + ProposalPlan { + title: "Shortest run", + pass_percent: 50, + minimum_turnout_percent: 10, + end_date: proposals_block_time + WEEK_MS, + answers: failing.clone(), + }, + ProposalPlan { + title: "Three weeks, simple majority", + pass_percent: 50, + minimum_turnout_percent: 0, + end_date: proposals_block_time + 3 * WEEK_MS, + answers: failing, + }, + ProposalPlan { + title: "Three weeks, seven tenths", + pass_percent: 70, + minimum_turnout_percent: 10, + end_date: proposals_block_time + 3 * WEEK_MS, + answers: passing.clone(), + }, + ProposalPlan { + title: "Two weeks and a half day", + pass_percent: 60, + minimum_turnout_percent: 0, + end_date: proposals_block_time + 2 * WEEK_MS + DAY_MS / 2, + answers: even, + }, + ProposalPlan { + title: "Longest run", + pass_percent: 66, + minimum_turnout_percent: 25, + end_date: proposals_block_time + 4 * WEEK_MS, + answers: passing, + }, + ]; + + // Each proposer makes two of the proposals, in separate batches of the same block + let proposal_ops = plans + .iter() + .enumerate() + .map(|(index, plan)| { + once_per_block(DocumentOp { + contract: decision_proposals_contract.clone(), + action: DocumentAction::DocumentActionInsertSpecific( + BTreeMap::from([ + ("title".to_string(), Value::Text(plan.title.to_string())), + ( + "description".to_string(), + Value::Text(format!("Proposal {} of the chain test", index + 1)), + ), + ("passPercent".to_string(), Value::U8(plan.pass_percent)), + ( + "minimumTurnoutPercent".to_string(), + Value::U8(plan.minimum_turnout_percent), + ), + ("endDate".to_string(), Value::U64(plan.end_date)), + ]), + Some(proposer_ids[index / 2]), + DocumentFieldFillType::DoNotFillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + ), + document_type: proposal_type.clone(), + }) + }) + .collect(); + + let ChainExecutionOutcome { + abci_app, + proposers, + validator_quorums, + current_validator_quorum_hash, + instant_lock_quorums, + current_proposer_versions, + end_time_ms, + identity_nonce_counter, + identity_contract_nonce_counter, + state_transition_results_per_block, + .. + } = continue_chain_for_one_block(outcome, proposal_ops, config.clone()).await; + + let platform = abci_app.platform; + + let block_4_results = state_transition_results_per_block + .get(&4) + .expect("expected the results of block 4"); + assert_eq!(block_4_results.len(), plans.len()); + for (_, result) in block_4_results { + assert_eq!(result.code, 0, "a proposal was refused: {}", result.info); + } + + let all_proposals_query = DriveDocumentQuery::all_items_query( + &decision_proposals_contract, + proposal_type.as_ref(), + None, + ); + let proposal_documents = platform + .drive + .query_documents( + all_proposals_query.clone(), + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("expected to query the proposals") + .documents_owned(); + assert_eq!(proposal_documents.len(), plans.len()); + + // Each proposal with its plan, its poll as consensus reads it from the proposal, and + // the poll's id + let proposals: Vec<(&ProposalPlan, DecisionProposalVotePoll, Identifier)> = + proposal_documents + .iter() + .map(|document| { + let title = document + .get("title") + .and_then(|title| title.as_text()) + .expect("expected a proposal title"); + let plan = plans + .iter() + .find(|plan| plan.title == title) + .expect("expected a planned proposal"); + let poll = DecisionProposalVotePoll::from_proposal( + document.id(), + document.properties(), + platform_version, + ) + .expect("expected to read the proposal") + .into_data() + .expect("expected a valid proposal"); + let poll_id = poll + .vote_poll + .unique_id(platform_version) + .expect("expected the poll id"); + (plan, poll, poll_id) + }) + .collect(); + + // Every poll is open, with its rule, and holds the whole fund + let required_fund = decision_proposal_required_fund(platform_version); + for (plan, poll, poll_id) in &proposals { + assert_eq!(poll.end_date, plan.end_date, "{}", plan.title); + assert_eq!( + platform + .drive + .fetch_prefunded_specialized_balance( + poll_id.to_buffer(), + None, + platform_version + ) + .expect("expected to read the poll's fund"), + Some(required_fund), + "{}", + plan.title + ); + let stored_info = platform + .drive + .fetch_yes_no_vote_poll_stored_info(*poll_id, None, platform_version) + .expect("expected to read the poll") + .unwrap_or_else(|| panic!("expected {} to have opened its poll", plan.title)); + assert_eq!(stored_info.vote_poll(), &poll.vote_poll); + assert_matches!( + stored_info.status(), + YesNoVotePollStatus::Started(block_info) if block_info.height == 4 + ); + } + + // The contest's end date lists the contest and the three proposals ending with it + let at_contest_end_date = vote_polls_by_end_date( + platform, + Some((contest_end_date, contest_end_date)), + platform_version, + ); + let polls_at_contest_end_date = at_contest_end_date + .get(&contest_end_date) + .expect("expected polls at the contest's end date"); + assert_eq!( + polls_at_contest_end_date + .iter() + .filter(|vote_poll| matches!( + vote_poll, + VotePoll::ContestedDocumentResourceVotePoll(_) + )) + .count(), + 1 + ); + let yes_no_polls_at_contest_end_date: BTreeSet = polls_at_contest_end_date + .iter() + .filter_map(|vote_poll| match vote_poll { + VotePoll::YesNoVotePoll(yes_no_vote_poll) => Some( + yes_no_vote_poll + .unique_id(platform_version) + .expect("expected the poll id"), + ), + _ => None, + }) + .collect(); + let proposals_ending_with_the_contest: BTreeSet = proposals + .iter() + .filter(|(plan, ..)| plan.end_date == contest_end_date) + .map(|(_, _, poll_id)| *poll_id) + .collect(); + assert_eq!(proposals_ending_with_the_contest.len(), 3); + assert_eq!( + yes_no_polls_at_contest_end_date, + proposals_ending_with_the_contest + ); + + // Four weeks of daily blocks: masternodes answer every open proposal and vote on the + // contest + let contest = ContestedDocumentResourceVotePollWithContractInfo { + contract: DataContractOwnedResolvedInfo::OwnedDataContract(dpns_contract.clone()), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec!["dash".into(), "quantum".into()], + }; + let mut operations = vec![Operation { + op_type: OperationType::ResourceVote(ResourceVoteOp { + resolved_vote_poll: contest.clone(), + action: VoteAction { + vote_choices_with_weights: vec![ + (ResourceVoteChoice::Abstain, 1), + (ResourceVoteChoice::Lock, 1), + (ResourceVoteChoice::TowardsIdentity(contender_ids[0]), 2), + (ResourceVoteChoice::TowardsIdentity(contender_ids[1]), 10), + ], + }, + }), + frequency: Frequency { + times_per_block_range: 1..3, + chance_per_block: None, + }, + }]; + operations.extend(proposals.iter().map(|(plan, poll, poll_id)| Operation { + op_type: OperationType::YesNoVote(YesNoVoteOp { + vote_poll_id: *poll_id, + end_date: poll.end_date, + vote_choices_with_weights: plan.answers.clone(), + }), + frequency: Frequency { + times_per_block_range: 1..4, + chance_per_block: None, + }, + })); + + let daily_block_start = platform + .state + .load() + .last_committed_block_info() + .as_ref() + .expect("expected a committed block") + .basic_info() + .height + + 1; + let daily_block_start_time = end_time_ms; + let daily_config = PlatformConfig { + chain_lock: ChainLockConfig::default_100_67(), + instant_lock: InstantLockConfig::default_100_67(), + execution: ExecutionConfig { + use_document_triggers: false, + ..Default::default() + }, + block_spacing_ms: DAY_MS, + ..Default::default() + }; + + let outcome = continue_chain_for_strategy( + abci_app, + ChainExecutionParameters { + block_start: daily_block_start, + core_height_start: 1, + block_count: 30, + proposers, + validator_quorums, + current_validator_quorum_hash, + instant_lock_quorums, + current_proposer_versions: Some(current_proposer_versions), + current_identity_nonce_counter: identity_nonce_counter, + current_identity_contract_nonce_counter: identity_contract_nonce_counter, + current_votes: BTreeMap::default(), + start_time_ms: 1681094380000, + current_time_ms: end_time_ms, + current_identities: Vec::new(), + current_addresses_with_balance: AddressesWithBalance::default(), + }, + NetworkStrategy { + strategy: Strategy { + start_contracts: vec![], + operations, + start_identities: StartIdentities::default(), + start_addresses: StartAddresses::default(), + identity_inserts: Default::default(), + identity_contract_nonce_gaps: None, + signer: voting_signer, + }, + total_hpmns: 100, + extra_normal_mns: 0, + validator_quorum_count: 24, + chain_lock_quorum_count: 24, + upgrading_info: None, + proposer_strategy: Default::default(), + rotate_quorums: false, + failure_testing: None, + query_testing: None, + verify_state_transition_results: true, + ..Default::default() + }, + daily_config, + StrategyRandomness::SeedEntropy(9), + ) + .await; + + let platform = outcome.abci_app.platform; + let platform_state = platform.state.load(); + + // Every vote was accepted + for (height, results) in &outcome.state_transition_results_per_block { + for (_, result) in results { + assert_eq!( + result.code, 0, + "a transition was refused in block {}: {}", + height, result.info + ); + } + } + + // The contest closed + let (_, contest_stored_info) = platform + .drive + .fetch_contested_document_vote_poll_stored_info(&contest, None, None, platform_version) + .expect("expected to read the contest"); + let contest_stored_info = contest_stored_info.expect("expected the contest's record"); + let contest_finalization_block = contest_stored_info + .last_finalization_block() + .expect("expected the contest to have closed"); + assert_eq!( + contest_stored_info.winner(), + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_ids[1]) + ); + + // Every poll's name, end date and closing block, the contest's included + let mut closings: Vec<(&str, u64, BlockInfo)> = + vec![("DPNS contest", contest_end_date, contest_finalization_block)]; + + // A share of the network is measured against the closing block's masternode list, + // each masternode weighed as its vote is + let limits = &platform_version.system_limits; + let total_voting_power: VotingPower = platform_state + .full_masternode_list() + .values() + .filter(|masternode| masternode.state.pose_ban_height.is_none()) + .map(|masternode| { + VotingPower::from(match masternode.node_type { + MasternodeType::Regular => limits.yes_no_vote_poll_masternode_vote_strength, + MasternodeType::Evo => limits.yes_no_vote_poll_evonode_vote_strength, + }) + }) + .sum(); + + let mut passed = Vec::new(); + let mut failed_on_the_answers = Vec::new(); + let mut failed_short_of_turnout = Vec::new(); + for (plan, poll, poll_id) in &proposals { + let stored_info = platform + .drive + .fetch_yes_no_vote_poll_stored_info(*poll_id, None, platform_version) + .expect("expected to read the poll") + .unwrap_or_else(|| panic!("expected {} to keep its poll's record", plan.title)); + let result = stored_info + .result() + .unwrap_or_else(|| panic!("expected {} to have closed", plan.title)); + + assert_eq!(result.start_block.height, 4, "{}", plan.title); + closings.push((plan.title, poll.end_date, result.finalization_block.clone())); + + // The minimum is the turnout share of the network, rounded up, and the result + // follows from the rule: yes beyond the pass percent of yes plus no, and yes plus + // no reaching the minimum + assert_eq!( + result.required_voting_power, + (VotingPower::from(plan.minimum_turnout_percent) * total_voting_power) + .div_ceil(100), + "{}", + plan.title + ); + let cast = result.yes_voting_power + result.no_voting_power; + let reaches_turnout = cast >= result.required_voting_power; + let enough_yes = u64::from(result.yes_voting_power) * 100 + > u64::from(plan.pass_percent) * u64::from(cast); + assert_eq!( + result.passed, + reaches_turnout && enough_yes, + "{}", + plan.title + ); + assert_eq!( + result.passed, + poll.vote_poll + .passes( + result.yes_voting_power, + result.no_voting_power, + total_voting_power, + platform_version + ) + .expect("expected to decide the poll"), + "{}", + plan.title + ); + match (result.passed, reaches_turnout) { + (true, _) => passed.push(plan.title), + (false, true) => failed_on_the_answers.push(plan.title), + (false, false) => failed_short_of_turnout.push(plan.title), + } + + // What was left of the fund went to processing + assert_eq!( + platform + .drive + .fetch_prefunded_specialized_balance( + poll_id.to_buffer(), + None, + platform_version + ) + .expect("expected to read the poll's fund"), + None, + "{}", + plan.title + ); + } + assert!( + !passed.is_empty() + && !failed_on_the_answers.is_empty() + && !failed_short_of_turnout.is_empty(), + "passed {:?}, failed on the answers {:?}, failed short of turnout {:?}", + passed, + failed_on_the_answers, + failed_short_of_turnout + ); + + // Due polls close in end date order, at most `maximum_vote_polls_to_process` a block: a + // poll closes in the first daily block at or past its end date that has room, and every + // block it waits through closes as many polls as it may, none due later than it + let maximum_vote_polls_to_process = usize::from( + platform_version + .drive_abci + .validation_and_processing + .event_constants + .maximum_vote_polls_to_process, + ); + let mut end_dates_closed_by_height: BTreeMap> = BTreeMap::new(); + for (_, end_date, block) in &closings { + end_dates_closed_by_height + .entry(block.height) + .or_default() + .push(*end_date); + } + // The daily blocks start at the height and time the proposals' block ended on + let first_due_height = |end_date: u64| { + daily_block_start + (end_date - daily_block_start_time).div_ceil(DAY_MS) + }; + for (title, end_date, block) in &closings { + assert_eq!( + block.time_ms, + daily_block_start_time + (block.height - daily_block_start) * DAY_MS + ); + assert!( + block.time_ms >= *end_date, + "{} closed before its end date", + title + ); + for height in first_due_height(*end_date)..block.height { + let closed = end_dates_closed_by_height + .get(&height) + .map(Vec::as_slice) + .unwrap_or_default(); + assert!( + closed.len() == maximum_vote_polls_to_process + && closed + .iter() + .all(|closed_end_date| closed_end_date <= end_date), + "{} waited in block {}, which closed polls due at {:?}", + title, + height, + closed + ); + } + } + for (height, end_dates) in &end_dates_closed_by_height { + assert!( + end_dates.len() <= maximum_vote_polls_to_process, + "block {} closed {} polls", + height, + end_dates.len() + ); + } + // Four polls due on the contest's end date take two blocks, so the proposal due half a + // day later waits a block behind them + assert_eq!(maximum_vote_polls_to_process, 2); + let (_, waiting_end_date, waiting_block) = closings + .iter() + .find(|(title, ..)| *title == "Two weeks and a half day") + .expect("expected the proposal due after the contest"); + assert_eq!( + waiting_block.height, + first_due_height(*waiting_end_date) + 1 + ); + + // Nothing is left in the end date index, and the proposals stay + assert!(vote_polls_by_end_date(platform, None, platform_version).is_empty()); + assert_eq!( + platform + .drive + .query_documents( + all_proposals_query, + None, + false, + None, + Some(platform_version.protocol_version), + ) + .expect("expected to query the proposals") + .documents_owned() + .len(), + plans.len() + ); + } } diff --git a/packages/strategy-tests/src/operations.rs b/packages/strategy-tests/src/operations.rs index eb78c12bbf6..7942ed6d60c 100644 --- a/packages/strategy-tests/src/operations.rs +++ b/packages/strategy-tests/src/operations.rs @@ -48,12 +48,14 @@ use dpp::identifier::Identifier; use dpp::identity::{IdentityPublicKey, KeyCount}; use dpp::platform_serialization::PLATFORM_BINCODE_CONFIG; use dpp::platform_value::Value; +use dpp::prelude::TimestampMillis; use dpp::serialization::{ PlatformDeserializableWithPotentialValidationFromVersionedStructureTrusted, PlatformSerializableWithPlatformVersion, }; use dpp::tokens::token_event::TokenEvent; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_choices::yes_no_abstain_vote_choice::YesNoAbstainVoteChoice; use dpp::ProtocolError; use dpp::ProtocolError::{PlatformDeserializationError, PlatformSerializationError}; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; @@ -696,23 +698,59 @@ impl VoteAction { /// are proportionally more likely to be chosen. Returns `Abstain` /// if no choices are configured. pub fn choose_weighted_choice(&self, rng: &mut StdRng) -> ResourceVoteChoice { - if self.vote_choices_with_weights.is_empty() { - ResourceVoteChoice::Abstain - } else if self.vote_choices_with_weights.len() == 1 { - self.vote_choices_with_weights[0].0 - } else { - let weights: Vec = self - .vote_choices_with_weights - .iter() - .map(|(_, weight)| *weight) - .collect(); + choose_weighted( + &self.vote_choices_with_weights, + ResourceVoteChoice::Abstain, + rng, + ) + } +} + +/// One of `choices_with_weights`, picked with probability proportional to its weight, or +/// `fallback` when there is none. +fn choose_weighted( + choices_with_weights: &[(Choice, u8)], + fallback: Choice, + rng: &mut StdRng, +) -> Choice { + match choices_with_weights { + [] => fallback, + [(choice, _)] => *choice, + _ => { + let weights = choices_with_weights.iter().map(|(_, weight)| *weight); let dist = WeightedIndex::new(weights).unwrap(); - let index = dist.sample(rng); - self.vote_choices_with_weights[index].0 + choices_with_weights[dist.sample(rng)].0 } } } +/// Masternodes answering a yes/no vote poll, such as the one a decision proposal opens. +/// +/// Each hit picks a random masternode and an answer. A masternode giving the answer it already +/// gave, or past the votes it is allowed on a poll, is skipped, as the vote would be refused, and +/// so is one that already answered the poll in the block. +#[derive(Clone, Debug, PartialEq, Encode, Decode)] +pub struct YesNoVoteOp { + /// The poll answered (`YesNoVotePoll::unique_id`). + pub vote_poll_id: Identifier, + /// The poll's end date. A poll closes at the start of the first block at or past its end + /// date, before the block's transitions, so no vote is cast from that block on. + pub end_date: TimestampMillis, + /// Pairs of (answer, weight). Higher weights make that answer more likely. + pub vote_choices_with_weights: Vec<(YesNoAbstainVoteChoice, u8)>, +} + +impl YesNoVoteOp { + /// An answer picked by the weights, or `Abstain` when there are none. + pub fn choose_weighted_choice(&self, rng: &mut StdRng) -> YesNoAbstainVoteChoice { + choose_weighted( + &self.vote_choices_with_weights, + YesNoAbstainVoteChoice::Abstain, + rng, + ) + } +} + /// Inclusive range for credit amounts in operations. /// /// Used to specify minimum and maximum amounts for transfers, top-ups, @@ -839,6 +877,9 @@ pub enum OperationType { KeyCount, ExtraKeys, ), + + /// Cast masternode votes on a yes/no vote poll. + YesNoVote(YesNoVoteOp), } #[allow(clippy::large_enum_variant)] @@ -879,6 +920,7 @@ enum OperationTypeInSerializationFormat { KeyCount, ExtraKeys, ), + YesNoVote(YesNoVoteOp), } impl PlatformSerializableWithPlatformVersion for OperationType { @@ -984,6 +1026,9 @@ impl PlatformSerializableWithPlatformVersion for OperationType { key_count, extra_keys, ), + OperationType::YesNoVote(yes_no_vote_op) => { + OperationTypeInSerializationFormat::YesNoVote(yes_no_vote_op) + } }; let config = PLATFORM_BINCODE_CONFIG; bincode::encode_to_vec(op, config).map_err(|e| { @@ -1098,6 +1143,9 @@ impl PlatformDeserializableWithPotentialValidationFromVersionedStructureTrusted key_count, extra_keys, ), + OperationTypeInSerializationFormat::YesNoVote(yes_no_vote_op) => { + OperationType::YesNoVote(yes_no_vote_op) + } }) } }