Add solana-stake-monitor program (#9081)
This commit is contained in:
24
Cargo.lock
generated
24
Cargo.lock
generated
@ -4584,6 +4584,30 @@ dependencies = [
|
|||||||
"syn 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)",
|
"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]]
|
[[package]]
|
||||||
name = "solana-stake-program"
|
name = "solana-stake-program"
|
||||||
version = "1.1.0"
|
version = "1.1.0"
|
||||||
|
@ -52,6 +52,7 @@ members = [
|
|||||||
"sdk",
|
"sdk",
|
||||||
"sdk-c",
|
"sdk-c",
|
||||||
"scripts",
|
"scripts",
|
||||||
|
"stake-monitor",
|
||||||
"sys-tuner",
|
"sys-tuner",
|
||||||
"transaction-status",
|
"transaction-status",
|
||||||
"upload-perf",
|
"upload-perf",
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
// Wallet settings that can be configured for long-term use
|
// Wallet settings that can be configured for long-term use
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::{
|
use std::io;
|
||||||
fs::{create_dir_all, File},
|
|
||||||
io::{self, Write},
|
|
||||||
path::Path,
|
|
||||||
};
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
@ -46,23 +42,11 @@ impl Default for Config {
|
|||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load(config_file: &str) -> Result<Self, io::Error> {
|
pub fn load(config_file: &str) -> Result<Self, io::Error> {
|
||||||
let file = File::open(config_file.to_string())?;
|
crate::load_config_file(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(&self, config_file: &str) -> Result<(), io::Error> {
|
pub fn save(&self, config_file: &str) -> Result<(), io::Error> {
|
||||||
let serialized = serde_yaml::to_string(self)
|
crate::save_config_file(self, config_file)
|
||||||
.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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn compute_websocket_url(json_rpc_url: &str) -> String {
|
pub fn compute_websocket_url(json_rpc_url: &str) -> String {
|
||||||
|
@ -3,3 +3,37 @@ extern crate lazy_static;
|
|||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
pub use config::{Config, CONFIG_FILE};
|
pub use config::{Config, CONFIG_FILE};
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
fs::{create_dir_all, File},
|
||||||
|
io::{self, Write},
|
||||||
|
path::Path,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn load_config_file<T, P>(config_file: P) -> Result<T, io::Error>
|
||||||
|
where
|
||||||
|
T: serde::de::DeserializeOwned,
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
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<T, P>(config: &T, config_file: P) -> Result<(), io::Error>
|
||||||
|
where
|
||||||
|
T: serde::ser::Serialize,
|
||||||
|
P: AsRef<Path>,
|
||||||
|
{
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
@ -96,6 +96,7 @@ else
|
|||||||
solana-ledger-tool
|
solana-ledger-tool
|
||||||
solana-log-analyzer
|
solana-log-analyzer
|
||||||
solana-net-shaper
|
solana-net-shaper
|
||||||
|
solana-stake-monitor
|
||||||
solana-sys-tuner
|
solana-sys-tuner
|
||||||
solana-validator
|
solana-validator
|
||||||
solana-watchtower
|
solana-watchtower
|
||||||
|
2
stake-monitor/.gitignore
vendored
Normal file
2
stake-monitor/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/target/
|
||||||
|
/farf/
|
35
stake-monitor/Cargo.toml
Normal file
35
stake-monitor/Cargo.toml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
authors = ["Solana Maintainers <maintainers@solana.com>"]
|
||||||
|
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"
|
14
stake-monitor/README.md
Normal file
14
stake-monitor/README.md
Normal file
@ -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"
|
591
stake-monitor/src/lib.rs
Normal file
591
stake-monitor/src/lib.rs
Normal file
@ -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<Slot>, // The slot when the account was first in compliance
|
||||||
|
pub lamports: u64, // Account balance
|
||||||
|
pub transactions: Vec<StakeAccountTransactionInfo>, // Transactions affecting the account
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default, Debug)]
|
||||||
|
pub struct StakeAccountsInfo {
|
||||||
|
pub slot: Slot, // Latest processed slot
|
||||||
|
pub account_info: HashMap<PubkeyString, StakeAccountInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_transaction(
|
||||||
|
slot: Slot,
|
||||||
|
transaction: &Transaction,
|
||||||
|
meta: &TransactionStatusMeta,
|
||||||
|
stake_accounts: &mut HashMap<PubkeyString, StakeAccountInfo>,
|
||||||
|
) {
|
||||||
|
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::<StakeInstruction>(&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<PubkeyString, StakeAccountInfo>,
|
||||||
|
) {
|
||||||
|
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<Vec<(Slot, ConfirmedBlock)>> {
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
188
stake-monitor/src/main.rs
Normal file
188
stake-monitor/src/main.rs
Normal file
@ -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!(),
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate serde_derive;
|
extern crate serde_derive;
|
||||||
|
|
||||||
use bincode::serialize;
|
use bincode;
|
||||||
use solana_sdk::{
|
use solana_sdk::{
|
||||||
clock::Slot,
|
clock::Slot,
|
||||||
message::MessageHeader,
|
message::MessageHeader,
|
||||||
@ -122,7 +122,18 @@ impl EncodedTransaction {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else {
|
} 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<Transaction> {
|
||||||
|
match self {
|
||||||
|
EncodedTransaction::Json(_) => None,
|
||||||
|
EncodedTransaction::Binary(blob) => bs58::decode(blob)
|
||||||
|
.into_vec()
|
||||||
|
.ok()
|
||||||
|
.and_then(|bytes| bincode::deserialize(&bytes).ok()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user