Skip to content

Commit

Permalink
add jcli merge voteplan results command (#3988)
Browse files Browse the repository at this point in the history
  • Loading branch information
ecioppettini authored Jun 7, 2022
1 parent 23b428a commit d36a286
Show file tree
Hide file tree
Showing 5 changed files with 342 additions and 2 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
- Add chain-evm as optional dependency for jcli
- Update gas price and block gas limit for EVM params
- Add new 'evm' REST API endpoints 'address_mapping/jormungandr_address', 'address_mapping/evm_address` for getting info about address mapping. They are optional for the 'evm' feature.
- Add jcli command to merge the results of multiple voteplans with the same proposals.

## Release 0.13.0

Expand Down
2 changes: 2 additions & 0 deletions jcli/src/jcli_lib/vote/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ pub enum Error {
source: std::io::Error,
path: PathBuf,
},
#[error(transparent)]
MergeError(#[from] tally::merge_results::Error),
}

#[derive(StructOpt)]
Expand Down
316 changes: 316 additions & 0 deletions jcli/src/jcli_lib/vote/tally/merge_results.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
use crate::jcli_lib::utils::io;
use crate::jcli_lib::utils::OutputFormat;
use jormungandr_lib::crypto::hash::Hash;
use jormungandr_lib::interfaces::VotePlanId;
use jormungandr_lib::interfaces::{PrivateTallyState, Tally, VotePlanStatus};
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::ops::Range;
use std::path::PathBuf;
use structopt::StructOpt;

#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("voteplan should be already decrypted before merging")]
VotePlanEncrypted,
#[error("voteplans have different privacy type")]
PrivacyMismatch,
}

#[derive(StructOpt)]
#[structopt(rename_all = "kebab-case")]
pub struct MergeVotePlan {
/// The path to json-encoded list of voteplans to merge. If this parameter is not specified, it
/// will be read from the standard input. Voteplans must be already decrypted before merging.
/// Two voteplans in the list will be merged if they have ALL the same proposals according to
/// the proposal (external) id.
#[structopt(long)]
vote_plans: Option<PathBuf>,
#[structopt(flatten)]
output_format: OutputFormat,
}

impl MergeVotePlan {
pub fn exec(&self) -> Result<(), super::Error> {
let voteplans: Vec<VotePlanStatus> =
serde_json::from_reader(io::open_file_read(&self.vote_plans)?)?;

let results = merge_voteplans(voteplans)?;

let output = self
.output_format
.format_json(serde_json::to_value(results)?)?;
println!("{}", output);

Ok(())
}
}

#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
pub struct MergedVotePlan {
pub ids: BTreeSet<VotePlanId>,
pub proposals: Vec<MergedVoteProposalStatus>,
}

/// like VoteProposalStatus but without the index field, since the proposals can be in different
/// indexes with the current implementation.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct MergedVoteProposalStatus {
pub proposal_id: Hash,
pub options: Range<u8>,
pub tally: Tally,
pub votes_cast: usize,
}

fn merge_voteplans(voteplans: Vec<VotePlanStatus>) -> Result<Vec<MergedVotePlan>, Error> {
let mut group_by_proposals: HashMap<Vec<Hash>, Vec<VotePlanStatus>> = HashMap::new();

for mut voteplan in voteplans.into_iter() {
// this matters not only to have a normal form for the keys of the hashmap, but also to be
// able to zip and merge later
voteplan.proposals.sort_by_key(|p| p.proposal_id);

let ids = voteplan
.proposals
.iter()
.map(|p| p.proposal_id)
.collect::<Vec<_>>();

group_by_proposals.entry(ids).or_default().push(voteplan);
}

group_by_proposals
.into_iter()
.map(|(_key, mut group)| {
let ids = group.iter().map(|group| group.id).collect();

let mut proposals = group
.pop()
.map(|p| {
p.proposals
.into_iter()
.map(|p| MergedVoteProposalStatus {
proposal_id: p.proposal_id,
options: p.options,
tally: p.tally,
votes_cast: p.votes_cast,
})
.collect::<Vec<_>>()
})
// there has to be at least one entry, since the key comes from the value, so this
// can't panic.
.unwrap();

for vps in group {
for (a, b) in proposals.iter_mut().zip(vps.proposals.iter()) {
a.votes_cast += b.votes_cast;

a.tally = match (&a.tally, &b.tally) {
(Tally::Public { result: result1 }, Tally::Public { result: result2 }) => {
Tally::Public {
result: result1.merge(result2),
}
}
(
Tally::Private {
state: PrivateTallyState::Decrypted { result: result1 },
},
Tally::Private {
state: PrivateTallyState::Decrypted { result: result2 },
},
) => Tally::Private {
state: PrivateTallyState::Decrypted {
result: result1.merge(result2),
},
},
(Tally::Public { result: _ }, Tally::Private { state: _ })
| (Tally::Private { state: _ }, Tally::Public { result: _ }) => {
return Err(Error::PrivacyMismatch);
}
(Tally::Private { state: _ }, Tally::Private { state: _ }) => {
return Err(Error::VotePlanEncrypted)
}
};
}
}

Ok(MergedVotePlan { ids, proposals })
})
.collect()
}

