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/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."), ]), 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(), 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) + } }) } }