diff --git a/.env.example b/.env.example index 4426f24..e9ea656 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,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/backend/crates/atlas-api/src/handlers/mod.rs b/backend/crates/atlas-api/src/handlers/mod.rs index 3bd5818..3148170 100644 --- a/backend/crates/atlas-api/src/handlers/mod.rs +++ b/backend/crates/atlas-api/src/handlers/mod.rs @@ -14,13 +14,12 @@ pub mod transactions; use sqlx::PgPool; -/// 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. @@ -53,9 +52,10 @@ pub async fn get_table_count(pool: &PgPool) -> Result { Ok(approx) } else { // Exact count for small tables - let exact: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM transactions") - .fetch_one(pool) - .await?; + let exact: (i64,) = + sqlx::query_as(&format!("SELECT COUNT(*) FROM {table_name}")) + .fetch_one(pool) + .await?; Ok(exact.0) } } diff --git a/backend/crates/atlas-api/src/handlers/status.rs b/backend/crates/atlas-api/src/handlers/status.rs index 3744536..81ffaea 100644 --- a/backend/crates/atlas-api/src/handlers/status.rs +++ b/backend/crates/atlas-api/src/handlers/status.rs @@ -3,16 +3,43 @@ use serde::Serialize; use std::sync::Arc; use crate::error::ApiResult; +use crate::handlers::get_table_count; use crate::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, + 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 +/// GET /api/height - Lightweight endpoint for current block height. +/// Returns in <1ms, optimized for frequent polling. +pub async fn get_height(State(state): State>) -> ApiResult> { + let result: (String, chrono::DateTime) = sqlx::query_as( + "SELECT value, updated_at FROM indexer_state WHERE key = 'last_indexed_block'", + ) + .fetch_one(&state.pool) + .await?; + + let block_height: i64 = result.0.parse().unwrap_or(0); + + Ok(Json(HeightResponse { + block_height, + indexed_at: result.1.to_rfc3339(), + })) +} + +/// GET /api/status - Full chain status including chain ID, name, and counts. pub async fn get_status(State(state): State>) -> ApiResult> { let result: (String, chrono::DateTime) = sqlx::query_as( "SELECT value, updated_at FROM indexer_state WHERE key = 'last_indexed_block'", @@ -22,8 +49,15 @@ pub async fn get_status(State(state): State>) -> ApiResult, ) -> 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-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index 3e471f0..4a156da 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -14,11 +14,40 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod error; mod handlers; +async fn fetch_chain_id(rpc_url: &str) -> u64 { + 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; + + match resp { + Ok(r) => { + let json: serde_json::Value = r.json().await.unwrap_or_default(); + let hex = json["result"].as_str().unwrap_or("0x0"); + u64::from_str_radix(hex.trim_start_matches("0x"), 16).unwrap_or(0) + } + Err(e) => { + tracing::warn!("Failed to fetch chain ID from RPC: {}", e); + 0 + } + } +} + pub struct AppState { pub pool: PgPool, pub rpc_url: String, pub solc_path: String, pub admin_api_key: Option, + pub chain_id: u64, + pub chain_name: String, } #[tokio::main] @@ -40,12 +69,18 @@ async fn main() -> Result<()> { let rpc_url = std::env::var("RPC_URL").expect("RPC_URL must be set"); let solc_path = std::env::var("SOLC_PATH").unwrap_or_else(|_| "solc".to_string()); let admin_api_key = std::env::var("ADMIN_API_KEY").ok(); + let chain_name = std::env::var("CHAIN_NAME").unwrap_or_else(|_| "Unknown".to_string()); let host = std::env::var("API_HOST").unwrap_or_else(|_| "0.0.0.0".to_string()); let port: u16 = std::env::var("API_PORT") .unwrap_or_else(|_| "3000".to_string()) .parse() .expect("Invalid API_PORT"); + // Fetch chain ID once at startup — it never changes + tracing::info!("Fetching chain ID from RPC"); + let chain_id = fetch_chain_id(&rpc_url).await; + tracing::info!("Chain ID: {}", chain_id); + // Create database pool let pool = atlas_common::db::create_pool(&database_url, 20).await?; @@ -58,6 +93,8 @@ async fn main() -> Result<()> { rpc_url, solc_path, admin_api_key, + chain_id, + chain_name, }); // Build router @@ -208,6 +245,7 @@ async fn main() -> Result<()> { // 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" })) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 93898a3..4b5ebd7 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'; @@ -34,10 +35,11 @@ export default function App() { } /> } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/status.ts b/frontend/src/api/status.ts index 4f5df2c..93a6e66 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 } -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 39642e1..6acfbad 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -134,6 +134,9 @@ export default function Layout() { NFTs + + Status + {/* Right status: latest height + live pulse */} @@ -206,6 +209,9 @@ export default function Layout() { NFTs + + Status +