#[cfg(test)]
mod tests {
use chain_core::property::FromStr;
use chain_impl_mockchain::{
tokens::identifier::{self, TokenIdentifier},
vote::PayloadType,
};
use jormungandr_lib::interfaces::{BlockDate, TallyResult, VotePlanId, VoteProposalStatus};

use super::*;

fn gen_voteplan_status(
token: TokenIdentifier,
results: [u64; 2],
votes_cast: usize,
proposal_id: Hash,
id: VotePlanId,
) -> VotePlanStatus {
VotePlanStatus {
id,
payload: PayloadType::Private,
vote_start: BlockDate::new(0, 0),
vote_end: BlockDate::new(0, 1),
committee_end: BlockDate::new(0, 2),
committee_member_keys: vec![],
proposals: vec![VoteProposalStatus {
index: 0,
proposal_id,
options: 0..2,
tally: Tally::Private {
state: PrivateTallyState::Decrypted {
result: TallyResult {
results: results.try_into().unwrap(),
options: 0..2,
},
},
},
votes_cast,
}],
voting_token: token.into(),
}
}

#[test]
fn merge_decrypted_voteplans() {
let mut voteplans = Vec::new();

let voting_token1 = TokenIdentifier::from_str(
"00000000000000000000000000000000000000000000000000000000.00000000",
)
.unwrap();

let voting_token2 = identifier::TokenIdentifier::from_str(
"11111111111111111111111111111111111111111111111111111111.00000000",
)
.unwrap();

let voting_token3 = identifier::TokenIdentifier::from_str(
"22222222222222222222222222222222222222222222222222222222.00000000",
)
.unwrap();

let voting_token4 = identifier::TokenIdentifier::from_str(
"33333333333333333333333333333333333333333333333333333333.00000000",
)
.unwrap();

let voteplan1 = gen_voteplan_status(
voting_token1.clone(),
[1, 1],
2,
Hash::from([1u8; 32]),
VotePlanId::from([1u8; 32]),
);
voteplans.push(voteplan1.clone());

let voteplan2 = gen_voteplan_status(
voting_token2.clone(),
[1, 1],
2,
Hash::from([1u8; 32]),
VotePlanId::from([2u8; 32]),
);
voteplans.push(voteplan2.clone());

let voteplan3 = gen_voteplan_status(
voting_token1,
[1, 10],
3,
Hash::from([2u8; 32]),
VotePlanId::from([3u8; 32]),
);
voteplans.push(voteplan3.clone());

let voteplan4 = gen_voteplan_status(
voting_token2,
[2, 8],
4,
Hash::from([2u8; 32]),
VotePlanId::from([4u8; 32]),
);
voteplans.push(voteplan4.clone());

let voteplan5 = gen_voteplan_status(
voting_token3,
[1, 1],
2,
Hash::from([2u8; 32]),
VotePlanId::from([5u8; 32]),
);
voteplans.push(voteplan5.clone());

// standalone voteplan, should be ignored
let voteplan6 = gen_voteplan_status(
voting_token4,
[1, 0],
1,
Hash::from([3u8; 32]),
VotePlanId::from([6u8; 32]),
);
voteplans.push(voteplan6);

let mut result = merge_voteplans(voteplans).unwrap();

result.sort_by_key(|r| r.ids.clone());

assert_eq!(result.len(), 3);

match &result[0].proposals[0].tally {
Tally::Private {
state:
PrivateTallyState::Decrypted {
result:
TallyResult {
results,
options: _,
},
},
} => {
assert_eq!(results.clone(), vec![2, 2]);
}
_ => unreachable!(),
}

match &result[1].proposals[0].tally {
Tally::Private {
state:
PrivateTallyState::Decrypted {
result:
TallyResult {
results,
options: _,
},
},
} => {
assert_eq!(results.clone(), vec![4, 19]);
}
_ => unreachable!(),
}

assert_eq!(result[0].proposals[0].votes_cast, 4);
assert_eq!(result[1].proposals[0].votes_cast, 3 + 4 + 2);

assert_eq!(
result[0].ids,
BTreeSet::from_iter([voteplan1.id, voteplan2.id])
);
assert_eq!(
result[1].ids,
BTreeSet::from_iter([voteplan3.id, voteplan4.id, voteplan5.id])
);
}
}
7 changes: 7 additions & 0 deletions jcli/src/jcli_lib/vote/tally/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod decrypt_tally;
mod decryption_shares;
pub(crate) mod merge_results;

use super::Error;
use structopt::StructOpt;
Expand All @@ -20,6 +21,11 @@ pub enum Tally {
/// The decrypted tally data will be printed in hexadecimal encoding
/// on standard output.
DecryptResults(decrypt_tally::TallyVotePlanWithAllShares),
/// Merge voteplans that have the same external proposal ids.
///
/// The tally data will be printed in json encoding on standard output. There order of the
/// result is unspecified.
MergeResults(merge_results::MergeVotePlan),
}

impl Tally {
Expand All @@ -28,6 +34,7 @@ impl Tally {
Tally::DecryptionShares(cmd) => cmd.exec(),
Tally::DecryptResults(cmd) => cmd.exec(),
Tally::MergeShares(cmd) => cmd.exec(),
Tally::MergeResults(cmd) => cmd.exec(),
}
}
}
18 changes: 16 additions & 2 deletions jormungandr-lib/src/interfaces/vote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,14 +460,28 @@ pub enum Tally {

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)]
pub struct TallyResult {
results: Vec<u64>,
options: Range<u8>,
pub results: Vec<u64>,
pub options: Range<u8>,
}

impl TallyResult {
pub fn results(&self) -> Vec<u64> {
self.results.clone()
}

pub fn merge(&self, other: &Self) -> Self {
assert_eq!(self.options, other.options);

Self {
results: self
.results
.iter()
.zip(other.results().iter())
.map(|(l, r)| l + r)
.collect(),
options: self.options.clone(),
}
}
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down

0 comments on commit d36a286

Please sign in to comment.