diff --git a/cli-output/src/cli_output.rs b/cli-output/src/cli_output.rs index 9cbd48bf25..d471eeff3f 100644 --- a/cli-output/src/cli_output.rs +++ b/cli-output/src/cli_output.rs @@ -571,6 +571,48 @@ impl fmt::Display for CliKeyedStakeState { } } +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CliEpochReward { + pub epoch: Epoch, + pub effective_slot: Slot, + pub amount: u64, // lamports + pub post_balance: u64, // lamports + pub percent_change: f64, + pub apr: f64, +} + +fn show_epoch_rewards( + f: &mut fmt::Formatter, + epoch_rewards: &Option>, +) -> fmt::Result { + if let Some(epoch_rewards) = epoch_rewards { + if epoch_rewards.is_empty() { + return Ok(()); + } + + writeln!(f, "Epoch Rewards:")?; + writeln!( + f, + " {:<8} {:<11} {:<15} {:<15} {:>14} {:>14}", + "Epoch", "Reward Slot", "Amount", "New Balance", "Percent Change", "APR" + )?; + for reward in epoch_rewards { + writeln!( + f, + " {:<8} {:<11} ◎{:<14.9} ◎{:<14.9} {:>13.9}% {:>13.9}%", + reward.epoch, + reward.effective_slot, + lamports_to_sol(reward.amount), + lamports_to_sol(reward.post_balance), + reward.percent_change, + reward.apr, + )?; + } + } + Ok(()) +} + #[derive(Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CliStakeState { @@ -600,6 +642,8 @@ pub struct CliStakeState { pub activating_stake: Option, #[serde(skip_serializing_if = "Option::is_none")] pub deactivating_stake: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch_rewards: Option>, } impl QuietDisplay for CliStakeState {} @@ -753,13 +797,14 @@ impl fmt::Display for CliStakeState { } show_authorized(f, self.authorized.as_ref().unwrap())?; show_lockup(f, self.lockup.as_ref())?; + show_epoch_rewards(f, &self.epoch_rewards)? } } Ok(()) } } -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq)] pub enum CliStakeType { Stake, RewardsPool, @@ -936,6 +981,8 @@ pub struct CliVoteAccount { pub epoch_voting_history: Vec, #[serde(skip_serializing)] pub use_lamports_unit: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch_rewards: Option>, } impl QuietDisplay for CliVoteAccount {} @@ -980,6 +1027,7 @@ impl fmt::Display for CliVoteAccount { )?; } } + show_epoch_rewards(f, &self.epoch_rewards)?; Ok(()) } } diff --git a/cli/src/cluster_query.rs b/cli/src/cluster_query.rs index 551e85563f..c5b859e5e5 100644 --- a/cli/src/cluster_query.rs +++ b/cli/src/cluster_query.rs @@ -713,6 +713,7 @@ pub fn process_get_block(rpc_client: &RpcClient, _config: &CliConfig, slot: Slot } if !block.rewards.is_empty() { block.rewards.sort_by(|a, b| a.pubkey.cmp(&b.pubkey)); + let mut total_rewards = 0; println!("Rewards:",); println!( " {:<44} {:<15} {:<13} {:>14}", @@ -721,6 +722,7 @@ pub fn process_get_block(rpc_client: &RpcClient, _config: &CliConfig, slot: Slot for reward in block.rewards { let sign = if reward.lamports < 0 { "-" } else { "" }; + total_rewards += reward.lamports; println!( " {:<44} {:>15} {}", reward.pubkey, @@ -741,6 +743,13 @@ pub fn process_get_block(rpc_client: &RpcClient, _config: &CliConfig, slot: Slot } ); } + + let sign = if total_rewards < 0 { "-" } else { "" }; + println!( + "Total Rewards: {}◎{:12.9}", + sign, + lamports_to_sol(total_rewards.abs() as u64) + ); } for (index, transaction_with_meta) in block.transactions.iter().enumerate() { println!("Transaction {}:", index); diff --git a/cli/src/stake.rs b/cli/src/stake.rs index a5e4051c58..570bc35f0e 100644 --- a/cli/src/stake.rs +++ b/cli/src/stake.rs @@ -7,6 +7,7 @@ use crate::{ nonce::check_nonce_account, spend_utils::{resolve_spend_tx_and_check_account_balances, SpendAmount}, }; +use chrono::{Local, TimeZone}; use clap::{App, Arg, ArgGroup, ArgMatches, SubCommand}; use solana_clap_utils::{ fee_payer::{fee_payer_arg, FEE_PAYER_ARG}, @@ -18,7 +19,8 @@ use solana_clap_utils::{ ArgConstant, }; use solana_cli_output::{ - return_signers, CliStakeHistory, CliStakeHistoryEntry, CliStakeState, CliStakeType, + return_signers, CliEpochReward, CliStakeHistory, CliStakeHistoryEntry, CliStakeState, + CliStakeType, }; use solana_client::{ blockhash_query::BlockhashQuery, nonce_utils, rpc_client::RpcClient, @@ -27,7 +29,7 @@ use solana_client::{ use solana_remote_wallet::remote_wallet::RemoteWalletManager; use solana_sdk::{ account_utils::StateMut, - clock::Clock, + clock::{Clock, Epoch, Slot, UnixTimestamp}, message::Message, pubkey::Pubkey, system_instruction::SystemError, @@ -43,7 +45,7 @@ use solana_stake_program::{ stake_state::{Authorized, Lockup, Meta, StakeAuthorize, StakeState}, }; use solana_vote_program::vote_state::VoteState; -use std::{ops::Deref, sync::Arc}; +use std::{convert::TryInto, ops::Deref, sync::Arc}; pub const STAKE_AUTHORITY_ARG: ArgConstant<'static> = ArgConstant { name: "stake_authority", @@ -1543,6 +1545,7 @@ pub fn build_stake_state( active_stake: u64_some_if_not_zero(active_stake), activating_stake: u64_some_if_not_zero(activating_stake), deactivating_stake: u64_some_if_not_zero(deactivating_stake), + ..CliStakeState::default() } } StakeState::RewardsPool => CliStakeState { @@ -1577,17 +1580,96 @@ pub fn build_stake_state( } } +pub(crate) fn fetch_epoch_rewards( + rpc_client: &RpcClient, + address: &Pubkey, + lowest_epoch: Epoch, +) -> Result, Box> { + let mut all_epoch_rewards = vec![]; + + let epoch_schedule = rpc_client.get_epoch_schedule()?; + let slot = rpc_client.get_slot()?; + let first_available_block = rpc_client.get_first_available_block()?; + + let mut epoch = epoch_schedule.get_epoch_and_slot_index(slot).0; + let mut epoch_info: Option<(Slot, UnixTimestamp, solana_transaction_status::Rewards)> = None; + while epoch > lowest_epoch { + let first_slot_in_epoch = epoch_schedule.get_first_slot_in_epoch(epoch); + if first_slot_in_epoch < first_available_block { + // RPC node is out of history data + break; + } + + let first_confirmed_block_in_epoch = *rpc_client + .get_confirmed_blocks_with_limit(first_slot_in_epoch, 1)? + .get(0) + .ok_or_else(|| format!("Unable to fetch first confirmed block for epoch {}", epoch))?; + + let first_confirmed_block = rpc_client.get_confirmed_block_with_encoding( + first_confirmed_block_in_epoch, + solana_transaction_status::UiTransactionEncoding::Base64, + )?; + + let epoch_start_time = if let Some(block_time) = first_confirmed_block.block_time { + block_time + } else { + break; + }; + + // Rewards for the previous epoch are found in the first confirmed block of the current epoch + let previous_epoch_rewards = first_confirmed_block.rewards; + + if let Some((effective_slot, epoch_end_time, epoch_rewards)) = epoch_info { + let wall_clock_epoch_duration = + { Local.timestamp(epoch_end_time, 0) - Local.timestamp(epoch_start_time, 0) } + .to_std()? + .as_secs_f64(); + + const SECONDS_PER_YEAR: f64 = (24 * 60 * 60 * 356) as f64; + let percent_of_year = SECONDS_PER_YEAR / wall_clock_epoch_duration; + + if let Some(reward) = epoch_rewards + .into_iter() + .find(|reward| reward.pubkey == address.to_string()) + { + if reward.post_balance > reward.lamports.try_into().unwrap_or(0) { + let balance_increase_percent = reward.lamports.abs() as f64 + / (reward.post_balance as f64 - reward.lamports as f64); + + all_epoch_rewards.push(CliEpochReward { + epoch, + effective_slot, + amount: reward.lamports.abs() as u64, + post_balance: reward.post_balance, + percent_change: balance_increase_percent, + apr: balance_increase_percent * percent_of_year, + }); + } + } + } + + epoch -= 1; + epoch_info = Some(( + first_confirmed_block_in_epoch, + epoch_start_time, + previous_epoch_rewards, + )); + } + + Ok(all_epoch_rewards) +} + pub fn process_show_stake_account( rpc_client: &RpcClient, config: &CliConfig, - stake_account_pubkey: &Pubkey, + stake_account_address: &Pubkey, use_lamports_unit: bool, ) -> ProcessResult { - let stake_account = rpc_client.get_account(stake_account_pubkey)?; + let stake_account = rpc_client.get_account(stake_account_address)?; if stake_account.owner != solana_stake_program::id() { return Err(CliError::RpcRequestError(format!( "{:?} is not a stake account", - stake_account_pubkey, + stake_account_address, )) .into()); } @@ -1603,13 +1685,23 @@ pub fn process_show_stake_account( CliError::RpcRequestError("Failed to deserialize clock sysvar".to_string()) })?; - let state = build_stake_state( + let mut state = build_stake_state( stake_account.lamports, &stake_state, use_lamports_unit, &stake_history, &clock, ); + + if state.stake_type == CliStakeType::Stake { + if let Some(activation_epoch) = state.activation_epoch { + state.epoch_rewards = Some(fetch_epoch_rewards( + rpc_client, + stake_account_address, + activation_epoch, + )?); + } + } Ok(config.output_format.formatted_string(&state)) } Err(err) => Err(CliError::RpcRequestError(format!( diff --git a/cli/src/vote.rs b/cli/src/vote.rs index 4a59d5bee7..29ce0a73a4 100644 --- a/cli/src/vote.rs +++ b/cli/src/vote.rs @@ -671,11 +671,11 @@ fn get_vote_account( pub fn process_show_vote_account( rpc_client: &RpcClient, config: &CliConfig, - vote_account_pubkey: &Pubkey, + vote_account_address: &Pubkey, use_lamports_unit: bool, ) -> ProcessResult { let (vote_account, vote_state) = - get_vote_account(rpc_client, vote_account_pubkey, config.commitment)?; + get_vote_account(rpc_client, vote_account_address, config.commitment)?; let epoch_schedule = rpc_client.get_epoch_schedule()?; @@ -696,6 +696,12 @@ pub fn process_show_vote_account( } } + let epoch_rewards = Some(crate::stake::fetch_epoch_rewards( + rpc_client, + vote_account_address, + 1, + )?); + let vote_account_data = CliVoteAccount { account_balance: vote_account.lamports, validator_identity: vote_state.node_pubkey.to_string(), @@ -708,6 +714,7 @@ pub fn process_show_vote_account( votes, epoch_voting_history, use_lamports_unit, + epoch_rewards, }; Ok(config.output_format.formatted_string(&vote_account_data))