From 79bf3cf70ddd3fa54ec8cd720886f9db0154b25d Mon Sep 17 00:00:00 2001 From: Rob Walker Date: Sun, 7 Apr 2019 21:45:28 -0700 Subject: [PATCH] add rewards math (#3673) * add rewards math * fixup --- programs/stake_api/src/stake_instruction.rs | 3 +- programs/stake_api/src/stake_state.rs | 163 ++++++++++++++++---- programs/vote_api/src/vote_state.rs | 20 +++ 3 files changed, 155 insertions(+), 31 deletions(-) diff --git a/programs/stake_api/src/stake_instruction.rs b/programs/stake_api/src/stake_instruction.rs index ef39912eda..b00260effd 100644 --- a/programs/stake_api/src/stake_instruction.rs +++ b/programs/stake_api/src/stake_instruction.rs @@ -91,7 +91,8 @@ pub fn process_instruction( } let (stake, vote) = rest.split_at_mut(1); let stake = &mut stake[0]; - let vote = &vote[0]; + let vote = &mut vote[0]; + me.redeem_vote_credits(stake, vote) } } diff --git a/programs/stake_api/src/stake_state.rs b/programs/stake_api/src/stake_state.rs index 070498f378..d99d8945bd 100644 --- a/programs/stake_api/src/stake_state.rs +++ b/programs/stake_api/src/stake_state.rs @@ -29,13 +29,56 @@ impl Default for StakeState { } } } +// TODO: trusted values of network parameters come from where? +const TICKS_PER_SECOND: f64 = 10f64; +const TICKS_PER_SLOT: f64 = 8f64; + +// credits/yr or slots/yr is seconds/year * ticks/second * slots/tick +const CREDITS_PER_YEAR: f64 = (365f64 * 24f64 * 3600f64) * TICKS_PER_SECOND / TICKS_PER_SLOT; + +// TODO: 20% is a niiice rate... TODO: make this a member of MiningPool? +const STAKE_REWARD_TARGET_RATE: f64 = 0.20; + +#[cfg(test)] +const STAKE_GETS_PAID_EVERY_VOTE: u64 = 200_000_000; // if numbers above move, fix this + +impl StakeState { + pub fn calculate_rewards( + credits_observed: u64, + stake: u64, + vote_state: &VoteState, + ) -> Option<(u64, u64)> { + if credits_observed >= vote_state.credits() { + return None; + } + + let total_rewards = stake as f64 + * STAKE_REWARD_TARGET_RATE + * (vote_state.credits() - credits_observed) as f64 + / CREDITS_PER_YEAR; + + // don't bother trying to collect fractional lamports + if total_rewards < 1f64 { + return None; + } + + let (voter_rewards, staker_rewards, is_split) = vote_state.commission_split(total_rewards); + + if (voter_rewards < 1f64 || staker_rewards < 1f64) && is_split { + // don't bother trying to collect fractional lamports + return None; + } + + Some((voter_rewards as u64, staker_rewards as u64)) + } +} pub trait StakeAccount { fn delegate_stake(&mut self, vote_account: &KeyedAccount) -> Result<(), InstructionError>; fn redeem_vote_credits( &mut self, stake_account: &mut KeyedAccount, - vote_account: &KeyedAccount, + vote_account: &mut KeyedAccount, ) -> Result<(), InstructionError>; } @@ -59,13 +102,13 @@ impl<'a> StakeAccount for KeyedAccount<'a> { fn redeem_vote_credits( &mut self, stake_account: &mut KeyedAccount, - vote_account: &KeyedAccount, + vote_account: &mut KeyedAccount, ) -> Result<(), InstructionError> { if let ( StakeState::MiningPool, StakeState::Delegate { voter_id, - mut credits_observed, + credits_observed, }, ) = (self.state()?, stake_account.state()?) { @@ -79,23 +122,26 @@ impl<'a> StakeAccount for KeyedAccount<'a> { return Err(InstructionError::InvalidAccountData); } - let credits = vote_state.credits() - credits_observed; - credits_observed = vote_state.credits(); - - if self.account.lamports < credits { - return Err(InstructionError::UnbalancedInstruction); - } - // TODO: commission and network inflation parameter - // mining pool lamports reduced by credits * network_inflation_param - // stake_account and vote_account lamports up by the net - // split by a commission in vote_state - self.account.lamports -= credits; - stake_account.account.lamports += credits; - - stake_account.set_state(&StakeState::Delegate { - voter_id, + if let Some((stakers_reward, voters_reward)) = StakeState::calculate_rewards( credits_observed, - }) + stake_account.account.lamports, + &vote_state, + ) { + if self.account.lamports < (stakers_reward + voters_reward) { + return Err(InstructionError::UnbalancedInstruction); + } + self.account.lamports -= stakers_reward + voters_reward; + stake_account.account.lamports += stakers_reward; + vote_account.account.lamports += voters_reward; + + stake_account.set_state(&StakeState::Delegate { + voter_id, + credits_observed: vote_state.credits(), + }) + } else { + // not worth collecting + Ok(()) + } } else { Err(InstructionError::InvalidAccountData) } @@ -153,6 +199,53 @@ mod tests { .delegate_stake(&vote_keyed_account) .is_err()); } + #[test] + fn test_stake_state_calculate_rewards() { + let mut vote_state = VoteState::default(); + let mut vote_i = 0; + + // put a credit in the vote_state + while vote_state.credits() == 0 { + vote_state.process_vote(Vote::new(vote_i)); + vote_i += 1; + } + // this guy can't collect now, not enough stake to get paid on 1 credit + assert_eq!(None, StakeState::calculate_rewards(0, 100, &vote_state)); + // this guy can + assert_eq!( + Some((0, 1)), + StakeState::calculate_rewards(0, STAKE_GETS_PAID_EVERY_VOTE, &vote_state) + ); + // but, there's not enough to split + vote_state.commission = std::u32::MAX / 2; + assert_eq!( + None, + StakeState::calculate_rewards(0, STAKE_GETS_PAID_EVERY_VOTE, &vote_state) + ); + + // put more credit in the vote_state + while vote_state.credits() < 10 { + vote_state.process_vote(Vote::new(vote_i)); + vote_i += 1; + } + vote_state.commission = 0; + assert_eq!( + Some((0, 10)), + StakeState::calculate_rewards(0, STAKE_GETS_PAID_EVERY_VOTE, &vote_state) + ); + vote_state.commission = std::u32::MAX; + assert_eq!( + Some((10, 0)), + StakeState::calculate_rewards(0, STAKE_GETS_PAID_EVERY_VOTE, &vote_state) + ); + vote_state.commission = std::u32::MAX / 2; + assert_eq!( + Some((5, 5)), + StakeState::calculate_rewards(0, STAKE_GETS_PAID_EVERY_VOTE, &vote_state) + ); + // not even enough stake to get paid on 10 credits... + assert_eq!(None, StakeState::calculate_rewards(0, 100, &vote_state)); + } #[test] fn test_stake_redeem_vote_credits() { @@ -168,7 +261,11 @@ mod tests { vote_keyed_account.set_state(&vote_state).unwrap(); let pubkey = Pubkey::default(); - let mut stake_account = Account::new(0, std::mem::size_of::(), &id()); + let mut stake_account = Account::new( + STAKE_GETS_PAID_EVERY_VOTE, + std::mem::size_of::(), + &id(), + ); let mut stake_keyed_account = KeyedAccount::new(&pubkey, true, &mut stake_account); // delegate the stake @@ -180,10 +277,10 @@ mod tests { let mut mining_pool_keyed_account = KeyedAccount::new(&pubkey, true, &mut mining_pool_account); - // no mining pool yet... + // not a mining pool yet... assert_eq!( mining_pool_keyed_account - .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account), + .redeem_vote_credits(&mut stake_keyed_account, &mut vote_keyed_account), Err(InstructionError::InvalidAccountData) ); @@ -193,25 +290,31 @@ mod tests { // no movement in vote account, so no redemption needed assert!(mining_pool_keyed_account - .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account) + .redeem_vote_credits(&mut stake_keyed_account, &mut vote_keyed_account) .is_ok()); // move the vote account forward vote_state.process_vote(Vote::new(1000)); vote_keyed_account.set_state(&vote_state).unwrap(); - // no lamports in the pool + // now, no lamports in the pool! assert_eq!( mining_pool_keyed_account - .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account), + .redeem_vote_credits(&mut stake_keyed_account, &mut vote_keyed_account), Err(InstructionError::UnbalancedInstruction) ); - // add a lamport + // add a lamport to pool mining_pool_keyed_account.account.lamports = 2; assert!(mining_pool_keyed_account - .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account) - .is_ok()); + .redeem_vote_credits(&mut stake_keyed_account, &mut vote_keyed_account) + .is_ok()); // yay + + // lamports only shifted around, none made or lost + assert_eq!( + 2 + 100 + STAKE_GETS_PAID_EVERY_VOTE, + mining_pool_account.lamports + vote_account.lamports + stake_account.lamports + ); } #[test] @@ -252,7 +355,7 @@ mod tests { // voter credits lower than stake_delegate credits... TODO: is this an error? assert_eq!( mining_pool_keyed_account - .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account), + .redeem_vote_credits(&mut stake_keyed_account, &mut vote_keyed_account), Err(InstructionError::InvalidAccountData) ); @@ -265,7 +368,7 @@ mod tests { // wrong voter_id... assert_eq!( mining_pool_keyed_account - .redeem_vote_credits(&mut stake_keyed_account, &vote1_keyed_account), + .redeem_vote_credits(&mut stake_keyed_account, &mut vote1_keyed_account), Err(InstructionError::InvalidArgument) ); } diff --git a/programs/vote_api/src/vote_state.rs b/programs/vote_api/src/vote_state.rs index 8bf9103656..52c1ccefdf 100644 --- a/programs/vote_api/src/vote_state.rs +++ b/programs/vote_api/src/vote_state.rs @@ -49,6 +49,9 @@ pub struct VoteState { pub votes: VecDeque, pub delegate_id: Pubkey, pub authorized_voter_id: Pubkey, + /// fraction of std::u32::MAX that represents what part of a rewards + /// payout should be given to this VoteAccount + pub commission: u32, pub root_slot: Option, credits: u64, } @@ -58,11 +61,13 @@ impl VoteState { let votes = VecDeque::new(); let credits = 0; let root_slot = None; + let commission = 0; Self { votes, delegate_id: *staker_id, authorized_voter_id: *staker_id, credits, + commission, root_slot, } } @@ -87,6 +92,21 @@ impl VoteState { }) } + /// returns commission split as (voter_portion, staker_portion) tuple + /// + /// if commission calculation is 100% one way or other, + /// indicate with None for the 0% side + pub fn commission_split(&self, on: f64) -> (f64, f64, bool) { + match self.commission { + 0 => (0.0, on, false), + std::u32::MAX => (on, 0.0, false), + split => { + let mine = on * f64::from(split) / f64::from(std::u32::MAX); + (mine, on - mine, true) + } + } + } + pub fn process_vote(&mut self, vote: Vote) { // Ignore votes for slots earlier than we already have votes for if self