diff --git a/.env.example b/.env.example index e73ebf3..a29284a 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ # Required: Your L2 RPC endpoint RPC_URL=http://localhost:8545 +# Human-readable name for your chain, displayed in the explorer UI +CHAIN_NAME="My Chain" + # Optional settings (defaults shown) START_BLOCK=0 BATCH_SIZE=100 diff --git a/CLAUDE.md b/CLAUDE.md index 68efadf..68e11d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,8 +87,9 @@ pub struct AppState { ### Frontend API client - Base URL: `/api` (proxied by nginx to `atlas-server:3000`) -- `GET /api/status` → `{ block_height, indexed_at }` — single key-value lookup from `indexer_state`, sub-ms. Used by the navbar as a polling fallback when SSE is disconnected. -- `GET /api/events` → SSE stream of `new_block` events, one per block in order. Primary live-update path for navbar counter and blocks page. Falls back to `/api/status` polling on disconnect. +- Fast polling endpoint: `GET /api/height` → `{ block_height, indexed_at }` — single key-value lookup from `indexer_state`, sub-ms. Used by the navbar as a polling fallback when SSE is disconnected. +- Chain status: `GET /api/status` → `{ chain_id, chain_name, block_height, total_transactions, total_addresses, indexed_at }` — full chain info, fetched once on page load. +- `GET /api/events` → SSE stream of `new_block` events, one per block in order. Primary live-update path for navbar counter and blocks page. Falls back to `/api/height` polling on disconnect. ## Important Conventions diff --git a/backend/crates/atlas-server/src/api/handlers/mod.rs b/backend/crates/atlas-server/src/api/handlers/mod.rs index c0f72c2..61c1eb7 100644 --- a/backend/crates/atlas-server/src/api/handlers/mod.rs +++ b/backend/crates/atlas-server/src/api/handlers/mod.rs @@ -21,15 +21,26 @@ pub async fn get_latest_block(pool: &PgPool) -> Result, sqlx::Erro .fetch_optional(pool) .await } +fn exact_count_sql(table_name: &str) -> Result<&'static str, sqlx::Error> { + match table_name { + "transactions" => Ok("SELECT COUNT(*) FROM transactions"), + "addresses" => Ok("SELECT COUNT(*) FROM addresses"), + _ => Err(sqlx::Error::Protocol(format!( + "unsupported table for exact count: {table_name}" + ))), + } +} + +fn should_use_approximate_count(approx: i64) -> bool { + approx > 100_000 +} -/// Get transactions table row count efficiently. +/// Get a table's row count efficiently. /// - For tables > 100k rows: uses PostgreSQL's approximate count (instant, ~99% accurate) /// - For smaller tables: uses exact COUNT(*) (fast enough) /// /// This avoids the slow COUNT(*) full table scan on large tables. -pub async fn get_table_count(pool: &PgPool) -> Result { - let table_name = "transactions"; - +pub async fn get_table_count(pool: &PgPool, table_name: &str) -> Result { // Sum approximate reltuples across partitions if any, else use parent. // This is instant and reasonably accurate for large tables. // Cast to float8 (f64) since reltuples is float4 and SUM returns float4 @@ -57,13 +68,47 @@ pub async fn get_table_count(pool: &PgPool) -> Result { parent.0.unwrap_or(0.0) as i64 }; - if approx > 100_000 { + if should_use_approximate_count(approx) { Ok(approx) } else { // Exact count for small tables - let exact: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM transactions") + let exact: (i64,) = sqlx::query_as(exact_count_sql(table_name)?) .fetch_one(pool) .await?; Ok(exact.0) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_count_sql_whitelists_supported_tables() { + assert_eq!( + exact_count_sql("transactions").unwrap(), + "SELECT COUNT(*) FROM transactions" + ); + assert_eq!( + exact_count_sql("addresses").unwrap(), + "SELECT COUNT(*) FROM addresses" + ); + } + + #[test] + fn exact_count_sql_rejects_unsupported_tables() { + let err = exact_count_sql("blocks").unwrap_err(); + assert!(err.to_string().contains("unsupported table")); + } + + #[test] + fn should_use_approximate_count_above_threshold() { + assert!(should_use_approximate_count(100_001)); + } + + #[test] + fn should_use_approximate_count_uses_exact_count_at_threshold_and_below() { + assert!(!should_use_approximate_count(100_000)); + assert!(!should_use_approximate_count(42)); + } +} diff --git a/backend/crates/atlas-server/src/api/handlers/sse.rs b/backend/crates/atlas-server/src/api/handlers/sse.rs index c68bef0..b53b407 100644 --- a/backend/crates/atlas-server/src/api/handlers/sse.rs +++ b/backend/crates/atlas-server/src/api/handlers/sse.rs @@ -1,6 +1,7 @@ use axum::{ extract::State, response::sse::{Event, Sse}, + response::IntoResponse, }; use futures::stream::Stream; use serde::Serialize; @@ -94,9 +95,7 @@ fn make_block_stream( /// New connections receive only the current latest block and then stream /// forward from in-memory committed head state. Historical catch-up stays on /// the canonical block endpoints. -pub async fn block_events( - State(state): State>, -) -> Sse> { +pub async fn block_events(State(state): State>) -> impl IntoResponse { let stream = make_block_stream( state.pool.clone(), state.head_tracker.clone(), @@ -105,7 +104,7 @@ pub async fn block_events( sse_response(stream) } -fn sse_response(stream: S) -> Sse> +fn sse_response(stream: S) -> impl IntoResponse where S: Stream> + Send + 'static, { diff --git a/backend/crates/atlas-server/src/api/handlers/status.rs b/backend/crates/atlas-server/src/api/handlers/status.rs index acff0d4..597760d 100644 --- a/backend/crates/atlas-server/src/api/handlers/status.rs +++ b/backend/crates/atlas-server/src/api/handlers/status.rs @@ -3,23 +3,28 @@ use serde::Serialize; use std::sync::Arc; use crate::api::error::ApiResult; +use crate::api::handlers::get_table_count; use crate::api::AppState; +#[derive(Serialize)] +pub struct HeightResponse { + pub block_height: i64, + pub indexed_at: String, +} + #[derive(Serialize)] pub struct ChainStatus { + pub chain_id: u64, + pub chain_name: String, pub block_height: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub indexed_at: Option, + pub total_transactions: i64, + pub total_addresses: i64, + pub indexed_at: String, } -/// GET /api/status - Lightweight endpoint for current chain status -/// Returns in <1ms, optimized for frequent polling -pub async fn get_status(State(state): State>) -> ApiResult> { +async fn latest_height_and_indexed_at(state: &AppState) -> Result<(i64, String), sqlx::Error> { if let Some(block) = state.head_tracker.latest().await { - return Ok(Json(ChainStatus { - block_height: block.number, - indexed_at: Some(block.indexed_at.to_rfc3339()), - })); + return Ok((block.number, block.indexed_at.to_rfc3339())); } // Fallback: single key-value lookup from indexer_state (sub-ms, avoids blocks table) @@ -30,15 +35,36 @@ pub async fn get_status(State(state): State>) -> ApiResult>) -> ApiResult> { + let (block_height, indexed_at) = latest_height_and_indexed_at(&state).await?; + + Ok(Json(HeightResponse { + block_height, + indexed_at, + })) +} + +/// GET /api/status - Full chain status including chain ID, name, and counts. +pub async fn get_status(State(state): State>) -> ApiResult> { + let (block_height, indexed_at) = latest_height_and_indexed_at(&state).await?; + let total_transactions = get_table_count(&state.pool, "transactions").await?; + let total_addresses = get_table_count(&state.pool, "addresses").await?; + Ok(Json(ChainStatus { - block_height: 0, - indexed_at: None, + chain_id: state.chain_id, + chain_name: state.chain_name.clone(), + block_height, + total_transactions, + total_addresses, + indexed_at, })) } @@ -72,32 +98,34 @@ mod tests { block_events_tx: tx, head_tracker, rpc_url: String::new(), + chain_id: 1, + chain_name: "Test Chain".to_string(), })) } #[tokio::test] - async fn status_returns_head_tracker_block() { + async fn height_returns_head_tracker_block() { let tracker = Arc::new(HeadTracker::empty(10)); tracker .publish_committed_batch(vec![sample_block(42)]) .await; - let result = get_status(test_state(tracker)).await; - let Json(status) = result.unwrap_or_else(|_| panic!("get_status should not fail")); + let result = get_height(test_state(tracker)).await; + let Json(status) = result.unwrap_or_else(|_| panic!("get_height should not fail")); assert_eq!(status.block_height, 42); - assert!(status.indexed_at.is_some()); + assert!(!status.indexed_at.is_empty()); } #[tokio::test] - async fn status_returns_latest_head_after_multiple_publishes() { + async fn height_returns_latest_head_after_multiple_publishes() { let tracker = Arc::new(HeadTracker::empty(10)); tracker .publish_committed_batch(vec![sample_block(10), sample_block(11), sample_block(12)]) .await; - let result = get_status(test_state(tracker)).await; - let Json(status) = result.unwrap_or_else(|_| panic!("get_status should not fail")); + let result = get_height(test_state(tracker)).await; + let Json(status) = result.unwrap_or_else(|_| panic!("get_height should not fail")); assert_eq!(status.block_height, 12); } diff --git a/backend/crates/atlas-server/src/api/handlers/transactions.rs b/backend/crates/atlas-server/src/api/handlers/transactions.rs index 2145aea..6c1270f 100644 --- a/backend/crates/atlas-server/src/api/handlers/transactions.rs +++ b/backend/crates/atlas-server/src/api/handlers/transactions.rs @@ -16,7 +16,7 @@ pub async fn list_transactions( Query(pagination): Query, ) -> ApiResult>> { // Use optimized count (approximate for large tables, exact for small) - let total = get_table_count(&state.pool).await?; + let total = get_table_count(&state.pool, "transactions").await?; let transactions: Vec = sqlx::query_as( "SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp diff --git a/backend/crates/atlas-server/src/api/mod.rs b/backend/crates/atlas-server/src/api/mod.rs index 3310b6f..b931e08 100644 --- a/backend/crates/atlas-server/src/api/mod.rs +++ b/backend/crates/atlas-server/src/api/mod.rs @@ -17,6 +17,8 @@ pub struct AppState { pub block_events_tx: broadcast::Sender<()>, pub head_tracker: Arc, pub rpc_url: String, + pub chain_id: u64, + pub chain_name: String, } /// Build the Axum router. @@ -139,6 +141,7 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router // Search .route("/api/search", get(handlers::search::search)) // Status + .route("/api/height", get(handlers::status::get_height)) .route("/api/status", get(handlers::status::get_status)) // Health .route("/health", get(|| async { "OK" })) @@ -153,7 +156,6 @@ pub fn build_router(state: Arc, cors_origin: Option) -> Router .layer(build_cors_layer(cors_origin)) .layer(TraceLayer::new_for_http()) } - /// Construct the CORS layer. /// /// When `cors_origin` is `Some`, restrict to that exact origin. diff --git a/backend/crates/atlas-server/src/config.rs b/backend/crates/atlas-server/src/config.rs index 60f5389..f30ec4d 100644 --- a/backend/crates/atlas-server/src/config.rs +++ b/backend/crates/atlas-server/src/config.rs @@ -31,6 +31,7 @@ pub struct Config { /// (backwards-compatible default for development / self-hosted deployments). pub cors_origin: Option, pub sse_replay_buffer_blocks: usize, + pub chain_name: String, } impl Config { @@ -98,6 +99,7 @@ impl Config { .context("Invalid API_PORT")?, cors_origin: env::var("CORS_ORIGIN").ok(), sse_replay_buffer_blocks, + chain_name: env::var("CHAIN_NAME").unwrap_or_else(|_| "Unknown".to_string()), }) } } diff --git a/backend/crates/atlas-server/src/main.rs b/backend/crates/atlas-server/src/main.rs index b350418..7f5f899 100644 --- a/backend/crates/atlas-server/src/main.rs +++ b/backend/crates/atlas-server/src/main.rs @@ -13,6 +13,32 @@ mod indexer; const RETRY_DELAYS: &[u64] = &[5, 10, 20, 30, 60]; const MAX_RETRY_DELAY: u64 = 60; +fn parse_chain_id(hex: &str) -> Option { + u64::from_str_radix(hex.trim_start_matches("0x"), 16).ok() +} + +async fn fetch_chain_id(rpc_url: &str) -> Result { + let client = reqwest::Client::new(); + let resp = client + .post(rpc_url) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": "eth_chainId", + "params": [], + "id": 1 + })) + .timeout(Duration::from_secs(5)) + .send() + .await? + .error_for_status()?; + + let json: serde_json::Value = resp.json().await?; + let hex = json["result"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("eth_chainId result missing"))?; + parse_chain_id(hex).ok_or_else(|| anyhow::anyhow!("invalid eth_chainId hex")) +} + #[tokio::main] async fn main() -> Result<()> { // Initialize tracing @@ -30,6 +56,10 @@ async fn main() -> Result<()> { dotenvy::dotenv().ok(); let config = config::Config::from_env()?; + tracing::info!("Fetching chain ID from RPC"); + let chain_id = fetch_chain_id(&config.rpc_url).await?; + tracing::info!("Chain ID: {}", chain_id); + // Run migrations once (dedicated pool, no statement_timeout) tracing::info!("Running database migrations"); atlas_common::db::run_migrations(&config.database_url).await?; @@ -55,6 +85,8 @@ async fn main() -> Result<()> { block_events_tx: block_events_tx.clone(), head_tracker: head_tracker.clone(), rpc_url: config.rpc_url.clone(), + chain_id, + chain_name: config.chain_name.clone(), }); // Spawn indexer task with retry logic @@ -180,8 +212,32 @@ where #[cfg(test)] mod tests { - use super::wait_for_shutdown_signal; - use tokio::sync::oneshot; + use super::*; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + sync::oneshot, + }; + + async fn serve_json_once(body: &'static str) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buf = [0_u8; 1024]; + let _ = socket.read(&mut buf).await.unwrap(); + + let response = format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + }); + + format!("http://{}", addr) + } #[tokio::test] async fn wait_for_shutdown_signal_returns_on_ctrl_c_future() { @@ -218,4 +274,27 @@ mod tests { term_tx.send(()).unwrap(); shutdown.await.unwrap(); } + + #[tokio::test] + async fn fetch_chain_id_reads_hex_result_from_rpc_response() { + let url = serve_json_once(r#"{"jsonrpc":"2.0","id":1,"result":"0xa4b1"}"#).await; + assert_eq!(fetch_chain_id(&url).await.unwrap(), 42161); + } + + #[tokio::test] + async fn fetch_chain_id_returns_error_for_invalid_result() { + let url = serve_json_once(r#"{"jsonrpc":"2.0","id":1,"result":"not_hex"}"#).await; + let err = fetch_chain_id(&url).await.unwrap_err(); + assert!(err.to_string().contains("invalid eth_chainId hex")); + } + + #[tokio::test] + async fn fetch_chain_id_returns_error_for_http_failure() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + + let url = format!("http://{}", addr); + assert!(fetch_chain_id(&url).await.is_err()); + } } diff --git a/docker-compose.yml b/docker-compose.yml index 3dce345..1e9a455 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: FETCH_WORKERS: ${FETCH_WORKERS:-10} RPC_REQUESTS_PER_SECOND: ${RPC_REQUESTS_PER_SECOND:-100} RPC_BATCH_SIZE: ${RPC_BATCH_SIZE:-20} + CHAIN_NAME: ${CHAIN_NAME:-Unknown} API_HOST: 0.0.0.0 API_PORT: 3000 RUST_LOG: atlas_server=info,tower_http=info diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93898a3..7c5c2cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,6 +16,7 @@ import { WelcomePage, SearchResultsPage, AddressesPage, + StatusPage, } from './pages'; import { ThemeProvider } from './context/ThemeContext'; @@ -37,6 +38,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/status.ts b/frontend/src/api/status.ts index 87dd4b6..ca79103 100644 --- a/frontend/src/api/status.ts +++ b/frontend/src/api/status.ts @@ -1,12 +1,25 @@ import client from './client'; -export interface StatusResponse { +export interface HeightResponse { block_height: number; indexed_at?: string; // ISO timestamp, absent when no blocks indexed } -export async function getStatus(): Promise { - const response = await client.get('/status'); +export interface ChainStatusResponse { + chain_id: number; + chain_name: string; + block_height: number; + total_transactions: number; + total_addresses: number; + indexed_at: string; // ISO timestamp +} + +export async function getStatus(): Promise { + const response = await client.get('/height'); return response.data; } +export async function getChainStatus(): Promise { + const response = await client.get('/status'); + return response.data; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index aa024f5..dcf2fa8 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -61,6 +61,9 @@ export default function Layout() { NFTs + + Status + {/* Right status: latest height + live pulse */} @@ -133,6 +136,9 @@ export default function Layout() { NFTs + + Status +