diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md index 06b3f3a79aa..c41f5122fa5 100644 --- a/book/src/SUMMARY.md +++ b/book/src/SUMMARY.md @@ -57,6 +57,7 @@ - [Contract Groups](data-model/contract-groups.md) - [Contract Moderation](data-model/contract-moderation.md) - [Documents](data-model/documents.md) +- [Contested Documents](data-model/contested-documents.md) - [Identities](data-model/identities.md) - [Key Budgets and Expiry](data-model/key-limits.md) diff --git a/book/src/data-model/contested-documents.md b/book/src/data-model/contested-documents.md new file mode 100644 index 00000000000..6637722ba8d --- /dev/null +++ b/book/src/data-model/contested-documents.md @@ -0,0 +1,49 @@ +# Contested Documents + +A unique index may be declared `contested`. A document whose index values fall in the contested +range is not stored outright: it opens or joins a **contest**, a `ContestedDocumentResourceVotePoll` +that names the contract, the document type, the index and the index values, and masternodes and +evonodes decide who gets the value. A masternode's vote counts once, an evonode's four times. Every +vote is a `MasternodeVote` state transition carrying the poll and a `ResourceVoteChoice`. + +The contest is funded by the contenders' prefunded voting balances, and each vote costs a fixed +amount from that balance. Contenders may join for the **join window** (one week on mainnet) after +the first document; the contest runs for the **poll duration** (two weeks on mainnet). The first +document's owner may not be joined by the same identity twice. + +The index's `contested.resolution` says how the contest is decided. + +## Resolution 0: masternode vote + +The DPNS rule. The choices are a contender, abstain, or **lock**, which gives the value to nobody. +The contender with the most votes wins unless the lock tally exceeds it, in which case the value is +locked and may be contested again later. The contest always runs the full poll duration, even with +a single contender, so the masternodes may lock the value. + +## Resolution 1: masternode vote without locking + +`ContestedIndexResolution::MasternodeVoteNoLocking`, meta-schema v3 (protocol version 14). The +choices are a contender or abstain. A Lock vote is refused with `VoteChoiceNotAllowedForVotePollError` +(40307). The contest always ends with a winner: the contender with the most votes, no minimum. + +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. + +The moderation charters contract uses this resolution to elect moderation teams. + +## Ties + +From protocol version 14, a tie among the top contenders goes to the **earliest** contender: +creation time, then block height, then core block height, then document id. This holds for both +resolutions; contests ending before version 14 awarded the latest contender. + +## Storage + +A contest's state lives under `votes / contested_resource / active_polls`, laid out like the +contested index it decides: the contenders' documents, one votes sum tree per contender, and the +abstain and lock tallies. The masternodes' vote references live under +`votes / contested_resource / identity_votes`, and the end dates under `votes / end_date_queries`. +Once the contest ends, the winning document is awarded, the losers are removed, and the stored +result stays for the `getContestedResourceVoteState` query. diff --git a/book/src/error-handling/error-codes.md b/book/src/error-handling/error-codes.md index fac8d378190..4b9a1c34d97 100644 --- a/book/src/error-handling/error-codes.md +++ b/book/src/error-handling/error-codes.md @@ -109,7 +109,7 @@ The fee category currently has a single code. The 30000 range is reserved for fu | 40000-40009 | Data Contract | `DataContractAlreadyPresentError` (40000), `DataContractIsReadonlyError` (40001), `DataContractNotFoundError` (40008) | | 40100-40134 | Documents | `DocumentAlreadyPresentError` (40100), `DocumentNotFoundError` (40101), `DuplicateUniqueIndexError` (40105), `DocumentActionFeeAgreementNotSetError` (40132), `DocumentActionFeeAgreementMismatchError` (40133), `DocumentActionFeeMultiplierNotToleratedError` (40134) | | 40200-40217 | Identity | `IdentityAlreadyExistsError` (40200), `InvalidIdentityRevisionError` (40203), `IdentityInsufficientBalanceError` (40210) | -| 40300-40306 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304) | +| 40300-40307 | Voting | `MasternodeNotFoundError` (40300), `MasternodeVoteAlreadyPresentError` (40304), `VoteChoiceNotAllowedForVotePollError` (40307) | | 40400-40401 | Prefunded Balances | `PrefundedSpecializedBalanceInsufficientError` (40400) | | 40500-40502 | Data Triggers | `DataTriggerConditionError` (40500), `DataTriggerExecutionError` (40501) | | 40600-40603 | Addresses | `AddressDoesNotExistError` (40600), `AddressNotEnoughFundsError` (40601) | diff --git a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json index 93621398d5b..949d5016af1 100644 --- a/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json +++ b/packages/rs-dpp/schema/meta_schemas/document/v3/document-meta.json @@ -587,9 +587,10 @@ "resolution": { "type": "integer", "enum": [ - 0 + 0, + 1 ], - "description": "Resolution. 0 - Masternode Vote" + "description": "Resolution. 0 - Masternode Vote. 1 - Masternode Vote without a Lock choice: the contest always ends with a winner, a tie goes to the earliest contender, and a contest with a single contender at the end of the join window is awarded at once" }, "description": { "type": "string", diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs index 30e6d21dba1..597adf4a2e2 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/common/mod.rs @@ -225,6 +225,10 @@ pub(super) struct ParserGeneration { /// `rangeCountable` has always implied `documentsCountable`. Forwarded to /// [`Index::try_from_value_map`] exactly like the admissions above. pub admit_range_countable_implies_countable: bool, + /// Whether a contested index may declare `"resolution": 1`, the masternode + /// vote without a Lock choice. Forwarded to [`Index::try_from_value_map`] + /// exactly like the admissions above. + pub admit_index_no_locking_resolution: bool, } /// Reject a document type whose name is not a non-empty ASCII @@ -869,6 +873,7 @@ fn parse_indices( range_countable_implies_countable: ctx .generation .admit_range_countable_implies_countable, + no_locking_resolution: ctx.generation.admit_index_no_locking_resolution, }, ) .map_err(consensus_or_protocol_data_contract_error)?; diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs index 162bc735540..eba4f863edb 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v1/mod.rs @@ -117,6 +117,8 @@ impl DocumentTypeV1 { // the parser demands an explicit countable `countable`, as the frozen // v1 and v2 meta-schemas do. admit_range_countable_implies_countable: false, + // NO LOCKING RESOLUTION: a generation-3 value; not in this grammar. + admit_index_no_locking_resolution: false, }, platform_version, ) diff --git a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs index f1478ec756c..79ef89772c6 100644 --- a/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/class_methods/try_from_schema/v3/mod.rs @@ -358,6 +358,10 @@ fn try_from_schema_generation_3( 3, ) .range_countable_implies_countable, + // NO LOCKING RESOLUTION: a contested index resolved without a Lock + // choice, a generation-3 value from the same shared mapping. + admit_index_no_locking_resolution: IndexGrammarAdmissions::for_schema_generation(3) + .no_locking_resolution, }, platform_version, )?; diff --git a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs index b3f44dbc7d3..35f857b897a 100644 --- a/packages/rs-dpp/src/data_contract/document_type/index/mod.rs +++ b/packages/rs-dpp/src/data_contract/document_type/index/mod.rs @@ -121,7 +121,14 @@ pub const SKIP_IF_ABSENT: &str = "skipIfAbsent"; #[derive(Clone, Copy, Debug, PartialEq, Eq, Ord, PartialOrd)] #[cfg_attr(feature = "serde-conversion", derive(Serialize, Deserialize))] pub enum ContestedIndexResolution { + /// Masternodes and evonodes vote for a contender, abstain, or lock the value so nobody + /// gets it. This is the DPNS rule. MasternodeVote = 0, + /// Masternodes and evonodes vote for a contender or abstain; there is no Lock choice, + /// so the contest always ends with a winner. A contest whose join window closes with a + /// single contender is awarded at once, without the vote window. Meta-schema v3+ + /// (protocol version 14). + MasternodeVoteNoLocking = 1, } impl TryFrom for ContestedIndexResolution { @@ -130,6 +137,7 @@ impl TryFrom for ContestedIndexResolution { fn try_from(value: u8) -> Result { match value { 0 => Ok(MasternodeVote), + 1 => Ok(ContestedIndexResolution::MasternodeVoteNoLocking), value => Err(ProtocolError::UnknownStorageKeyRequirements(format!( "contested index resolution unknown: {}", value @@ -774,6 +782,9 @@ pub(crate) struct IndexGrammarAdmissions { /// generations reject the omission, and their frozen meta-schemas (v1, /// v2) carry a `dependentRequired` row that says the same. pub(crate) range_countable_implies_countable: bool, + /// Whether a contested index may be resolved without a Lock choice + /// (`"resolution": 1`, [`ContestedIndexResolution::MasternodeVoteNoLocking`]). + pub(crate) no_locking_resolution: bool, } impl IndexGrammarAdmissions { @@ -789,6 +800,7 @@ impl IndexGrammarAdmissions { preallocated: generation >= 3, skip_if_absent: generation >= 3, range_countable_implies_countable: generation >= 3, + no_locking_resolution: generation >= 3, } } } @@ -1329,6 +1341,7 @@ impl TryFrom<&[(Value, Value)]> for Index { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) } @@ -1369,6 +1382,7 @@ impl Index { preallocated: preallocated_allowed, skip_if_absent: skip_if_absent_allowed, range_countable_implies_countable, + no_locking_resolution: no_locking_resolution_allowed, } = admissions; // Decouple the map // It contains properties and a unique key @@ -1538,6 +1552,14 @@ impl Index { resolution_int.try_into().map_err(|e: ProtocolError| { DataContractError::ValueWrongType(e.to_string()) })?; + if contested_index_information.resolution + == ContestedIndexResolution::MasternodeVoteNoLocking + && !no_locking_resolution_allowed + { + return Err(DataContractError::InvalidContractStructure( + "contested index resolution 1 (masternode vote without locking) requires document type schema generation 3 (protocol version 14)".to_string(), + )); + } } "description" => {} key => { @@ -2720,6 +2742,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2738,6 +2761,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2756,6 +2780,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2778,6 +2803,7 @@ mod tests { preallocated: true, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }; let mut map = index_value_map("postId", None); @@ -2809,6 +2835,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2841,6 +2868,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2865,6 +2893,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2889,6 +2918,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2914,6 +2944,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -2940,6 +2971,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2962,6 +2994,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -2989,6 +3022,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -3016,6 +3050,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3041,6 +3076,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3071,6 +3107,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("the parser applies structural rules only"); @@ -3096,6 +3133,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3117,6 +3155,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3142,6 +3181,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3198,6 +3238,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3236,6 +3277,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("should parse"); @@ -3267,6 +3309,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3300,6 +3343,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3327,6 +3371,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ) .expect("a non-unique $updatedAt bucketing stays legal"); @@ -3349,6 +3394,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_err(); @@ -3365,9 +3411,15 @@ mod tests { assert_eq!(res, ContestedIndexResolution::MasternodeVote); } + #[test] + fn test_contested_index_resolution_try_from_no_locking() { + let res = ContestedIndexResolution::try_from(1u8).unwrap(); + assert_eq!(res, ContestedIndexResolution::MasternodeVoteNoLocking); + } + #[test] fn test_contested_index_resolution_try_from_invalid() { - let res = ContestedIndexResolution::try_from(1u8); + let res = ContestedIndexResolution::try_from(2u8); assert!(res.is_err()); } @@ -4583,6 +4635,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("all three ranked keywords must parse when the grammar allows them"); @@ -4611,6 +4664,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("index without ranked keywords must parse"); @@ -4655,6 +4709,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("ranked flags on a compound index must be accepted"); @@ -4687,6 +4742,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4720,6 +4776,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4751,6 +4808,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4779,6 +4837,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4809,6 +4868,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4839,6 +4899,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -4871,6 +4932,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("rankedAverageable on the averageable sugar form must parse"); @@ -4909,6 +4971,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("rankedAverageable on the explicit longhand form must parse"); @@ -4937,6 +5000,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!(result.is_err(), "{key} must reject a non-boolean value"); @@ -4971,6 +5035,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ); assert!( @@ -5044,6 +5109,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: true, } } @@ -5505,6 +5571,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, }, ); let msg = format!( @@ -5569,6 +5636,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ); assert!( @@ -5599,6 +5667,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_or_else(|e| panic!("{axis} with no nullSearchable key must parse: {e:?}")); @@ -5625,6 +5694,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .unwrap_or_else(|e| { @@ -5649,6 +5719,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("nullSearchable: false on a plain index must still parse"); @@ -5668,6 +5739,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: true, + no_locking_resolution: false, }, ) .expect("nullSearchable: false on a range-averageable index must still parse"); @@ -5694,6 +5766,47 @@ mod tests { assert!(result.is_err()); // contest supported only for unique indexes } + fn contested_unique_index_map(resolution: u64) -> Vec<(Value, Value)> { + vec![ + (Value::Text("unique".to_string()), Value::Bool(true)), + ( + Value::Text("properties".to_string()), + Value::Array(vec![Value::Map(vec![( + Value::Text("fieldA".to_string()), + Value::Text("asc".to_string()), + )])]), + ), + ( + Value::Text("contested".to_string()), + Value::Map(vec![( + Value::Text("resolution".to_string()), + Value::U64(resolution), + )]), + ), + ] + } + + /// `"resolution": 1` is a generation-3 value: the grammar without the admission + /// refuses it, generation 3 parses it as the masternode vote without locking. + #[test] + fn test_index_contested_resolution_no_locking_needs_the_admission() { + let index_map = contested_unique_index_map(1); + assert!(Index::try_from(index_map.as_slice()).is_err()); + let index = Index::try_from_value_map(index_map.as_slice(), v3_admissions()) + .expect("generation 3 admits the no-locking resolution"); + assert_eq!( + index.contested_index.expect("contested").resolution, + ContestedIndexResolution::MasternodeVoteNoLocking + ); + let index = + Index::try_from_value_map(contested_unique_index_map(0).as_slice(), v3_admissions()) + .expect("the masternode vote resolution parses in every generation"); + assert_eq!( + index.contested_index.expect("contested").resolution, + ContestedIndexResolution::MasternodeVote + ); + } + #[test] fn test_index_try_from_contested_with_unique() { let index_map: Vec<(Value, Value)> = vec![ @@ -5965,6 +6078,7 @@ mod tests { preallocated: false, skip_if_absent: false, range_countable_implies_countable: false, + no_locking_resolution: false, } } diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index 3a7c90ddc08..dd220c68b59 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -391,6 +391,7 @@ impl ErrorWithCode for StateError { Self::MasternodeVoteAlreadyPresentError(_) => 40304, Self::MasternodeIncorrectVotingAddressError(_) => 40305, Self::MasternodeIncorrectVoterIdentityIdError(_) => 40306, + Self::VoteChoiceNotAllowedForVotePollError(_) => 40307, // Prefunded specialized balances Errors: 40400-40499 Self::PrefundedSpecializedBalanceInsufficientError(_) => 40400, diff --git a/packages/rs-dpp/src/errors/consensus/state/state_error.rs b/packages/rs-dpp/src/errors/consensus/state/state_error.rs index f2fb01662e2..c48ab728c22 100644 --- a/packages/rs-dpp/src/errors/consensus/state/state_error.rs +++ b/packages/rs-dpp/src/errors/consensus/state/state_error.rs @@ -99,6 +99,7 @@ use crate::consensus::state::voting::masternode_not_found_error::MasternodeNotFo use crate::consensus::state::voting::masternode_vote_already_present_error::MasternodeVoteAlreadyPresentError; use crate::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; use crate::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; +use crate::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use crate::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; use super::document::document_timestamps_are_equal_error::DocumentTimestampsAreEqualError; @@ -577,6 +578,10 @@ pub enum StateError { // Elected moderation teams (protocol version 14). #[error(transparent)] ContractModeratedDocumentTypeNotYetUsableError(ContractModeratedDocumentTypeNotYetUsableError), + + // Contested indexes resolved without a Lock choice (protocol version 14). + #[error(transparent)] + VoteChoiceNotAllowedForVotePollError(VoteChoiceNotAllowedForVotePollError), } impl From for ConsensusError { @@ -598,6 +603,9 @@ mod tests { ActionFeePricing, ContractFeePot, DocumentActionFee, }; use crate::tokens::gas_fees_paid_by::GasFeesPaidBy; + use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use crate::voting::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePoll; + use crate::voting::vote_polls::VotePoll; use platform_value::Identifier; /// `StateError` is encoded by variant position, so inserting a variant @@ -1056,5 +1064,22 @@ mod tests { )), 141 ); + // Contested indexes without a Lock choice (protocol version 14): the tail of the enum. + assert_eq!( + discriminant_of(StateError::VoteChoiceNotAllowedForVotePollError( + VoteChoiceNotAllowedForVotePollError::new( + VotePoll::ContestedDocumentResourceVotePoll( + ContestedDocumentResourceVotePoll { + contract_id: Identifier::new([7; 32]), + document_type_name: "domain".to_string(), + index_name: "parentNameAndLabel".to_string(), + index_values: vec![], + } + ), + ResourceVoteChoice::Lock, + ) + )), + 142 + ); } } diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs index fcf092f07aa..0414bd7656a 100644 --- a/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/state/voting/mod.rs @@ -3,5 +3,6 @@ pub mod masternode_incorrect_voting_address_error; pub mod masternode_not_found_error; pub mod masternode_vote_already_present_error; pub mod masternode_voted_too_many_times; +pub mod vote_choice_not_allowed_for_vote_poll_error; pub mod vote_poll_not_available_for_voting_error; pub mod vote_poll_not_found_error; diff --git a/packages/rs-dpp/src/errors/consensus/state/voting/vote_choice_not_allowed_for_vote_poll_error.rs b/packages/rs-dpp/src/errors/consensus/state/voting/vote_choice_not_allowed_for_vote_poll_error.rs new file mode 100644 index 00000000000..d3a0a44896c --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/state/voting/vote_choice_not_allowed_for_vote_poll_error.rs @@ -0,0 +1,59 @@ +use crate::consensus::state::state_error::StateError; +use crate::consensus::ConsensusError; +use crate::errors::ProtocolError; +use crate::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use crate::voting::vote_polls::VotePoll; +use bincode::{Decode, DecodeUntrusted, Encode}; +use platform_serialization_derive::{ + PlatformDeserializeTrusted, PlatformDeserializeUntrusted, PlatformSerialize, +}; +use thiserror::Error; + +/// A vote choice the vote poll does not offer: a contested index resolved by +/// `MasternodeVoteNoLocking` has no Lock choice. +#[derive( + Error, + Debug, + Clone, + PartialEq, + Encode, + Decode, + PlatformSerialize, + PlatformDeserializeTrusted, + PlatformDeserializeUntrusted, + DecodeUntrusted, +)] +#[error("VotePoll {vote_poll} does not allow the vote choice {vote_choice}")] +#[platform_serialize(unversioned)] +pub struct VoteChoiceNotAllowedForVotePollError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + vote_poll: VotePoll, + vote_choice: ResourceVoteChoice, +} + +impl VoteChoiceNotAllowedForVotePollError { + pub fn new(vote_poll: VotePoll, vote_choice: ResourceVoteChoice) -> Self { + Self { + vote_poll, + vote_choice, + } + } + + pub fn vote_poll(&self) -> &VotePoll { + &self.vote_poll + } + + pub fn vote_choice(&self) -> ResourceVoteChoice { + self.vote_choice + } +} + +impl From for ConsensusError { + fn from(err: VoteChoiceNotAllowedForVotePollError) -> Self { + Self::StateError(StateError::VoteChoiceNotAllowedForVotePollError(err)) + } +} diff --git a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs index 71c8cf00da6..7b69dfc9b2e 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/mod.rs @@ -8,6 +8,7 @@ use dpp::version::PlatformVersion; use drive::grovedb::TransactionArg; mod v0; +mod v1; impl Platform where @@ -35,9 +36,16 @@ where transaction, platform_version, ), + 1 => self.check_for_ended_vote_polls_v1( + last_committed_platform_state, + block_platform_state, + block_info, + transaction, + platform_version, + ), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "check_for_ended_vote_polls".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } 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 new file mode 100644 index 00000000000..39f9378b96b --- /dev/null +++ b/packages/rs-drive-abci/src/execution/platform_events/voting/check_for_ended_vote_polls/v1/mod.rs @@ -0,0 +1,195 @@ +use crate::error::Error; +use crate::platform_types::platform::Platform; +use crate::platform_types::platform_state::PlatformState; +use crate::rpc::core::CoreRPCLike; +use dpp::block::block_info::BlockInfo; +use dpp::document::DocumentV0Getters; +use dpp::prelude::TimestampMillis; +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::resolved::vote_polls::resolve::VotePollResolver; +use drive::drive::votes::resolved::vote_polls::{ResolvedVotePoll, ResolvedVotePollWithVotes}; +use drive::grovedb::TransactionArg; +use drive::query::VotePollsByEndDateDriveQuery; +use itertools::Itertools; +use std::collections::BTreeMap; + +impl Platform +where + C: CoreRPCLike, +{ + /// Checks for ended vote polls, awarding a tie to the earliest contender + #[inline(always)] + pub(super) fn check_for_ended_vote_polls_v1( + &self, + _last_committed_platform_state: &PlatformState, + block_platform_state: &PlatformState, + block_info: &BlockInfo, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> 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 + 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; + let vote_polls_by_timestamp = + VotePollsByEndDateDriveQuery::execute_no_proof_for_specialized_end_time_query( + distribute_after_time, + maximum_vote_polls_to_process, + &self.drive, + transaction, + &mut vec![], + platform_version, + )?; + + // Check if awarding is disabled + let vote_polls_with_info = vote_polls_by_timestamp.into_iter().map(|(end_date, vote_polls)| { + let vote_polls_with_votes = vote_polls.into_iter().map(|vote_poll| { + let resolved_vote_poll = + vote_poll.resolve(&self.drive, transaction, platform_version)?; + match resolved_vote_poll { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + resolved_contested_document_resource_vote_poll, + ) => { + let document_type = + resolved_contested_document_resource_vote_poll.document_type()?; + // let's see who actually won + let result = self.tally_votes_for_contested_document_resource_vote_poll( + (&resolved_contested_document_resource_vote_poll).into(), + transaction, + platform_version, + )?; + let contenders = result.contenders; + // For each contender if there vote_tally is 1 or more we need to get their votes + // We don't do this for contenders with 0 votes, as there is no point. + + let sorted_contenders: Vec<_> = contenders + .into_iter() + .sorted_by(|a, b| Ord::cmp(&b.final_vote_tally, &a.final_vote_tally)) + .collect(); + + let (contenders_with_votes, contenders_with_no_votes): (Vec<_>, Vec<_>) = sorted_contenders.iter().partition(|a| a.final_vote_tally > 0); + + let fetch_contenders = contenders_with_votes + .iter() + .map(|contender| contender.identity_id) + .collect::>(); + let mut other_contenders = if contenders_with_no_votes.is_empty() + { + BTreeMap::new() + } else { + // Other contenders are only those with no votes + contenders_with_no_votes.into_iter().map(|contender| (TowardsIdentity(contender.identity_id), vec![])).collect() + }; + + // We need to get the votes of the sorted contenders + let mut identifiers_voting_for_contenders = + self.drive.fetch_identities_voting_for_contenders( + &resolved_contested_document_resource_vote_poll, + fetch_contenders, + true, + transaction, + platform_version, + )?; + + identifiers_voting_for_contenders.append(&mut other_contenders); + + let highest_vote_tally = sorted_contenders + .first() + .map(|max_voted_contender| max_voted_contender.final_vote_tally) + .unwrap_or_default(); + // These are all the people who got top votes + let top_contenders: Vec = sorted_contenders + .into_iter() + .filter(|c| c.final_vote_tally == highest_vote_tally) + .take(100) // Limit to the first 100 before the expensive operation + .map(|contender| { + FinalizedContender::try_from_contender_with_serialized_document( + contender, + document_type, + platform_version, + ) + .map_err(Error::Protocol) + }) + .collect::, Error>>()?; + // A tie among the top contenders goes to the earliest document: + // creation time, then block height, then core height, then id. + // Version 0 awarded the latest. + let maybe_top_contender = top_contenders.into_iter().min_by(|a, b| { + a.document + .created_at() + .cmp(&b.document.created_at()) + .then_with(|| { + a.document + .created_at_block_height() + .cmp(&b.document.created_at_block_height()) + }) + .then_with(|| { + a.document + .created_at_core_block_height() + .cmp(&b.document.created_at_core_block_height()) + }) + .then_with(|| a.document.id().cmp(&b.document.id())) + }); + // We award the document to the top contender + let winner_info = if let Some(top_contender) = maybe_top_contender { + // let's check to make sure the lock votes didn't win it + // if the lock is tied with the top contender the top contender gets it + if result.locked_vote_tally > top_contender.final_vote_tally { + // the record will show it's locked + ContestedDocumentVotePollWinnerInfo::Locked + } else { + let contender_id = top_contender.identity_id; + // We award the document to the winner of the vote poll + self.award_document_to_winner( + block_info, + top_contender, + &resolved_contested_document_resource_vote_poll, + transaction, + platform_version, + )?; + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_id) + } + } else { + ContestedDocumentVotePollWinnerInfo::NoWinner + }; + // We want to keep a record of how everyone voted + self.keep_record_of_finished_contested_resource_vote_poll( + block_platform_state, + block_info, + &resolved_contested_document_resource_vote_poll, + &identifiers_voting_for_contenders, + winner_info, + transaction, + platform_version, + )?; + Ok(ResolvedVotePollWithVotes::ContestedDocumentResourceVotePollWithContractInfoAndVotes(resolved_contested_document_resource_vote_poll, identifiers_voting_for_contenders)) + } + } + }).collect::, Error>>()?; + Ok((end_date, vote_polls_with_votes)) + }).collect::>, Error>>() +?; + + // We need to clean up the vote polls + // This means removing it and also removing all current votes + if !vote_polls_with_info.is_empty() { + self.clean_up_after_vote_polls_end( + block_info, + &vote_polls_with_info, + false, + transaction, + platform_version, + )?; + } + + Ok(()) + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs index 4991e63e9b9..8a901bb38a7 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/mod.rs @@ -22,6 +22,7 @@ use crate::platform_types::platform::PlatformRef; use crate::rpc::core::CoreRPCLike; use crate::execution::validation::state_transition::masternode_vote::state::v0::MasternodeVoteStateTransitionStateValidationV0; +use crate::execution::validation::state_transition::masternode_vote::state::v1::MasternodeVoteStateTransitionStateValidationV1; use crate::execution::validation::state_transition::masternode_vote::transform_into_action::v0::MasternodeVoteStateTransitionTransformIntoActionValidationV0; use crate::execution::validation::state_transition::processor::state::StateTransitionStateValidation; use crate::execution::validation::state_transition::transformer::StateTransitionActionTransformer; @@ -79,9 +80,10 @@ impl StateTransitionStateValidation for MasternodeVoteTransition { .state { 0 => self.validate_state_v0(action, platform, tx, platform_version), + 1 => self.validate_state_v1(action, platform, tx, platform_version), version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { method: "masternode votes state transition: validate_state".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } @@ -92,6 +94,9 @@ impl StateTransitionStateValidation for MasternodeVoteTransition { } } +#[cfg(test)] +mod no_locking_contest_tests; + #[cfg(test)] mod tests { use crate::test::helpers::setup::TestPlatformBuilder; @@ -7750,7 +7755,7 @@ mod tests { .unwrap() .expect("expected to commit transaction"); - // At this point the document should have been awarded to contender 1. + // At this point the document should have been awarded to contender 2, the earliest document (a tie, protocol version 14). { let (contenders, abstaining, locking, finished_vote_info) = get_vote_states( @@ -7770,7 +7775,7 @@ mod tests { Some(FinishedVoteInfo { finished_vote_outcome: finished_vote_info::FinishedVoteOutcome::TowardsIdentity as i32, - won_by_identity_id: Some(contender_1.id().to_vec()), + won_by_identity_id: Some(contender_2.id().to_vec()), finished_at_block_height: 10000, finished_at_core_block_height: 42, finished_at_block_time_ms: 1209900000, @@ -7818,7 +7823,7 @@ mod tests { assert_eq!( finished_vote_info, Some(( - ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_1.id()), + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_2.id()), block_info )) ); @@ -8165,7 +8170,7 @@ mod tests { .unwrap() .expect("expected to commit transaction"); - // At this point the document should have been awarded to contender 1. + // At this point the document should have been awarded to contender 2, the earliest document (a tie, protocol version 14). { let (contenders, abstaining, locking, finished_vote_info) = get_vote_states( @@ -8185,7 +8190,7 @@ mod tests { Some(FinishedVoteInfo { finished_vote_outcome: finished_vote_info::FinishedVoteOutcome::TowardsIdentity as i32, - won_by_identity_id: Some(contender_1.id().to_vec()), + won_by_identity_id: Some(contender_2.id().to_vec()), finished_at_block_height: 10000, finished_at_core_block_height: 42, finished_at_block_time_ms: 1209900000, @@ -8233,7 +8238,7 @@ mod tests { assert_eq!( finished_vote_info, Some(( - ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_1.id()), + ContestedDocumentVotePollWinnerInfo::WonByIdentity(contender_2.id()), block_info )) ); 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 new file mode 100644 index 00000000000..26d876ad0b8 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/no_locking_contest_tests.rs @@ -0,0 +1,761 @@ +//! Contests on a unique index resolved without locking +//! (`ContestedIndexResolution::MasternodeVoteNoLocking`, protocol version 14): no Lock choice, +//! a single contender is awarded when the join window closes, a second contender opens the +//! vote window, and a tie goes to the earliest contender. + +use crate::execution::validation::state_transition::state_transitions::tests::{ + create_dpns_identity_name_contest, get_vote_states, perform_vote, perform_votes_multi, + setup_identity, setup_masternode_voting_identity, +}; +use crate::platform_types::platform_state::PlatformStateV0Methods; +use crate::rpc::core::MockCoreRPCLike; +use crate::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_request::get_contested_resource_vote_state_request_v0::ResultType; +use dapi_grpc::platform::v0::get_contested_resource_vote_state_response::get_contested_resource_vote_state_response_v0::{ + finished_vote_info, FinishedVoteInfo, +}; +use dpp::block::block_info::BlockInfo; +use dpp::block::extended_block_info::v0::ExtendedBlockInfoV0; +use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; +use dpp::dash_to_credits; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, +}; +use dpp::data_contract::DataContract; +use dpp::document::{DocumentV0Getters, DocumentV0Setters}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, TimestampMillis}; +use dpp::platform_value::{Bytes32, Value}; +use dpp::prelude::Identifier; +use dpp::serialization::PlatformSerializable; +use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; +use dpp::state_transition::batch_transition::BatchTransition; +use dpp::util::hash::hash_double; +use dpp::util::strings::convert_to_homograph_safe_chars; +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::query::VotePollsByEndDateDriveQuery; +use drive::util::test_helpers::setup_contract; +use platform_version::version::PlatformVersion; +use rand::prelude::StdRng; +use rand::{Rng, SeedableRng}; +use simple_signer::signer::SimpleSigner; +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"; + +type IdentityInfo = (Identity, SimpleSigner, IdentityPublicKey); + +fn block(time_ms: TimestampMillis, height: u64) -> BlockInfo { + BlockInfo { + time_ms, + height, + core_height: 42, + epoch: Default::default(), + } +} + +fn setup() -> ( + TempPlatform, + &'static PlatformVersion, + DataContract, + StdRng, +) { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let mut rng = StdRng::seed_from_u64(0x9010_C41A); + let owner = setup_identity(&mut platform, rng.gen(), dash_to_credits!(0.5)); + let contract = setup_contract( + &platform.drive, + NO_LOCKING_CONTRACT, + None, + Some(owner.0.id().to_buffer()), + None::, + None, + Some(platform_version), + ); + (platform, platform_version, contract, rng) +} + +/// The join window and the poll duration the platform under test applies. +fn windows( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> (TimestampMillis, TimestampMillis) { + match platform.config.network { + Network::Mainnet => ( + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_mainnet_ms, + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_mainnet_ms, + ), + _ => ( + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_testing_ms, + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_test_network_ms, + ), + } +} + +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)), + ], + }) +} + +/// The serialized preorder and domain creations of one contender for `NAME`. +async fn contest_transitions( + contract: &DataContract, + (identity, signer, key): &IdentityInfo, + salt_discriminator: u8, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> (Vec, Vec) { + let preorder = contract + .document_type_for_name("preorder") + .expect("expected preorder document type"); + let domain = contract + .document_type_for_name("domain") + .expect("expected domain document type"); + let entropy = Bytes32::random_with_rng(rng); + let mut preorder_document = preorder + .random_document_with_identifier_and_entropy( + rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random preorder document"); + preorder_document + .set_id_for_creation(preorder, &entropy.0, 2, platform_version) + .expect("expected to set the document id"); + let mut domain_document = domain + .random_document_with_identifier_and_entropy( + rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version, + ) + .expect("expected a random domain document"); + domain_document + .set_id_for_creation(domain, &entropy.0, 3, platform_version) + .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( + "normalizedLabel", + convert_to_homograph_safe_chars(NAME).into(), + ); + domain_document.set("records.identity", domain_document.owner_id().into()); + domain_document.set("subdomainRules.allowSubdomains", false.into()); + let mut salt: [u8; 32] = [0u8; 32]; + 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()); + 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( + preorder_document, + preorder, + entropy.0, + key, + 2, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create preorder batch transition"); + let domain_transition = BatchTransition::new_document_creation_transition_from_document( + domain_document, + domain, + entropy.0, + key, + 3, + 0, + None, + signer, + platform_version, + None, + ) + .await + .expect("expect to create domain batch transition"); + ( + preorder_transition + .serialize_to_bytes() + .expect("serialize preorder transition"), + domain_transition + .serialize_to_bytes() + .expect("serialize domain transition"), + ) +} + +/// Processes transitions in a block at `time_ms` and expects every one of them to pass. +fn process_valid( + platform: &TempPlatform, + transitions: &[Vec], + time_ms: TimestampMillis, + platform_version: &PlatformVersion, +) { + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + transitions, + &platform_state, + &block(time_ms, 1), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transitions"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); + assert_eq!( + processing_result.valid_count(), + transitions.len(), + "every transition should pass: {:?}", + processing_result.execution_results() + ); +} + +/// A contender joins the contest for `NAME`: the preorder at `time_ms`, the domain one second +/// later. Returns the domain's block time, which is when the contest starts or is joined. +async fn join( + platform: &TempPlatform, + contract: &DataContract, + identity: &IdentityInfo, + salt_discriminator: u8, + time_ms: TimestampMillis, + rng: &mut StdRng, + platform_version: &PlatformVersion, +) -> TimestampMillis { + let (preorder, domain) = contest_transitions( + contract, + identity, + salt_discriminator, + rng, + platform_version, + ) + .await; + process_valid(platform, &[preorder], time_ms, platform_version); + process_valid(platform, &[domain], time_ms + 1000, platform_version); + time_ms + 1000 +} + +fn end_dates( + platform: &TempPlatform, + platform_version: &PlatformVersion, +) -> Vec<(TimestampMillis, VotePoll)> { + VotePollsByEndDateDriveQuery { + start_time: None, + end_time: None, + limit: None, + offset: None, + order_ascending: true, + } + .execute_no_proof(&platform.drive, None, &mut vec![], platform_version) + .expect("expected the end date entries") + .into_iter() + .flat_map(|(time, polls)| polls.into_iter().map(move |poll| (time, poll))) + .collect() +} + +/// Ends every poll due at `time_ms`, as the block at that time would. +fn end_polls_at( + platform: &TempPlatform, + time_ms: TimestampMillis, + height: u64, + platform_version: &PlatformVersion, +) { + let mut platform_state = (**platform.state.load()).clone(); + let block_info = block(time_ms, height); + platform_state.set_last_committed_block_info(Some( + ExtendedBlockInfoV0 { + basic_info: block_info, + app_hash: platform + .drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap() + .unwrap(), + quorum_hash: [0u8; 32], + block_id_hash: [0u8; 32], + proposer_pro_tx_hash: [0u8; 32], + signature: [0u8; 96], + round: 0, + } + .into(), + )); + platform.state.store(Arc::new(platform_state)); + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + platform + .check_for_ended_vote_polls( + &platform_state, + &platform_state, + &block_info, + Some(&transaction), + platform_version, + ) + .expect("expected to check for ended vote polls"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit transaction"); +} + +/// The identity the finished contest was awarded to, if it finished. +fn winner( + platform: &TempPlatform, + contract: &DataContract, + platform_version: &PlatformVersion, +) -> Option { + let platform_state = platform.state.load(); + let (_, _, _, finished_vote_info) = get_vote_states( + platform, + &platform_state, + contract, + NAME, + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + finished_vote_info.map( + |FinishedVoteInfo { + finished_vote_outcome, + won_by_identity_id, + .. + }| { + assert_eq!( + finished_vote_outcome, + finished_vote_info::FinishedVoteOutcome::TowardsIdentity as i32, + "a contest without locking always has a winner" + ); + Identifier::from_vec(won_by_identity_id.expect("expected a winner")) + .expect("expected an identifier") + }, + ) +} + +#[tokio::test] +async fn should_refuse_a_lock_vote_and_accept_the_other_choices() { + let (mut platform, platform_version, contract, mut rng) = setup(); + 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)); + join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + 20_000, + &mut rng, + platform_version, + ) + .await; + + let (pro_tx_hash, _, signer, voting_key) = + setup_masternode_voting_identity(&mut platform, 0x10c, platform_version); + let platform_state = platform.state.load(); + let refused = + VoteChoiceNotAllowedForVotePollError::new(vote_poll(&contract), ResourceVoteChoice::Lock) + .to_string(); + perform_vote( + &mut platform, + &platform_state, + &contract, + ResourceVoteChoice::Lock, + NAME, + &signer, + pro_tx_hash, + &voting_key, + 1, + Some(&refused), + platform_version, + ) + .await; + perform_vote( + &mut platform, + &platform_state, + &contract, + ResourceVoteChoice::Abstain, + NAME, + &signer, + pro_tx_hash, + &voting_key, + 1, + None, + platform_version, + ) + .await; + let (abstaining, locking, tallies) = tallies_of(&platform, &contract, platform_version); + assert_eq!(abstaining, Some(1)); + assert_eq!(locking, Some(0)); + assert!(tallies.contains(&(alice.0.id(), Some(0)))); + assert!(tallies.contains(&(bob.0.id(), Some(0)))); + + // The same masternode changes its vote: a changed vote replaces the previous one + perform_vote( + &mut platform, + &platform_state, + &contract, + ResourceVoteChoice::TowardsIdentity(alice.0.id()), + NAME, + &signer, + pro_tx_hash, + &voting_key, + 2, + None, + platform_version, + ) + .await; + + // The masternode changed its vote, so its abstain vote is gone and alice has it + let (abstaining, locking, tallies) = tallies_of(&platform, &contract, platform_version); + assert_eq!(abstaining, Some(0)); + assert_eq!(locking, Some(0)); + assert!(tallies.contains(&(alice.0.id(), Some(1)))); + assert!(tallies.contains(&(bob.0.id(), Some(0)))); +} + +/// The abstain and lock tallies and every contender's tally of the still-running contest. +fn tallies_of( + platform: &TempPlatform, + contract: &DataContract, + platform_version: &PlatformVersion, +) -> (Option, Option, Vec<(Identifier, Option)>) { + let platform_state = platform.state.load(); + let (contenders, abstaining, locking, finished) = get_vote_states( + platform, + &platform_state, + contract, + NAME, + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + assert!(finished.is_none()); + let tallies = contenders + .iter() + .map(|contender| (contender.identity_id(), contender.vote_tally())) + .collect(); + (abstaining, locking, tallies) +} + +#[tokio::test] +async fn should_award_a_single_contender_when_the_join_window_closes() { + 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 start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + + assert_eq!( + end_dates(&platform, platform_version), + vec![(start + join_window, vote_poll(&contract))], + "the contest ends with its join window while it has one contender" + ); + assert!(start + join_window < start + poll_duration); + + end_polls_at(&platform, start + join_window - 1, 10, platform_version); + assert_eq!(winner(&platform, &contract, platform_version), None); + + end_polls_at(&platform, start + join_window, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()) + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_open_the_vote_window_when_a_second_contender_joins() { + 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 start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + assert_eq!( + end_dates(&platform, platform_version), + vec![(start + poll_duration, vote_poll(&contract))], + "the second contender moves the end to the full poll duration" + ); + + // A third contender finds the end date there already + join( + &platform, + &contract, + &carol, + 3, + start + 120_000, + &mut rng, + platform_version, + ) + .await; + assert_eq!( + end_dates(&platform, platform_version), + vec![(start + poll_duration, vote_poll(&contract))] + ); + + perform_votes_multi( + &mut platform, + &contract, + vec![ + (ResourceVoteChoice::TowardsIdentity(bob.0.id()), 3), + (ResourceVoteChoice::TowardsIdentity(alice.0.id()), 2), + (ResourceVoteChoice::Abstain, 1), + ], + NAME, + 100, + None, + platform_version, + ) + .await; + + end_polls_at(&platform, start + join_window, 10, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + None, + "the join window closing no longer ends the contest" + ); + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(bob.0.id()), + "plurality" + ); + assert!(end_dates(&platform, platform_version).is_empty()); +} + +#[tokio::test] +async fn should_award_a_tie_to_the_earliest_contender() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (_, 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 start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + perform_votes_multi( + &mut platform, + &contract, + vec![ + (ResourceVoteChoice::TowardsIdentity(bob.0.id()), 2), + (ResourceVoteChoice::TowardsIdentity(alice.0.id()), 2), + ], + NAME, + 100, + None, + platform_version, + ) + .await; + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()), + "alice joined first" + ); +} + +#[tokio::test] +async fn should_award_the_earliest_contender_when_nobody_votes() { + let (mut platform, platform_version, contract, mut rng) = setup(); + let (_, 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 start = join( + &platform, + &contract, + &alice, + 1, + 10_000, + &mut rng, + platform_version, + ) + .await; + join( + &platform, + &contract, + &bob, + 2, + start + 60_000, + &mut rng, + platform_version, + ) + .await; + + end_polls_at(&platform, start + poll_duration, 11, platform_version); + assert_eq!( + winner(&platform, &contract, platform_version), + Some(alice.0.id()) + ); +} + +/// The DPNS rule keeps its Lock choice, and from protocol version 14 its ties go to the +/// earliest contender too: two documents created in the same block tie on time and +/// heights, so the smaller document id wins. +#[tokio::test] +async fn should_award_a_dpns_tie_to_the_earliest_contender_from_version_14() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + let platform_state = platform.state.load(); + let (contender_1, contender_2, dpns_contract) = create_dpns_identity_name_contest( + &mut platform, + &platform_state, + 7, + NAME, + platform_version, + ) + .await; + perform_votes_multi( + &mut platform, + dpns_contract.as_ref(), + vec![ + (ResourceVoteChoice::TowardsIdentity(contender_1.id()), 4), + (ResourceVoteChoice::TowardsIdentity(contender_2.id()), 4), + ], + NAME, + 10, + None, + platform_version, + ) + .await; + let (contenders, _, _, _) = get_vote_states( + &platform, + &platform_state, + dpns_contract.as_ref(), + NAME, + None, + true, + None, + ResultType::DocumentsAndVoteTally, + platform_version, + ); + let earliest = contenders + .iter() + .map(|contender| { + let document = contender + .document() + .as_ref() + .expect("expected the contender's document"); + (document.id(), contender.identity_id()) + }) + .min() + .expect("expected contenders") + .1; + + let (_, poll_duration) = windows(&platform, platform_version); + end_polls_at(&platform, poll_duration + 300_000, 11, platform_version); + assert_eq!( + winner(&platform, dpns_contract.as_ref(), platform_version), + Some(earliest) + ); +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs index 9a1925de7fc..008be12cc67 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/mod.rs @@ -1 +1,2 @@ pub(crate) mod v0; +pub(crate) mod v1; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs new file mode 100644 index 00000000000..4ea6d6b31b4 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/masternode_vote/state/v1/mod.rs @@ -0,0 +1,120 @@ +use crate::error::Error; +use crate::platform_types::platform::PlatformRef; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; +use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; +use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; +use dpp::consensus::ConsensusError; + +use dpp::prelude::ConsensusValidationResult; +use dpp::state_transition::masternode_vote_transition::MasternodeVoteTransition; + +use crate::error::execution::ExecutionError; +use dpp::data_contract::document_type::ContestedIndexResolution; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ + ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfoV0Getters, +}; +use drive::drive::votes::resolved::vote_polls::ResolvedVotePoll; +use drive::drive::votes::resolved::votes::resolved_resource_vote::accessors::v0::ResolvedResourceVoteGettersV0; +use drive::drive::votes::resolved::votes::ResolvedVote; +use drive::grovedb::TransactionArg; +use drive::state_transition_action::StateTransitionAction; + +pub(in crate::execution::validation::state_transition::state_transitions::masternode_vote) trait MasternodeVoteStateTransitionStateValidationV1 +{ + fn validate_state_v1( + &self, + action: Option, + platform: &PlatformRef, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error>; +} + +impl MasternodeVoteStateTransitionStateValidationV1 for MasternodeVoteTransition { + fn validate_state_v1( + &self, + action: Option, + platform: &PlatformRef, + tx: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(StateTransitionAction::MasternodeVoteAction(masternode_vote_action)) = action + else { + return Err(Error::Execution(ExecutionError::CorruptedCodeExecution( + "action should be known in validate state for masternode vote transition", + ))); + }; + + // We need to make sure that the vote poll exists and is in started state + match masternode_vote_action.vote_ref() { + ResolvedVote::ResolvedResourceVote(resource_vote) => { + let vote_poll = resource_vote.vote_poll(); + match vote_poll { + ResolvedVotePoll::ContestedDocumentResourceVotePollWithContractInfo( + contested_document_resource_vote_poll, + ) => { + let Some(stored_info) = platform + .drive + .fetch_contested_document_vote_poll_stored_info( + contested_document_resource_vote_poll, + None, + tx, + platform_version, + )? + .1 + else { + return Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError(StateError::VotePollNotFoundError( + VotePollNotFoundError::new(vote_poll.into()), + )), + )); + }; + // A contested index resolved without locking offers no Lock choice + if resource_vote.resource_vote_choice() == ResourceVoteChoice::Lock + && contested_document_resource_vote_poll + .index() + .map_err(Error::Drive)? + .contested_index + .as_ref() + .map(|contested| contested.resolution) + == Some(ContestedIndexResolution::MasternodeVoteNoLocking) + { + return Ok(ConsensusValidationResult::new_with_error( + VoteChoiceNotAllowedForVotePollError::new( + vote_poll.into(), + ResourceVoteChoice::Lock, + ) + .into(), + )); + } + let vote_poll_status = stored_info.vote_poll_status(); + match &vote_poll_status { + ContestedDocumentVotePollStatus::NotStarted + | ContestedDocumentVotePollStatus::Awarded(_) + | ContestedDocumentVotePollStatus::Locked => { + Ok(ConsensusValidationResult::new_with_error( + ConsensusError::StateError( + StateError::VotePollNotAvailableForVotingError( + VotePollNotAvailableForVotingError::new( + vote_poll.into(), + vote_poll_status, + ), + ), + ), + )) + } + ContestedDocumentVotePollStatus::Started(_) => { + Ok(ConsensusValidationResult::new_with_data( + masternode_vote_action.into(), + )) + } + } + } + } + } + } + } +} diff --git a/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json new file mode 100644 index 00000000000..db473f41d27 --- /dev/null +++ b/packages/rs-drive-abci/tests/supporting_files/contract/dpns/dpns-contract-contested-unique-index-no-locking.json @@ -0,0 +1,169 @@ +{ + "$formatVersion": "0", + "id": "DWBXe9EXFPHxvbArQgT45uQR5gMmi8dfMpLhR5KSbwnZ", + "ownerId": "2QjL594djCH2NyDsn45vd6yQjEDHupMKo7CEGVTHtQxU", + "version": 1, + "documentSchemas": { + "domain": { + "documentsMutable": false, + "canBeDeleted": true, + "transferable": 1, + "tradeMode": 1, + "type": "object", + "indices": [ + { + "name": "parentNameAndLabel", + "properties": [ + { + "normalizedParentDomainName": "asc" + }, + { + "normalizedLabel": "asc" + } + ], + "unique": true, + "contested": { + "fieldMatches": [ + { + "field": "normalizedLabel", + "regexPattern": "^[a-zA-Z01]{3,19}$" + } + ], + "resolution": 1, + "description": "If the normalized label part of this index is less than 20 characters (all alphabet a-z and 0 and 1) then this index is non unique while contest resolution takes place." + } + }, + { + "name": "identityId", + "nullSearchable": false, + "properties": [ + { + "records.identity": "asc" + } + ] + } + ], + "properties": { + "label": { + "type": "string", + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 3, + "maxLength": 63, + "position": 0, + "description": "Domain label. e.g. 'Bob'." + }, + "normalizedLabel": { + "type": "string", + "pattern": "^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-]{0,61}[a-hj-km-np-z0-9]$", + "maxLength": 63, + "position": 1, + "description": "Domain label converted to lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'b0b'", + "$comment": "Must be equal to the label in lowercase. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\"." + }, + "parentDomainName": { + "type": "string", + "pattern": "^$|^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 2, + "description": "A full parent domain name. e.g. 'dash'." + }, + "normalizedParentDomainName": { + "type": "string", + "pattern": "^$|^[a-hj-km-np-z0-9][a-hj-km-np-z0-9-\\.]{0,61}[a-hj-km-np-z0-9]$", + "minLength": 0, + "maxLength": 63, + "position": 3, + "description": "A parent domain name in lowercase for case-insensitive uniqueness validation. \"o\", \"i\" and \"l\" replaced with \"0\" and \"1\" to mitigate homograph attack. e.g. 'dash'", + "$comment": "Must either be equal to an existing domain or empty to create a top level domain. \"o\", \"i\" and \"l\" must be replaced with \"0\" and \"1\". Only the data contract owner can create top level domains." + }, + "preorderSalt": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 4, + "description": "Salt used in the preorder document" + }, + "records": { + "type": "object", + "properties": { + "identity": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 1, + "contentMediaType": "application/x.dash.dpp.identifier", + "description": "Identifier name record that refers to an Identity" + } + }, + "minProperties": 1, + "position": 5, + "additionalProperties": false + }, + "subdomainRules": { + "type": "object", + "properties": { + "allowSubdomains": { + "type": "boolean", + "description": "This option defines who can create subdomains: true - anyone; false - only the domain owner", + "$comment": "Only the domain owner is allowed to create subdomains for non top-level domains", + "position": 0 + } + }, + "position": 6, + "description": "Subdomain rules allow domain owners to define rules for subdomains", + "additionalProperties": false, + "required": [ + "allowSubdomains" + ] + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "$transferredAt", + "label", + "normalizedLabel", + "normalizedParentDomainName", + "preorderSalt", + "records", + "subdomainRules" + ], + "additionalProperties": false, + "$comment": "In order to register a domain you need to create a preorder. The preorder step is needed to prevent man-in-the-middle attacks. normalizedLabel + '.' + normalizedParentDomain must not be longer than 253 chars length as defined by RFC 1035. Domain documents are immutable: modification and deletion are restricted" + }, + "preorder": { + "documentsMutable": false, + "canBeDeleted": true, + "type": "object", + "indices": [ + { + "name": "saltedHash", + "properties": [ + { + "saltedDomainHash": "asc" + } + ], + "unique": true + } + ], + "properties": { + "saltedDomainHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 0, + "description": "Double sha-256 of the concatenation of a 32 byte random salt and a normalized domain name" + } + }, + "required": [ + "saltedDomainHash" + ], + "additionalProperties": false, + "$comment": "Preorder documents are immutable: modification and deletion are restricted" + } + } +} \ No newline at end of file diff --git a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs index df1bb844000..10fcd209aa7 100644 --- a/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -49,9 +50,20 @@ impl Drive { transaction, platform_version, ), + 1 => self.add_contested_document_for_contract_operations_v1( + document_and_contract_info, + contested_document_resource_vote_poll, + insert_without_check, + block_info, + also_insert_vote_poll_stored_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_contested_document_for_contract_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } 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 new file mode 100644 index 00000000000..0543952b4a1 --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert_contested/add_contested_document_for_contract_operations/v1/mod.rs @@ -0,0 +1,233 @@ +use crate::drive::votes::paths::{ + vote_contested_resource_end_date_queries_at_time_tree_path_vec, + vote_end_date_queries_tree_path_vec, +}; +use crate::drive::votes::resolved::vote_polls::contested_document_resource_vote_poll::ContestedDocumentResourceVotePollWithContractInfo; +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::vote_poll_vote_state_query::{ + ContestedDocumentVotePollDriveQueryResultType, ResolvedContestedDocumentVotePollDriveQuery, +}; +use crate::util::grove_operations::BatchDeleteUpTreeApplyType; +use crate::util::object_size_info::DocumentAndContractInfo; +use dpp::block::block_info::BlockInfo; +use dpp::dashcore::Network; +use dpp::data_contract::document_type::ContestedIndexResolution; +use dpp::version::PlatformVersion; +use dpp::voting::vote_info_storage::contested_document_vote_poll_stored_info::{ + ContestedDocumentVotePollStatus, ContestedDocumentVotePollStoredInfo, + ContestedDocumentVotePollStoredInfoV0Getters, +}; +use dpp::voting::vote_polls::VotePoll; +use grovedb::batch::KeyInfoPath; +use grovedb::{EstimatedLayerInformation, MaybeTree, TransactionArg}; +use std::collections::HashMap; + +impl Drive { + /// Gathers the operations to add a contested document to a contract. + /// + /// Version 1 (protocol version 14) reads the contested index's resolution. A contest + /// resolved without locking (`MasternodeVoteNoLocking`) ends when its join window closes + /// while it has a single contender; the first additional contender moves its end date to + /// the full poll duration, opening the vote window. + #[inline(always)] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_contested_document_for_contract_operations_v1( + &self, + document_and_contract_info: DocumentAndContractInfo, + contested_document_resource_vote_poll: ContestedDocumentResourceVotePollWithContractInfo, + insert_without_check: bool, + block_info: &BlockInfo, + also_insert_vote_poll_stored_info: Option, + previous_batch_operations: &mut Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let mut batch_operations: Vec = vec![]; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + Self::add_estimation_costs_for_contested_document_tree_levels_up_to_contract( + document_and_contract_info.contract, + Some(document_and_contract_info.document_type), + estimated_costs_only_with_layer_info, + &platform_version.drive, + )?; + } + + self.add_contested_document_to_primary_storage( + &document_and_contract_info, + insert_without_check, + estimated_costs_only_with_layer_info, + transaction, + &mut batch_operations, + platform_version, + )?; + + let (poll_time, join_time) = match self.config.network { + Network::Mainnet => ( + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_mainnet_ms, + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_mainnet_ms, + ), + _ => ( + platform_version + .dpp + .voting_versions + .default_vote_poll_time_duration_test_network_ms, + platform_version + .dpp + .validation + .voting + .allow_other_contenders_time_testing_ms, + ), + }; + + let no_locking = contested_document_resource_vote_poll + .index()? + .contested_index + .as_ref() + .map(|contested| contested.resolution) + == Some(ContestedIndexResolution::MasternodeVoteNoLocking); + + let contest_already_existed = self.add_contested_indices_for_contract_operations( + &document_and_contract_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + &mut batch_operations, + platform_version, + )?; + + let owner_id = document_and_contract_info.owned_document_info.owner_id; + + if !contest_already_existed { + if let Some(vote_poll_stored_start_info) = also_insert_vote_poll_stored_info { + let mut operations = self + .insert_stored_info_for_contested_resource_vote_poll_operations( + &contested_document_resource_vote_poll, + vote_poll_stored_start_info, + platform_version, + )?; + batch_operations.append(&mut operations); + } + + // Without locking, a contest runs only to the end of its join window until a + // second contender joins; with locking, it always runs the full poll duration + // so the masternodes may lock a single contender out + let end_date = if no_locking { + block_info.time_ms.saturating_add(join_time) + } else { + block_info.time_ms.saturating_add(poll_time) + }; + + self.add_vote_poll_end_date_query_operations( + owner_id, + VotePoll::ContestedDocumentResourceVotePoll( + contested_document_resource_vote_poll.into(), + ), + end_date, + block_info, + estimated_costs_only_with_layer_info, + previous_batch_operations, + &mut batch_operations, + transaction, + platform_version, + )?; + } else if no_locking && estimated_costs_only_with_layer_info.is_none() { + // The first additional contender opens the vote window: the end date moves from + // the end of the join window to the full poll duration. Later contenders find + // it there already. An estimation never reaches this branch, since a stateless + // insert reports every contest as new. + let existing_contenders = ResolvedContestedDocumentVotePollDriveQuery { + vote_poll: (&contested_document_resource_vote_poll).into(), + result_type: ContestedDocumentVotePollDriveQueryResultType::VoteTally, + offset: None, + limit: Some(2), + start_at: None, + allow_include_locked_and_abstaining_vote_tally: false, + } + .execute(self, transaction, &mut batch_operations, platform_version)? + .contenders + .len(); + + if existing_contenders == 1 { + let (fee_result, stored_info) = self + .fetch_contested_document_vote_poll_stored_info( + &contested_document_resource_vote_poll, + Some(&block_info.epoch), + transaction, + platform_version, + )?; + if let Some(fee_result) = fee_result { + batch_operations + .push(LowLevelDriveOperation::PreCalculatedFeeResult(fee_result)); + } + let Some(stored_info) = stored_info else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "a contest with a contender has no stored info".to_string(), + ))); + }; + let ContestedDocumentVotePollStatus::Started(start_block) = + stored_info.vote_poll_status() + else { + return Err(Error::Drive(DriveError::CorruptedDriveState( + "a contest accepting a contender has not started".to_string(), + ))); + }; + + let join_end = start_block.time_ms.saturating_add(join_time); + let vote_end = start_block.time_ms.saturating_add(poll_time); + let vote_poll = VotePoll::ContestedDocumentResourceVotePoll( + contested_document_resource_vote_poll.into(), + ); + + if join_end != vote_end { + let unique_id = vote_poll.unique_id()?; + // The join-window entry goes, and its time tree with it when it was + // the only entry at that time + self.batch_delete_up_tree_while_empty( + KeyInfoPath::from_known_owned_path( + vote_contested_resource_end_date_queries_at_time_tree_path_vec( + join_end, + ), + ), + unique_id.as_slice(), + Some(vote_end_date_queries_tree_path_vec().len() as u16), + BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + &*previous_batch_operations, + &mut batch_operations, + &platform_version.drive, + )?; + + self.add_vote_poll_end_date_query_operations( + owner_id, + vote_poll, + vote_end, + block_info, + estimated_costs_only_with_layer_info, + previous_batch_operations, + &mut batch_operations, + transaction, + platform_version, + )?; + } + } + } + + Ok(batch_operations) + } +} diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs index bc98d8b3a97..49de2a0083f 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_method_versions/v10.rs @@ -104,7 +104,7 @@ pub const DRIVE_ABCI_METHOD_VERSIONS_V10: DriveAbciMethodVersions = DriveAbciMet keep_record_of_finished_contested_resource_vote_poll: 0, clean_up_after_vote_poll_end: 0, clean_up_after_contested_resources_vote_poll_end: 1, - check_for_ended_vote_polls: 0, + check_for_ended_vote_polls: 1, // changed in v14: a tie goes to the earliest contender tally_votes_for_contested_document_resource_vote_poll: 0, award_document_to_winner: 0, delay_vote_poll: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs index 87db84be22e..bf3417cbf7b 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v10.rs @@ -103,7 +103,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = advanced_structure: Some(0), identity_signatures: None, nonce: Some(1), - state: 0, + state: 1, // changed in v14: refuses a Lock vote on a contested index resolved without locking transform_into_action: 0, }, masternode_vote_state_transition_balance_pre_check: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index 7bb30acbc0e..1388834b000 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -128,7 +128,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = add_contested_document: 0, add_contested_document_for_contract: 0, add_contested_document_for_contract_apply_and_add_to_operations: 0, - add_contested_document_for_contract_operations: 0, + add_contested_document_for_contract_operations: 1, // changed in v14: no-locking contests end at the join window until a second contender joins add_contested_document_to_primary_storage: 0, add_contested_indices_for_contract_operations: 0, add_contested_reference_and_vote_subtree_to_document_operations: 0, diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 64f89f6b189..e83cbe90de7 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -512,6 +512,20 @@ pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; /// of a moderated type (`ContractModeratedDocumentTypeNotYetUsableError`, /// 41200). No election exists yet. /// +/// 23. **Contested indexes without a Lock choice, and ties to the earliest +/// contender**: a contested unique index may declare `"resolution": 1`, +/// `ContestedIndexResolution::MasternodeVoteNoLocking` (meta-schema v3, +/// parser generation 3). Such a contest offers no Lock choice +/// (`VoteChoiceNotAllowedForVotePollError`, 40307, from `validate_state` 1 +/// of the masternode vote) and always ends with a winner. Its end date is +/// the end of the join window until a second contender joins, when +/// `add_contested_document_for_contract_operations` 1 moves it to the full +/// poll duration, so a contest with a single contender is awarded without +/// the vote window. `check_for_ended_vote_polls` 1 awards a tie to the +/// **earliest** contender (creation time, block height, core height, +/// document id) for every resolution, where the shipped rule awarded the +/// latest; DPNS contests ending from this version on follow the new rule. +/// /// The app-connect system contract (`SystemDataContract::AppConnect`, schema v1) /// carries only the wallet's `loginKeyResponse`: a flat indexOnly entry keyed by /// the app's ephemeral key hash and the responding identity, with the wallet's @@ -682,6 +696,66 @@ mod tests { /// The ranked index keywords are gated by the meta-schema version, so v14 /// must select meta-schema v3 while v13 stays on v2. + /// Contested indexes without a Lock choice (item 23): the three method + /// versions that read the resolution are selected by v14 only, so a v13 + /// replay keeps the shipped rules (a full poll for every contest, ties to + /// the latest contender, a Lock vote accepted on any contest). + #[test] + fn no_locking_contests_are_selected_by_v14_only() { + assert_eq!( + PLATFORM_V13 + .drive_abci + .methods + .voting + .check_for_ended_vote_polls, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive_abci + .methods + .voting + .check_for_ended_vote_polls, + 1 + ); + assert_eq!( + PLATFORM_V13 + .drive_abci + .validation_and_processing + .state_transitions + .masternode_vote_state_transition + .state, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive_abci + .validation_and_processing + .state_transitions + .masternode_vote_state_transition + .state, + 1 + ); + assert_eq!( + PLATFORM_V13 + .drive + .methods + .document + .insert_contested + .add_contested_document_for_contract_operations, + 0 + ); + assert_eq!( + PLATFORM_V14 + .drive + .methods + .document + .insert_contested + .add_contested_document_for_contract_operations, + 1 + ); + } + #[test] fn ranked_index_keywords_are_gated_by_meta_schema_v3() { assert_eq!( diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 016b1de3477..91fa6068698 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -164,6 +164,7 @@ use dpp::consensus::state::voting::masternode_not_found_error::MasternodeNotFoun use dpp::consensus::state::voting::masternode_vote_already_present_error::MasternodeVoteAlreadyPresentError; use dpp::consensus::state::voting::masternode_voted_too_many_times::MasternodeVotedTooManyTimesError; use dpp::consensus::state::voting::vote_poll_not_available_for_voting_error::VotePollNotAvailableForVotingError; +use dpp::consensus::state::voting::vote_choice_not_allowed_for_vote_poll_error::VoteChoiceNotAllowedForVotePollError; use dpp::consensus::state::voting::vote_poll_not_found_error::VotePollNotFoundError; use crate::errors::consensus::basic::data_contract::{ @@ -679,6 +680,9 @@ pub fn from_state_error(state_error: &StateError) -> JsValue { StateError::ContractModeratedDocumentTypeNotYetUsableError(e) => { generic_consensus_error!(ContractModeratedDocumentTypeNotYetUsableError, e).into() } + StateError::VoteChoiceNotAllowedForVotePollError(e) => { + generic_consensus_error!(VoteChoiceNotAllowedForVotePollError, e).into() + } } }