here i describe a design of a gas-efficent, ranked-choice voting system.
the most common form of ranked choice voting, instant run off, works like this:
- voters submit a list of candidates sorted by their preference.
- if there is an option with the majority of first-preference votes, that option is the winner.
- otherwise, remove the option with the fewest first-preference votes from all preference lists and repeat.
this algorithm presents a problem: the compute cost for tallying an election result increases with the number of votes cast; a proposal can be DOSed by an adversary casting many votes.
when encountering problems like this, you've basically got two options:
- invent or discover a different algorithm for doing the same thing.
- move the complexity somewhere running out of gas isn't a security risk.
for an example of the second point, consider the problem of counting the number of elements in a linked list. at first glance, this is O(N)
, but one can trivially modify the linked list datastructure to put the length in storage and then it can be accessed instantly so long as it is updated when new elements are added. the complexity is moved from getting the length, to the extra storage read / write every time an element is added or removed.
our approach uses both techniques. at a high level, our algorithm has:
- constant gas voting as the number of votes cast increases.
- higher gas costs for casting a vote than tallying the result of votes.
in essence, we move complexity into the act of voting, out of the act of tallying. we ensure that the highest cost operation is the act of casting a vote, and that if one vote can be cast, all votes can be cast.
we change our ranked choice voting system from instant run off, to the Condorcet Method. which works as follows:
- voters submit a list of candidates sorted by their preference.
- if there is a candidate who is perfered over every other candidate by the majority of voters, that candidate wins.
- otherwise, there is no winner.
it is possible for this algorithm to produce no winner, yet these cases, practically speaking, appear to be rare (see the section titled "A Condorcet winner always exists" here).
let
from here, we set our sights on pre-computing this value whenever a vote is cast. to do so, we define a new matrix
the math notation here is unplesant, so in psudocode:
M[i][j] = number_of_times_i_has_beaten_j - number_of_times_j_has_beaten_i
given such a matrix, a candidate
in english: a candidate wins if the number of times they were perfered over every other candidate by the majority of voters.
implementing this in Rust is quite straightforward.
pub struct Condorcet {
m: Vec<Vec<i32>>,
}
impl Condorcet {
pub fn new(candidates: usize) -> Self {
Self {
m: vec![vec![0; candidates]; candidates],
}
}
pub fn vote(&mut self, preferences: Vec<usize>) {
for (index, preference) in preferences.iter().enumerate() {
// increment every value in self.m[preference] which
// appears in preferences[index + 1..] as preference is
// ranked higher.
for victory in (index + 1)..preferences.len() {
self.m[*preference][preferences[victory]] += 1
}
// decrement every value in self.m[preference] which
// appears in preferences[0..index] as perference is
// ranked lower.
for defeat in 0..index {
self.m[*preference][preferences[defeat]] -= 1
}
}
}
pub fn winner(&self) -> Option<usize> {
// a winner is someone who wins a majority runoff with all of
// the other candidates.
self.m
.iter()
.enumerate()
.find(|(index, row)| row.iter().skip_nth(*index).all(|&p| p > 0))
.map(|(index, _)| index)
}
}
the complete crate is here.
additional analysis is still needed to determine if there is a strategy an attacker could use while voting that would produce elections with no winner. if such a strategy does exist, what percentage of voting power must collude to DOS a proposal?
thanks for the feedback. i considered this for a while and it's pretty hard to prove an upper bound on the runtime efficiency of instant-run-off. i tried one version where i wrote out an example and analyzed it, but that felt a little too much of a detour from the main point to go into the writeup, and because of the earlier point i wasn't sure if it was useful.
in the "polished version" of this i ended up just saying: