diff --git a/Cargo.lock b/Cargo.lock index 4fcc55c18..3a37bb22d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2465,6 +2465,31 @@ dependencies = [ "untrusted 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "solana-stake-api" +version = "0.13.0" +dependencies = [ + "bincode 1.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.89 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_derive 1.0.89 (registry+https://github.com/rust-lang/crates.io-index)", + "solana-logger 0.13.0", + "solana-metrics 0.13.0", + "solana-runtime 0.13.0", + "solana-sdk 0.13.0", + "solana-vote-api 0.13.0", +] + +[[package]] +name = "solana-stake-program" +version = "0.13.0" +dependencies = [ + "log 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)", + "solana-logger 0.13.0", + "solana-sdk 0.13.0", + "solana-stake-api 0.13.0", +] + [[package]] name = "solana-storage-api" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index c98c0a6c3..89c7fa83c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,8 @@ members = [ "programs/token_program", "programs/failure_program", "programs/noop_program", + "programs/stake_api", + "programs/stake_program", "programs/storage_api", "programs/storage_program", "programs/vote_api", diff --git a/programs/stake_api/Cargo.toml b/programs/stake_api/Cargo.toml new file mode 100644 index 000000000..669d8475a --- /dev/null +++ b/programs/stake_api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "solana-stake-api" +version = "0.13.0" +description = "Solana Stake program API" +authors = ["Solana Maintainers "] +repository = "https://github.com/solana-labs/solana" +license = "Apache-2.0" +homepage = "https://solana.com/" +edition = "2018" + +[dependencies] +bincode = "1.1.2" +log = "0.4.2" +serde = "1.0.89" +serde_derive = "1.0.89" +solana-logger = { path = "../../logger", version = "0.13.0" } +solana-metrics = { path = "../../metrics", version = "0.13.0" } +solana-sdk = { path = "../../sdk", version = "0.13.0" } +solana-vote-api = { path = "../vote_api", version = "0.13.0" } + +[dev-dependencies] +solana-runtime = { path = "../../runtime", version = "0.13.0" } + +[lib] +name = "solana_stake_api" +crate-type = ["lib"] diff --git a/programs/stake_api/src/lib.rs b/programs/stake_api/src/lib.rs new file mode 100644 index 000000000..d21a108ea --- /dev/null +++ b/programs/stake_api/src/lib.rs @@ -0,0 +1,17 @@ +pub mod stake_instruction; +pub mod stake_state; + +use solana_sdk::pubkey::Pubkey; + +const STAKE_PROGRAM_ID: [u8; 32] = [ + 135, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, +]; + +pub fn check_id(program_id: &Pubkey) -> bool { + program_id.as_ref() == STAKE_PROGRAM_ID +} + +pub fn id() -> Pubkey { + Pubkey::new(&STAKE_PROGRAM_ID) +} diff --git a/programs/stake_api/src/stake_instruction.rs b/programs/stake_api/src/stake_instruction.rs new file mode 100644 index 000000000..418fca217 --- /dev/null +++ b/programs/stake_api/src/stake_instruction.rs @@ -0,0 +1,161 @@ +use crate::id; +use crate::stake_state::{StakeAccount, StakeState}; +use bincode::deserialize; +use log::*; +use serde_derive::{Deserialize, Serialize}; +use solana_sdk::account::KeyedAccount; +use solana_sdk::instruction::{AccountMeta, Instruction, InstructionError}; +use solana_sdk::pubkey::Pubkey; +use solana_sdk::system_instruction::SystemInstruction; + +#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)] +pub enum StakeInstruction { + /// `Delegate` or `Assign` a stake account to a particular node + /// expects 2 KeyedAccounts: + /// StakeAccount to be updated + /// VoteAccount to which this Stake will be delegated + DelegateStake, + + /// Redeem credits in the stake account + /// expects 3 KeyedAccounts: the StakeAccount to be updated + /// and the VoteAccount to which this Stake will be delegated + RedeemVoteCredits, +} + +impl StakeInstruction { + pub fn new_account(from_id: &Pubkey, staker_id: &Pubkey, lamports: u64) -> Vec { + vec![SystemInstruction::new_program_account( + from_id, + staker_id, + lamports, + std::mem::size_of::() as u64, + &id(), + )] + } + + pub fn new_redeem_vote_credits( + from_id: &Pubkey, + mining_pool_id: &Pubkey, + stake_id: &Pubkey, + vote_id: &Pubkey, + ) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*from_id, true), + AccountMeta::new(*mining_pool_id, false), + AccountMeta::new(*stake_id, false), + AccountMeta::new(*vote_id, false), + ]; + Instruction::new(id(), &StakeInstruction::RedeemVoteCredits, account_metas) + } + + pub fn new_delegate_stake( + from_id: &Pubkey, + stake_id: &Pubkey, + vote_id: &Pubkey, + ) -> Instruction { + let account_metas = vec![ + AccountMeta::new(*from_id, true), + AccountMeta::new(*stake_id, true), + AccountMeta::new(*vote_id, false), + ]; + Instruction::new(id(), &StakeInstruction::DelegateStake, account_metas) + } +} + +pub fn process_instruction( + _program_id: &Pubkey, + keyed_accounts: &mut [KeyedAccount], + data: &[u8], + _tick_height: u64, +) -> Result<(), InstructionError> { + solana_logger::setup(); + + trace!("process_instruction: {:?}", data); + trace!("keyed_accounts: {:?}", keyed_accounts); + + if keyed_accounts.len() < 3 { + Err(InstructionError::InvalidInstructionData)?; + } + + // 0th index is the guy who paid for the transaction + let (me, rest) = &mut keyed_accounts.split_at_mut(2); + + let me = &mut me[1]; + + // TODO: data-driven unpack and dispatch of KeyedAccounts + match deserialize(data).map_err(|_| InstructionError::InvalidInstructionData)? { + StakeInstruction::DelegateStake => { + if rest.len() != 1 { + Err(InstructionError::InvalidInstructionData)?; + } + let vote = &rest[0]; + me.delegate_stake(vote) + } + StakeInstruction::RedeemVoteCredits => { + if rest.len() != 2 { + Err(InstructionError::InvalidInstructionData)?; + } + let (stake, vote) = rest.split_at_mut(1); + let stake = &mut stake[0]; + let vote = &vote[0]; + me.redeem_vote_credits(stake, vote) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bincode::serialize; + use solana_sdk::account::Account; + + #[test] + fn test_stake_process_instruction_decode_bail() { + // these will not call stake_state, have bogus contents + + // gets the first check + assert_eq!( + process_instruction( + &Pubkey::default(), + &mut [KeyedAccount::new( + &Pubkey::default(), + false, + &mut Account::default(), + )], + &serialize(&StakeInstruction::DelegateStake).unwrap(), + 0, + ), + Err(InstructionError::InvalidInstructionData), + ); + + // gets the check in delegate_stake + assert_eq!( + process_instruction( + &Pubkey::default(), + &mut [ + KeyedAccount::new(&Pubkey::default(), false, &mut Account::default()), + KeyedAccount::new(&Pubkey::default(), false, &mut Account::default()), + ], + &serialize(&StakeInstruction::DelegateStake).unwrap(), + 0, + ), + Err(InstructionError::InvalidInstructionData), + ); + + // gets the check in redeem_vote_credits + assert_eq!( + process_instruction( + &Pubkey::default(), + &mut [ + KeyedAccount::new(&Pubkey::default(), false, &mut Account::default()), + KeyedAccount::new(&Pubkey::default(), false, &mut Account::default()), + KeyedAccount::new(&Pubkey::default(), false, &mut Account::default()), + ], + &serialize(&StakeInstruction::RedeemVoteCredits).unwrap(), + 0, + ), + Err(InstructionError::InvalidInstructionData), + ); + } + +} diff --git a/programs/stake_api/src/stake_state.rs b/programs/stake_api/src/stake_state.rs new file mode 100644 index 000000000..92d3711bb --- /dev/null +++ b/programs/stake_api/src/stake_state.rs @@ -0,0 +1,293 @@ +//! Stake state +//! * delegate stakes to vote accounts +//! * keep track of rewards +//! * own mining pools + +//use crate::{check_id, id}; +//use log::*; +use bincode::{deserialize, serialize_into, ErrorKind}; +use serde_derive::{Deserialize, Serialize}; +use solana_sdk::account::KeyedAccount; +use solana_sdk::instruction::InstructionError; +use solana_sdk::pubkey::Pubkey; +use solana_vote_api::vote_state::VoteState; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq, Clone)] +pub enum StakeState { + Delegate { + voter_id: Pubkey, + credits_observed: u64, + }, + MiningPool, +} + +impl Default for StakeState { + fn default() -> Self { + StakeState::Delegate { + voter_id: Pubkey::default(), + credits_observed: 0, + } + } +} + +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, + ) -> Result<(), InstructionError>; +} + +pub trait State { + fn state(&self) -> Result; + fn set_state(&mut self, state: &T) -> Result<(), InstructionError>; +} + +impl<'a, T> State for KeyedAccount<'a> +where + T: serde::Serialize + serde::de::DeserializeOwned, +{ + fn state(&self) -> Result { + deserialize(&self.account.data).map_err(|_| InstructionError::InvalidAccountData) + } + fn set_state(&mut self, state: &T) -> Result<(), InstructionError> { + serialize_into(&mut self.account.data[..], state).map_err(|err| match *err { + ErrorKind::SizeLimit => InstructionError::AccountDataTooSmall, + _ => InstructionError::GenericError, + }) + } +} + +impl<'a> StakeAccount for KeyedAccount<'a> { + fn delegate_stake(&mut self, vote_account: &KeyedAccount) -> Result<(), InstructionError> { + if self.signer_key().is_none() { + return Err(InstructionError::MissingRequiredSignature); + } + + if let StakeState::Delegate { .. } = self.state()? { + let vote_state: VoteState = vote_account.state()?; + self.set_state(&StakeState::Delegate { + voter_id: *vote_account.unsigned_key(), + credits_observed: vote_state.credits(), + }) + } else { + Err(InstructionError::InvalidAccountData) + } + } + + fn redeem_vote_credits( + &mut self, + stake_account: &mut KeyedAccount, + vote_account: &KeyedAccount, + ) -> Result<(), InstructionError> { + if let ( + StakeState::MiningPool, + StakeState::Delegate { + voter_id, + mut credits_observed, + }, + ) = (self.state()?, stake_account.state()?) + { + let vote_state: VoteState = vote_account.state()?; + + if voter_id != *vote_account.unsigned_key() { + return Err(InstructionError::InvalidArgument); + } + + if credits_observed > vote_state.credits() { + 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, + credits_observed, + }) + } else { + Err(InstructionError::InvalidAccountData) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::id; + use solana_sdk::account::Account; + use solana_sdk::signature::{Keypair, KeypairUtil}; + use solana_vote_api::vote_instruction::Vote; + use solana_vote_api::vote_state::create_vote_account; + + #[test] + fn test_stake_delegate_stake() { + let vote_keypair = Keypair::new(); + let mut vote_state = VoteState::default(); + for i in 0..1000 { + vote_state.process_vote(Vote::new(i)); + } + + let vote_pubkey = vote_keypair.pubkey(); + let mut vote_account = create_vote_account(100); + let mut vote_keyed_account = KeyedAccount::new(&vote_pubkey, false, &mut vote_account); + vote_keyed_account.set_state(&vote_state).unwrap(); + + let stake_pubkey = Pubkey::default(); + let mut stake_account = Account::new(0, std::mem::size_of::(), &id()); + + let mut stake_keyed_account = KeyedAccount::new(&stake_pubkey, false, &mut stake_account); + + assert_eq!( + stake_keyed_account.delegate_stake(&vote_keyed_account), + Err(InstructionError::MissingRequiredSignature) + ); + + let mut stake_keyed_account = KeyedAccount::new(&stake_pubkey, true, &mut stake_account); + assert!(stake_keyed_account + .delegate_stake(&vote_keyed_account) + .is_ok()); + + let stake_state: StakeState = stake_keyed_account.state().unwrap(); + assert_eq!( + stake_state, + StakeState::Delegate { + voter_id: vote_keypair.pubkey(), + credits_observed: vote_state.credits() + } + ); + let stake_state = StakeState::MiningPool; + stake_keyed_account.set_state(&stake_state).unwrap(); + assert!(stake_keyed_account + .delegate_stake(&vote_keyed_account) + .is_err()); + } + + #[test] + fn test_stake_redeem_vote_credits() { + let vote_keypair = Keypair::new(); + let mut vote_state = VoteState::default(); + for i in 0..1000 { + vote_state.process_vote(Vote::new(i)); + } + + let vote_pubkey = vote_keypair.pubkey(); + let mut vote_account = create_vote_account(100); + let mut vote_keyed_account = KeyedAccount::new(&vote_pubkey, false, &mut vote_account); + 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_keyed_account = KeyedAccount::new(&pubkey, true, &mut stake_account); + + // delegate the stake + assert!(stake_keyed_account + .delegate_stake(&vote_keyed_account) + .is_ok()); + + let mut mining_pool_account = Account::new(0, std::mem::size_of::(), &id()); + let mut mining_pool_keyed_account = + KeyedAccount::new(&pubkey, true, &mut mining_pool_account); + + // no mining pool yet... + assert_eq!( + mining_pool_keyed_account + .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account), + Err(InstructionError::InvalidAccountData) + ); + + mining_pool_keyed_account + .set_state(&StakeState::MiningPool) + .unwrap(); + + // no movement in vote account, so no redemption needed + assert!(mining_pool_keyed_account + .redeem_vote_credits(&mut stake_keyed_account, &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 + assert_eq!( + mining_pool_keyed_account + .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account), + Err(InstructionError::UnbalancedInstruction) + ); + + // add a lamport + mining_pool_keyed_account.account.lamports = 2; + assert!(mining_pool_keyed_account + .redeem_vote_credits(&mut stake_keyed_account, &vote_keyed_account) + .is_ok()); + } + + #[test] + fn test_stake_redeem_vote_credits_vote_errors() { + let vote_keypair = Keypair::new(); + let mut vote_state = VoteState::default(); + for i in 0..1000 { + vote_state.process_vote(Vote::new(i)); + } + + let vote_pubkey = vote_keypair.pubkey(); + let mut vote_account = create_vote_account(100); + let mut vote_keyed_account = KeyedAccount::new(&vote_pubkey, false, &mut vote_account); + 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_keyed_account = KeyedAccount::new(&pubkey, true, &mut stake_account); + + // delegate the stake + assert!(stake_keyed_account + .delegate_stake(&vote_keyed_account) + .is_ok()); + + let mut mining_pool_account = Account::new(0, std::mem::size_of::(), &id()); + let mut mining_pool_keyed_account = + KeyedAccount::new(&pubkey, true, &mut mining_pool_account); + mining_pool_keyed_account + .set_state(&StakeState::MiningPool) + .unwrap(); + + let mut vote_state = VoteState::default(); + for i in 0..100 { + // go back in time, previous state had 1000 votes + vote_state.process_vote(Vote::new(i)); + } + vote_keyed_account.set_state(&vote_state).unwrap(); + // 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), + Err(InstructionError::InvalidAccountData) + ); + + let vote1_keypair = Keypair::new(); + let vote1_pubkey = vote1_keypair.pubkey(); + let mut vote1_account = create_vote_account(100); + let mut vote1_keyed_account = KeyedAccount::new(&vote1_pubkey, false, &mut vote1_account); + vote1_keyed_account.set_state(&vote_state).unwrap(); + + // wrong voter_id... + assert_eq!( + mining_pool_keyed_account + .redeem_vote_credits(&mut stake_keyed_account, &vote1_keyed_account), + Err(InstructionError::InvalidArgument) + ); + } + +} diff --git a/programs/stake_program/Cargo.toml b/programs/stake_program/Cargo.toml new file mode 100644 index 000000000..343c964fc --- /dev/null +++ b/programs/stake_program/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "solana-stake-program" +version = "0.13.0" +description = "Solana stake program" +authors = ["Solana Maintainers "] +repository = "https://github.com/solana-labs/solana" +license = "Apache-2.0" +homepage = "https://solana.com/" +edition = "2018" + +[dependencies] +log = "0.4.2" +solana-logger = { path = "../../logger", version = "0.13.0" } +solana-sdk = { path = "../../sdk", version = "0.13.0" } +solana-stake-api = { path = "../stake_api", version = "0.13.0" } + +[lib] +name = "solana_stake_program" +crate-type = ["cdylib"] + diff --git a/programs/stake_program/src/lib.rs b/programs/stake_program/src/lib.rs new file mode 100644 index 000000000..8cd37c397 --- /dev/null +++ b/programs/stake_program/src/lib.rs @@ -0,0 +1,3 @@ +use solana_stake_api::stake_instruction::process_instruction; + +solana_sdk::solana_entrypoint!(process_instruction);