From 033a04129af15f841cd130081f2da3eb7850f34a Mon Sep 17 00:00:00 2001 From: carllin Date: Tue, 26 Feb 2019 21:19:31 -0800 Subject: [PATCH] Add lockouts to vote program (#2944) * Add lockouts to vote program * Rename MAX_VOTE_HISTORY TO MAX_LOCKOUT_HISTORY, change process_vote() to only pop votes after MAX_LOCKOUT_HISTORY + 1 votes have arrived * Correctly calculate serialized size of an Option, rename root_block to root_slot --- programs/native/rewards/src/lib.rs | 6 +- programs/native/rewards/tests/rewards.rs | 8 +- runtime/src/bank.rs | 4 +- sdk/src/vote_program.rs | 205 +++++++++++++++++++---- 4 files changed, 188 insertions(+), 35 deletions(-) diff --git a/programs/native/rewards/src/lib.rs b/programs/native/rewards/src/lib.rs index c0ae474872..5358b68d7d 100644 --- a/programs/native/rewards/src/lib.rs +++ b/programs/native/rewards/src/lib.rs @@ -131,15 +131,15 @@ mod tests { ) .unwrap(); - for _ in 0..vote_program::MAX_VOTE_HISTORY { - let vote = Vote::new(1); + for i in 0..vote_program::MAX_LOCKOUT_HISTORY { + let vote = Vote::new(i as u64); let vote_state = vote_program::vote_and_deserialize(&vote_id, &mut vote_account, vote.clone()) .unwrap(); assert_eq!(vote_state.credits(), 0); } - let vote = Vote::new(1); + let vote = Vote::new(vote_program::MAX_LOCKOUT_HISTORY as u64 + 1); let vote_state = vote_program::vote_and_deserialize(&vote_id, &mut vote_account, vote.clone()).unwrap(); assert_eq!(vote_state.credits(), 1); diff --git a/programs/native/rewards/tests/rewards.rs b/programs/native/rewards/tests/rewards.rs index fa9b37c8bc..72cf3dda24 100644 --- a/programs/native/rewards/tests/rewards.rs +++ b/programs/native/rewards/tests/rewards.rs @@ -86,11 +86,13 @@ fn test_redeem_vote_credits_via_bank() { .unwrap(); // The validator submits votes to accumulate credits. - for _ in 0..vote_program::MAX_VOTE_HISTORY { - let vote_state = rewards_bank.submit_vote(&vote_keypair, 1).unwrap(); + for i in 0..vote_program::MAX_LOCKOUT_HISTORY { + let vote_state = rewards_bank.submit_vote(&vote_keypair, i as u64).unwrap(); assert_eq!(vote_state.credits(), 0); } - let vote_state = rewards_bank.submit_vote(&vote_keypair, 1).unwrap(); + let vote_state = rewards_bank + .submit_vote(&vote_keypair, vote_program::MAX_LOCKOUT_HISTORY as u64 + 1) + .unwrap(); assert_eq!(vote_state.credits(), 1); // TODO: Add VoteInstruction::RegisterStakerId so that we don't need to point the "to" diff --git a/runtime/src/bank.rs b/runtime/src/bank.rs index c16099d3d0..29b7a2915a 100644 --- a/runtime/src/bank.rs +++ b/runtime/src/bank.rs @@ -223,7 +223,9 @@ impl Bank { genesis_block.bootstrap_leader_id, genesis_block.bootstrap_leader_id, ); - vote_state.votes.push_back(vote_program::Vote::new(0)); + vote_state + .votes + .push_back(vote_program::Lockout::new(&vote_program::Vote::new(0))); vote_state .serialize(&mut bootstrap_leader_vote_account.userdata) .unwrap(); diff --git a/sdk/src/vote_program.rs b/sdk/src/vote_program.rs index e521dceb51..1c1f019c68 100644 --- a/sdk/src/vote_program.rs +++ b/sdk/src/vote_program.rs @@ -22,7 +22,8 @@ pub fn id() -> Pubkey { } // Maximum number of votes to keep around -pub const MAX_VOTE_HISTORY: usize = 32; +pub const MAX_LOCKOUT_HISTORY: usize = 31; +pub const INITIAL_LOCKOUT: usize = 2; #[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Clone)] pub struct Vote { @@ -37,6 +38,32 @@ impl Vote { } } +#[derive(Serialize, Default, Deserialize, Debug, PartialEq, Eq, Clone)] +pub struct Lockout { + pub slot_height: u64, + pub confirmation_count: u32, +} + +impl Lockout { + pub fn new(vote: &Vote) -> Self { + Self { + slot_height: vote.slot_height, + confirmation_count: 1, + } + } + + // The number of slots for which this vote is locked + pub fn lockout(&self) -> u64 { + (INITIAL_LOCKOUT as u64).pow(self.confirmation_count) + } + + // The slot height at which this vote expires (cannot vote for any slot + // less than this) + pub fn expiration_slot_height(&self) -> u64 { + self.slot_height + self.lockout() + } +} + #[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] pub enum VoteInstruction { /// Register a new "vote account" to represent a particular validator in the Vote Contract, @@ -53,17 +80,19 @@ pub enum VoteInstruction { #[derive(Debug, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct VoteState { - pub votes: VecDeque, + pub votes: VecDeque, pub node_id: Pubkey, pub staker_id: Pubkey, + pub root_slot: Option, credits: u64, } pub fn get_max_size() -> usize { // Upper limit on the size of the Vote State. Equal to - // sizeof(VoteState) when votes.len() is MAX_VOTE_HISTORY + // sizeof(VoteState) when votes.len() is MAX_LOCKOUT_HISTORY let mut vote_state = VoteState::default(); - vote_state.votes = VecDeque::from(vec![Vote::default(); MAX_VOTE_HISTORY]); + vote_state.votes = VecDeque::from(vec![Lockout::default(); MAX_LOCKOUT_HISTORY]); + vote_state.root_slot = Some(std::u64::MAX); serialized_size(&vote_state).unwrap() as usize } @@ -71,11 +100,13 @@ impl VoteState { pub fn new(node_id: Pubkey, staker_id: Pubkey) -> Self { let votes = VecDeque::new(); let credits = 0; + let root_slot = None; Self { votes, node_id, staker_id, credits, + root_slot, } } @@ -91,17 +122,29 @@ impl VoteState { } pub fn process_vote(&mut self, vote: Vote) { - // TODO: Integrity checks - // a) Verify the vote's bank hash matches what is expected - // b) Verify vote is older than previous votes - - // Only keep around the most recent MAX_VOTE_HISTORY votes - if self.votes.len() == MAX_VOTE_HISTORY { - self.votes.pop_front(); - self.credits += 1; + // Ignore votes for slots earlier than we already have votes for + if self + .votes + .back() + .map_or(false, |old_vote| old_vote.slot_height >= vote.slot_height) + { + return; } + let vote = Lockout::new(&vote); + + // TODO: Integrity checks + // Verify the vote's bank hash matches what is expected + + self.pop_expired_votes(vote.slot_height); + // Once the stack is full, pop the oldest vote and distribute rewards + if self.votes.len() == MAX_LOCKOUT_HISTORY { + let vote = self.votes.pop_front().unwrap(); + self.root_slot = Some(vote.slot_height); + self.credits += 1; + } self.votes.push_back(vote); + self.double_lockouts(); } /// Number of "credits" owed to this account from the mining pool. Submit this @@ -114,6 +157,33 @@ impl VoteState { pub fn clear_credits(&mut self) { self.credits = 0; } + + fn pop_expired_votes(&mut self, slot_height: u64) { + loop { + if self + .votes + .back() + .map_or(false, |v| v.expiration_slot_height() < slot_height) + { + self.votes.pop_back(); + } else { + break; + } + } + } + + fn double_lockouts(&mut self) { + let stack_depth = self.votes.len(); + for (i, v) in self.votes.iter_mut().enumerate() { + // Don't increase the lockout for this vote until we get more confirmations + // than the max number of confirmations this vote has seen + if stack_depth > i + v.confirmation_count as usize { + v.confirmation_count += 1; + } else { + break; + } + } + } } // TODO: Deprecate the RegisterAccount instruction and its awkward delegation @@ -203,24 +273,13 @@ mod tests { fn test_vote_serialize() { let mut buffer: Vec = vec![0; get_max_size()]; let mut vote_state = VoteState::default(); - vote_state.votes.resize(MAX_VOTE_HISTORY, Vote::default()); + vote_state + .votes + .resize(MAX_LOCKOUT_HISTORY, Lockout::default()); vote_state.serialize(&mut buffer).unwrap(); assert_eq!(VoteState::deserialize(&buffer).unwrap(), vote_state); } - #[test] - fn test_vote_credits() { - let mut vote_state = VoteState::default(); - vote_state.votes.resize(MAX_VOTE_HISTORY, Vote::default()); - assert_eq!(vote_state.credits(), 0); - vote_state.process_vote(Vote::new(42)); - assert_eq!(vote_state.credits(), 1); - vote_state.process_vote(Vote::new(43)); - assert_eq!(vote_state.credits(), 2); - vote_state.clear_credits(); - assert_eq!(vote_state.credits(), 0); - } - #[test] fn test_voter_registration() { let from_id = Keypair::new().pubkey(); @@ -247,7 +306,7 @@ mod tests { let vote = Vote::new(1); let vote_state = vote_and_deserialize(&vote_id, &mut vote_account, vote.clone()).unwrap(); - assert_eq!(vote_state.votes, vec![vote]); + assert_eq!(vote_state.votes, vec![Lockout::new(&vote)]); assert_eq!(vote_state.credits(), 0); } @@ -259,6 +318,96 @@ mod tests { let vote = Vote::new(1); let vote_state = vote_and_deserialize(&vote_id, &mut vote_account, vote.clone()).unwrap(); assert_eq!(vote_state.node_id, Pubkey::default()); - assert_eq!(vote_state.votes, vec![vote]); + assert_eq!(vote_state.votes, vec![Lockout::new(&vote)]); + } + + #[test] + fn test_vote_lockout() { + let voter_id = Keypair::new().pubkey(); + let staker_id = Keypair::new().pubkey(); + let mut vote_state = VoteState::new(voter_id, staker_id); + + for i in 0..(MAX_LOCKOUT_HISTORY + 1) { + vote_state.process_vote(Vote::new((INITIAL_LOCKOUT as usize * i) as u64)); + } + + // The last vote should have been popped b/c it reached a depth of MAX_LOCKOUT_HISTORY + assert_eq!(vote_state.votes.len(), MAX_LOCKOUT_HISTORY); + assert_eq!(vote_state.root_slot, Some(0)); + check_lockouts(&vote_state); + + // One more vote that confirms the entire stack, + // the root_slot should change to the + // second vote + let top_vote = vote_state.votes.front().unwrap().slot_height; + vote_state.process_vote(Vote::new( + vote_state.votes.back().unwrap().expiration_slot_height(), + )); + assert_eq!(Some(top_vote), vote_state.root_slot); + + // Expire everything except the first vote + let vote = Vote::new(vote_state.votes.front().unwrap().expiration_slot_height()); + vote_state.process_vote(vote); + // First vote and new vote are both stored for a total of 2 votes + assert_eq!(vote_state.votes.len(), 2); + } + + #[test] + fn test_vote_double_lockout_after_expiration() { + let voter_id = Keypair::new().pubkey(); + let staker_id = Keypair::new().pubkey(); + let mut vote_state = VoteState::new(voter_id, staker_id); + + for i in 0..3 { + let vote = Vote::new(i as u64); + vote_state.process_vote(vote); + } + + // Check the lockouts for first and second votes. Lockouts should be + // INITIAL_LOCKOUT^3 and INITIAL_LOCKOUT^2 respectively + check_lockouts(&vote_state); + + // Expire the third vote (which was a vote for slot 2). The height of the + // vote stack is unchanged, so none of the previous votes should have + // doubled in lockout + vote_state.process_vote(Vote::new((2 + INITIAL_LOCKOUT + 1) as u64)); + check_lockouts(&vote_state); + + // Vote again, this time the vote stack depth increases, so the lockouts should + // double for everybody + vote_state.process_vote(Vote::new((2 + INITIAL_LOCKOUT + 2) as u64)); + check_lockouts(&vote_state); + } + + #[test] + fn test_vote_credits() { + let voter_id = Keypair::new().pubkey(); + let staker_id = Keypair::new().pubkey(); + let mut vote_state = VoteState::new(voter_id, staker_id); + + for i in 0..MAX_LOCKOUT_HISTORY { + vote_state.process_vote(Vote::new(i as u64)); + } + + assert_eq!(vote_state.credits, 0); + + vote_state.process_vote(Vote::new(MAX_LOCKOUT_HISTORY as u64 + 1)); + assert_eq!(vote_state.credits, 1); + vote_state.process_vote(Vote::new(MAX_LOCKOUT_HISTORY as u64 + 2)); + assert_eq!(vote_state.credits(), 2); + vote_state.process_vote(Vote::new(MAX_LOCKOUT_HISTORY as u64 + 3)); + assert_eq!(vote_state.credits(), 3); + vote_state.clear_credits(); + assert_eq!(vote_state.credits(), 0); + } + + fn check_lockouts(vote_state: &VoteState) { + for (i, vote) in vote_state.votes.iter().enumerate() { + let num_lockouts = vote_state.votes.len() - i; + assert_eq!( + vote.lockout(), + INITIAL_LOCKOUT.pow(num_lockouts as u32) as u64 + ); + } } }