diff --git a/Cargo.lock b/Cargo.lock index 3439c951da..14c10247f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4584,6 +4584,30 @@ dependencies = [ "syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)", ] +[[package]] +name = "solana-stake-monitor" +version = "1.1.0" +dependencies = [ + "clap 2.33.0 (registry+https://github.com/rust-lang/crates.io-index)", + "console 0.10.0 (registry+https://github.com/rust-lang/crates.io-index)", + "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", + "serde 1.0.105 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_yaml 0.8.11 (registry+https://github.com/rust-lang/crates.io-index)", + "serial_test 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "serial_test_derive 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)", + "solana-clap-utils 1.1.0", + "solana-cli-config 1.1.0", + "solana-client 1.1.0", + "solana-core 1.1.0", + "solana-local-cluster 1.1.0", + "solana-logger 1.1.0", + "solana-metrics 1.1.0", + "solana-sdk 1.1.0", + "solana-stake-program 1.1.0", + "solana-transaction-status 1.1.0", + "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "solana-stake-program" version = "1.1.0" diff --git a/Cargo.toml b/Cargo.toml index a1a869f59f..d8caa70e88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,7 @@ members = [ "sdk", "sdk-c", "scripts", + "stake-monitor", "sys-tuner", "transaction-status", "upload-perf", diff --git a/cli-config/src/config.rs b/cli-config/src/config.rs index 6503dc4b5e..876ffc01bd 100644 --- a/cli-config/src/config.rs +++ b/cli-config/src/config.rs @@ -1,10 +1,6 @@ // Wallet settings that can be configured for long-term use use serde_derive::{Deserialize, Serialize}; -use std::{ - fs::{create_dir_all, File}, - io::{self, Write}, - path::Path, -}; +use std::io; use url::Url; lazy_static! { @@ -46,23 +42,11 @@ impl Default for Config { impl Config { pub fn load(config_file: &str) -> Result { - let file = File::open(config_file.to_string())?; - let config = serde_yaml::from_reader(file) - .map_err(|err| io::Error::new(io::ErrorKind::Other, format!("{:?}", err)))?; - Ok(config) + crate::load_config_file(config_file) } pub fn save(&self, config_file: &str) -> Result<(), io::Error> { - let serialized = serde_yaml::to_string(self) - .map_err(|err| io::Error::new(io::ErrorKind::Other, format!("{:?}", err)))?; - - if let Some(outdir) = Path::new(&config_file).parent() { - create_dir_all(outdir)?; - } - let mut file = File::create(config_file)?; - file.write_all(&serialized.into_bytes())?; - - Ok(()) + crate::save_config_file(self, config_file) } pub fn compute_websocket_url(json_rpc_url: &str) -> String { diff --git a/cli-config/src/lib.rs b/cli-config/src/lib.rs index 80853fe50d..435e9762c3 100644 --- a/cli-config/src/lib.rs +++ b/cli-config/src/lib.rs @@ -3,3 +3,37 @@ extern crate lazy_static; mod config; pub use config::{Config, CONFIG_FILE}; + +use std::{ + fs::{create_dir_all, File}, + io::{self, Write}, + path::Path, +}; + +pub fn load_config_file(config_file: P) -> Result +where + T: serde::de::DeserializeOwned, + P: AsRef, +{ + let file = File::open(config_file)?; + let config = serde_yaml::from_reader(file) + .map_err(|err| io::Error::new(io::ErrorKind::Other, format!("{:?}", err)))?; + Ok(config) +} + +pub fn save_config_file(config: &T, config_file: P) -> Result<(), io::Error> +where + T: serde::ser::Serialize, + P: AsRef, +{ + let serialized = serde_yaml::to_string(config) + .map_err(|err| io::Error::new(io::ErrorKind::Other, format!("{:?}", err)))?; + + if let Some(outdir) = config_file.as_ref().parent() { + create_dir_all(outdir)?; + } + let mut file = File::create(config_file)?; + file.write_all(&serialized.into_bytes())?; + + Ok(()) +} diff --git a/scripts/cargo-install-all.sh b/scripts/cargo-install-all.sh index 8ecf55b3cc..f7e4a2a1c1 100755 --- a/scripts/cargo-install-all.sh +++ b/scripts/cargo-install-all.sh @@ -96,6 +96,7 @@ else solana-ledger-tool solana-log-analyzer solana-net-shaper + solana-stake-monitor solana-sys-tuner solana-validator solana-watchtower diff --git a/stake-monitor/.gitignore b/stake-monitor/.gitignore new file mode 100644 index 0000000000..5404b132db --- /dev/null +++ b/stake-monitor/.gitignore @@ -0,0 +1,2 @@ +/target/ +/farf/ diff --git a/stake-monitor/Cargo.toml b/stake-monitor/Cargo.toml new file mode 100644 index 0000000000..47270df254 --- /dev/null +++ b/stake-monitor/Cargo.toml @@ -0,0 +1,35 @@ +[package] +authors = ["Solana Maintainers "] +edition = "2018" +name = "solana-stake-monitor" +description = "Blockchain, Rebuilt for Scale" +version = "1.1.0" +repository = "https://github.com/solana-labs/solana" +license = "Apache-2.0" +homepage = "https://solana.com/" + +[dependencies] +clap = "2.33.0" +console = "0.10.0" +log = "0.4.8" +serde = "1.0.105" +serde_yaml = "0.8.11" +solana-clap-utils = { path = "../clap-utils", version = "1.1.0" } +solana-cli-config = { path = "../cli-config", version = "1.1.0" } +solana-client = { path = "../client", version = "1.1.0" } +solana-logger = { path = "../logger", version = "1.1.0" } +solana-metrics = { path = "../metrics", version = "1.1.0" } +solana-sdk = { path = "../sdk", version = "1.1.0" } +solana-stake-program = { path = "../programs/stake", version = "1.1.0" } +solana-transaction-status = { path = "../transaction-status", version = "1.1.0" } + +[dev-dependencies] +serial_test = "0.4.0" +serial_test_derive = "0.4.0" +solana-local-cluster = { path = "../local-cluster", version = "1.1.0" } +solana-core = { path = "../core", version = "1.1.0" } +tempfile = "3.1.0" + +[[bin]] +name = "solana-stake-monitor" +path = "src/main.rs" diff --git a/stake-monitor/README.md b/stake-monitor/README.md new file mode 100644 index 0000000000..736f274752 --- /dev/null +++ b/stake-monitor/README.md @@ -0,0 +1,14 @@ +## Overview +`solana-stake-monitor` is a utility that scans all transactions to ensure that stake accounts remain in compliance with the following rules: + +1. The stake account must be created after genesis +1. The "compliant balance" of a stake account is set upon stake account initialization, system transfers of additional funds into a compliant stake account are excluded from the "compliant balance" +1. The stake account cannot have a lockup or custodian +1. Withdrawing funds from the stake account trigger non-compliance +1. Stake accounts split from a compliant stake account remain compliant, and the "compliant balance" is adjusted accordingly for the original stake account + +In terms of `solana` command-line subcommands: +* `create-stake-account`: Creates a compliant stake account provided the `--lockup-date`, `--lockup-epoch`, or `--custodian` options are not specified +* `delegate-stake` / `deactivate-stake` / `stake-authorize` / `split-stake`: These commands do not affect compliance +* `withdraw-stake` / `stake-set-lockup`: These commands will cause non-compliance +* `transfer`: Any additional funds transferred after `create-stake-account` are excluded from the "compliant balance" diff --git a/stake-monitor/src/lib.rs b/stake-monitor/src/lib.rs new file mode 100644 index 0000000000..c0a94e9be6 --- /dev/null +++ b/stake-monitor/src/lib.rs @@ -0,0 +1,591 @@ +use log::*; +use serde::{Deserialize, Serialize}; +use solana_client::{client_error::Result as ClientResult, rpc_client::RpcClient}; +use solana_metrics::{datapoint_error, datapoint_info}; +use solana_sdk::{clock::Slot, program_utils::limited_deserialize, transaction::Transaction}; +use solana_stake_program::{stake_instruction::StakeInstruction, stake_state::Lockup}; +use solana_transaction_status::{ConfirmedBlock, TransactionEncoding, TransactionStatusMeta}; +use std::{collections::HashMap, thread::sleep, time::Duration}; + +pub type PubkeyString = String; +pub type SignatureString = String; + +#[derive(Serialize, Deserialize, Debug, PartialEq)] +pub enum StakeAccountOperation { + Initialize, + Withdraw, + SplitSource, + SplitDestination, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct StakeAccountTransactionInfo { + pub op: StakeAccountOperation, + pub slot: Slot, // Slot the transaction completed in + pub signature: SignatureString, // Transaction signature +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct StakeAccountInfo { + pub compliant_since: Option, // The slot when the account was first in compliance + pub lamports: u64, // Account balance + pub transactions: Vec, // Transactions affecting the account +} + +#[derive(Serialize, Deserialize, Default, Debug)] +pub struct StakeAccountsInfo { + pub slot: Slot, // Latest processed slot + pub account_info: HashMap, +} + +fn process_transaction( + slot: Slot, + transaction: &Transaction, + meta: &TransactionStatusMeta, + stake_accounts: &mut HashMap, +) { + let mut last_instruction = true; + let message = &transaction.message; + for instruction in message.instructions.iter().rev() { + let program_pubkey = message.account_keys[instruction.program_id_index as usize]; + if program_pubkey != solana_stake_program::id() { + continue; + } + + // Only look for stake instructions in the last instruction of a + // transaction. This ensures that the `meta.post_balances` for the + // transaction reflects the account balances after the stake instruction + // executed. At this time the `solana` cli will only create transactions with the stake + // instruction as the last instruction. + if !last_instruction { + datapoint_error!( + "stake-monitor-failure", + ("slot", slot, i64), + ("err", "Stake instruction ignored", String) + ); + continue; + } + last_instruction = false; + + match limited_deserialize::(&instruction.data) { + Err(err) => datapoint_error!( + "stake-monitor-failure", + ("slot", slot, i64), + ( + "err", + format!("Failed to deserialize stake instruction: {}", err), + String + ) + ), + Ok(stake_instruction) => { + let signature = transaction.signatures[0].to_string(); + + match stake_instruction { + StakeInstruction::Initialize(_authorized, lockup) => { + // The initialized stake account is at instruction account 0 + let stake_account_index = instruction.accounts[0] as usize; + + let stake_pubkey = message.account_keys[stake_account_index].to_string(); + + // The amount staked is the stake account's post balance + let lamports = meta.post_balances[stake_account_index]; + + stake_accounts.insert( + stake_pubkey, + StakeAccountInfo { + compliant_since: if lockup != Lockup::default() { + None // Initialize with a lockup or custodian is non-compliant + } else { + Some(slot) + }, + lamports, + transactions: vec![StakeAccountTransactionInfo { + op: StakeAccountOperation::Initialize, + slot, + signature, + }], + }, + ); + } + StakeInstruction::Authorize(_, _) + | StakeInstruction::DelegateStake + | StakeInstruction::Deactivate => { + // These instructions are always permitted + } + StakeInstruction::Split(lamports) => { + // Split is permitted and propagates compliance + let source_stake_account_index = instruction.accounts[0] as usize; + let split_stake_account_index = instruction.accounts[1] as usize; + + let source_stake_pubkey = + message.account_keys[source_stake_account_index].to_string(); + let split_stake_pubkey = + message.account_keys[split_stake_account_index].to_string(); + + if let Some(mut source_stake_account_info) = + stake_accounts.get_mut(&source_stake_pubkey) + { + if source_stake_account_info.compliant_since.is_some() { + source_stake_account_info.transactions.push( + StakeAccountTransactionInfo { + op: StakeAccountOperation::SplitSource, + slot, + signature: signature.clone(), + }, + ); + source_stake_account_info.lamports -= lamports; + + let split_stake_account_info = StakeAccountInfo { + compliant_since: source_stake_account_info.compliant_since, + lamports, + transactions: vec![StakeAccountTransactionInfo { + op: StakeAccountOperation::SplitDestination, + slot, + signature, + }], + }; + stake_accounts.insert(split_stake_pubkey, split_stake_account_info); + } + } + } + StakeInstruction::Withdraw(_) => { + // Withdrawing is not permitted + + let stake_account_index = instruction.accounts[0] as usize; + let stake_pubkey = message.account_keys[stake_account_index].to_string(); + + if let Some(mut stake_account_info) = stake_accounts.get_mut(&stake_pubkey) + { + if stake_account_info.compliant_since.is_some() { + stake_account_info.compliant_since = None; + stake_account_info + .transactions + .push(StakeAccountTransactionInfo { + op: StakeAccountOperation::Withdraw, + slot, + signature, + }); + } + } + } + StakeInstruction::SetLockup(_lockup_args) => { + // No processing is required because SetLockup requires a custodian key, + // and this is already blocked in the StakeInstruction::Initialize + // processing + } + } + } + } + } +} + +fn process_confirmed_block( + slot: Slot, + confirmed_block: ConfirmedBlock, + stake_accounts: &mut HashMap, +) { + for rpc_transaction in confirmed_block.transactions { + match rpc_transaction.meta { + None => { + datapoint_error!( + "stake-monitor-failure", + ("slot", slot, i64), + ("err", "Transaction meta not available", String) + ); + } + Some(meta) => { + if meta.status.is_ok() { + if let Some(transaction) = rpc_transaction.transaction.decode() { + if transaction.verify().is_ok() { + process_transaction(slot, &transaction, &meta, stake_accounts); + } else { + datapoint_error!( + "stake-monitor-failure", + ("slot", slot, i64), + ("err", "Transaction signature verification failed", String) + ); + } + } + } + } + } + } +} + +fn load_blocks( + rpc_client: &RpcClient, + start_slot: Slot, + end_slot: Slot, +) -> ClientResult> { + info!( + "Loading confirmed blocks between slots: {} - {}", + start_slot, end_slot + ); + + let slots = rpc_client.get_confirmed_blocks(start_slot, Some(end_slot))?; + + let mut blocks = vec![]; + for slot in slots.into_iter() { + let block = + rpc_client.get_confirmed_block_with_encoding(slot, TransactionEncoding::Binary)?; + blocks.push((slot, block)); + } + Ok(blocks) +} + +pub fn process_slots( + rpc_client: &RpcClient, + stake_accounts_info: &mut StakeAccountsInfo, + batch_size: u64, +) { + let end_slot = stake_accounts_info.slot + batch_size; + loop { + let start_slot = stake_accounts_info.slot + 1; + info!("start_slot:{} - end_slot:{}", start_slot, end_slot); + if start_slot >= end_slot { + break; + } + let latest_available_slot = rpc_client.get_slot().unwrap_or_else(|err| { + datapoint_error!( + "stake-monitor-failure", + ("err", format!("get_slot() failed: {}", err), String) + ); + 0 + }); + + if stake_accounts_info.slot >= latest_available_slot { + info!( + "Waiting for a slot greater than {}...", + stake_accounts_info.slot + ); + sleep(Duration::from_secs(5)); + continue; + } + + match load_blocks(&rpc_client, start_slot, end_slot) { + Ok(blocks) => { + info!("Loaded {} blocks", blocks.len()); + + for (slot, block) in blocks.into_iter() { + process_confirmed_block(slot, block, &mut stake_accounts_info.account_info); + stake_accounts_info.slot = slot; + datapoint_info!("stake-monitor-slot", ("slot", slot, i64)); + } + } + Err(err) => { + datapoint_error!( + "stake-monitor-failure", + ( + "err", + format!( + "failed to get blocks in range ({},{}): {}", + start_slot, end_slot, err + ), + String + ) + ); + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use serial_test_derive::serial; + use solana_core::{rpc::JsonRpcConfig, validator::ValidatorConfig}; + use solana_local_cluster::local_cluster::{ClusterConfig, LocalCluster}; + use solana_sdk::{ + commitment_config::CommitmentConfig, + genesis_config::OperatingMode, + message::Message, + native_token::sol_to_lamports, + signature::{Keypair, Signer}, + system_transaction, + transaction::Transaction, + }; + use solana_stake_program::{stake_instruction, stake_state::Authorized}; + + #[test] + #[serial] + fn test_record() { + solana_logger::setup(); + let one_sol = sol_to_lamports(1.0); + let cluster = LocalCluster::new(&ClusterConfig { + operating_mode: OperatingMode::Stable, + node_stakes: vec![10; 1], + cluster_lamports: sol_to_lamports(1_000_000_000.0), + validator_configs: vec![ValidatorConfig { + rpc_config: JsonRpcConfig { + enable_rpc_transaction_history: true, + ..JsonRpcConfig::default() + }, + ..ValidatorConfig::default() + }], + ..ClusterConfig::default() + }); + + let payer = &cluster.funding_keypair; + + let rpc_client = RpcClient::new_socket(cluster.entry_point_info.rpc); + + let (blockhash, _fee_calculator) = rpc_client.get_recent_blockhash().unwrap(); + + // Configure stake1 + let stake1_keypair = Keypair::new(); + let stake1_signature = rpc_client + .send_transaction(&Transaction::new_signed_instructions( + &[&payer, &stake1_keypair], + stake_instruction::create_account( + &payer.pubkey(), + &stake1_keypair.pubkey(), + &Authorized::auto(&payer.pubkey()), + &Lockup::default(), + one_sol, + ), + blockhash, + )) + .unwrap(); + + rpc_client + .poll_for_signature_with_commitment(&stake1_signature, CommitmentConfig::recent()) + .unwrap(); + + // A balance increase by system transfer is ignored + rpc_client + .send_transaction(&system_transaction::transfer( + &payer, + &stake1_keypair.pubkey(), + one_sol, + blockhash, + )) + .unwrap(); + + // Configure stake2 with non-compliant lockup + let stake2_keypair = Keypair::new(); + let stake2_signature = rpc_client + .send_transaction(&Transaction::new_signed_instructions( + &[&payer, &stake2_keypair], + stake_instruction::create_account( + &payer.pubkey(), + &stake2_keypair.pubkey(), + &Authorized::auto(&payer.pubkey()), + &Lockup { + custodian: payer.pubkey(), + ..Lockup::default() + }, + one_sol, + ), + blockhash, + )) + .unwrap(); + + // Configure stake3 + let stake3_keypair = Keypair::new(); + let stake3_initialize_signature = rpc_client + .send_transaction(&Transaction::new_signed_instructions( + &[&payer, &stake3_keypair], + stake_instruction::create_account( + &payer.pubkey(), + &stake3_keypair.pubkey(), + &Authorized::auto(&payer.pubkey()), + &Lockup::default(), + one_sol, + ), + blockhash, + )) + .unwrap(); + + rpc_client + .poll_for_signature_with_commitment( + &stake3_initialize_signature, + CommitmentConfig::recent(), + ) + .unwrap(); + + // Withdraw instruction causes non-compliance + let stake3_withdraw_signature = rpc_client + .send_transaction(&Transaction::new( + &[&payer, &stake3_keypair], + Message::new_with_payer( + &[stake_instruction::withdraw( + &stake3_keypair.pubkey(), + &stake3_keypair.pubkey(), + &payer.pubkey(), + one_sol, + )], + Some(&payer.pubkey()), + ), + blockhash, + )) + .unwrap(); + + rpc_client + .poll_for_signature_with_commitment( + &stake3_withdraw_signature, + CommitmentConfig::recent(), + ) + .unwrap(); + + // Configure stake4 + let stake4_keypair = Keypair::new(); + let stake4_initialize_signature = rpc_client + .send_transaction(&Transaction::new_signed_instructions( + &[&payer, &stake4_keypair], + stake_instruction::create_account( + &payer.pubkey(), + &stake4_keypair.pubkey(), + &Authorized::auto(&payer.pubkey()), + &Lockup::default(), + 2 * one_sol, + ), + blockhash, + )) + .unwrap(); + + rpc_client + .poll_for_signature_with_commitment( + &stake4_initialize_signature, + CommitmentConfig::recent(), + ) + .unwrap(); + + // Split stake4 into stake5 + let stake5_keypair = Keypair::new(); + let stake45_split_signature = rpc_client + .send_transaction(&Transaction::new( + &[&payer, &stake5_keypair], + Message::new_with_payer( + &stake_instruction::split( + &stake4_keypair.pubkey(), + &payer.pubkey(), + one_sol, + &stake5_keypair.pubkey(), + ), + Some(&payer.pubkey()), + ), + blockhash, + )) + .unwrap(); + + rpc_client + .poll_for_signature_with_commitment( + &stake45_split_signature, + CommitmentConfig::recent(), + ) + .unwrap(); + + // Process all the transactions + let mut stake_accounts_info = StakeAccountsInfo::default(); + let current_slot = rpc_client + .get_slot_with_commitment(CommitmentConfig::recent()) + .unwrap(); + process_slots(&rpc_client, &mut stake_accounts_info, current_slot + 1); + + // + // Check that `stake_accounts_info` was populated with the expected results + // + + info!("Check the data recorded for stake1"); + let stake_account_info = stake_accounts_info + .account_info + .get(&stake1_keypair.pubkey().to_string()) + .unwrap(); + assert!(stake_account_info.compliant_since.is_some()); + assert_eq!(stake_account_info.lamports, one_sol); + assert_eq!(stake_account_info.transactions.len(), 1); + assert_eq!( + stake_account_info.transactions[0].op, + StakeAccountOperation::Initialize + ); + assert_eq!( + stake_account_info.transactions[0].signature, + stake1_signature.to_string() + ); + + info!("Check the data recorded for stake2"); + let stake_account_info = stake_accounts_info + .account_info + .get(&stake2_keypair.pubkey().to_string()) + .unwrap(); + assert!(stake_account_info.compliant_since.is_none()); + assert_eq!(stake_account_info.lamports, one_sol); + assert_eq!(stake_account_info.transactions.len(), 1); + assert_eq!( + stake_account_info.transactions[0].op, + StakeAccountOperation::Initialize + ); + assert_eq!( + stake_account_info.transactions[0].signature, + stake2_signature.to_string() + ); + + info!("Check the data recorded for stake3"); + let stake_account_info = stake_accounts_info + .account_info + .get(&stake3_keypair.pubkey().to_string()) + .unwrap(); + assert!(stake_account_info.compliant_since.is_none()); + assert_eq!(stake_account_info.lamports, one_sol); + assert_eq!(stake_account_info.transactions.len(), 2); + assert_eq!( + stake_account_info.transactions[0].op, + StakeAccountOperation::Initialize + ); + assert_eq!( + stake_account_info.transactions[0].signature, + stake3_initialize_signature.to_string() + ); + assert_eq!( + stake_account_info.transactions[1].op, + StakeAccountOperation::Withdraw, + ); + assert_eq!( + stake_account_info.transactions[1].signature, + stake3_withdraw_signature.to_string() + ); + + info!("Check the data recorded for stake4"); + let stake_account_info = stake_accounts_info + .account_info + .get(&stake4_keypair.pubkey().to_string()) + .unwrap(); + assert!(stake_account_info.compliant_since.is_some()); + assert_eq!(stake_account_info.lamports, one_sol); + assert_eq!(stake_account_info.transactions.len(), 2); + assert_eq!( + stake_account_info.transactions[0].op, + StakeAccountOperation::Initialize + ); + assert_eq!( + stake_account_info.transactions[0].signature, + stake4_initialize_signature.to_string() + ); + assert_eq!( + stake_account_info.transactions[1].op, + StakeAccountOperation::SplitSource, + ); + assert_eq!( + stake_account_info.transactions[1].signature, + stake45_split_signature.to_string() + ); + + info!("Check the data recorded for stake5"); + let stake_account_info = stake_accounts_info + .account_info + .get(&stake5_keypair.pubkey().to_string()) + .unwrap(); + error!("stake_account_info 5: {:?}", stake_account_info); + assert!(stake_account_info.compliant_since.is_some()); + assert_eq!(stake_account_info.lamports, one_sol); + assert_eq!(stake_account_info.transactions.len(), 1); + assert_eq!( + stake_account_info.transactions[0].op, + StakeAccountOperation::SplitDestination, + ); + assert_eq!( + stake_account_info.transactions[0].signature, + stake45_split_signature.to_string() + ); + } +} diff --git a/stake-monitor/src/main.rs b/stake-monitor/src/main.rs new file mode 100644 index 0000000000..45d9a0b94d --- /dev/null +++ b/stake-monitor/src/main.rs @@ -0,0 +1,188 @@ +use clap::{ + crate_description, crate_name, value_t, value_t_or_exit, App, AppSettings, Arg, SubCommand, +}; +use console::Emoji; +use log::*; +use solana_clap_utils::{ + input_parsers::pubkey_of, + input_validators::{is_pubkey, is_slot, is_url}, +}; +use solana_client::rpc_client::RpcClient; +use solana_metrics::datapoint_error; +use solana_sdk::{clock::Slot, native_token::lamports_to_sol, pubkey::Pubkey}; +use solana_stake_monitor::*; +use std::{fs, io, process}; + +fn load_stake_accounts_info(data_file: &str) -> StakeAccountsInfo { + let data_file_new = data_file.to_owned() + "new"; + let stake_accounts_info = solana_cli_config::load_config_file(&data_file_new) + .or_else(|_| solana_cli_config::load_config_file(data_file)) + .unwrap_or_default(); + + // Ensure `data_file` always exists + save_stake_accounts_info(data_file, &stake_accounts_info).expect("save_stake_accounts_info"); + + stake_accounts_info +} + +fn save_stake_accounts_info( + data_file: &str, + stake_accounts_info: &StakeAccountsInfo, +) -> io::Result<()> { + let data_file_new = data_file.to_owned() + "new"; + solana_cli_config::save_config_file(&stake_accounts_info, &data_file_new)?; + let _ = fs::remove_file(data_file); + fs::rename(&data_file_new, data_file) +} + +fn command_record(data_file: String, json_rpc_url: String, first_slot: Slot, batch_size: u64) { + let mut stake_accounts_info = load_stake_accounts_info(&data_file); + + info!("RPC URL: {}", json_rpc_url); + let rpc_client = RpcClient::new(json_rpc_url); + if stake_accounts_info.slot < first_slot { + stake_accounts_info.slot = first_slot; + } + loop { + process_slots(&rpc_client, &mut stake_accounts_info, batch_size); + save_stake_accounts_info(&data_file, &stake_accounts_info).unwrap_or_else(|err| { + datapoint_error!( + "stake-monitor-failure", + ( + "err", + format!("failed to save stake_accounts_info: {}", err), + String + ) + ); + }); + } +} + +fn command_check(data_file: String, stake_account_pubkey: Pubkey) { + let stake_accounts_info = load_stake_accounts_info(&data_file); + + if let Some(stake_account_info) = stake_accounts_info + .account_info + .get(&stake_account_pubkey.to_string()) + { + if let Some(slot) = stake_account_info.compliant_since { + println!( + "{}Stake account compliant since slot {} with a balance of {} SOL", + Emoji("✅ ", ""), + slot, + lamports_to_sol(stake_account_info.lamports) + ); + process::exit(0); + } else { + eprintln!( + "{}Stake account not compliant due to: {:?}", + Emoji("❌ ", ""), + stake_account_info.transactions.last().unwrap() + ); + process::exit(1); + } + } else { + eprintln!("{} Unknown stake account", Emoji("⚠️ ", "")); + process::exit(1); + } +} + +fn main() { + solana_logger::setup_with_default("solana=info"); + solana_metrics::set_panic_hook("stake-monitor"); + + let matches = App::new(crate_name!()) + .about(crate_description!()) + .version(solana_clap_utils::version!()) + .setting(AppSettings::SubcommandRequiredElseHelp) + .arg( + Arg::with_name("data_file") + .long("data-file") + .value_name("PATH") + .takes_value(true) + .default_value("stake-info.yml") + .global(true) + .help( + "Output YAML file that receives the information for all stake accounts.\ + This file is updated atomically after each batch of slots is processed.", + ), + ) + .subcommand( + SubCommand::with_name("record") + .about("Monitor all Cluster transactions for state account compliance") + .arg({ + let arg = Arg::with_name("config_file") + .short("C") + .long("config") + .value_name("PATH") + .takes_value(true) + .help("Configuration file to use"); + if let Some(ref config_file) = *solana_cli_config::CONFIG_FILE { + arg.default_value(&config_file) + } else { + arg + } + }) + .arg( + Arg::with_name("json_rpc_url") + .long("url") + .value_name("URL") + .takes_value(true) + .validator(is_url) + .help("JSON RPC URL for the cluster"), + ) + .arg( + Arg::with_name("first_slot") + .long("--first-slot") + .value_name("SLOT") + .validator(is_slot) + .takes_value(true) + .default_value("0") + .help("Don't process slots lower than this value"), + ) + .arg( + Arg::with_name("batch_size") + .long("--batch-size") + .value_name("NUMBER") + .takes_value(true) + .default_value("10") + .help("Process up to this many slots in one batch"), + ), + ) + .subcommand( + SubCommand::with_name("check") + .about("Check if a state account is in compliance") + .arg( + Arg::with_name("stake_account_pubkey") + .index(1) + .value_name("ADDRESS") + .validator(is_pubkey) + .required(true) + .help("Stake account address"), + ), + ) + .get_matches(); + + let data_file = value_t_or_exit!(matches, "data_file", String); + + match matches.subcommand() { + ("record", Some(matches)) => { + let batch_size = value_t_or_exit!(matches, "batch_size", u64); + let first_slot = value_t_or_exit!(matches, "first_slot", Slot); + let json_rpc_url = value_t!(matches, "json_rpc_url", String).unwrap_or_else(|_| { + let config = if let Some(config_file) = matches.value_of("config_file") { + solana_cli_config::Config::load(config_file).unwrap_or_default() + } else { + solana_cli_config::Config::default() + }; + config.json_rpc_url + }); + command_record(data_file, json_rpc_url, first_slot, batch_size); + } + ("check", Some(matches)) => { + let stake_account_pubkey = pubkey_of(&matches, "stake_account_pubkey").unwrap(); + command_check(data_file, stake_account_pubkey); + } + _ => unreachable!(), + } +} diff --git a/transaction-status/src/lib.rs b/transaction-status/src/lib.rs index 3a410340d8..ecabe07ce9 100644 --- a/transaction-status/src/lib.rs +++ b/transaction-status/src/lib.rs @@ -1,7 +1,7 @@ #[macro_use] extern crate serde_derive; -use bincode::serialize; +use bincode; use solana_sdk::{ clock::Slot, message::MessageHeader, @@ -122,7 +122,18 @@ impl EncodedTransaction { }, }) } else { - EncodedTransaction::Binary(bs58::encode(serialize(&transaction).unwrap()).into_string()) + EncodedTransaction::Binary( + bs58::encode(bincode::serialize(&transaction).unwrap()).into_string(), + ) + } + } + pub fn decode(&self) -> Option { + match self { + EncodedTransaction::Json(_) => None, + EncodedTransaction::Binary(blob) => bs58::decode(blob) + .into_vec() + .ok() + .and_then(|bytes| bincode::deserialize(&bytes).ok()), } } }