diff --git a/.gitignore b/.gitignore index 870e811..4bb2200 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,7 @@ k8s/*.env # TLS private keys / origin certs (keep out of git) certs/ + +# Maxmind +GeoIP.conf +leader-stream/*.mmdb diff --git a/README.md b/README.md index aab45dd..7c2a673 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,11 @@ docker run -p 3000:3000 --env-file .env leader-stream | `TRACK_LOOKAHEAD` | Slots to prefetch per tracked validator | 5000 | | `STATIC_DIR` | Override static dir | `/leader-stream/public` | | `NEXT_PUBLIC_LEADER_STREAM_URL` | Override SSE path injected into HTML | `/api/leader-stream` | +| `MAXMIND_DB_PATH` | Path to the MaxMind MMDB file to use for geolocation | `./GeoLite2-City.mmdb` | +| `MAXMIND_LICENSE_KEY` | Optional MaxMind license key for downloading GeoLite/GeoIP2 | none | +| `MAXMIND_DB_DOWNLOAD_URL` | Override URL for downloading the MMDB (expects raw file or tar.gz) | none | +| `MAXMIND_FALLBACK_URL` | Fallback URL for a free/test MaxMind database when no key is present | MaxMind test DB | +| `MAXMIND_EDITION_ID` | Edition ID when downloading via license key | `GeoLite2-City` | See `.env.example` and `k8s/secret.env.example` for templates. @@ -57,6 +62,7 @@ Static docs at `/docs.html` (source: `leader-stream/public/docs.html`). Key endp - `GET /api/next-leaders?limit=1000` - `GET /api/current-slot` - `GET /api/leader-stream?track=` (SSE) +- `GET /map` globe view of upcoming leaders with geolocation (uses `/api/leader-path`) ## Deployment (Kubernetes) `k8s/` uses Kustomize. Replace image `ghcr.io/trustless-engineering/leader-stream:${GIT_SHA}` and supply your own overlays/secrets: diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml index 6e6cde8..72ac12a 100644 --- a/k8s/deployment.yaml +++ b/k8s/deployment.yaml @@ -17,6 +17,9 @@ spec: # Required only if the GHCR repo is private. imagePullSecrets: - name: ghcr-pull-secret + volumes: + - name: geoip-data + emptyDir: {} containers: - name: leader-stream # Replace in CI/CD (e.g. envsubst) with the current git SHA. @@ -31,9 +34,14 @@ spec: env: - name: PORT value: "3000" + - name: MAXMIND_DB_PATH + value: "/var/lib/leader-stream/geoip/GeoLite2-City.mmdb" # Optional override for Solana RPC # - name: SOLANA_RPC_URL # value: "https://your-solana-rpc" + volumeMounts: + - name: geoip-data + mountPath: /var/lib/leader-stream/geoip readinessProbe: httpGet: path: /health diff --git a/k8s/kustomization.yaml b/k8s/kustomization.yaml index d667ac7..246aa72 100644 --- a/k8s/kustomization.yaml +++ b/k8s/kustomization.yaml @@ -7,7 +7,7 @@ resources: - service.yaml - ingress-public.yaml -namespace: leader-stream +namespace: leaderlist generatorOptions: disableNameSuffixHash: true diff --git a/leader-stream/Cargo.lock b/leader-stream/Cargo.lock index 4a4e13b..90f172c 100644 --- a/leader-stream/Cargo.lock +++ b/leader-stream/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.4" @@ -241,6 +247,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -321,12 +336,34 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -765,6 +802,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "1.0.16" @@ -797,16 +843,19 @@ dependencies = [ "axum", "bytes", "dotenvy", + "flate2", "futures-util", "gloo-timers", "http-body-util", "js-sys", + "maxminddb", "portpicker", "reqwest", "rustls 0.23.35", "serde", "serde-wasm-bindgen", "serde_json", + "tar", "tokio", "tokio-tungstenite", "tower", @@ -825,6 +874,17 @@ version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +[[package]] +name = "libredox" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -858,6 +918,18 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", +] + [[package]] name = "memchr" version = "2.7.6" @@ -880,6 +952,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1099,6 +1181,15 @@ dependencies = [ "getrandom 0.2.16", ] +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "regex-automata" version = "0.4.13" @@ -1442,6 +1533,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.11" @@ -1541,6 +1638,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -2262,6 +2370,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/leader-stream/Cargo.toml b/leader-stream/Cargo.toml index 41e4d33..1a94d93 100644 --- a/leader-stream/Cargo.toml +++ b/leader-stream/Cargo.toml @@ -14,7 +14,9 @@ anyhow = "1" async-stream = "0.3" axum = { version = "0.7", features = ["macros"] } bytes = "1" +flate2 = "1" futures-util = "0.3" +maxminddb = "0.24" reqwest = { version = "0.11", features = ["json", "rustls-tls", "stream"] } rustls = { version = "0.23", default-features = false, features = ["ring"] } serde_json = "1" @@ -23,6 +25,7 @@ tokio-tungstenite = { version = "0.23", features = ["rustls-tls-native-roots"] } tower-http = { version = "0.5", features = ["fs"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tar = "0.4" url = "2" dotenvy = "0.15" diff --git a/leader-stream/src/config.rs b/leader-stream/src/config.rs index 9161aee..0035d90 100644 --- a/leader-stream/src/config.rs +++ b/leader-stream/src/config.rs @@ -22,6 +22,11 @@ pub(crate) struct Config { pub(crate) ws_ping_interval: Duration, pub(crate) leader_lookahead: usize, pub(crate) track_lookahead: usize, + pub(crate) maxmind_db_path: String, + pub(crate) maxmind_license_key: Option, + pub(crate) maxmind_edition_id: String, + pub(crate) maxmind_db_download_url: Option, + pub(crate) maxmind_fallback_url: Option, } impl Config { @@ -32,10 +37,7 @@ impl Config { .clone() .unwrap_or_else(|| DEFAULT_RPC_URL.to_string()); if using_default_rpc { - warn!( - "SOLANA_RPC_URL not set; defaulting to {}", - DEFAULT_RPC_URL - ); + warn!("SOLANA_RPC_URL not set; defaulting to {}", DEFAULT_RPC_URL); } let rpc_x_token = read_env_first(&["SOLANA_RPC_X_TOKEN"]); let ws_override = read_env_first(&["SOLANA_WSS_URL", "SOLANA_WS_URL"]); @@ -94,6 +96,14 @@ impl Config { .and_then(|value| value.parse::().ok()) .unwrap_or(DEFAULT_TRACK_LOOKAHEAD); + let maxmind_db_path = + env::var("MAXMIND_DB_PATH").unwrap_or_else(|_| "./GeoLite2-City.mmdb".to_string()); + let maxmind_license_key = read_env_first(&["MAXMIND_LICENSE_KEY", "GEOIP_LICENSE_KEY"]); + let maxmind_edition_id = + env::var("MAXMIND_EDITION_ID").unwrap_or_else(|_| "GeoLite2-City".to_string()); + let maxmind_db_download_url = read_env_first(&["MAXMIND_DB_DOWNLOAD_URL"]); + let maxmind_fallback_url = read_env_first(&["MAXMIND_FALLBACK_URL"]); + Ok(Self { rpc_url, rpc_x_token, @@ -106,6 +116,11 @@ impl Config { ws_ping_interval, leader_lookahead, track_lookahead, + maxmind_db_path, + maxmind_license_key, + maxmind_edition_id, + maxmind_db_download_url, + maxmind_fallback_url, }) } } diff --git a/leader-stream/src/docs.html b/leader-stream/src/docs.html index cbdb9f9..6382cc6 100644 --- a/leader-stream/src/docs.html +++ b/leader-stream/src/docs.html @@ -46,6 +46,7 @@

/api/next-leaders /api/current-slot /api/leader-stream + /api/leader-path @@ -158,6 +159,37 @@

GET /api/leader-stream

+ +
+
+
+

GET /api/leader-path

+

Returns upcoming leaders plus MaxMind geolocation data for the map view.

+
+
+
+
+ Query params +
    +
  • limit Optional integer. Defaults to 1000, clamps between 1 and 5000. Invalid values fall back to the default.
  • +
+
+
+ Response fields +
    +
  • currentSlot Latest slot used as the starting point.
  • +
  • limit The resolved limit after clamping.
  • +
  • slotMs Estimated milliseconds per slot.
  • +
  • path Array of rows with slot, leader, ip, port, and geolocation fields latitude, longitude, city, country.
  • +
  • ts Server timestamp (ms since epoch).
  • +
+
+
+ Example +
+
+
+
+ + + +
+ +
+
+
+ + Solana Mainnet - Live Leader Path +
+

+ Solana TPU + Leader Map +

+
Ready.
+
+ +
+
+
+
+

Leader path

+

+ Consecutive leaders are connected in order. Only TPUs with valid + geolocation appear on the globe. +

+
+
+
+
+
+ Follow leader + +
+
+
+ +
+
+
+ Leaders + - +
+
+ With location + - +
+
+ Current slot + - +
+
+
    +
  1. Loading path…
  2. +
+
+
+
+
+ + + diff --git a/leader-stream/src/models.rs b/leader-stream/src/models.rs index d7e4ebf..c5a2976 100644 --- a/leader-stream/src/models.rs +++ b/leader-stream/src/models.rs @@ -35,6 +35,29 @@ pub(crate) struct LeaderRowPayload { pub(crate) port: Option, } +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LeaderLocationPayload { + pub(crate) slot: u64, + pub(crate) leader: String, + pub(crate) ip: Option, + pub(crate) port: Option, + pub(crate) latitude: Option, + pub(crate) longitude: Option, + pub(crate) city: Option, + pub(crate) country: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LeaderPathPayload { + pub(crate) current_slot: u64, + pub(crate) limit: usize, + pub(crate) slot_ms: u64, + pub(crate) ts: u64, + pub(crate) path: Vec, +} + #[derive(Clone)] pub(crate) struct NodeInfo { pub(crate) tpu: Option, @@ -90,8 +113,7 @@ pub(crate) struct TrackSchedule { impl TrackSchedule { pub(crate) fn covers_slot(&self, slot: u64) -> bool { - slot >= self.epoch_start - && slot < self.epoch_start.saturating_add(self.slots_in_epoch) + slot >= self.epoch_start && slot < self.epoch_start.saturating_add(self.slots_in_epoch) } pub(crate) fn slots_until(&self, slot: u64) -> Option { diff --git a/leader-stream/src/server.rs b/leader-stream/src/server.rs index 2a789ce..c43772a 100644 --- a/leader-stream/src/server.rs +++ b/leader-stream/src/server.rs @@ -13,8 +13,8 @@ use tower_http::services::ServeDir; use tracing::info; use crate::handlers::{ - current_slot_handler, docs_handler, health_handler, index_handler, leader_stream, - next_leaders_handler, options_handler, + current_slot_handler, docs_handler, health_handler, index_handler, leader_path_handler, + leader_stream, map_handler, next_leaders_handler, options_handler, }; use crate::state::AppState; @@ -23,6 +23,8 @@ pub(crate) fn build_router(state: Arc, static_dir: String) -> Router { .route("/", get(index_handler)) .route("/docs", get(docs_handler)) .route("/docs.html", get(docs_handler)) + .route("/map", get(map_handler)) + .route("/map.html", get(map_handler)) .route( "/api/current-slot", get(current_slot_handler).options(options_handler), @@ -31,7 +33,14 @@ pub(crate) fn build_router(state: Arc, static_dir: String) -> Router { "/api/next-leaders", get(next_leaders_handler).options(options_handler), ) - .route("/api/leader-stream", get(leader_stream).options(options_handler)) + .route( + "/api/leader-path", + get(leader_path_handler).options(options_handler), + ) + .route( + "/api/leader-stream", + get(leader_stream).options(options_handler), + ) .route("/health", get(health_handler)) .fallback_service(ServeDir::new(static_dir)) .with_state(state) diff --git a/leader-stream/src/state.rs b/leader-stream/src/state.rs index 3a40311..ac508af 100644 --- a/leader-stream/src/state.rs +++ b/leader-stream/src/state.rs @@ -5,10 +5,11 @@ use std::sync::Arc; use bytes::Bytes; use tokio::sync::{broadcast, RwLock}; -use leader_stream::{render_docs, render_index}; +use leader_stream::{render_docs, render_index, render_map}; use crate::config::Config; use crate::constants::{API_FALLBACK_SLOT_MS, BROADCAST_BUFFER}; +use crate::geo::GeoIpService; use crate::models::{ BasePayload, CachedPayload, CurrentSlotPayload, LeaderCache, NextLeadersPayload, NodesCache, TrackSchedule, @@ -40,6 +41,7 @@ pub(crate) struct AppState { pub(crate) next_leaders_cache: Arc>>>, pub(crate) initial_html: Arc>, pub(crate) docs_html: Bytes, + pub(crate) map_html: Bytes, pub(crate) leader_stream_url: String, pub(crate) cache_bust: String, pub(crate) rpc: RpcClient, @@ -47,14 +49,21 @@ pub(crate) struct AppState { pub(crate) track_subscribers: AtomicUsize, pub(crate) leader_lookahead: AtomicU64, pub(crate) slot_ms_estimate: AtomicU64, + pub(crate) geoip: Option>, } impl AppState { - pub(crate) fn new(config: Config, rpc: RpcClient, leader_stream_url: String) -> Arc { + pub(crate) fn new( + config: Config, + rpc: RpcClient, + leader_stream_url: String, + geoip: Option, + ) -> Arc { let (sender, _) = broadcast::channel(BROADCAST_BUFFER); let cache_bust = now_ms().to_string(); let base_html = render_index(&leader_stream_url, &cache_bust, None); let docs_html = render_docs(&cache_bust); + let map_html = render_map(&cache_bust, &leader_stream_url); Arc::new(Self { sender, latest: Arc::new(RwLock::new(None)), @@ -66,6 +75,7 @@ impl AppState { next_leaders_cache: Arc::new(RwLock::new(HashMap::new())), initial_html: Arc::new(RwLock::new(Bytes::from(base_html))), docs_html: Bytes::from(docs_html), + map_html: Bytes::from(map_html), leader_stream_url, cache_bust, rpc, @@ -73,6 +83,7 @@ impl AppState { track_subscribers: AtomicUsize::new(0), slot_ms_estimate: AtomicU64::new(API_FALLBACK_SLOT_MS), config, + geoip: geoip.map(Arc::new), }) } diff --git a/leader-stream/src/template.rs b/leader-stream/src/template.rs index c5db154..ff85736 100644 --- a/leader-stream/src/template.rs +++ b/leader-stream/src/template.rs @@ -1,5 +1,6 @@ const INDEX_TEMPLATE: &str = include_str!("index.html"); const DOCS_TEMPLATE: &str = include_str!("docs.html"); +const MAP_TEMPLATE: &str = include_str!("map.html"); const LEADER_STREAM_TOKEN: &str = "{{leader_stream_url}}"; const INITIAL_SCRIPT_TOKEN: &str = "{{initial_script}}"; const CACHE_BUST_TOKEN: &str = "{{cache_bust}}"; @@ -27,3 +28,9 @@ pub fn render_index( pub fn render_docs(cache_bust: &str) -> String { DOCS_TEMPLATE.replace(CACHE_BUST_TOKEN, cache_bust) } + +pub fn render_map(cache_bust: &str, leader_stream_url: &str) -> String { + MAP_TEMPLATE + .replace(CACHE_BUST_TOKEN, cache_bust) + .replace(LEADER_STREAM_TOKEN, leader_stream_url) +} diff --git a/leader-stream/src/tests.rs b/leader-stream/src/tests.rs index e687832..f065f0d 100644 --- a/leader-stream/src/tests.rs +++ b/leader-stream/src/tests.rs @@ -8,6 +8,7 @@ use tower::ServiceExt; use crate::config::Config; use crate::constants::DEFAULT_STATIC_DIR; +use crate::geo::{GeoIpService, GeoPoint}; use crate::models::{CachedPayload, CurrentSlotPayload, NodeInfo}; use crate::rpc::RpcClient; use crate::server::build_router; @@ -26,6 +27,11 @@ fn test_config() -> Config { ws_ping_interval: Duration::from_millis(1000), leader_lookahead: 100, track_lookahead: 200, + maxmind_db_path: "./GeoLite2-City.mmdb".to_string(), + maxmind_license_key: None, + maxmind_edition_id: "GeoLite2-City".to_string(), + maxmind_db_download_url: None, + maxmind_fallback_url: None, } } @@ -37,7 +43,7 @@ fn test_state() -> Arc { config.request_timeout, ) .expect("rpc client"); - AppState::new(config, rpc, "/api/leader-stream".to_string()) + AppState::new(config, rpc, "/api/leader-stream".to_string(), None) } fn test_app(state: Arc) -> axum::Router { @@ -48,7 +54,12 @@ fn test_app(state: Arc) -> axum::Router { async fn health_endpoint_returns_ok() { let app = test_app(test_state()); let response = app - .oneshot(Request::builder().uri("/health").body(Body::empty()).unwrap()) + .oneshot( + Request::builder() + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) .await .expect("health response"); assert_eq!(response.status(), StatusCode::OK); @@ -181,14 +192,12 @@ async fn leader_stream_sets_event_stream_headers() { .await .expect("leader stream response"); assert_eq!(response.status(), StatusCode::OK); - assert!( - response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .map(|value| value.starts_with("text/event-stream")) - .unwrap_or(false) - ); + assert!(response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.starts_with("text/event-stream")) + .unwrap_or(false)); assert_eq!( response .headers() @@ -224,3 +233,82 @@ async fn leader_stream_sets_event_stream_headers() { let text = String::from_utf8_lossy(data.as_ref()); assert!(text.contains("stream-open")); } + +#[tokio::test] +async fn map_endpoint_returns_html() { + let app = test_app(test_state()); + let response = app + .oneshot(Request::builder().uri("/map").body(Body::empty()).unwrap()) + .await + .expect("map response"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/html; charset=utf-8") + ); +} + +#[tokio::test] +async fn leader_path_returns_geolocated_entries() { + let config = test_config(); + let rpc = RpcClient::new( + config.rpc_url.clone(), + config.rpc_x_token.clone(), + config.request_timeout, + ) + .expect("rpc client"); + let mut cache_map = std::collections::HashMap::new(); + cache_map.insert( + "1.2.3.4".to_string(), + Some(GeoPoint { + latitude: 10.5, + longitude: -20.25, + city: Some("Test City".to_string()), + country: Some("Testland".to_string()), + }), + ); + let geoip = GeoIpService::from_static(cache_map); + let state = AppState::new(config, rpc, "/api/leader-stream".to_string(), Some(geoip)); + + { + let mut cache = state.leader_cache.write().await; + cache.start_slot = Some(10); + cache.leaders = vec!["leader-geo".to_string()]; + } + { + let mut cache = state.nodes_cache.write().await; + cache.nodes_by_pubkey.insert( + "leader-geo".to_string(), + NodeInfo { + tpu: Some("1.2.3.4:1000".to_string()), + }, + ); + } + + let app = test_app(state); + let response = app + .oneshot( + Request::builder() + .uri("/api/leader-path?limit=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .expect("leader path response"); + assert_eq!(response.status(), StatusCode::OK); + let body = response + .into_body() + .collect() + .await + .expect("leader path body") + .to_bytes(); + let value: serde_json::Value = serde_json::from_slice(&body).expect("leader path json"); + assert_eq!(value["limit"], 1); + assert_eq!(value["path"][0]["leader"], "leader-geo"); + assert_eq!(value["path"][0]["latitude"], 10.5); + assert_eq!(value["path"][0]["longitude"], -20.25); + assert_eq!(value["path"][0]["city"], "Test City"); +} diff --git a/leader-stream/tests/integration_http.rs b/leader-stream/tests/integration_http.rs index 06a6275..d839475 100644 --- a/leader-stream/tests/integration_http.rs +++ b/leader-stream/tests/integration_http.rs @@ -24,4 +24,13 @@ async fn http_endpoints_smoke() { assert!(docs.status().is_success()); let body = docs.text().await.expect("docs body"); assert!(body.contains("/api/next-leaders")); + + let map = client + .get(format!("{}/map", server.base_url())) + .send() + .await + .expect("map request"); + assert!(map.status().is_success()); + let body = map.text().await.expect("map body"); + assert!(body.contains("Leader Map")); }