diff --git a/packages/rs-drive-abci/tests/masternode_vote_proof.rs b/packages/rs-drive-abci/tests/masternode_vote_proof.rs new file mode 100644 index 00000000000..e4d5306cd68 --- /dev/null +++ b/packages/rs-drive-abci/tests/masternode_vote_proof.rs @@ -0,0 +1,209 @@ +//! The proof of a masternode's vote on one vote poll, as the SDK reads it when the network +//! answers a vote broadcast with "already exists" and the SDK fetches the stored vote instead. + +// Both modules are shared between suites, and this one uses only part of each. +#[allow(dead_code)] +mod signed_response; + +#[path = "../../rs-drive/tests/support/voting_default_limits.rs"] +#[allow(dead_code, unused_imports)] +mod fixture; + +use dapi_grpc::platform::v0::get_contested_resource_identity_votes_request::get_contested_resource_identity_votes_request_v0::StartAtVotePollIdInfo; +use dapi_grpc::platform::v0::get_contested_resource_identity_votes_request::GetContestedResourceIdentityVotesRequestV0; +use dapi_grpc::platform::v0::get_contested_resource_identity_votes_response::{ + get_contested_resource_identity_votes_response_v0, GetContestedResourceIdentityVotesResponseV0, + Version, +}; +use dapi_grpc::platform::v0::{ + GetContestedResourceIdentityVotesRequest, GetContestedResourceIdentityVotesResponse, + ResponseMetadata, +}; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::TokenConfiguration; +use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; +use dpp::version::PlatformVersion; +use dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dpp::voting::votes::resource_vote::v0::ResourceVoteV0; +use dpp::voting::votes::resource_vote::ResourceVote; +use dpp::voting::votes::Vote; +use drive_abci::rpc::core::MockCoreRPCLike; +use drive_abci::test::helpers::setup::{TempPlatform, TestPlatformBuilder}; +use drive_proof_verifier::{ContextProvider, ContextProviderError, FromProof}; +use signed_response::{signed, Provider}; +use std::sync::Arc; + +/// The number of vote polls the masternode has voted on. +const VOTED_POLLS: usize = 3; + +/// Serves the voting fixture's DPNS contract, and the test quorum's public key. +struct ContractProvider(Arc); +impl ContextProvider for ContractProvider { + fn get_data_contract( + &self, + id: &Identifier, + _: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok((*id == self.0.id()).then(|| self.0.clone())) + } + fn get_token_configuration( + &self, + _: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + fn get_quorum_public_key( + &self, + kind: u32, + hash: [u8; 32], + height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Provider.get_quorum_public_key(kind, hash, height) + } + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } +} + +/// A platform where the fixture's masternode has voted to abstain on the first +/// [`VOTED_POLLS`] DPNS name contests. +fn platform_with_votes(version: &PlatformVersion) -> (TempPlatform, DataContract) { + let platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc(); + platform + .drive + .create_initial_state_structure(None, version) + .expect("Drive structure"); + let contract = fixture::populate(&platform.drive, VOTED_POLLS, version); + (platform, contract) +} + +/// The request the SDK's `VoteQuery` sends for the masternode's vote on `vote_poll_id`: the +/// masternode's votes from that poll on, at most one. +fn vote_request(vote_poll_id: Identifier) -> GetContestedResourceIdentityVotesRequest { + GetContestedResourceIdentityVotesRequestV0 { + identity_id: fixture::VOTER.to_vec(), + limit: Some(1), + offset: None, + order_ascending: true, + start_at_vote_poll_id_info: Some(StartAtVotePollIdInfo { + start_at_poll_identifier: vote_poll_id.to_vec(), + start_poll_identifier_included: true, + }), + prove: true, + } + .into() +} + +/// Answers `request` with the node's query handler, its proof signed by the test quorum over +/// the platform's state. +fn answer( + platform: &TempPlatform, + request: GetContestedResourceIdentityVotesRequest, + version: &PlatformVersion, +) -> GetContestedResourceIdentityVotesResponse { + let state = platform.state.load(); + let response = platform + .query_contested_resource_identity_votes(request, &state, version) + .expect("query handled") + .into_data() + .expect("valid query"); + let Some(Version::V0(GetContestedResourceIdentityVotesResponseV0 { + result: Some(get_contested_resource_identity_votes_response_v0::Result::Proof(proof)), + .. + })) = response.version + else { + panic!("expected a proof") + }; + let metadata = ResponseMetadata { + height: 10, + core_chain_locked_height: 1, + time_ms: 1_700_000_000_000, + protocol_version: version.protocol_version, + chain_id: "dash-testnet-51".into(), + ..Default::default() + }; + let root = platform + .drive + .grove + .root_hash(None, &version.drive.grove_version) + .unwrap() + .expect("root hash"); + GetContestedResourceIdentityVotesResponse { + version: Some(Version::V0(GetContestedResourceIdentityVotesResponseV0 { + result: Some( + get_contested_resource_identity_votes_response_v0::Result::Proof(signed( + proof.grovedb_proof, + &metadata, + root, + )), + ), + metadata: Some(metadata), + })), + } +} + +/// Fetches the masternode's vote on `vote_poll_id` the way the SDK does, and reads it from the +/// proof. +fn fetch_vote( + platform: &TempPlatform, + contract: &DataContract, + vote_poll_id: Identifier, + version: &PlatformVersion, +) -> Result, Box> { + let request = vote_request(vote_poll_id); + let response = answer(platform, request.clone(), version); + >::maybe_from_proof( + request, + response, + Network::Testnet, + version, + &ContractProvider(Arc::new(contract.clone())), + ) + .map_err(Box::new) +} + +/// A vote transition broadcast again, as when a broadcast is retried after its answer was lost, +/// is answered that it already exists, and the SDK then confirms the vote from a proof of it. +/// The proved vote is keyed by its vote poll, so the SDK must read the vote of the requested +/// poll from it. +#[test] +fn should_read_the_proved_vote_of_a_masternode_vote_that_already_exists() { + let version = PlatformVersion::latest(); + let (platform, contract) = platform_with_votes(version); + + for i in 0..VOTED_POLLS { + let vote_poll = fixture::poll(&contract, i); + let vote_poll_id = vote_poll.unique_id().expect("vote poll id"); + + let vote = fetch_vote(&platform, &contract, vote_poll_id, version) + .expect("the proof of an existing vote decodes"); + + assert_eq!( + vote, + Some(Vote::ResourceVote(ResourceVote::V0(ResourceVoteV0 { + vote_poll, + resource_vote_choice: ResourceVoteChoice::Abstain, + }))), + "vote on poll {i}" + ); + } +} + +/// The request starts at the requested vote poll, so when the masternode has not voted on it +/// the proof carries its vote on the next poll instead. That vote is not the requested one: +/// the masternode is proved to have no vote on the requested poll. +#[test] +fn should_prove_no_vote_on_a_vote_poll_the_masternode_has_not_voted_on() { + let version = PlatformVersion::latest(); + let (platform, contract) = platform_with_votes(version); + // Below the id of every vote poll the masternode voted on. + let not_voted_on = Identifier::new([0; 32]); + + let vote = fetch_vote(&platform, &contract, not_voted_on, version) + .expect("the proof of the masternode's next vote decodes"); + + assert_eq!(vote, None); +} diff --git a/packages/rs-drive-proof-verifier/src/proof.rs b/packages/rs-drive-proof-verifier/src/proof.rs index 17dfafc258f..455efc94a05 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -2360,10 +2360,43 @@ impl FromProof for PrefundedSpe } } +/// The vote poll of the vote a single-vote request asks for. +/// +/// A vote is the identity's vote on one vote poll, so the request has to start at that poll, +/// including it. +fn requested_vote_poll_id( + request: &platform::GetContestedResourceIdentityVotesRequest, +) -> Result { + match request.version.as_ref().ok_or(Error::EmptyVersion)? { + get_contested_resource_identity_votes_request::Version::V0(v0) => { + match &v0.start_at_vote_poll_id_info { + Some(start) if start.start_poll_identifier_included => { + Identifier::from_bytes(&start.start_at_poll_identifier).map_err(|e| { + Error::RequestError { + error: e.to_string(), + } + }) + } + _ => Err(Error::RequestError { + error: "a vote request must start at the vote poll of the vote, including it" + .to_string(), + }), + } + } + } +} + impl FromProof for Vote { type Request = platform::GetContestedResourceIdentityVotesRequest; type Response = platform::GetContestedResourceIdentityVotesResponse; + fn check_request( + request: &Self::Request, + _platform_version: &PlatformVersion, + ) -> Result<(), Error> { + requested_vote_poll_id(request).map(|_| ()) + } + fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( request: I, response: O, @@ -2375,13 +2408,7 @@ impl FromProof for Vote { Self: Sized + 'a, { let request = request.into(); - let id_in_request = match request.version.as_ref().ok_or(Error::EmptyVersion)? { - get_contested_resource_identity_votes_request::Version::V0(v0) => { - Identifier::from_bytes(&v0.identity_id).map_err(|e| Error::RequestError { - error: e.to_string(), - })? - } - }; + let vote_poll_id = requested_vote_poll_id(&request)?; let (maybe_votes, mtd, proof) = ResourceVotesByIdentity::maybe_from_proof_with_metadata( request, @@ -2391,30 +2418,15 @@ impl FromProof for Vote { provider, )?; - let (id, vote) = match maybe_votes { - Some(v) if v.len() > 1 => { - return Err(Error::ResponseDecodeError { - error: format!("expected 1 vote, got {}", v.len()), - }) - } - Some(v) if v.is_empty() => return Ok((None, mtd, proof)), - Some(v) => v - .into_iter() - .next() - .expect("is_empty() must detect empty map"), - None => return Ok((None, mtd, proof)), - }; - - if id != id_in_request { - return Err(Error::ResponseDecodeError { - error: format!( - "expected vote for identity {}, got vote for identity {}", - id_in_request, id - ), - }); - } + // The proved votes are keyed by vote poll id. When the identity has not voted on the + // requested poll, the proof carries its votes on the polls that follow it in the + // requested order instead, up to the limit, and none of them is the requested vote. + let vote = maybe_votes + .and_then(|mut votes| votes.shift_remove(&vote_poll_id)) + .flatten() + .map(Vote::ResourceVote); - Ok((vote.map(Vote::ResourceVote), mtd, proof)) + Ok((vote, mtd, proof)) } } @@ -6343,15 +6355,19 @@ mod tests { #[test] fn vote_identity_vote_rejects_bad_identity_id_length() { - // The Vote impl validates id_in_request length before delegating. + // The request is validated before the proof is read. + use dapi_grpc::platform::v0::get_contested_resource_identity_votes_request::get_contested_resource_identity_votes_request_v0::StartAtVotePollIdInfo; use dapi_grpc::platform::v0::get_contested_resource_identity_votes_request::GetContestedResourceIdentityVotesRequestV0; let request: platform::GetContestedResourceIdentityVotesRequest = GetContestedResourceIdentityVotesRequestV0 { identity_id: vec![0u8; 5], // bad - limit: None, + limit: Some(1), offset: None, order_ascending: true, - start_at_vote_poll_id_info: None, + start_at_vote_poll_id_info: Some(StartAtVotePollIdInfo { + start_at_poll_identifier: vec![1u8; 32], + start_poll_identifier_included: true, + }), prove: true, } .into(); @@ -6370,6 +6386,71 @@ mod tests { assert!(matches!(err, Error::RequestError { .. }), "got: {err:?}"); } + #[test] + fn should_refuse_a_vote_request_that_does_not_start_at_its_vote_poll() { + // The proved votes are keyed by vote poll id, so without the poll the request starts + // at, including it, there is no vote to pick from them. Such a request is refused + // before it is sent: refused only once a node answered, it would count against that + // node although the node answered it correctly. + use dapi_grpc::platform::v0::get_contested_resource_identity_votes_request::get_contested_resource_identity_votes_request_v0::StartAtVotePollIdInfo; + use dapi_grpc::platform::v0::get_contested_resource_identity_votes_request::GetContestedResourceIdentityVotesRequestV0; + let request = + |start_at_vote_poll_id_info| -> platform::GetContestedResourceIdentityVotesRequest { + GetContestedResourceIdentityVotesRequestV0 { + identity_id: vec![7u8; 32], + limit: Some(1), + offset: None, + order_ascending: true, + start_at_vote_poll_id_info, + prove: true, + } + .into() + }; + + let starting_at_its_vote_poll = request(Some(StartAtVotePollIdInfo { + start_at_poll_identifier: vec![1u8; 32], + start_poll_identifier_included: true, + })); + >::check_request( + &starting_at_its_vote_poll, + default_platform_version(), + ) + .expect("a request starting at its vote poll is sent"); + + for start_at_vote_poll_id_info in [ + None, + Some(StartAtVotePollIdInfo { + start_at_poll_identifier: vec![1u8; 32], + start_poll_identifier_included: false, + }), + // Not a vote poll id: 5 bytes instead of 32. + Some(StartAtVotePollIdInfo { + start_at_poll_identifier: vec![1u8; 5], + start_poll_identifier_included: true, + }), + ] { + let request = request(start_at_vote_poll_id_info); + let err = >::check_request(&request, default_platform_version()) + .unwrap_err(); + assert!(matches!(err, Error::RequestError { .. }), "got: {err:?}"); + + let provider = unreachable_provider(); + let err = >::maybe_from_proof( + request, + platform::GetContestedResourceIdentityVotesResponse::default(), + Network::Testnet, + default_platform_version(), + &provider, + ) + .unwrap_err(); + assert!(matches!(err, Error::RequestError { .. }), "got: {err:?}"); + } + } + #[test] fn vote_identity_vote_empty_version_on_request_none() { let request = platform::GetContestedResourceIdentityVotesRequest { version: None };