From f21e475c961ab3e47d302916e445985e272c689d Mon Sep 17 00:00:00 2001 From: Evan B Date: Wed, 10 Jan 2024 16:07:38 -0500 Subject: [PATCH] Cluster History account with blocks per epoch tracking (#14) Cluster History Struct: - Sets up new account type: ClusterHistory - Reads number of landed blocks from SlotHistory for last epoch - Adds support for cranking this per epoch - Backfill instruction + CLI command for filling in data since epoch 500 - Tests Also includes IDL updated with ClusterHistory types --- keepers/validator-keeper/src/cluster_info.rs | 29 + keepers/validator-keeper/src/lib.rs | 34 +- keepers/validator-keeper/src/main.rs | 64 +- .../idl/validator_history.json | 927 ++++++++++++++++++ programs/validator-history/src/errors.rs | 2 + .../src/instructions/backfill_total_blocks.rs | 32 + .../src/instructions/copy_cluster_info.rs | 68 ++ .../initialize_cluster_history_account.rs | 21 + .../validator-history/src/instructions/mod.rs | 8 + .../realloc_cluster_history_account.rs | 62 ++ programs/validator-history/src/lib.rs | 24 + programs/validator-history/src/state.rs | 132 ++- tests/src/fixtures.rs | 44 +- tests/tests/test_cluster_history.rs | 72 ++ utils/validator-history-cli/src/main.rs | 120 ++- 15 files changed, 1628 insertions(+), 11 deletions(-) create mode 100644 keepers/validator-keeper/src/cluster_info.rs create mode 100644 programs/validator-history/idl/validator_history.json create mode 100644 programs/validator-history/src/instructions/backfill_total_blocks.rs create mode 100644 programs/validator-history/src/instructions/copy_cluster_info.rs create mode 100644 programs/validator-history/src/instructions/initialize_cluster_history_account.rs create mode 100644 programs/validator-history/src/instructions/realloc_cluster_history_account.rs create mode 100644 tests/tests/test_cluster_history.rs diff --git a/keepers/validator-keeper/src/cluster_info.rs b/keepers/validator-keeper/src/cluster_info.rs new file mode 100644 index 00000000..0f45c5bb --- /dev/null +++ b/keepers/validator-keeper/src/cluster_info.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use anchor_lang::{InstructionData, ToAccountMetas}; +use keeper_core::{submit_instructions, SubmitStats, TransactionExecutionError}; +use solana_client::nonblocking::rpc_client::RpcClient; +use solana_sdk::{instruction::Instruction, pubkey::Pubkey, signature::Keypair, signer::Signer}; +use validator_history::state::ClusterHistory; + +pub async fn update_cluster_info( + client: Arc, + keypair: Arc, + program_id: &Pubkey, +) -> Result { + let (cluster_history_account, _) = + Pubkey::find_program_address(&[ClusterHistory::SEED], program_id); + + let update_instruction = Instruction { + program_id: *program_id, + accounts: validator_history::accounts::CopyClusterInfo { + cluster_history_account, + slot_history: solana_program::sysvar::slot_history::id(), + signer: keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::CopyClusterInfo {}.data(), + }; + + submit_instructions(&client, vec![update_instruction], &keypair).await +} diff --git a/keepers/validator-keeper/src/lib.rs b/keepers/validator-keeper/src/lib.rs index b2d0b489..f1aaf728 100644 --- a/keepers/validator-keeper/src/lib.rs +++ b/keepers/validator-keeper/src/lib.rs @@ -4,11 +4,10 @@ use std::{ }; use anchor_lang::{AccountDeserialize, Discriminator}; -use keeper_core::CreateUpdateStats; +use keeper_core::{CreateUpdateStats, SubmitStats}; use log::error; use solana_account_decoder::UiDataSliceConfig; use solana_client::{ - client_error::ClientError, nonblocking::rpc_client::RpcClient, rpc_config::{RpcAccountInfoConfig, RpcProgramAccountsConfig}, rpc_filter::{Memcmp, RpcFilterType}, @@ -26,8 +25,9 @@ use solana_sdk::{ use solana_streamer::socket::SocketAddrSpace; use jito_tip_distribution::state::TipDistributionAccount; -use validator_history::{ValidatorHistory, ValidatorHistoryEntry}; +use validator_history::{ClusterHistory, ValidatorHistory, ValidatorHistoryEntry}; +pub mod cluster_info; pub mod gossip; pub mod mev_commission; pub mod stake; @@ -91,10 +91,19 @@ pub fn emit_validator_commission_datapoint(stats: CreateUpdateStats, runs_for_ep ); } +pub fn emit_cluster_history_datapoint(stats: SubmitStats, runs_for_epoch: i64) { + datapoint_info!( + "cluster-history-stats", + ("num_success", stats.successes, i64), + ("num_errors", stats.errors, i64), + ("runs_for_epoch", runs_for_epoch, i64), + ); +} + pub async fn emit_validator_history_metrics( client: &Arc, program_id: Pubkey, -) -> Result<(), ClientError> { +) -> Result<(), Box> { let epoch = client.get_epoch_info().await?; // Fetch every ValidatorHistory account @@ -161,6 +170,21 @@ pub async fn emit_validator_history_metrics( } } + let (cluster_history_address, _) = + Pubkey::find_program_address(&[ClusterHistory::SEED], &program_id); + let cluster_history_account = client.get_account(&cluster_history_address).await?; + let cluster_history = + ClusterHistory::try_deserialize(&mut cluster_history_account.data.as_slice())?; + + let mut cluster_history_blocks: i64 = 0; + let cluster_history_entry = cluster_history.history.last(); + if let Some(cluster_history) = cluster_history_entry { + // Looking for previous epoch to be updated + if cluster_history.epoch as u64 == epoch.epoch - 1 { + cluster_history_blocks = 1; + } + } + datapoint_info!( "validator-history-stats", ("num_validator_histories", num_validators, i64), @@ -171,8 +195,10 @@ pub async fn emit_validator_history_metrics( ("num_commissions", comms, i64), ("num_epoch_credits", epoch_credits, i64), ("num_stakes", stakes, i64), + ("cluster_history_blocks", cluster_history_blocks, i64), ("slot_index", epoch.slot_index, i64), ); + Ok(()) } diff --git a/keepers/validator-keeper/src/main.rs b/keepers/validator-keeper/src/main.rs index ee749cde..22e6a848 100644 --- a/keepers/validator-keeper/src/main.rs +++ b/keepers/validator-keeper/src/main.rs @@ -7,7 +7,7 @@ It will emits metrics for each data feed, if env var SOLANA_METRICS_CONFIG is se use std::{collections::HashMap, net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; use clap::{arg, command, Parser}; -use keeper_core::{Cluster, CreateUpdateStats}; +use keeper_core::{Cluster, CreateUpdateStats, SubmitStats}; use log::*; use solana_client::nonblocking::rpc_client::RpcClient; use solana_metrics::{datapoint_error, set_host_id}; @@ -17,8 +17,9 @@ use solana_sdk::{ }; use tokio::time::sleep; use validator_keeper::{ - emit_mev_commission_datapoint, emit_validator_commission_datapoint, - emit_validator_history_metrics, + cluster_info::update_cluster_info, + emit_cluster_history_datapoint, emit_mev_commission_datapoint, + emit_validator_commission_datapoint, emit_validator_history_metrics, gossip::{emit_gossip_datapoint, upload_gossip_values}, mev_commission::update_mev_commission, stake::{emit_stake_history_datapoint, update_stake_history}, @@ -262,6 +263,56 @@ async fn gossip_upload_loop( } } +async fn cluster_history_loop( + client: Arc, + keypair: Arc, + program_id: Pubkey, + interval: u64, +) { + let mut runs_for_epoch = 0; + let mut current_epoch = 0; + + loop { + let epoch_info = match client.get_epoch_info().await { + Ok(epoch_info) => epoch_info, + Err(e) => { + error!("Failed to get epoch info: {}", e); + sleep(Duration::from_secs(5)).await; + continue; + } + }; + let epoch = epoch_info.epoch; + + let mut stats = SubmitStats::default(); + + if current_epoch != epoch { + runs_for_epoch = 0; + } + + // Run at 0.1%, 50% and 90% completion of epoch + let should_run = (epoch_info.slot_index > epoch_info.slots_in_epoch / 1000 + && runs_for_epoch < 1) + || (epoch_info.slot_index > epoch_info.slots_in_epoch / 2 && runs_for_epoch < 2) + || (epoch_info.slot_index > epoch_info.slots_in_epoch * 9 / 10 && runs_for_epoch < 3); + if should_run { + stats = match update_cluster_info(client.clone(), keypair.clone(), &program_id).await { + Ok(run_stats) => { + runs_for_epoch += 1; + run_stats + } + Err((e, run_stats)) => { + datapoint_error!("cluster-history-error", ("error", e.to_string(), String),); + run_stats + } + }; + } + + current_epoch = epoch; + emit_cluster_history_datapoint(stats, runs_for_epoch); + sleep(Duration::from_secs(interval)).await; + } +} + #[tokio::main] async fn main() { env_logger::init(); @@ -284,6 +335,13 @@ async fn main() { args.interval, )); + tokio::spawn(cluster_history_loop( + Arc::clone(&client), + Arc::clone(&keypair), + args.program_id, + args.interval, + )); + tokio::spawn(vote_account_loop( Arc::clone(&client), Arc::clone(&keypair), diff --git a/programs/validator-history/idl/validator_history.json b/programs/validator-history/idl/validator_history.json new file mode 100644 index 00000000..e9febf2a --- /dev/null +++ b/programs/validator-history/idl/validator_history.json @@ -0,0 +1,927 @@ +{ + "version": "0.1.0", + "name": "validator_history", + "instructions": [ + { + "name": "initializeValidatorHistoryAccount", + "accounts": [ + { + "name": "validatorHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "voteAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "reallocValidatorHistoryAccount", + "accounts": [ + { + "name": "validatorHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "voteAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "Used to read validator commission." + ] + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "initializeClusterHistoryAccount", + "accounts": [ + { + "name": "clusterHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "reallocClusterHistoryAccount", + "accounts": [ + { + "name": "clusterHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "copyVoteAccount", + "accounts": [ + { + "name": "validatorHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "voteAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "updateMevCommission", + "accounts": [ + { + "name": "validatorHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "voteAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "Used to read validator commission." + ] + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "tipDistributionAccount", + "isMut": false, + "isSigner": false, + "docs": [ + "`owner = config.tip_distribution_program.key()` here is sufficient." + ] + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "initializeConfig", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "systemProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [ + { + "name": "authority", + "type": "publicKey" + } + ] + }, + { + "name": "setNewTipDistributionProgram", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newTipDistributionProgram", + "isMut": false, + "isSigner": false + }, + { + "name": "admin", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "setNewAdmin", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newAdmin", + "isMut": false, + "isSigner": false + }, + { + "name": "admin", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "setNewOracleAuthority", + "accounts": [ + { + "name": "config", + "isMut": true, + "isSigner": false + }, + { + "name": "newOracleAuthority", + "isMut": false, + "isSigner": false + }, + { + "name": "admin", + "isMut": false, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "updateStakeHistory", + "accounts": [ + { + "name": "validatorHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "voteAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "oracleAuthority", + "isMut": true, + "isSigner": true + } + ], + "args": [ + { + "name": "epoch", + "type": "u64" + }, + { + "name": "lamports", + "type": "u64" + }, + { + "name": "rank", + "type": "u32" + }, + { + "name": "isSuperminority", + "type": "bool" + } + ] + }, + { + "name": "copyGossipContactInfo", + "accounts": [ + { + "name": "validatorHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "voteAccount", + "isMut": false, + "isSigner": false + }, + { + "name": "instructions", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "copyClusterInfo", + "accounts": [ + { + "name": "clusterHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "slotHistory", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [] + }, + { + "name": "backfillTotalBlocks", + "accounts": [ + { + "name": "clusterHistoryAccount", + "isMut": true, + "isSigner": false + }, + { + "name": "config", + "isMut": false, + "isSigner": false + }, + { + "name": "signer", + "isMut": true, + "isSigner": true + } + ], + "args": [ + { + "name": "epoch", + "type": "u64" + }, + { + "name": "blocksInEpoch", + "type": "u32" + } + ] + } + ], + "accounts": [ + { + "name": "Config", + "type": { + "kind": "struct", + "fields": [ + { + "name": "tipDistributionProgram", + "type": "publicKey" + }, + { + "name": "admin", + "type": "publicKey" + }, + { + "name": "oracleAuthority", + "type": "publicKey" + }, + { + "name": "counter", + "type": "u32" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + }, + { + "name": "ValidatorHistory", + "type": { + "kind": "struct", + "fields": [ + { + "name": "structVersion", + "type": "u32" + }, + { + "name": "voteAccount", + "type": "publicKey" + }, + { + "name": "index", + "type": "u32" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "lastIpTimestamp", + "type": "u64" + }, + { + "name": "lastVersionTimestamp", + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u8", + 232 + ] + } + }, + { + "name": "history", + "type": { + "defined": "CircBuf" + } + } + ] + } + }, + { + "name": "ClusterHistory", + "type": { + "kind": "struct", + "fields": [ + { + "name": "structVersion", + "type": "u64" + }, + { + "name": "bump", + "type": "u8" + }, + { + "name": "padding0", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "clusterHistoryLastUpdateSlot", + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u8", + 232 + ] + } + }, + { + "name": "history", + "type": { + "defined": "CircBufCluster" + } + } + ] + } + } + ], + "types": [ + { + "name": "ValidatorHistoryEntry", + "type": { + "kind": "struct", + "fields": [ + { + "name": "activatedStakeLamports", + "type": "u64" + }, + { + "name": "epoch", + "type": "u16" + }, + { + "name": "mevCommission", + "type": "u16" + }, + { + "name": "epochCredits", + "type": "u32" + }, + { + "name": "commission", + "type": "u8" + }, + { + "name": "clientType", + "type": "u8" + }, + { + "name": "version", + "type": { + "defined": "ClientVersion" + } + }, + { + "name": "ip", + "type": { + "array": [ + "u8", + 4 + ] + } + }, + { + "name": "padding0", + "type": "u8" + }, + { + "name": "isSuperminority", + "type": "u8" + }, + { + "name": "rank", + "type": "u32" + }, + { + "name": "voteAccountLastUpdateSlot", + "type": "u64" + }, + { + "name": "padding1", + "type": { + "array": [ + "u8", + 88 + ] + } + } + ] + } + }, + { + "name": "ClientVersion", + "type": { + "kind": "struct", + "fields": [ + { + "name": "major", + "type": "u8" + }, + { + "name": "minor", + "type": "u8" + }, + { + "name": "patch", + "type": "u16" + } + ] + } + }, + { + "name": "CircBuf", + "type": { + "kind": "struct", + "fields": [ + { + "name": "idx", + "type": "u64" + }, + { + "name": "isEmpty", + "type": "u8" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "arr", + "type": { + "array": [ + { + "defined": "ValidatorHistoryEntry" + }, + 512 + ] + } + } + ] + } + }, + { + "name": "ClusterHistoryEntry", + "type": { + "kind": "struct", + "fields": [ + { + "name": "totalBlocks", + "type": "u32" + }, + { + "name": "epoch", + "type": "u16" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 250 + ] + } + } + ] + } + }, + { + "name": "CircBufCluster", + "type": { + "kind": "struct", + "fields": [ + { + "name": "idx", + "type": "u64" + }, + { + "name": "isEmpty", + "type": "u8" + }, + { + "name": "padding", + "type": { + "array": [ + "u8", + 7 + ] + } + }, + { + "name": "arr", + "type": { + "array": [ + { + "defined": "ClusterHistoryEntry" + }, + 512 + ] + } + } + ] + } + }, + { + "name": "CrdsData", + "docs": [ + "CrdsData that defines the different types of items CrdsValues can hold", + "* Merge Strategy - Latest wallclock is picked", + "* LowestSlot index is deprecated" + ], + "type": { + "kind": "enum", + "variants": [ + { + "name": "LegacyContactInfo", + "fields": [ + { + "defined": "LegacyContactInfo" + } + ] + }, + { + "name": "Vote" + }, + { + "name": "LowestSlot" + }, + { + "name": "LegacySnapshotHashes" + }, + { + "name": "AccountsHashes" + }, + { + "name": "EpochSlots" + }, + { + "name": "LegacyVersion", + "fields": [ + { + "defined": "LegacyVersion" + } + ] + }, + { + "name": "Version", + "fields": [ + { + "defined": "Version2" + } + ] + }, + { + "name": "NodeInstance" + }, + { + "name": "DuplicateShred" + }, + { + "name": "SnapshotHashes" + }, + { + "name": "ContactInfo", + "fields": [ + { + "defined": "ContactInfo" + } + ] + } + ] + } + }, + { + "name": "Error", + "type": { + "kind": "enum", + "variants": [ + { + "name": "DuplicateIpAddr", + "fields": [ + { + "defined": "IpAddr" + } + ] + }, + { + "name": "DuplicateSocket", + "fields": [ + "u8" + ] + }, + { + "name": "InvalidIpAddrIndex", + "fields": [ + { + "name": "index", + "type": "u8" + }, + { + "name": "num_addrs", + "type": { + "defined": "usize" + } + } + ] + }, + { + "name": "InvalidPort", + "fields": [ + "u16" + ] + }, + { + "name": "InvalidQuicSocket", + "fields": [ + { + "option": { + "defined": "SocketAddr" + } + }, + { + "option": { + "defined": "SocketAddr" + } + } + ] + }, + { + "name": "IpAddrsSaturated" + }, + { + "name": "MulticastIpAddr", + "fields": [ + { + "defined": "IpAddr" + } + ] + }, + { + "name": "PortOffsetsOverflow" + }, + { + "name": "SocketNotFound", + "fields": [ + "u8" + ] + }, + { + "name": "UnspecifiedIpAddr", + "fields": [ + { + "defined": "IpAddr" + } + ] + }, + { + "name": "UnusedIpAddr", + "fields": [ + { + "defined": "IpAddr" + } + ] + } + ] + } + }, + { + "name": "ValidatorHistoryVersion", + "type": { + "kind": "enum", + "variants": [ + { + "name": "V0" + } + ] + } + } + ], + "errors": [ + { + "code": 6000, + "name": "AccountFullySized", + "msg": "Account already reached proper size, no more allocations allowed" + }, + { + "code": 6001, + "name": "InvalidEpochCredits", + "msg": "Invalid epoch credits, credits must be greater than previous credits" + }, + { + "code": 6002, + "name": "EpochOutOfRange", + "msg": "Epoch is out of range of history" + }, + { + "code": 6003, + "name": "NotSigVerified", + "msg": "Gossip Signature Verification not performed" + }, + { + "code": 6004, + "name": "GossipDataInvalid", + "msg": "Gossip Data Invalid" + }, + { + "code": 6005, + "name": "UnsupportedIpFormat", + "msg": "Unsupported IP Format, only IpAddr::V4 is supported" + }, + { + "code": 6006, + "name": "NotEnoughVotingHistory", + "msg": "Not enough voting history to create account. Minimum 10 epochs required" + }, + { + "code": 6007, + "name": "GossipDataTooOld", + "msg": "Gossip data too old. Data cannot be older than the last recorded timestamp for a field" + }, + { + "code": 6008, + "name": "GossipDataInFuture", + "msg": "Gossip timestamp too far in the future" + }, + { + "code": 6009, + "name": "ArithmeticError", + "msg": "Arithmetic Error (overflow/underflow)" + }, + { + "code": 6010, + "name": "SlotHistoryOutOfDate", + "msg": "Slot history sysvar is not containing expected slots" + } + ] +} \ No newline at end of file diff --git a/programs/validator-history/src/errors.rs b/programs/validator-history/src/errors.rs index 03239c13..4d0c4d52 100644 --- a/programs/validator-history/src/errors.rs +++ b/programs/validator-history/src/errors.rs @@ -24,4 +24,6 @@ pub enum ValidatorHistoryError { GossipDataInFuture, #[msg("Arithmetic Error (overflow/underflow)")] ArithmeticError, + #[msg("Slot history sysvar is not containing expected slots")] + SlotHistoryOutOfDate, } diff --git a/programs/validator-history/src/instructions/backfill_total_blocks.rs b/programs/validator-history/src/instructions/backfill_total_blocks.rs new file mode 100644 index 00000000..4517e4d2 --- /dev/null +++ b/programs/validator-history/src/instructions/backfill_total_blocks.rs @@ -0,0 +1,32 @@ +use anchor_lang::prelude::*; + +use crate::{errors::ValidatorHistoryError, utils::cast_epoch, ClusterHistory, Config}; + +#[derive(Accounts)] +pub struct BackfillTotalBlocks<'info> { + #[account( + mut, + seeds = [ClusterHistory::SEED], + bump, + )] + pub cluster_history_account: AccountLoader<'info, ClusterHistory>, + pub config: Account<'info, Config>, + #[account(mut, address = config.oracle_authority )] + pub signer: Signer<'info>, +} + +pub fn handler(ctx: Context, epoch: u64, blocks_in_epoch: u32) -> Result<()> { + let mut cluster_history_account = ctx.accounts.cluster_history_account.load_mut()?; + + let epoch = cast_epoch(epoch); + + // Need to backfill in ascending order when initially filling in the account otherwise entries will be out of order + if !cluster_history_account.history.is_empty() + && epoch != cluster_history_account.history.last().unwrap().epoch + 1 + { + return Err(ValidatorHistoryError::EpochOutOfRange.into()); + } + cluster_history_account.set_blocks(epoch, blocks_in_epoch)?; + + Ok(()) +} diff --git a/programs/validator-history/src/instructions/copy_cluster_info.rs b/programs/validator-history/src/instructions/copy_cluster_info.rs new file mode 100644 index 00000000..8134813c --- /dev/null +++ b/programs/validator-history/src/instructions/copy_cluster_info.rs @@ -0,0 +1,68 @@ +use anchor_lang::{ + prelude::*, + solana_program::{clock::Clock, slot_history::Check}, +}; + +use crate::{errors::ValidatorHistoryError, utils::cast_epoch, ClusterHistory}; + +#[derive(Accounts)] +pub struct CopyClusterInfo<'info> { + #[account( + mut, + seeds = [ClusterHistory::SEED], + bump, + )] + pub cluster_history_account: AccountLoader<'info, ClusterHistory>, + /// CHECK: slot_history sysvar + #[account(address = anchor_lang::solana_program::sysvar::slot_history::id())] + pub slot_history: UncheckedAccount<'info>, + #[account(mut)] + pub signer: Signer<'info>, +} + +pub fn handler(ctx: Context) -> Result<()> { + let mut cluster_history_account = ctx.accounts.cluster_history_account.load_mut()?; + let slot_history: Box = + Box::new(bincode::deserialize(&ctx.accounts.slot_history.try_borrow_data()?).unwrap()); + + let clock = Clock::get()?; + + let epoch = cast_epoch(clock.epoch); + + // Sets the slot history for the previous epoch, since the current epoch is not yet complete. + if epoch > 0 { + cluster_history_account + .set_blocks(epoch - 1, blocks_in_epoch(epoch - 1, &slot_history)?)?; + } + cluster_history_account.set_blocks(epoch, blocks_in_epoch(epoch, &slot_history)?)?; + + cluster_history_account.cluster_history_last_update_slot = clock.slot; + + Ok(()) +} + +fn blocks_in_epoch(epoch: u16, slot_history: &SlotHistory) -> Result { + let epoch_schedule = EpochSchedule::get()?; + let start_slot = epoch_schedule.get_first_slot_in_epoch(epoch as u64); + let end_slot = epoch_schedule.get_last_slot_in_epoch(epoch as u64); + + let mut blocks_in_epoch = 0; + for i in start_slot..=end_slot { + match slot_history.check(i) { + Check::Found => { + blocks_in_epoch += 1; + } + Check::NotFound => { + // do nothing + } + Check::TooOld => { + return Err(ValidatorHistoryError::SlotHistoryOutOfDate.into()); + } + Check::Future => { + // do nothing + } + }; + } + + Ok(blocks_in_epoch) +} diff --git a/programs/validator-history/src/instructions/initialize_cluster_history_account.rs b/programs/validator-history/src/instructions/initialize_cluster_history_account.rs new file mode 100644 index 00000000..9230f858 --- /dev/null +++ b/programs/validator-history/src/instructions/initialize_cluster_history_account.rs @@ -0,0 +1,21 @@ +use crate::{constants::MAX_ALLOC_BYTES, ClusterHistory}; +use anchor_lang::prelude::*; + +#[derive(Accounts)] +pub struct InitializeClusterHistoryAccount<'info> { + #[account( + init, + payer = signer, + space = MAX_ALLOC_BYTES, + seeds = [ClusterHistory::SEED], + bump + )] + pub cluster_history_account: AccountLoader<'info, ClusterHistory>, + pub system_program: Program<'info, System>, + #[account(mut)] + pub signer: Signer<'info>, +} + +pub fn handler(_: Context) -> Result<()> { + Ok(()) +} diff --git a/programs/validator-history/src/instructions/mod.rs b/programs/validator-history/src/instructions/mod.rs index 9995550e..6930a028 100644 --- a/programs/validator-history/src/instructions/mod.rs +++ b/programs/validator-history/src/instructions/mod.rs @@ -1,8 +1,12 @@ #![allow(ambiguous_glob_reexports)] +pub mod backfill_total_blocks; +pub mod copy_cluster_info; pub mod copy_gossip_contact_info; pub mod copy_vote_account; +pub mod initialize_cluster_history_account; pub mod initialize_config; pub mod initialize_validator_history_account; +pub mod realloc_cluster_history_account; pub mod realloc_validator_history_account; pub mod set_new_admin; pub mod set_new_oracle_authority; @@ -10,10 +14,14 @@ pub mod set_new_tip_distribution_program; pub mod update_mev_commission; pub mod update_stake_history; +pub use backfill_total_blocks::*; +pub use copy_cluster_info::*; pub use copy_gossip_contact_info::*; pub use copy_vote_account::*; +pub use initialize_cluster_history_account::*; pub use initialize_config::*; pub use initialize_validator_history_account::*; +pub use realloc_cluster_history_account::*; pub use realloc_validator_history_account::*; pub use set_new_admin::*; pub use set_new_oracle_authority::*; diff --git a/programs/validator-history/src/instructions/realloc_cluster_history_account.rs b/programs/validator-history/src/instructions/realloc_cluster_history_account.rs new file mode 100644 index 00000000..1ae3c511 --- /dev/null +++ b/programs/validator-history/src/instructions/realloc_cluster_history_account.rs @@ -0,0 +1,62 @@ +use crate::{constants::MAX_ALLOC_BYTES, ClusterHistory, ClusterHistoryEntry}; +use anchor_lang::prelude::*; + +fn get_realloc_size(account_info: &AccountInfo) -> usize { + let account_size = account_info.data_len(); + + // If account is already over-allocated, don't try to shrink + if account_size < ClusterHistory::SIZE { + ClusterHistory::SIZE.min(account_size + MAX_ALLOC_BYTES) + } else { + account_size + } +} + +fn is_initialized(account_info: &AccountInfo) -> Result { + let account_data = account_info.as_ref().try_borrow_data()?; + + // discriminator + version_number + let vote_account_pubkey_bytes = account_data[(8 + 8)..(8 + 8 + 32)].to_vec(); + + // If pubkey is all zeroes, then it's not initialized + Ok(vote_account_pubkey_bytes.iter().any(|&x| x != 0)) +} + +#[derive(Accounts)] +pub struct ReallocClusterHistoryAccount<'info> { + #[account( + mut, + realloc = get_realloc_size(cluster_history_account.as_ref()), + realloc::payer = signer, + realloc::zero = false, + seeds = [ClusterHistory::SEED], + bump + )] + pub cluster_history_account: AccountLoader<'info, ClusterHistory>, + pub system_program: Program<'info, System>, + #[account(mut)] + pub signer: Signer<'info>, +} + +pub fn handler(ctx: Context) -> Result<()> { + let account_size = ctx.accounts.cluster_history_account.as_ref().data_len(); + if account_size >= ClusterHistory::SIZE + && !is_initialized(ctx.accounts.cluster_history_account.as_ref())? + { + // Can actually initialize values now that the account is proper size + let mut cluster_history_account = ctx.accounts.cluster_history_account.load_mut()?; + + cluster_history_account.bump = *ctx.bumps.get("cluster_history_account").unwrap(); + cluster_history_account.struct_version = 0; + cluster_history_account.history.idx = + (cluster_history_account.history.arr.len() - 1) as u64; + for _ in 0..cluster_history_account.history.arr.len() { + cluster_history_account + .history + .push(ClusterHistoryEntry::default()); + } + cluster_history_account.history.is_empty = 1; + } + + Ok(()) +} diff --git a/programs/validator-history/src/lib.rs b/programs/validator-history/src/lib.rs index ab0a9591..e545323f 100644 --- a/programs/validator-history/src/lib.rs +++ b/programs/validator-history/src/lib.rs @@ -53,6 +53,18 @@ pub mod validator_history { instructions::realloc_validator_history_account::handler(ctx) } + pub fn initialize_cluster_history_account( + ctx: Context, + ) -> Result<()> { + instructions::initialize_cluster_history_account::handler(ctx) + } + + pub fn realloc_cluster_history_account( + ctx: Context, + ) -> Result<()> { + instructions::realloc_cluster_history_account::handler(ctx) + } + pub fn copy_vote_account(ctx: Context) -> Result<()> { instructions::copy_vote_account::handler(ctx) } @@ -92,4 +104,16 @@ pub mod validator_history { pub fn copy_gossip_contact_info(ctx: Context) -> Result<()> { instructions::copy_gossip_contact_info::handler(ctx) } + + pub fn copy_cluster_info(ctx: Context) -> Result<()> { + instructions::copy_cluster_info::handler(ctx) + } + + pub fn backfill_total_blocks( + ctx: Context, + epoch: u64, + blocks_in_epoch: u32, + ) -> Result<()> { + instructions::backfill_total_blocks::handler(ctx, epoch, blocks_in_epoch) + } } diff --git a/programs/validator-history/src/state.rs b/programs/validator-history/src/state.rs index 7258f6fc..00592137 100644 --- a/programs/validator-history/src/state.rs +++ b/programs/validator-history/src/state.rs @@ -18,10 +18,10 @@ pub struct Config { // This program is used to distribute MEV + track which validators are running jito-solana for a given epoch pub tip_distribution_program: Pubkey, - // Has the ability to upgrade the tip_distribution_program in case of a program upgrade + // Has the ability to upgrade config fields pub admin: Pubkey, - // Has the ability to publish stake amounts per validator + // Has the ability to publish data for specific permissioned fields (e.g. stake per validator) pub oracle_authority: Pubkey, // Tracks number of initialized ValidatorHistory accounts @@ -524,6 +524,134 @@ impl ValidatorHistory { } } +#[account(zero_copy)] +pub struct ClusterHistory { + pub struct_version: u64, + pub bump: u8, + pub _padding0: [u8; 7], + pub cluster_history_last_update_slot: u64, + pub _padding1: [u8; 232], + pub history: CircBufCluster, +} + +#[zero_copy] +pub struct ClusterHistoryEntry { + pub total_blocks: u32, + pub epoch: u16, + pub padding: [u8; 250], +} + +impl Default for ClusterHistoryEntry { + fn default() -> Self { + Self { + total_blocks: u32::MAX, + epoch: u16::MAX, + padding: [u8::MAX; 250], + } + } +} + +#[zero_copy] +pub struct CircBufCluster { + pub idx: u64, + pub is_empty: u8, + pub padding: [u8; 7], + pub arr: [ClusterHistoryEntry; MAX_ITEMS], +} + +impl CircBufCluster { + pub fn push(&mut self, item: ClusterHistoryEntry) { + self.idx = (self.idx + 1) % self.arr.len() as u64; + self.arr[self.idx as usize] = item; + self.is_empty = 0; + } + + pub fn is_empty(&self) -> bool { + self.is_empty == 1 + } + + pub fn last(&self) -> Option<&ClusterHistoryEntry> { + if self.is_empty() { + None + } else { + Some(&self.arr[self.idx as usize]) + } + } + + pub fn last_mut(&mut self) -> Option<&mut ClusterHistoryEntry> { + if self.is_empty() { + None + } else { + Some(&mut self.arr[self.idx as usize]) + } + } + + pub fn arr_mut(&mut self) -> &mut [ClusterHistoryEntry] { + &mut self.arr + } + + /// Returns &ClusterHistoryEntry for each existing entry in range [start_epoch, end_epoch], factoring for wraparound + /// Returns None if either start_epoch or end_epoch is not in the CircBuf + pub fn epoch_range(&self, start_epoch: u16, end_epoch: u16) -> Vec<&ClusterHistoryEntry> { + // creates an iterator that lays out the entries in consecutive order, handling wraparound + self.arr[(self.idx as usize + 1)..] + .iter() + .chain(self.arr[..=(self.idx as usize)].iter()) + .filter(|entry| entry.epoch >= start_epoch && entry.epoch <= end_epoch) + .collect() + } + + pub fn total_blocks_latest(&self) -> Option { + if let Some(entry) = self.last() { + if entry.total_blocks != ClusterHistoryEntry::default().total_blocks { + Some(entry.total_blocks) + } else { + None + } + } else { + None + } + } + + pub fn total_blocks_range(&self, start_epoch: u16, end_epoch: u16) -> Vec> { + let epoch_range = self.epoch_range(start_epoch, end_epoch); + epoch_range + .iter() + .map(|entry| { + if entry.total_blocks != ClusterHistoryEntry::default().total_blocks { + Some(entry.total_blocks) + } else { + None + } + }) + .collect::>>() + } +} + +impl ClusterHistory { + pub const SIZE: usize = 8 + size_of::(); + pub const MAX_ITEMS: usize = MAX_ITEMS; + pub const SEED: &'static [u8] = b"cluster-history"; + + // Sets total blocks for the target epoch + pub fn set_blocks(&mut self, epoch: u16, blocks_in_epoch: u32) -> Result<()> { + if let Some(entry) = self.history.last_mut() { + if entry.epoch == epoch { + entry.total_blocks = blocks_in_epoch; + return Ok(()); + } + } + let entry = ClusterHistoryEntry { + epoch, + total_blocks: blocks_in_epoch, + ..ClusterHistoryEntry::default() + }; + self.history.push(entry); + + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/tests/src/fixtures.rs b/tests/src/fixtures.rs index fb4c39fb..8c198550 100644 --- a/tests/src/fixtures.rs +++ b/tests/src/fixtures.rs @@ -17,12 +17,13 @@ use std::{cell::RefCell, rc::Rc}; use jito_tip_distribution::{ sdk::derive_tip_distribution_account_address, state::TipDistributionAccount, }; -use validator_history::{self, constants::MAX_ALLOC_BYTES, ValidatorHistory}; +use validator_history::{self, constants::MAX_ALLOC_BYTES, ClusterHistory, ValidatorHistory}; pub struct TestFixture { pub ctx: Rc>, pub vote_account: Pubkey, pub identity_keypair: Keypair, + pub cluster_history_account: Pubkey, pub validator_history_account: Pubkey, pub validator_history_config: Pubkey, pub tip_distribution_account: Pubkey, @@ -52,6 +53,8 @@ impl TestFixture { let vote_account = Pubkey::new_unique(); let identity_keypair = Keypair::new(); let identity_pubkey = identity_keypair.pubkey(); + let cluster_history_account = + Pubkey::find_program_address(&[ClusterHistory::SEED], &validator_history::id()).0; let tip_distribution_account = derive_tip_distribution_account_address( &jito_tip_distribution::id(), &vote_account, @@ -86,6 +89,7 @@ impl TestFixture { ctx, validator_history_config, validator_history_account, + cluster_history_account, identity_keypair, vote_account, tip_distribution_account, @@ -191,6 +195,44 @@ impl TestFixture { self.submit_transaction_assert_success(transaction).await; } + pub async fn initialize_cluster_history_account(&self) { + let instruction = Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::InitializeClusterHistoryAccount { + cluster_history_account: self.cluster_history_account, + system_program: anchor_lang::solana_program::system_program::id(), + signer: self.keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::InitializeClusterHistoryAccount {}.data(), + }; + + let mut ixs = vec![instruction]; + + // Realloc cluster history account + let num_reallocs = (ClusterHistory::SIZE - MAX_ALLOC_BYTES) / MAX_ALLOC_BYTES + 1; + ixs.extend(vec![ + Instruction { + program_id: validator_history::id(), + accounts: validator_history::accounts::ReallocClusterHistoryAccount { + cluster_history_account: self.cluster_history_account, + system_program: anchor_lang::solana_program::system_program::id(), + signer: self.keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::ReallocClusterHistoryAccount {}.data(), + }; + num_reallocs + ]); + let transaction = Transaction::new_signed_with_payer( + &ixs, + Some(&self.keypair.pubkey()), + &[&self.keypair], + self.ctx.borrow().last_blockhash, + ); + self.submit_transaction_assert_success(transaction).await; + } + pub async fn advance_num_epochs(&self, num_epochs: u64) { let clock: Clock = self .ctx diff --git a/tests/tests/test_cluster_history.rs b/tests/tests/test_cluster_history.rs new file mode 100644 index 00000000..2ed2780d --- /dev/null +++ b/tests/tests/test_cluster_history.rs @@ -0,0 +1,72 @@ +#![allow(clippy::await_holding_refcell_ref)] +use anchor_lang::{ + solana_program::{instruction::Instruction, slot_history::SlotHistory}, + InstructionData, ToAccountMetas, +}; +use solana_program_test::*; +use solana_sdk::{clock::Clock, signer::Signer, transaction::Transaction}; +use tests::fixtures::TestFixture; +use validator_history::ClusterHistory; + +#[tokio::test] +async fn test_copy_cluster_info() { + // Initialize + let fixture = TestFixture::new().await; + let ctx = &fixture.ctx; + fixture.initialize_config().await; + fixture.initialize_cluster_history_account().await; + + fixture.advance_num_epochs(1).await; + + // Set SlotHistory sysvar with a few slots + let mut slot_history = SlotHistory::default(); + slot_history.add(0); + slot_history.add(1); + slot_history.add(2); + + let latest_slot = ctx.borrow_mut().banks_client.get_root_slot().await.unwrap(); + slot_history.add(latest_slot); + slot_history.add(latest_slot + 1); + println!("latest_slot: {}", latest_slot); + + // Submit instruction + let instruction = Instruction { + program_id: validator_history::id(), + data: validator_history::instruction::CopyClusterInfo {}.data(), + accounts: validator_history::accounts::CopyClusterInfo { + cluster_history_account: fixture.cluster_history_account, + slot_history: anchor_lang::solana_program::sysvar::slot_history::id(), + signer: fixture.keypair.pubkey(), + } + .to_account_metas(None), + }; + + let transaction = Transaction::new_signed_with_payer( + &[instruction], + Some(&fixture.keypair.pubkey()), + &[&fixture.keypair], + ctx.borrow().last_blockhash, + ); + + ctx.borrow_mut().set_sysvar(&slot_history); + fixture.submit_transaction_assert_success(transaction).await; + + let account: ClusterHistory = fixture + .load_and_deserialize(&fixture.cluster_history_account) + .await; + + let clock: Clock = ctx + .borrow_mut() + .banks_client + .get_sysvar() + .await + .expect("clock"); + + assert!(clock.epoch == 1); + assert!(account.history.idx == 1); + assert!(account.history.arr[0].epoch == 0); + assert!(account.history.arr[0].total_blocks == 3); + assert!(account.history.arr[1].epoch == 1); + assert!(account.history.arr[1].total_blocks == 2); + assert_eq!(account.cluster_history_last_update_slot, latest_slot) +} diff --git a/utils/validator-history-cli/src/main.rs b/utils/validator-history-cli/src/main.rs index fce8c5e0..9557bf27 100644 --- a/utils/validator-history-cli/src/main.rs +++ b/utils/validator-history-cli/src/main.rs @@ -11,7 +11,9 @@ use solana_program::instruction::Instruction; use solana_sdk::{ pubkey::Pubkey, signature::read_keypair_file, signer::Signer, transaction::Transaction, }; -use validator_history::{Config, ValidatorHistory, ValidatorHistoryEntry}; +use validator_history::{ + constants::MAX_ALLOC_BYTES, ClusterHistory, Config, ValidatorHistory, ValidatorHistoryEntry, +}; #[derive(Parser)] #[command(about = "CLI for validator history program")] @@ -32,8 +34,10 @@ struct Args { #[derive(Subcommand)] enum Commands { InitConfig(InitConfig), + InitClusterHistory(InitClusterHistory), CrankerStatus(CrankerStatus), History(History), + BackfillClusterHistory(BackfillClusterHistory), } #[derive(Parser)] @@ -60,6 +64,14 @@ struct InitConfig { stake_authority: Option, } +#[derive(Parser)] +#[command(about = "Initialize cluster history account")] +struct InitClusterHistory { + /// Path to keypair used to pay for account creation and execute transactions + #[arg(short, long, env, default_value = "~/.config/solana/id.json")] + keypair_path: PathBuf, +} + #[derive(Parser, Debug)] #[command(about = "Get cranker status")] struct CrankerStatus { @@ -79,6 +91,22 @@ struct History { start_epoch: Option, } +#[derive(Parser)] +#[command(about = "Backfill cluster history")] +struct BackfillClusterHistory { + /// Path to keypair used to pay for account creation and execute transactions + #[arg(short, long, env, default_value = "~/.config/solana/id.json")] + keypair_path: PathBuf, + + /// Epoch to backfill + #[arg(short, long, env)] + epoch: u64, + + /// Number of blocks in epoch + #[arg(short, long, env)] + blocks_in_epoch: u32, +} + fn command_init_config(args: InitConfig, client: RpcClient) { // Creates config account, sets tip distribution program address, and optionally sets authority for commission history program let keypair = read_keypair_file(args.keypair_path).expect("Failed reading keypair file"); @@ -152,6 +180,55 @@ fn command_init_config(args: InitConfig, client: RpcClient) { println!("Signature: {}", signature); } +fn command_init_cluster_history(args: InitClusterHistory, client: RpcClient) { + // Creates cluster history account + let keypair = read_keypair_file(args.keypair_path).expect("Failed reading keypair file"); + + let mut instructions = vec![]; + let (cluster_history_pda, _) = + Pubkey::find_program_address(&[ClusterHistory::SEED], &validator_history::ID); + instructions.push(Instruction { + program_id: validator_history::ID, + accounts: validator_history::accounts::InitializeClusterHistoryAccount { + cluster_history_account: cluster_history_pda, + system_program: solana_program::system_program::id(), + signer: keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::InitializeClusterHistoryAccount {}.data(), + }); + // Realloc insturctions + let num_reallocs = (ClusterHistory::SIZE - MAX_ALLOC_BYTES) / MAX_ALLOC_BYTES + 1; + instructions.extend(vec![ + Instruction { + program_id: validator_history::ID, + accounts: validator_history::accounts::ReallocClusterHistoryAccount { + cluster_history_account: cluster_history_pda, + system_program: solana_program::system_program::id(), + signer: keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::ReallocClusterHistoryAccount {}.data(), + }; + num_reallocs + ]); + + let blockhash = client + .get_latest_blockhash() + .expect("Failed to get recent blockhash"); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = client + .send_and_confirm_transaction_with_spinner(&transaction) + .expect("Failed to send transaction"); + println!("Signature: {}", signature); +} + fn get_entry(validator_history: ValidatorHistory, epoch: u64) -> Option { // Util to fetch an entry for a specific epoch validator_history @@ -427,12 +504,53 @@ fn command_history(args: History, client: RpcClient) { } } +fn command_backfill_cluster_history(args: BackfillClusterHistory, client: RpcClient) { + // Backfill cluster history account for a specific epoch + let keypair = read_keypair_file(args.keypair_path).expect("Failed reading keypair file"); + + let mut instructions = vec![]; + let (cluster_history_pda, _) = + Pubkey::find_program_address(&[ClusterHistory::SEED], &validator_history::ID); + let (config, _) = Pubkey::find_program_address(&[Config::SEED], &validator_history::ID); + instructions.push(Instruction { + program_id: validator_history::ID, + accounts: validator_history::accounts::BackfillTotalBlocks { + cluster_history_account: cluster_history_pda, + config, + signer: keypair.pubkey(), + } + .to_account_metas(None), + data: validator_history::instruction::BackfillTotalBlocks { + epoch: args.epoch, + blocks_in_epoch: args.blocks_in_epoch, + } + .data(), + }); + + let blockhash = client + .get_latest_blockhash() + .expect("Failed to get recent blockhash"); + let transaction = Transaction::new_signed_with_payer( + &instructions, + Some(&keypair.pubkey()), + &[&keypair], + blockhash, + ); + + let signature = client + .send_and_confirm_transaction_with_spinner(&transaction) + .expect("Failed to send transaction"); + println!("Signature: {}", signature); +} + fn main() { let args = Args::parse(); let client = RpcClient::new_with_timeout(args.json_rpc_url.clone(), Duration::from_secs(60)); match args.commands { Commands::InitConfig(args) => command_init_config(args, client), Commands::CrankerStatus(args) => command_cranker_status(args, client), + Commands::InitClusterHistory(args) => command_init_cluster_history(args, client), Commands::History(args) => command_history(args, client), + Commands::BackfillClusterHistory(args) => command_backfill_cluster_history(args, client), }; }