diff --git a/Cargo.lock b/Cargo.lock index ad0897d..ae8eff0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -679,7 +679,11 @@ dependencies = [ "pin-project-lite", "rustversion", "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", + "tokio", "tower", "tower-layer", "tower-service", @@ -2250,6 +2254,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.8.0" @@ -2522,6 +2532,31 @@ dependencies = [ "solana-program", ] +[[package]] +name = "jito-stakenet-api" +version = "0.1.0" +dependencies = [ + "anchor-lang", + "axum", + "clap 4.4.18", + "http", + "serde", + "serde_derive", + "serde_json", + "solana-program", + "solana-rpc-client", + "solana-rpc-client-api", + "stakenet-sdk", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-core", + "tracing-subscriber", + "validator-history", +] + [[package]] name = "jito-steward" version = "0.1.0" @@ -2767,6 +2802,15 @@ dependencies = [ "libc", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matchit" version = "0.7.3" @@ -2960,6 +3004,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num" version = "0.2.1" @@ -3265,6 +3319,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + [[package]] name = "parking_lot" version = "0.11.2" @@ -3866,8 +3926,17 @@ checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.5", + "regex-syntax 0.8.2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -3878,9 +3947,15 @@ checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.2", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.2" @@ -4223,6 +4298,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa", + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.6" @@ -6809,6 +6894,25 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.4.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower-layer" version = "0.3.2" @@ -6854,6 +6958,17 @@ dependencies = [ "valuable", ] +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + [[package]] name = "tracing-opentelemetry" version = "0.17.4" @@ -6873,9 +6988,16 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad0f048c97dbd9faa9b7df56362b8ebcaa52adb06b498c050d2f4e32f90a7a8b" dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", "sharded-slab", + "smallvec", "thread_local", + "tracing", "tracing-core", + "tracing-log", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index d77dea1..4b6253f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "api", "keepers/*", "programs/*", "sdk", @@ -7,5 +8,28 @@ members = [ "utils/*", ] +resolver = "2" + [profile.release] overflow-checks = true + +[workspace.dependencies] +anchor-lang = "0.30.0" +axum = "0.6.2" +clap = { version = "4.3.0", features = ["derive", "env"] } +http = { version = "0.2.1" } +serde = "1.0.183" +serde_derive = "1.0.183" +serde_json = "1.0.102" +solana-program = "1.18" +solana-rpc-client = "1.18" +solana-rpc-client-api = "1.18" +stakenet-sdk = { path = "sdk", version = "0.1.0" } +thiserror = "1.0.37" +tokio = { version = "1.36.0", features = ["full"] } +tower = { version = "0.4.13", features = ["limit", "buffer", "timeout", "load-shed"] } +tower-http = { version = "0.4.0", features = ["trace"] } +tracing = { version = "0.1.37" } +tracing-core = "0.1.32" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +validator-history = { path = "programs/validator-history", version = "0.1.0" } diff --git a/api/Cargo.toml b/api/Cargo.toml new file mode 100644 index 0000000..cdb046e --- /dev/null +++ b/api/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "jito-stakenet-api" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "jito-stakenet-api" +path = "src/bin/main.rs" + +[dependencies] +anchor-lang = { workspace = true } +axum = { workspace = true } +clap = { workspace = true } +http = { workspace = true } +serde = { workspace = true } +serde_derive = { workspace = true } +serde_json = { workspace = true } +solana-program = { workspace = true } +solana-rpc-client = { workspace = true } +solana-rpc-client-api = { workspace = true } +stakenet-sdk = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tower = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } +tracing-core = { workspace = true } +tracing-subscriber = { workspace = true } +validator-history = { workspace = true } diff --git a/api/src/bin/main.rs b/api/src/bin/main.rs new file mode 100644 index 0000000..ec55af7 --- /dev/null +++ b/api/src/bin/main.rs @@ -0,0 +1,54 @@ +use std::{net::SocketAddr, str::FromStr, sync::Arc}; + +use clap::Parser; +use solana_program::pubkey::Pubkey; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use tracing::{info, instrument}; + +#[derive(Parser, Debug)] +#[clap(author, version, about, long_about = None)] +pub struct Args { + /// Bind address for the server + #[arg(long, env, default_value_t = SocketAddr::from_str("0.0.0.0:7001").unwrap())] + pub bind_addr: SocketAddr, + + /// RPC url + #[arg(long, env, default_value = "https://api.mainnet-beta.solana.com")] + pub json_rpc_url: String, + + /// Validator history program ID (Pubkey as base58 string) + #[arg( + long, + env, + default_value = "HistoryJTGbKQD2mRgLZ3XhqHnN811Qpez8X9kCcGHoa" + )] + pub validator_history_program_id: Pubkey, +} + +#[tokio::main] +#[instrument] +async fn main() -> Result<(), Box> { + let args = Args::parse(); + + tracing_subscriber::fmt().init(); + + info!("args: {:?}", args); + + info!("starting server at {}", args.bind_addr); + + let rpc_client = RpcClient::new(args.json_rpc_url.clone()); + info!("started rpc client at {}", args.json_rpc_url); + + let state = Arc::new(jito_stakenet_api::router::RouterState { + validator_history_program_id: args.validator_history_program_id, + rpc_client, + }); + + let app = jito_stakenet_api::router::get_routes(state); + + axum::Server::bind(&args.bind_addr) + .serve(app.into_make_service_with_connect_info::()) + .await?; + + Ok(()) +} diff --git a/api/src/error.rs b/api/src/error.rs new file mode 100644 index 0000000..a88e0cc --- /dev/null +++ b/api/src/error.rs @@ -0,0 +1,100 @@ +use std::convert::Infallible; + +use axum::{ + response::{IntoResponse, Response}, + BoxError, Json, +}; +use http::StatusCode; +use serde_derive::{Deserialize, Serialize}; +use serde_json::json; +use solana_program::pubkey::ParsePubkeyError; +use solana_rpc_client_api::client_error::Error as RpcError; +use thiserror::Error; +use tracing::error; + +#[derive(Error, Debug)] +pub enum ApiError { + #[error("Rpc Error")] + RpcError(#[from] RpcError), + + #[error("Validator History not found for vote_account {0}")] + ValidatorHistoryNotFound(String), + + #[error("Parse Pubkey Error")] + ParsePubkeyError(#[from] ParsePubkeyError), + + #[error("Validator History Error")] + ValidatorHistoryError(String), + + #[error("Internal Error")] + InternalError, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Error { + pub error: String, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let (status, error_message) = match self { + ApiError::RpcError(e) => { + error!("Rpc error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Rpc error") + } + ApiError::ValidatorHistoryNotFound(v) => { + error!("Validator History not found for vote_account {v}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Validator History not found", + ) + } + + ApiError::ParsePubkeyError(e) => { + error!("Parse pubkey error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Pubkey parse error") + } + ApiError::ValidatorHistoryError(e) => { + error!("Validator History error: {e}"); + (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error") + } + ApiError::InternalError => (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error"), + }; + ( + status, + Json(Error { + error: error_message.to_string(), + }), + ) + .into_response() + } +} + +pub async fn handle_error(error: BoxError) -> Result { + if error.is::() { + return Ok(( + StatusCode::REQUEST_TIMEOUT, + Json(json!({ + "code" : 408, + "error" : "Request Timeout", + })), + )); + }; + if error.is::() { + return Ok(( + StatusCode::SERVICE_UNAVAILABLE, + Json(json!({ + "code" : 503, + "error" : "Service Unavailable", + })), + )); + } + + Ok(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ + "code" : 500, + "error" : "Internal Server Error", + })), + )) +} diff --git a/api/src/lib.rs b/api/src/lib.rs new file mode 100644 index 0000000..d2d8b43 --- /dev/null +++ b/api/src/lib.rs @@ -0,0 +1,111 @@ +use error::ApiError; +use serde_derive::{Deserialize, Serialize}; +use solana_program::pubkey::Pubkey; +use validator_history::{ClientVersion, ValidatorHistory, ValidatorHistoryEntry}; + +pub mod error; +pub mod router; + +pub type Result = std::result::Result; + +#[derive(Serialize, Deserialize)] +pub(crate) struct ValidatorHistoryResponse { + /// Cannot be enum due to Pod and Zeroable trait limitations + pub(crate) struct_version: u32, + + pub(crate) vote_account: Pubkey, + /// Index of validator of all ValidatorHistory accounts + pub(crate) index: u32, + + /// These Crds gossip values are only signed and dated once upon startup and then never updated + /// so we track latest time on-chain to make sure old messages aren't uploaded + pub(crate) last_ip_timestamp: u64, + pub(crate) last_version_timestamp: u64, + + pub(crate) history: Vec, +} + +impl ValidatorHistoryResponse { + pub fn from_validator_history( + acc: ValidatorHistory, + history_entries: Vec, + ) -> Self { + Self { + struct_version: acc.struct_version, + vote_account: acc.vote_account, + index: acc.index, + last_ip_timestamp: acc.last_ip_timestamp, + last_version_timestamp: acc.last_version_timestamp, + history: history_entries, + } + } +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct ValidatorHistoryEntryResponse { + pub(crate) activated_stake_lamports: u64, + pub(crate) epoch: u16, + + // MEV commission in basis points + pub(crate) mev_commission: u16, + + // Number of successful votes in current epoch. Not finalized until subsequent epoch + pub(crate) epoch_credits: u32, + + // Validator commission in points + pub(crate) commission: u8, + + // 0 if Solana Labs client, 1 if Jito client, >1 if other + pub(crate) client_type: u8, + pub(crate) version: ClientVersionResponse, + pub(crate) ip: [u8; 4], + + // 0 if not a superminority validator, 1 if superminority validator + pub(crate) is_superminority: u8, + + // rank of validator by stake amount + pub(crate) rank: u32, + + // Most recent updated slot for epoch credits and commission + pub(crate) vote_account_last_update_slot: u64, + + // MEV earned, stored as 1/100th SOL. mev_earned = 100 means 1.00 SOL earned + pub(crate) mev_earned: u32, +} + +impl ValidatorHistoryEntryResponse { + pub fn from_validator_history_entry(entry: &ValidatorHistoryEntry) -> Self { + let version = ClientVersionResponse::from_client_version(entry.version); + Self { + activated_stake_lamports: entry.activated_stake_lamports, + epoch: entry.epoch, + mev_commission: entry.mev_commission, + epoch_credits: entry.epoch_credits, + commission: entry.commission, + client_type: entry.client_type, + version, + ip: entry.ip, + is_superminority: entry.is_superminority, + rank: entry.rank, + vote_account_last_update_slot: entry.vote_account_last_update_slot, + mev_earned: entry.mev_earned, + } + } +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct ClientVersionResponse { + pub(crate) major: u8, + pub(crate) minor: u8, + pub(crate) patch: u16, +} + +impl ClientVersionResponse { + pub fn from_client_version(version: ClientVersion) -> Self { + Self { + major: version.major, + minor: version.minor, + patch: version.patch, + } + } +} diff --git a/api/src/router.rs b/api/src/router.rs new file mode 100644 index 0000000..6cc7621 --- /dev/null +++ b/api/src/router.rs @@ -0,0 +1,84 @@ +mod get_all_validator_histories; +mod get_latest_validator_history; + +use std::{sync::Arc, time::Duration}; + +use axum::{ + body::Body, error_handling::HandleErrorLayer, response::IntoResponse, routing::get, Router, +}; +use http::StatusCode; +use solana_program::pubkey::Pubkey; +use solana_rpc_client::nonblocking::rpc_client::RpcClient; +use tower::{ + buffer::BufferLayer, limit::RateLimitLayer, load_shed::LoadShedLayer, timeout::TimeoutLayer, + ServiceBuilder, +}; +use tower_http::{ + trace::{DefaultOnResponse, TraceLayer}, + LatencyUnit, +}; +use tracing::{info, instrument, Span}; + +pub struct RouterState { + pub validator_history_program_id: Pubkey, + pub rpc_client: RpcClient, +} + +impl std::fmt::Debug for RouterState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RouterState") + .field( + "validator_history_program_id", + &self.validator_history_program_id, + ) + .field("rpc_client", &self.rpc_client.url()) + .finish() + } +} + +#[instrument] +pub fn get_routes(state: Arc) -> Router { + let middleware = ServiceBuilder::new() + .layer(HandleErrorLayer::new(crate::error::handle_error)) + .layer(BufferLayer::new(1000)) + .layer(RateLimitLayer::new(10000, Duration::from_secs(1))) + .layer(TimeoutLayer::new(Duration::from_secs(20))) + .layer(LoadShedLayer::new()) + .layer( + TraceLayer::new_for_http() + .on_request(|request: &http::Request, _span: &Span| { + info!("started {} {}", request.method(), request.uri().path()) + }) + .on_response( + DefaultOnResponse::new() + .level(tracing_core::Level::INFO) + .latency_unit(LatencyUnit::Millis), + ), + ); + + let validator_history_routes = Router::new() + .route( + "/:vote_account", + get(get_all_validator_histories::get_all_validator_histories), + ) + .route( + "/:vote_account/latest", + get(get_latest_validator_history::get_latest_validator_history), + ); + + let api_routes = Router::new() + .route("/", get(root)) + .nest("/validator_history", validator_history_routes); + + let app = Router::new().nest("/api/v1", api_routes).fallback(fallback); + + app.layer(middleware).with_state(state) +} + +async fn root() -> impl IntoResponse { + "Jito Stakenet API" +} + +async fn fallback() -> (StatusCode, &'static str) { + (StatusCode::NOT_FOUND, "Not Found") +} diff --git a/api/src/router/get_all_validator_histories.rs b/api/src/router/get_all_validator_histories.rs new file mode 100644 index 0000000..bdcd482 --- /dev/null +++ b/api/src/router/get_all_validator_histories.rs @@ -0,0 +1,77 @@ +use std::{str::FromStr, sync::Arc}; + +use anchor_lang::AccountDeserialize; +use axum::{ + extract::{Path, Query, State}, + response::IntoResponse, + Json, +}; +use serde_derive::Deserialize; +use solana_program::pubkey::Pubkey; +use stakenet_sdk::utils::accounts::get_validator_history_address; +use tracing::warn; +use validator_history::ValidatorHistory; + +use crate::{error::ApiError, ValidatorHistoryEntryResponse, ValidatorHistoryResponse}; + +use super::RouterState; + +#[derive(Deserialize)] +pub(crate) struct EpochQuery { + epoch: Option, +} + +/// Retrieves the history of a specific validator, based on the provided vote account and optional epoch filter. +/// +/// # Returns +/// - `Ok(Json(history))`: A JSON response containing the validator history information. If the epoch filter is provided, it only returns the history for the specified epoch. +/// +/// # Example +/// This endpoint can be used to fetch the history of a validator's performance over time, either for a specific epoch or for all recorded epochs: +/// ``` +/// GET /validator_history/{vote_account}?epoch=200 +/// ``` +/// This request retrieves the history for the specified vote account, filtered by epoch 200. +pub(crate) async fn get_all_validator_histories( + State(state): State>, + Path(vote_account): Path, + Query(epoch_query): Query, +) -> crate::Result { + let vote_account = Pubkey::from_str(&vote_account)?; + let history_account = + get_validator_history_address(&vote_account, &state.validator_history_program_id); + let account = state.rpc_client.get_account(&history_account).await?; + let validator_history = ValidatorHistory::try_deserialize(&mut account.data.as_slice()) + .map_err(|e| { + warn!("error deserializing ValidatorHistory: {:?}", e); + ApiError::ValidatorHistoryError("Error parsing ValidatorHistory".to_string()) + })?; + + let history_entries: Vec = match epoch_query.epoch { + Some(epoch) => validator_history + .history + .arr + .iter() + .filter_map(|entry| { + if epoch == entry.epoch { + Some(ValidatorHistoryEntryResponse::from_validator_history_entry( + entry, + )) + } else { + None + } + }) + .collect(), + None => validator_history + .history + .arr + .iter() + .map(ValidatorHistoryEntryResponse::from_validator_history_entry) + .collect(), + }; + + let history = + ValidatorHistoryResponse::from_validator_history(validator_history, history_entries); + + Ok(Json(history)) +} diff --git a/api/src/router/get_latest_validator_history.rs b/api/src/router/get_latest_validator_history.rs new file mode 100644 index 0000000..ae8f4d6 --- /dev/null +++ b/api/src/router/get_latest_validator_history.rs @@ -0,0 +1,54 @@ +use std::{str::FromStr, sync::Arc}; + +use anchor_lang::AccountDeserialize; +use axum::{ + extract::{Path, State}, + response::IntoResponse, + Json, +}; +use solana_program::pubkey::Pubkey; +use stakenet_sdk::utils::accounts::get_validator_history_address; +use tracing::warn; +use validator_history::ValidatorHistory; + +use crate::{error::ApiError, ValidatorHistoryEntryResponse, ValidatorHistoryResponse}; + +use super::RouterState; + +/// Retrieves the latest historical entry for a specific validator based on the provided vote account. +/// +/// # Returns +/// - `Ok(Json(history))`: A JSON response containing the most recent entry from the validator's history, wrapped in a [`ValidatorHistoryResponse`]. +/// +/// # Example +/// This method can be used to query the latest performance record for a validator: +/// ``` +/// GET /validator_history/{vote_account}/latest +/// ``` +/// This will return the most recent history entry for the validator associated with the given vote account. +pub(crate) async fn get_latest_validator_history( + State(state): State>, + Path(vote_account): Path, +) -> crate::Result { + let vote_account = Pubkey::from_str(&vote_account)?; + let history_account = + get_validator_history_address(&vote_account, &state.validator_history_program_id); + let account = state.rpc_client.get_account(&history_account).await?; + let validator_history = ValidatorHistory::try_deserialize(&mut account.data.as_slice()) + .map_err(|e| { + warn!("error deserializing ValidatorHistory: {:?}", e); + ApiError::ValidatorHistoryError("Error parsing ValidatorHistory".to_string()) + })?; + + match validator_history.history.last() { + Some(entry) => { + let history_entry = ValidatorHistoryEntryResponse::from_validator_history_entry(entry); + let history = ValidatorHistoryResponse::from_validator_history( + validator_history, + vec![history_entry], + ); + Ok(Json(history)) + } + None => Err(ApiError::ValidatorHistoryNotFound(vote_account.to_string())), + } +}