From 8430344a3e158891783a3e4c1bbfcd9cca1aa0f2 Mon Sep 17 00:00:00 2001 From: sczembor <43810037+sczembor@users.noreply.github.com> Date: Wed, 16 Aug 2023 20:09:45 +0200 Subject: [PATCH] add revoke_vote method (#36) Co-authored-by: Amit Yadav Co-authored-by: Robert Zaremba --- elections/README.md | 13 +++- elections/src/errors.rs | 8 ++ elections/src/lib.rs | 52 +++++++++++++ elections/src/proposal.rs | 153 +++++++++++++++++++++++++++++++++++++- elections/src/storage.rs | 1 + elections/tests/iah.rs | 2 +- 6 files changed, 220 insertions(+), 9 deletions(-) diff --git a/elections/README.md b/elections/README.md index ea4e7a0b..892bbf62 100644 --- a/elections/README.md +++ b/elections/README.md @@ -7,8 +7,9 @@ - Only the authority (set during contract initialization) can create proposals. Each proposal specifies: - `typ`: must be HouseType variant - - `start`: voting start time as UNIX time (in seconds) - - `end`: voting start time as UNIX time (in seconds) + - `start`: voting start time as UNIX time (in miliseconds) + - `end`: voting start time as UNIX time (in miliseconds) + - `cooldown`: cooldown duration when votes from blacklisted accounts can be revoked by an authority (in miliseconds) - `ref_link`: string (can't be empty) - a link to external resource with more details (eg near social post). Max length is 120 characters. - `quorum`: minimum amount of legit accounts to vote to legitimize the elections. - `seats`: max number of candidates to elect, also max number of credits each user has when casting a vote. @@ -21,6 +22,7 @@ - Once the proposals are created and the elections start (`now >= proposal.start`), all human verified near accounts can vote according to the NDC Elections [v1 Framework](../README.md#elections). - Anyone can query the proposal and the ongoing result at any time. - Voting is active until the `proposal.end` time. +- Vote revocation is active until the `proposal.end` + `cooldown` time. ## Usage @@ -31,9 +33,9 @@ CTR=elections-v1.gwg.testnet REGISTRY=registry-1.i-am-human.testnet # create proposal -# note: start and end time must be in milliseconds +# note: start time, end time and cooldown must be in milliseconds -near call $CTR create_proposal '{"start": 1686221747000, "end": 1686653747000, "ref_link": "example.com", "quorum": 10, "candidates": ["candidate1.testnet", "candidate2.testnet", "candidate3.testnet", "candidate4.testnet"], "typ": "HouseOfMerit", "seats": 3, "policy": "f1c09f8686fe7d0d798517111a66675da0012d8ad1693a47e0e2a7d3ae1c69d4"}' --accountId $CTR +near call $CTR create_proposal '{"start": 1686221747000, "end": 1686653747000, "cooldown": 604800000 "ref_link": "example.com", "quorum": 10, "candidates": ["candidate1.testnet", "candidate2.testnet", "candidate3.testnet", "candidate4.testnet"], "typ": "HouseOfMerit", "seats": 3, "policy": "f1c09f8686fe7d0d798517111a66675da0012d8ad1693a47e0e2a7d3ae1c69d4"}' --accountId $CTR # fetch all proposal near view $CTR proposals '' @@ -49,6 +51,9 @@ near call $CTR accepted_policy '{"user": "alice.testnet"}' --accountId me.testne # vote near call $CTR vote '{"prop_id": 1, "vote": ["candidate1.testnet", "candidate3.testnet"]}' --gas 70000000000000 --deposit 0.0005 --accountId me.testnet + +# revoke vote (authority only) +near call $CTR revoke_vote '{"prop_id": 1, "token_id": 1}' ``` ## Deployed Contracts diff --git a/elections/src/errors.rs b/elections/src/errors.rs index 6030f4d5..a0ba0e3a 100644 --- a/elections/src/errors.rs +++ b/elections/src/errors.rs @@ -11,6 +11,9 @@ pub enum VoteError { NoSBTs, DuplicateCandidate, DoubleVote(TokenId), + RevokeNotActive, + NotVoted, + DoubleRevoke, } impl FunctionError for VoteError { @@ -24,6 +27,11 @@ impl FunctionError for VoteError { VoteError::DoubleVote(sbt) => { panic_str(&format!("user already voted with sbt={}", sbt)) } + VoteError::RevokeNotActive => panic_str( + "can only revoke votes between proposal start and (end time + cooldown)" + ), + VoteError::NotVoted => panic_str("voter did not vote on this proposal"), + VoteError::DoubleRevoke => panic_str("vote already revoked"), } } } diff --git a/elections/src/lib.rs b/elections/src/lib.rs index ad34cc46..6fc5be0a 100644 --- a/elections/src/lib.rs +++ b/elections/src/lib.rs @@ -58,6 +58,7 @@ impl Contract { typ: HouseType, start: u64, end: u64, + cooldown: u64, ref_link: String, quorum: u32, seats: u16, @@ -90,6 +91,7 @@ impl Contract { typ, start, end, + cooldown, quorum, ref_link, seats, @@ -97,6 +99,7 @@ impl Contract { result: vec![0; l], voters: LookupSet::new(StorageKey::ProposalVoters(self.prop_counter)), voters_num: 0, + voters_candidates: LookupMap::new(StorageKey::VotersCandidates(self.prop_counter)), policy, }; @@ -153,6 +156,18 @@ impl Contract { ) } + /// Method for the authority to revoke votes from blacklisted accounts. + /// Panics if the proposal doesn't exists or the it's called before the proposal starts or after proposal `end+cooldown`. + #[handle_result] + pub fn revoke_vote(&mut self, prop_id: u32, token_id: TokenId) -> Result<(), VoteError> { + // check if the caller is the authority allowed to revoke votes + self.assert_admin(); + let mut p = self._proposal(prop_id); + p.revoke_votes(token_id)?; + self.proposals.insert(&prop_id, &p); + Ok(()) + } + /***************** * QUERIES ****************/ @@ -280,6 +295,7 @@ mod unit_tests { crate::HouseType::HouseOfMerit, START - 1, START + 100, + 100, String::from("ref_link.io"), 2, 2, @@ -297,6 +313,7 @@ mod unit_tests { crate::HouseType::HouseOfMerit, START + 10, START, + 100, String::from("ref_link.io"), 2, 2, @@ -314,6 +331,7 @@ mod unit_tests { crate::HouseType::HouseOfMerit, START + 1, START + 10, + 100, String::from("short"), 2, 2, @@ -331,6 +349,7 @@ mod unit_tests { crate::HouseType::HouseOfMerit, START + 1, START + 10, + 100, String::from("ref_link.io"), 2, 2, @@ -344,6 +363,7 @@ mod unit_tests { crate::HouseType::HouseOfMerit, START + 1, START + 10, + 100, String::from("ref_link.io"), 2, 2, @@ -648,4 +668,36 @@ mod unit_tests { ctr.vote(prop_id, vec![]); // note: we can only check vote result and state change through an integration test. } + + #[test] + #[should_panic(expected = "not an admin")] + fn revoke_vote_not_admin() { + let (_, mut ctr) = setup(&alice()); + let prop_id = mk_proposal(&mut ctr); + let res = ctr.revoke_vote(prop_id, 1); + // this will never be checked since the method is panicing not returning an error + assert!(res.is_err()); + } + + #[test] + fn revoke_vote_no_votes() { + let (mut ctx, mut ctr) = setup(&admin()); + let prop_id = mk_proposal(&mut ctr); + ctx.block_timestamp = (START + 100) * MSECOND; + testing_env!(ctx); + match ctr.revoke_vote(prop_id, 1) { + Err(VoteError::NotVoted) => (), + x => panic!("expected NotVoted, got: {:?}", x), + } + } + + #[test] + #[should_panic(expected = "proposal not found")] + fn revoke_vote_no_proposal() { + let (_, mut ctr) = setup(&admin()); + let prop_id = 2; + match ctr.revoke_vote(prop_id, 1) { + x => panic!("{:?}", x), + } + } } diff --git a/elections/src/proposal.rs b/elections/src/proposal.rs index 2ef30b40..2ce619d3 100644 --- a/elections/src/proposal.rs +++ b/elections/src/proposal.rs @@ -1,9 +1,8 @@ -use std::collections::HashSet; - use near_sdk::borsh::{self, BorshDeserialize, BorshSerialize}; -use near_sdk::collections::LookupSet; +use near_sdk::collections::{LookupMap, LookupSet}; use near_sdk::serde::{Deserialize, Serialize}; use near_sdk::{env, require, AccountId}; +use std::collections::HashSet; use uint::hex; pub use crate::constants::*; @@ -27,6 +26,9 @@ pub struct Proposal { pub start: u64, /// end of voting as Unix timestamp (in milliseconds) pub end: u64, + /// duration of cooldown after the proposal ends. During this time votes cannot be submitted and + /// the malicious votes can be revoked by authorities (in milliseconds). + pub cooldown: u64, /// min amount of voters to legitimize the voting. pub quorum: u32, /// max amount of seats a voter can allocate candidates for. @@ -36,10 +38,11 @@ pub struct Proposal { /// running result (ongoing sum of votes per candidate), in the same order as `candidates`. /// result[i] = sum of votes for candidates[i] pub result: Vec, - /// set of tokenIDs, which were used for voting, as a proof of personhood pub voters: LookupSet, pub voters_num: u32, + // map of voters -> candidates they voted for (token IDs used for voting -> candidates index) + pub voters_candidates: LookupMap>, /// blake2s-256 hash of the Fair Voting Policy text. pub policy: [u8; 32], } @@ -56,6 +59,8 @@ pub struct ProposalView { pub start: u64, /// end of voting as Unix timestamp (in milliseconds) pub end: u64, + /// cooldown period after voting ends (in milliseconds) + pub cooldown: u64, /// min amount of voters to legitimize the voting. pub quorum: u32, pub voters_num: u32, @@ -81,6 +86,7 @@ impl Proposal { ref_link: self.ref_link, start: self.start, end: self.end, + cooldown: self.cooldown, quorum: self.quorum, voters_num: self.voters_num, seats: self.seats, @@ -97,6 +103,21 @@ impl Proposal { ) } + pub fn is_active_cooldown(&self) -> bool { + let now = env::block_timestamp_ms(); + if self.start <= now && now <= (self.end + self.cooldown) { + return true; + } + false + } + + pub fn is_used_token(&self, token_id: TokenId) -> bool { + if self.voters.contains(&token_id) { + return true; + } + false + } + /// once vote proof has been verified, we call this function to register a vote. pub fn vote_on_verified(&mut self, sbts: &Vec, vote: Vote) -> Result<(), VoteError> { self.assert_active(); @@ -105,11 +126,34 @@ impl Proposal { return Err(VoteError::DoubleVote(*t)); } } + let mut indexes = Vec::new(); self.voters_num += 1; for candidate in vote { let idx = self.candidates.binary_search(&candidate).unwrap(); self.result[idx] += 1; + indexes.push(idx); + } + // TODO: this logic needs to be updated once we use more tokens per user to vote + self.voters_candidates.insert(&sbts[0], &indexes); + Ok(()) + } + + pub fn revoke_votes(&mut self, token_id: TokenId) -> Result<(), VoteError> { + if !self.is_active_cooldown() { + return Err(VoteError::RevokeNotActive); + } + if !self.is_used_token(token_id) { + return Err(VoteError::NotVoted); } + for candidate in self + .voters_candidates + .get(&token_id) + .ok_or(VoteError::DoubleRevoke)? + { + self.result[candidate] -= 1; + } + self.voters_num -= 1; + self.voters_candidates.remove(&token_id); Ok(()) } } @@ -180,12 +224,14 @@ mod tests { ref_link: "near.social/abc".to_owned(), start: 10, end: 111222, + cooldown: 1000, quorum: 551, seats: 2, candidates: vec![mk_account(2), mk_account(1), mk_account(3), mk_account(4)], result: vec![10000, 5, 321, 121], voters: LookupSet::new(StorageKey::ProposalVoters(1)), voters_num: 10, + voters_candidates: LookupMap::new(StorageKey::VotersCandidates(1)), policy: policy1(), }; assert_eq!( @@ -195,6 +241,7 @@ mod tests { ref_link: p.ref_link.clone(), start: p.start, end: p.end, + cooldown: p.cooldown, quorum: p.quorum, seats: p.seats, voters_num: p.voters_num, @@ -210,4 +257,102 @@ mod tests { p.to_view(12) ) } + + #[test] + fn revoke_votes() { + let mut p = Proposal { + typ: HouseType::CouncilOfAdvisors, + ref_link: "near.social/abc".to_owned(), + start: 0, + end: 100, + cooldown: 10, + quorum: 551, + seats: 2, + candidates: vec![mk_account(1), mk_account(2)], + result: vec![3, 1], + voters: LookupSet::new(StorageKey::ProposalVoters(1)), + voters_num: 3, + voters_candidates: LookupMap::new(StorageKey::VotersCandidates(1)), + policy: policy1(), + }; + p.voters.insert(&1); + p.voters.insert(&2); + p.voters.insert(&3); + p.voters_candidates.insert(&1, &vec![0, 1]); + p.voters_candidates.insert(&2, &vec![0]); + p.voters_candidates.insert(&3, &vec![0]); + + match p.revoke_votes(1) { + Ok(_) => (), + x => panic!("expected OK, got: {:?}", x), + } + assert_eq!(p.result, vec![2, 0]); + match p.revoke_votes(2) { + Ok(_) => (), + x => panic!("expected OK, got: {:?}", x), + } + assert_eq!(p.result, vec![1, 0]); + match p.revoke_votes(3) { + Ok(_) => (), + x => panic!("expected OK, got: {:?}", x), + } + assert_eq!(p.result, vec![0, 0]); + } + + #[test] + fn revoke_revoked_votes() { + let mut p = Proposal { + typ: HouseType::CouncilOfAdvisors, + ref_link: "near.social/abc".to_owned(), + start: 0, + end: 100, + cooldown: 10, + quorum: 551, + seats: 2, + candidates: vec![mk_account(1), mk_account(2)], + result: vec![1, 1], + voters: LookupSet::new(StorageKey::ProposalVoters(1)), + voters_num: 1, + voters_candidates: LookupMap::new(StorageKey::VotersCandidates(1)), + policy: policy1(), + }; + p.voters.insert(&1); + p.voters_candidates.insert(&1, &vec![0, 1]); + + match p.revoke_votes(1) { + Ok(_) => (), + x => panic!("expected OK, got: {:?}", x), + } + assert_eq!(p.result, vec![0, 0]); + match p.revoke_votes(1) { + Err(VoteError::DoubleRevoke) => (), + x => panic!("expected DoubleRevoke, got: {:?}", x), + } + } + + #[test] + fn revoke_non_exising_votes() { + let mut p = Proposal { + typ: HouseType::CouncilOfAdvisors, + ref_link: "near.social/abc".to_owned(), + start: 0, + end: 100, + cooldown: 10, + quorum: 551, + seats: 2, + candidates: vec![mk_account(1), mk_account(2)], + result: vec![1, 1], + voters: LookupSet::new(StorageKey::ProposalVoters(1)), + voters_num: 1, + voters_candidates: LookupMap::new(StorageKey::VotersCandidates(1)), + policy: policy1(), + }; + p.voters.insert(&1); + p.voters_candidates.insert(&1, &vec![0, 1]); + + match p.revoke_votes(2) { + Err(VoteError::NotVoted) => (), + x => panic!("expected NotVoted, got: {:?}", x), + } + } } diff --git a/elections/src/storage.rs b/elections/src/storage.rs index 33b7e2ab..1b34098f 100644 --- a/elections/src/storage.rs +++ b/elections/src/storage.rs @@ -6,5 +6,6 @@ use near_sdk::BorshStorageKey; pub enum StorageKey { Proposals, ProposalVoters(u32), + VotersCandidates(u32), AcceptedPolicy, } diff --git a/elections/tests/iah.rs b/elections/tests/iah.rs index b0e5f338..fd17ef24 100644 --- a/elections/tests/iah.rs +++ b/elections/tests/iah.rs @@ -81,7 +81,7 @@ async fn init( .call(ndc_elections_contract.id(), "create_proposal") .args_json(json!({ "typ": HouseType::HouseOfMerit, "start": start_time, - "end": u64::MAX, "ref_link": "test.io", "quorum": 10, + "end": u64::MAX, "cooldown": 604800000, "ref_link": "test.io", "quorum": 10, "credits": 5, "seats": 1, "candidates": [john_acc.id(), alice_acc.id()], "policy": policy1(), }))