diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md index 12e0a18cbcb..11b29c27a69 100644 --- a/book/src/data-model/contested-documents.md +++ b/book/src/data-model/contested-documents.md @@ -74,7 +74,8 @@ choices are a contender or abstain. A Lock vote is refused with `VoteChoiceNotAl A contest without locking ends when its join window closes while it still has a single contender, so that contender is awarded the value without a vote window. Its end-date entry is written at the end of the join window when the contest opens; the first additional contender moves it to the full -poll duration, which opens the vote window. `getVotePollsByEndDate` shows whichever end applies. +poll duration, which opens the vote window, and removes the join window's end date when no other +contest ends then. `getVotePollsByEndDate` shows whichever end applies. The moderation charters contract uses this resolution to elect moderation teams. @@ -123,4 +124,5 @@ A block ends at most `maximum_vote_polls_to_process` contests (a drive-abci `eve value, two at protocol version 14), the earliest end date first, so contests due together may end over several blocks. The cleanup removes each ended contest's end-date entry, and removes an end date only once none of its contests remain under it; the rest -end in the next blocks. +end in the next blocks. Before that read, a block removes any end date among the first due that +holds no contest, since each would take a place in the read and end nothing. diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs index 7401c3243b4..0eb6ba217aa 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs @@ -10,10 +10,12 @@ use dpp::version::PlatformVersion; use dpp::voting::contender_structs::FinalizedContender; use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice::TowardsIdentity; use dpp::voting::vote_info_storage::contested_document_vote_poll_winner_info::ContestedDocumentVotePollWinnerInfo; +use drive::drive::votes::paths::vote_end_date_queries_tree_path_vec; use drive::drive::votes::resolved::vote_polls::resolve::VotePollResolver; use drive::drive::votes::resolved::vote_polls::{ResolvedVotePoll, ResolvedVotePollWithVotes}; use drive::grovedb::TransactionArg; use drive::query::VotePollsByEndDateDriveQuery; +use drive::util::common::encode::encode_u64; use itertools::Itertools; use std::collections::BTreeMap; @@ -21,7 +23,8 @@ impl Platform where C: CoreRPCLike, { - /// Checks for ended vote polls, awarding a tie to the earliest contender + /// Checks for ended vote polls, awarding a tie to the earliest contender, after removing + /// the end dates left with no vote poll among the first due #[inline(always)] pub(super) fn check_for_ended_vote_polls_v1( &self, @@ -33,13 +36,32 @@ where ) -> Result<(), Error> { // let's start by getting the vote polls that have finished. Version 0 carried two // testnet clean-ups of protocol versions 1 and 2; this version only runs from - // protocol version 14, so it fetches the polls due by the block time and nothing else + // protocol version 14, so it reads only what is due by the block time let distribute_after_time = block_info.time_ms; let maximum_vote_polls_to_process = platform_version .drive_abci .validation_and_processing .event_constants .maximum_vote_polls_to_process; + // An end date with no vote poll under it ends nothing yet takes a slot of the read + // below, so enough of them due before every vote poll would stall them all. Those among + // the first due go before the read; the read then reaches the vote polls behind them. + for empty_end_time in VotePollsByEndDateDriveQuery::execute_no_proof_empty_end_times( + distribute_after_time, + maximum_vote_polls_to_process, + &self.drive, + transaction, + &mut vec![], + platform_version, + )? { + self.drive.grove_delete( + vote_end_date_queries_tree_path_vec().as_slice().into(), + encode_u64(empty_end_time).as_slice(), + transaction, + &mut vec![], + &platform_version.drive, + )?; + } let vote_polls_by_timestamp = VotePollsByEndDateDriveQuery::execute_no_proof_for_specialized_end_time_query( distribute_after_time, diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs index 6380355cab6..d9083e260b4 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs @@ -40,22 +40,28 @@ use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dpp::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; use dpp::voting::vote_polls::VotePoll; use drive::drive::votes::paths::{ + vote_end_date_queries_tree_path_vec, RESOURCE_ABSTAIN_VOTE_TREE_KEY_U8_32, RESOURCE_LOCK_VOTE_TREE_KEY_U8_32, RESOURCE_STORED_INFO_KEY_U8_32, }; use drive::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::resolve::ContestedDocumentResourceVotePollResolver; use drive::query::VotePollsByEndDateDriveQuery; -use drive::util::test_helpers::setup_contract; +use drive::grovedb::TreeType; +use drive::util::common::encode::encode_u64; +use drive::util::test_helpers::{setup_contract, vote_poll_end_dates}; use platform_version::version::PlatformVersion; use rand::prelude::StdRng; use rand::{Rng, SeedableRng}; use simple_signer::signer::SimpleSigner; +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; /// The DPNS-shaped fixture whose `parentNameAndLabel` index is resolved without locking. const NO_LOCKING_CONTRACT: &str = "tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json"; const NAME: &str = "quantum"; +/// A second name, for a contest running alongside the one for `NAME`. +const OTHER_NAME: &str = "quasar"; type IdentityInfo = (Identity, SimpleSigner, IdentityPublicKey); @@ -125,20 +131,17 @@ fn windows( } fn vote_poll(contract: &DataContract) -> VotePoll { - VotePoll::ContestedDocumentResourceVotePoll(ContestedDocumentResourceVotePoll { - contract_id: contract.id(), - document_type_name: "domain".to_string(), - index_name: "parentNameAndLabel".to_string(), - index_values: vec![ - Value::Text("dash".to_string()), - Value::Text(convert_to_homograph_safe_chars(NAME)), - ], - }) + vote_poll_for(contract, NAME) +} + +fn vote_poll_for(contract: &DataContract, name: &str) -> VotePoll { + VotePoll::ContestedDocumentResourceVotePoll(dpns_name_vote_poll(contract, name)) } -/// The serialized preorder and domain creations of one contender for `NAME`. +/// The serialized preorder and domain creations of one contender for `name`. async fn contest_transitions( contract: &DataContract, + name: &str, (identity, signer, key): &IdentityInfo, salt_discriminator: u8, rng: &mut StdRng, @@ -179,10 +182,10 @@ async fn contest_transitions( .expect("expected to set the document id"); domain_document.set("parentDomainName", "dash".into()); domain_document.set("normalizedParentDomainName", "dash".into()); - domain_document.set("label", NAME.into()); + domain_document.set("label", name.into()); domain_document.set( "normalizedLabel", - convert_to_homograph_safe_chars(NAME).into(), + convert_to_homograph_safe_chars(name).into(), ); domain_document.set("records.identity", domain_document.owner_id().into()); domain_document.set("subdomainRules.allowSubdomains", false.into()); @@ -190,7 +193,7 @@ async fn contest_transitions( salt[31] = salt_discriminator; let mut salted_domain_buffer: Vec = vec![]; salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((convert_to_homograph_safe_chars(NAME) + ".dash").as_bytes()); + salted_domain_buffer.extend((convert_to_homograph_safe_chars(name) + ".dash").as_bytes()); preorder_document.set("saltedDomainHash", hash_double(salted_domain_buffer).into()); domain_document.set("preorderSalt", salt.into()); let preorder_transition = BatchTransition::new_document_creation_transition_from_document( @@ -276,9 +279,35 @@ async fn join( time_ms: TimestampMillis, rng: &mut StdRng, platform_version: &PlatformVersion, +) -> TimestampMillis { + join_contest_for( + platform, + contract, + NAME, + identity, + salt_discriminator, + time_ms, + rng, + platform_version, + ) + .await +} + +/// A contender joins the contest for `name`, as [`join`] does for `NAME`. +#[allow(clippy::too_many_arguments)] +async fn join_contest_for( + platform: &TempPlatform, + contract: &DataContract, + name: &str, + identity: &IdentityInfo, + salt_discriminator: u8, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, ) -> TimestampMillis { let (preorder, domain) = contest_transitions( contract, + name, identity, salt_discriminator, rng, @@ -359,13 +388,23 @@ fn winner( platform: &TempPlatform, contract: &DataContract, platform_version: &PlatformVersion, +) -> Option { + winner_for(platform, contract, NAME, platform_version) +} + +/// The identity the finished contest for `name` was awarded to, if it finished. +fn winner_for( + platform: &TempPlatform, + contract: &DataContract, + name: &str, + platform_version: &PlatformVersion, ) -> Option { let platform_state = platform.state.load(); let (_, _, _, finished_vote_info) = get_vote_states( platform, &platform_state, contract, - NAME, + name, None, true, None, @@ -593,7 +632,7 @@ async fn should_award_a_single_contender_when_the_join_window_closes() { winner(&platform, &contract, platform_version), Some(alice.0.id()) ); - assert!(end_dates(&platform, platform_version).is_empty()); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); } #[tokio::test] @@ -629,6 +668,19 @@ async fn should_open_the_vote_window_when_a_second_contender_joins() { vec![(start + poll_duration, vote_poll(&contract))], "the second contender moves the end to the full poll duration" ); + // Read raw, as the end dates above cannot show a time with no entry: the join window's end + // date goes with its only entry, since an empty one would take a slot of the per-block read + // of ended vote polls + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version), + BTreeMap::from([( + start + poll_duration, + BTreeSet::from([vote_poll(&contract) + .unique_id() + .expect("expected a vote poll id")]), + )]), + "no end date is left at the end of the join window" + ); // A third contender finds the end date there already join( @@ -674,7 +726,163 @@ async fn should_open_the_vote_window_when_a_second_contender_joins() { Some(bob.0.id()), "plurality" ); - assert!(end_dates(&platform, platform_version).is_empty()); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); +} + +/// Two contests started in the same block share the end date of their join window. A second +/// contender moving one of them keeps that end date for the other; it goes once the other moves. +/// Both then end in the one block at the end of their vote window. +#[tokio::test] +async fn should_keep_a_shared_join_window_end_date_until_its_last_contest_moves() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (join_window, poll_duration) = windows(&platform, platform_version); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let bob = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let carol = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let dave = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let unique_id = |name| { + vote_poll_for(&contract, name) + .unique_id() + .expect("expected a vote poll id") + }; + + // Both preorders in one block, then both domains in the next, which starts both contests + let (alice_preorder, alice_domain) = + contest_transitions(&contract, NAME, &alice, 1, &mut rng, platform_version).await; + let (carol_preorder, carol_domain) = + contest_transitions(&contract, OTHER_NAME, &carol, 2, &mut rng, platform_version).await; + process_valid( + &platform, + &[alice_preorder, carol_preorder], + 10_000, + platform_version, + ); + let start = 11_000; + process_valid( + &platform, + &[alice_domain, carol_domain], + start, + platform_version, + ); + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version), + BTreeMap::from([( + start + join_window, + BTreeSet::from([unique_id(NAME), unique_id(OTHER_NAME)]), + )]) + ); + + join_contest_for( + &platform, + &contract, + NAME, + &bob, + 3, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version), + BTreeMap::from([ + (start + join_window, BTreeSet::from([unique_id(OTHER_NAME)])), + (start + poll_duration, BTreeSet::from([unique_id(NAME)])), + ]), + "the join window's end date stays for the contest still under it" + ); + + join_contest_for( + &platform, + &contract, + OTHER_NAME, + &dave, + 4, + start + 120_000, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version), + BTreeMap::from([( + start + poll_duration, + BTreeSet::from([unique_id(NAME), unique_id(OTHER_NAME)]), + )]), + "the join window's end date goes with its last contest" + ); + + // Nobody voted, so each contest goes to its earliest contender, both in this one block + end_polls_at(&platform, start + poll_duration, 10, platform_version); + assert_eq!( + winner_for(&platform, &contract, NAME, platform_version), + Some(alice.0.id()) + ); + assert_eq!( + winner_for(&platform, &contract, OTHER_NAME, platform_version), + Some(carol.0.id()) + ); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); +} + +/// End dates left with no contest under them take a slot each of the read of ended contests. +/// As many of them as a block reads, due before a contest, would leave nothing to end in every +/// block; the block removes them first and ends the contest behind them. +#[tokio::test] +async fn should_end_a_contest_behind_end_dates_left_empty() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (join_window, _) = windows(&platform, platform_version); + let alice = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + let maximum_vote_polls_to_process = platform_version + .drive_abci + .validation_and_processing + .event_constants + .maximum_vote_polls_to_process as TimestampMillis; + for empty_end_date in 1..=maximum_vote_polls_to_process { + platform + .drive + .grove_insert_empty_tree( + vote_end_date_queries_tree_path_vec().as_slice().into(), + encode_u64(empty_end_date).as_slice(), + TreeType::NormalTree, + None, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("expected to insert an empty end date"); + } + let mut end_dates_before = (1..=maximum_vote_polls_to_process) + .map(|empty_end_date| (empty_end_date, BTreeSet::new())) + .collect::>(); + end_dates_before.insert( + start + join_window, + BTreeSet::from([vote_poll(&contract) + .unique_id() + .expect("expected a vote poll id")]), + ); + assert_eq!( + vote_poll_end_dates(&platform.drive, platform_version), + end_dates_before + ); + + end_polls_at(&platform, start + join_window, 10, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()), + "the contest behind the empty end dates ends" + ); + assert!(vote_poll_end_dates(&platform.drive, platform_version).is_empty()); } #[tokio::test] diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs index 073bfc9cfbd..88b7c8991d7 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs @@ -1,8 +1,7 @@ use crate::drive::contract::paths::contract_root_path; use crate::drive::document::ContestWindows; use crate::drive::votes::paths::{ - vote_contested_resource_end_date_queries_at_time_tree_path_vec, - vote_end_date_queries_tree_path_vec, + vote_contested_resource_end_date_queries_at_time_tree_path_vec, vote_root_path, }; use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; use crate::drive::Drive; @@ -231,11 +230,15 @@ impl Drive { if join_end != vote_end && join_entry_exists { // The join-window entry goes, and its time tree with it when it was - // the only entry at that time + // the only entry at that time: the climb stops at `Votes`, the path holding + // the end-date queries, so it removes the entry (a key of + // `Votes / e /