Initial commit.

This commit is contained in:
Andreas Fackler 2018-03-31 16:57:49 +02:00
commit 79723c1a31
14 changed files with 1955 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
**/*.rs.bk

1385
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

12
Cargo.toml Normal file
View File

@ -0,0 +1,12 @@
[package]
name = "poa-ballot-stats"
version = "0.1.0"
authors = ["Andreas Fackler <AndreasFackler@gmx.de>"]
description = "Read POA voting records and rank voters by how many ballots they missed."
[dependencies]
clap = "2.31.2"
colored = "1.6.0"
error-chain = { version = "0.11", default-features = false }
ethabi = "5.1.1"
web3 = "0.3.0"

19
README.md Normal file
View File

@ -0,0 +1,19 @@
# POA ballot stats
**Note**: This is still work in progress. It doesn't yet correctly determine the initial set of
validators.
A command line tool that displays voting statistics for the [POA network](https://poa.network/).
It requires a recent version of [Rust](https://www.rust-lang.org/), and needs to communicate with a
fully synchronized node that is connected to the network:
[POA installation](https://github.com/poanetwork/wiki/wiki/POA-Installation).
With the default setup, it should work without any additional options. You can view the command line
options with `-h`, and specify a different endpoint if your node e.g. uses a non-standard port.
```bash
$ cargo run
$ cargo run -- -h
$ cargo run -- http://127.0.0.1:8545
```

View File

@ -0,0 +1 @@
[{"constant":true,"inputs":[{"name":"","type":"uint256"}],"name":"pendingList","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getCurrentValidatorsLength","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_newAddress","type":"address"}],"name":"setProxyStorage","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"_validator","type":"address"},{"name":"_shouldFireEvent","type":"bool"}],"name":"addValidator","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"currentValidatorsLength","outputs":[{"name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"","type":"address"}],"name":"validatorsState","outputs":[{"name":"isValidator","type":"bool"},{"name":"index","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getPendingList","outputs":[{"name":"","type":"address[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getVotingToChangeKeys","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"finalizeChange","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"name":"_newKey","type":"address"},{"name":"_oldKey","type":"address"}],"name":"swapValidatorKey","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"name":"","type":"uint256"}],"name":"currentValidators","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getKeysManager","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"isMasterOfCeremonyInitialized","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"proxyStorage","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"finalized","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"getValidators","outputs":[{"name":"","type":"address[]"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"systemAddress","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"name":"_validator","type":"address"},{"name":"_shouldFireEvent","type":"bool"}],"name":"removeValidator","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"masterOfCeremony","outputs":[{"name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"name":"_someone","type":"address"}],"name":"isValidator","outputs":[{"name":"","type":"bool"}],"payable":false,"stateMutability":"view","type":"function"},{"inputs":[{"name":"_masterOfCeremony","type":"address"},{"name":"validators","type":"address[]"}],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"name":"parentHash","type":"bytes32"},{"indexed":false,"name":"newSet","type":"address[]"}],"name":"InitiateChange","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"newSet","type":"address[]"}],"name":"ChangeFinalized","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"nameOfContract","type":"string"},{"indexed":false,"name":"newAddress","type":"address"}],"name":"ChangeReference","type":"event"},{"anonymous":false,"inputs":[{"indexed":false,"name":"proxyStorage","type":"address"}],"name":"MoCInitializedProxyStorage","type":"event"}]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

27
src/cli.rs Normal file
View File

@ -0,0 +1,27 @@
use clap::{App, Arg, ArgMatches};
const VERSION: &str = env!("CARGO_PKG_VERSION");
const AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
const DESCRIPTION: &str = env!("CARGO_PKG_DESCRIPTION");
/// Returns the matched command line arguments.
pub fn get_matches() -> ArgMatches<'static> {
App::new("POA ballot statistics")
.author(AUTHORS)
.version(VERSION)
.about(DESCRIPTION)
.arg(
Arg::with_name("url")
.value_name("URL")
.help("The JSON-RPC endpoint")
.takes_value(true),
)
.arg(
Arg::with_name("verbose")
.short("v")
.long("verbose")
.help("More detailed output")
.takes_value(false),
)
.get_matches()
}

16
src/error.rs Normal file
View File

@ -0,0 +1,16 @@
use ethabi;
use web3;
error_chain! {
foreign_links {
Ethabi(ethabi::Error);
Web3(web3::Error);
Contract(web3::contract::Error);
}
errors {
UnexpectedLogParams {
description("Unexpected parameter types in log"),
}
}
}

93
src/events.rs Normal file
View File

@ -0,0 +1,93 @@
use error::{ErrorKind, Result};
use ethabi::{Address, Log, RawTopicFilter, Token, Topic, Uint};
use util::LogExt;
/// An event that is logged when the current set of validators has changed.
#[derive(Debug)]
pub struct ChangeFinalized {
/// The new set of validators.
pub new_set: Vec<Address>,
}
impl ChangeFinalized {
/// Parses the log and returns a `ChangeFinalized`, if the log corresponded to such an event.
pub fn from_log(log: &Log) -> Result<ChangeFinalized> {
log.param(0, "newSet")
.cloned()
.and_then(Token::to_array)
.map(|tokens| ChangeFinalized {
new_set: tokens.into_iter().filter_map(Token::to_address).collect(),
})
.ok_or_else(|| ErrorKind::UnexpectedLogParams.into())
}
}
/// An event that is logged when a new ballot is started.
#[derive(Debug)]
pub struct BallotCreated {
/// The ballot ID.
pub id: Uint,
/// The ballot type.
ballot_type: Uint,
/// The creator's voting key.
creator: Address,
}
impl BallotCreated {
/// Parses the log and returns a `BallotCreated`, if the log corresponded to such an event.
pub fn from_log(log: &Log) -> Result<BallotCreated> {
match (
log.uint_param(0, "id"),
log.uint_param(1, "ballotType"),
log.address_param(2, "creator"),
) {
(Some(&id), Some(&ballot_type), Some(&creator)) => Ok(BallotCreated {
id,
ballot_type,
creator,
}),
_ => Err(ErrorKind::UnexpectedLogParams.into()),
}
}
/// Returns a topic filter to find the votes corresponding to this ballot.
pub fn vote_topic_filter(&self) -> RawTopicFilter {
RawTopicFilter {
topic0: Topic::This(Token::Uint(self.id)),
..RawTopicFilter::default()
}
}
}
/// An event that is logged whenever someone casts a vote in a ballot.
#[derive(Debug)]
pub struct Vote {
/// The ballot ID.
id: Uint,
/// The decision this vote is for.
decision: Uint,
/// The voter's voting key.
pub voter: Address,
/// The timestamp of this vote.
time: Uint,
}
impl Vote {
/// Parses the log and returns a `Vote`, if the log corresponded to such an event.
pub fn from_log(log: &Log) -> Result<Vote> {
match (
log.uint_param(0, "id"),
log.uint_param(1, "decision"),
log.address_param(2, "voter"),
log.uint_param(3, "time"),
) {
(Some(&id), Some(&decision), Some(&voter), Some(&time)) => Ok(Vote {
id,
decision,
voter,
time,
}),
_ => Err(ErrorKind::UnexpectedLogParams.into()),
}
}
}

119
src/main.rs Normal file
View File

@ -0,0 +1,119 @@
extern crate clap;
extern crate colored;
#[macro_use]
extern crate error_chain;
extern crate ethabi;
extern crate web3;
mod cli;
mod error;
mod events;
mod stats;
mod util;
mod validator;
use error::{Error, ErrorKind};
use events::{BallotCreated, ChangeFinalized, Vote};
use stats::Stats;
use std::fs::File;
use util::{ContractExt, TopicFilterExt, Web3LogExt};
use web3::futures::Future;
// TODO: `ethabi_derive` produces unparseable tokens.
// mod voting_to_change_keys {
// #[derive(EthabiContract)]
// #[ethabi_contract_options(name = "VotingToChangeKeys", path = "abi/VotingToChangeKeys.json")]
// struct _Dummy;
// }
/// Finds all logged ballots and returns statistics about how many were missed by each voter.
fn count_votes(
url: &str,
verbose: bool,
voting_abi: &File,
net_con_abi: &File,
val_meta_abi: &File,
) -> Result<Stats, Error> {
let (_eloop, transport) = web3::transports::Http::new(url).unwrap();
let web3 = web3::Web3::new(transport);
let voting_contract = ethabi::Contract::load(voting_abi)?;
let net_con_contract = ethabi::Contract::load(net_con_abi)?;
let val_meta_contract = ethabi::Contract::load(val_meta_abi)?;
// TODO: Read contract addresses from chain spec.
let val_meta_addr = util::parse_address("0xfb9c7fC2a00DfFc53948e3bbeb11F3D4b56C31B8").unwrap();
let web3_val_meta = web3::contract::Contract::new(web3.eth(), val_meta_addr, val_meta_contract);
let ballot_event = voting_contract.event("BallotCreated")?;
let vote_event = voting_contract.event("Vote")?;
let change_event = net_con_contract.event("ChangeFinalized")?;
// Find all ballots and voter changes.
let ballot_or_change_filter = ethabi::TopicFilter {
topic0: ethabi::Topic::OneOf(vec![ballot_event.signature(), change_event.signature()]),
..ethabi::TopicFilter::default()
}.to_filter_builder()
.build();
let ballot_change_logs_filter = web3.eth_filter()
.create_logs_filter(ballot_or_change_filter)
.wait()?;
// FIXME: Find out why we see no `ChangeFinalized` events, and how to obtain the initial voters.
let mut voters: Vec<ethabi::Address> = Vec::new();
let mut stats = Stats::default();
// Iterate over all ballot and voter change events.
for log in ballot_change_logs_filter.logs().wait()? {
if let Ok(change_log) = change_event.parse_log(log.clone().into_raw()) {
// If it is a `ChangeFinalized`, update the current set of voters.
let change = ChangeFinalized::from_log(&change_log)?;
if verbose {
println!("{:?}", change);
}
voters = change.new_set;
} else if let Ok(ballot_log) = ballot_event.parse_log(log.into_raw()) {
// If it is a `BallotCreated`, find the corresponding votes and update the stats.
let ballot = BallotCreated::from_log(&ballot_log)?;
if verbose {
println!("{:?}", ballot);
}
let vote_filter = vote_event
.create_filter(ballot.vote_topic_filter())?
.to_filter_builder()
.build();
let vote_logs_filter = web3.eth_filter().create_logs_filter(vote_filter).wait()?;
let mut votes: Vec<Vote> = Vec::new();
for vote_log in vote_logs_filter.logs().wait()? {
let vote = Vote::from_log(&vote_event.parse_log(vote_log.into_raw())?)?;
if !voters.contains(&vote.voter) {
eprintln!("Unexpected voter {} for ballot {}", vote.voter, ballot.id);
voters.push(vote.voter);
}
votes.push(vote);
}
stats.add_ballot(&voters, &votes);
} else {
return Err(ErrorKind::UnexpectedLogParams.into());
}
}
// Finally, gather the metadata for all voters.
for voter in voters {
let mining_key = web3_val_meta.simple_query("getMiningByVotingKey", voter)?;
let validator = web3_val_meta.simple_query("validators", mining_key)?;
stats.set_metadata(&voter, mining_key, validator);
}
Ok(stats)
}
fn main() {
let matches = cli::get_matches();
let url = matches.value_of("url").unwrap_or("http://127.0.0.1:8545");
let verbose = matches.is_present("verbose");
let voting_abi = File::open("abi/VotingToChangeKeys.json").expect("read voting abi");
let net_con_abi = File::open("abi/PoaNetworkConsensus.json").expect("read consensus abi");
let val_meta_abi = File::open("abi/ValidatorMetadata.json").expect("read val meta abi");
let stats =
count_votes(url, verbose, &voting_abi, &net_con_abi, &val_meta_abi).expect("count votes");
println!("{}", stats);
}

115
src/stats.rs Normal file
View File

@ -0,0 +1,115 @@
use colored::{Color, Colorize};
use ethabi::Address;
use events::Vote;
use std::collections::HashMap;
use std::fmt::{self, Display, Formatter};
use validator::Validator;
/// The count of ballots and cast votes, as well as metadata for a particular voter.
#[derive(Clone, Default)]
struct VoterStats {
/// The number of ballots where this voter had a right to vote.
ballots: usize,
/// The number of votes cast by this voter.
voted: usize,
/// The validator metadata.
validator: Option<Validator>,
/// The mining key.
mining_key: Option<Address>,
}
/// A map of vote counts, by voting key.
#[derive(Clone, Default)]
pub struct Stats {
voter_stats: HashMap<Address, VoterStats>,
}
impl Stats {
/// Adds a ballot: `voters` are the voting keys of everyone who was allowed to cast a vote, and
/// `votes` are the ones that were actually cast.
pub fn add_ballot(&mut self, voters: &[Address], votes: &[Vote]) {
for voter in voters {
let mut vs = self.voter_stats
.entry(voter.clone())
.or_insert_with(VoterStats::default);
vs.ballots += 1;
if votes.iter().any(|vote| vote.voter == *voter) {
vs.voted += 1;
}
}
}
/// Inserts metadata about a voter: the mining key and the `Validator` information.
pub fn set_metadata(
&mut self,
voter: &Address,
mining_key: Address,
validator: Validator,
) -> bool {
match self.voter_stats.get_mut(voter) {
None => false,
Some(vs) => {
vs.validator = Some(validator);
vs.mining_key = Some(mining_key);
true
}
}
}
}
impl Display for Stats {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let mut lines: Vec<DisplayLine> = self.voter_stats
.iter()
.map(|(addr, s)| DisplayLine {
votes_per_thousand: s.voted * 1000 / s.ballots,
voting_address: *addr,
mining_key: match s.mining_key {
None => "".to_string(),
Some(ref key) => format!("{}", key),
},
name: match s.validator {
None => "".to_string(),
Some(ref v) => format!("{} {}", v.first_name, v.last_name),
},
})
.collect();
lines.sort();
writeln!(f, "{}", "Missed Voting key Mining key Name".bold())?;
for line in lines {
line.fmt(f)?;
}
Ok(())
}
}
/// A line in the output, corresponding to a particular voter.
#[derive(Ord, PartialOrd, Eq, PartialEq)]
struct DisplayLine {
votes_per_thousand: usize,
voting_address: Address,
mining_key: String,
name: String,
}
impl Display for DisplayLine {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
let text = format!(
"{:5.1}% {} {} {}",
100.0 - (self.votes_per_thousand as f32) / 10.0,
self.voting_address,
self.mining_key,
self.name
);
let c = if self.votes_per_thousand <= 500 {
Color::BrightRed
} else if self.votes_per_thousand <= 750 {
Color::BrightYellow
} else if self.votes_per_thousand < 1000 {
Color::White
} else {
Color::BrightGreen
};
writeln!(f, "{}", text.color(c))
}
}

130
src/util.rs Normal file
View File

@ -0,0 +1,130 @@
use ethabi;
use std::u8;
use web3;
use web3::futures::Future;
// TODO: Evaluate whether any of these would make sense to include in `web3`.
/// Converts the bytes to a string, interpreting them as null-terminated UTF-8.
pub fn bytes_to_string(bytes: &[u8]) -> String {
let zero = bytes
.iter()
.position(|b| *b == 0)
.unwrap_or_else(|| bytes.len());
String::from_utf8_lossy(&bytes[..zero]).to_string()
}
/// Parses the string as a 40-digit hexadecimal number, and returns the corresponding `Address`.
pub fn parse_address(mut s: &str) -> Option<ethabi::Address> {
let mut bytes = [0u8; 20];
if &s[..2] == "0x" {
s = &s[2..];
}
for i in 0..20 {
match u8::from_str_radix(&s[(2 * i)..(2 * i + 2)], 16) {
Ok(b) => bytes[i] = b,
Err(_) => return None,
}
}
Some(ethabi::Address::from_slice(&bytes))
}
pub trait ContractExt {
fn simple_query<P, R>(&self, func: &str, params: P) -> Result<R, web3::contract::Error>
where
R: web3::contract::tokens::Detokenize,
P: web3::contract::tokens::Tokenize;
}
impl ContractExt for web3::contract::Contract<web3::transports::Http> {
/// Calls a constant function with the latest block and default parameters.
fn simple_query<P, R>(&self, func: &str, params: P) -> Result<R, web3::contract::Error>
where
R: web3::contract::tokens::Detokenize,
P: web3::contract::tokens::Tokenize,
{
self.query(
func,
params,
None,
web3::contract::Options::default(),
web3::types::BlockNumber::Latest,
).wait()
}
}
pub trait TopicFilterExt {
/// Returns a `web3::types::FilterBuilder` with these topics, starting from the first block.
fn to_filter_builder(self) -> web3::types::FilterBuilder;
}
impl TopicFilterExt for ethabi::TopicFilter {
fn to_filter_builder(self) -> web3::types::FilterBuilder {
web3::types::FilterBuilder::default()
.topics(
to_topic(self.topic0),
to_topic(self.topic1),
to_topic(self.topic2),
to_topic(self.topic3),
)
.from_block(web3::types::BlockNumber::Earliest)
}
}
pub trait Web3LogExt {
fn into_raw(self) -> ethabi::RawLog;
}
impl Web3LogExt for web3::types::Log {
fn into_raw(self) -> ethabi::RawLog {
(self.topics, self.data.0).into()
}
}
/// Converts an `ethabi::Topic<T>` into an `Option<Vec<T>>`, where `Any` corresponds to `None`,
/// `This` to a vector with one element, and `OneOf` to any vector.
fn to_topic<T>(topic: ethabi::Topic<T>) -> Option<Vec<T>> {
match topic {
ethabi::Topic::Any => None,
ethabi::Topic::OneOf(v) => Some(v),
ethabi::Topic::This(t) => Some(vec![t]),
}
}
pub trait LogExt {
/// Returns the `i`-th parameter, if it has the given name, otherwise `None`.
fn param(&self, i: usize, name: &str) -> Option<&ethabi::Token>;
/// Returns the `i`-th parameter, if it is an `Address` and has the given name, otherwise
/// `None`.
fn address_param(&self, i: usize, name: &str) -> Option<&ethabi::Address>;
/// Returns the `i`-th parameter, if it is a `Uint` and has the given name, otherwise `None`.
fn uint_param(&self, i: usize, name: &str) -> Option<&ethabi::Uint>;
}
impl LogExt for ethabi::Log {
fn param(&self, i: usize, name: &str) -> Option<&ethabi::Token> {
self.params.get(i).and_then(|param| {
if param.name == name {
Some(&param.value)
} else {
None
}
})
}
fn address_param(&self, i: usize, name: &str) -> Option<&ethabi::Address> {
match self.param(i, name) {
Some(&ethabi::Token::Address(ref address)) => Some(address),
_ => None,
}
}
fn uint_param(&self, i: usize, name: &str) -> Option<&ethabi::Uint> {
match self.param(i, name) {
Some(&ethabi::Token::Uint(ref i)) => Some(i),
_ => None,
}
}
}

33
src/validator.rs Normal file
View File

@ -0,0 +1,33 @@
use ethabi::Token;
use util;
use web3::contract::{tokens, Error, ErrorKind};
/// Validator metadata.
#[derive(Clone, Debug)]
pub struct Validator {
pub first_name: String,
pub last_name: String,
// bytes32 licenseId,
// string fullAddress,
// bytes32 state,
// uint256 zipcode,
// uint256 expirationDate,
// uint256 createdDate,
// uint256 updatedDate,
// uint256 minThreshold,
}
impl tokens::Detokenize for Validator {
/// Returns a `Validator` if the token's types match the fields.
fn from_tokens(tokens: Vec<Token>) -> Result<Validator, Error> {
match (tokens.get(0), tokens.get(1)) {
(Some(&Token::FixedBytes(ref first)), Some(&Token::FixedBytes(ref last))) => {
Ok(Validator {
first_name: util::bytes_to_string(first),
last_name: util::bytes_to_string(last),
})
}
_ => Err(ErrorKind::InvalidOutputType("Validator".to_string()).into()),
}
}
}