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/README.md b/api/README.md new file mode 100644 index 0000000..91e1314 --- /dev/null +++ b/api/README.md @@ -0,0 +1,231 @@ +# StakeNet API + +## Overview + +The StakeNet API provides access to historical validator performance data on the Solana blockchain. This API can be useful for any website or application that needs to show validator performance history, specific epoch information, or the latest validator data. + +## Getting started + +### Prerequisites + +- [Solana's RPC Client](https://docs.rs/solana-rpc-client/latest/solana_rpc_client/) +- [Axum](https://docs.rs/axum/latest/axum/) + +### Build for release + +To build the API for release, run the following command: + +```bash +cargo b --release --bin jito-stakenet-api +``` + +### Check available options + +To view the options available for configuring the API: + +```bash +./target/release/jito-stakenet-api --help + +# Usage: jito-stakenet-api [OPTIONS] +# +# Options: +# --bind-addr +# Bind address for the server [env: BIND_ADDR=] [default: 0.0.0.0:7001] +# --json-rpc-url +# RPC url [env: JSON_RPC_URL=] [default: https://api.mainnet-beta.solana.com] +# --validator-history-program-id +# Validator history program ID (Pubkey as base58 string) [env: VALIDATOR_HISTORY_PROGRAM_ID=] [default: HistoryJTGbKQD2mRgLZ3XhqHnN811Qpez8X9kCcGHoa] +# -h, --help +# Print help +# -V, --version +# Print version +``` + +### Running the API + +Once built, run the API using the following command: + +```bash +./target/release/jito-stakenet-api +``` + +You can now send requests to http://localhost:7001 (or whichever address/port you specify in --bind-addr). + +## API Endpoints + +You can check `vote_account` [here](https://www.jito.network/stakenet/history/). + +|HTTP Method|Endpoint |Description | +|-----------|-------------------------------------------------------|---------------------------------------------------| +|GET |/api/v1/validator_history/{vote_account} |Fetch all validator histories by vote account | +|GET |/api/v1/validator_history/{vote_account}?epoch={epoch} |Fetch specific epoch history by vote account | +|GET |/api/v1/validator_history/{vote_account}/latest |Fetch the latest validator history by vote account | + + +### Example Requests + +#### Get all histories by vote_account: + +``` +curl http://localhost:7001/api/v1/validator_history/{vote_account} +``` + +#### Get specific epoch history by vote_account: + +``` +curl http://localhost:7001/api/v1/validator_history/{vote_account}?epoch=500 +``` + +#### Get latest history by vote_account: + +``` +curl http://localhost:7001/api/v1/validator_history/{vote_account}/latest +``` + +## Tips for Developers + +### Add a New Route + +If you want to add a new route to the router, you do it in `api/src/router.rs`: + +```rust +// api/src/router.rs + +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), + ) + .route( + "/:vote_account/new_route", + get(new_method), + ); +``` + +### Caching Validator History + +You can implement a caching layer for the validator histories using the [Moka](https://docs.rs/moka/latest/moka/index.html) library. Here's an example of adding caching to the server. + +#### Step 1: Add Moka Dependency + +```bash +cargo add moka --features future +``` + +#### Step 2: Update State to Include Cache + +```rust +// api/src/router.rs + +pub struct RouterState { + pub validator_history_program_id: Pubkey, + pub rpc_client: RpcClient, + + // add cache + pub cache: Cache, +} +``` + +#### Step 3: Modify Main To Use Cache + +```rust +// api/src/bin/main.rs + +#[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); + + // Create a cache that can store up to u64::MAX entries. + let cache: Cache = Cache::new(u64::MAX); + + let state = Arc::new(jito_stakenet_api::router::RouterState { + validator_history_program_id: args.validator_history_program_id, + rpc_client, + cache, + }); + + 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(()) +} +``` + +#### Step 4: Use Cache in Handlers + +```rust +// api/src/router/get_all_validator_histories.rs + +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); + + // Check history_account's pubkey key in cache + match state.cache.get(&history_account).await { + Some(history) => Ok(Json(history)), + None => { + 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, + ); + + // Insert new history in cache + state.cache.insert(history_account, history.clone()).await; + + Ok(Json(history)) + } + } +} +``` 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())), + } +}