diff --git a/.env.example b/.env.example index 4426f24..290c93e 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,8 @@ FETCH_WORKERS=10 # Number of blocks to fetch per RPC batch request (reduces HTTP round-trips) RPC_BATCH_SIZE=20 + +# API settings +# API_HOST=127.0.0.1 +# API_PORT=3000 +# API_DB_MAX_CONNECTIONS=20 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83080c4..b5017e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,7 +110,6 @@ jobs: image-tag: main apps: | [ - {"name": "atlas-oss-indexer", "context": "backend", "dockerfile": "backend/Dockerfile", "target": "indexer"}, - {"name": "atlas-oss-api", "context": "backend", "dockerfile": "backend/Dockerfile", "target": "api"}, + {"name": "atlas-oss-server", "context": "backend", "dockerfile": "backend/Dockerfile", "target": "server"}, {"name": "atlas-oss-frontend", "context": "frontend", "dockerfile": "frontend/Dockerfile", "target": ""} ] diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index e671c15..6e29466 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -11,8 +11,8 @@ concurrency: cancel-in-progress: true jobs: - build-indexer: - name: Indexer Docker (linux/amd64) + build-backend: + name: Backend Docker (linux/amd64) runs-on: ubuntu-latest steps: - name: Checkout @@ -21,31 +21,11 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Build indexer image + - name: Build backend image uses: docker/build-push-action@v6 with: context: backend - target: indexer - platforms: linux/amd64 - cache-from: type=gha,scope=backend - cache-to: type=gha,scope=backend,mode=max - outputs: type=cacheonly - - build-api: - name: API Docker (linux/amd64) - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build api image - uses: docker/build-push-action@v6 - with: - context: backend - target: api + target: server platforms: linux/amd64 cache-from: type=gha,scope=backend cache-to: type=gha,scope=backend,mode=max diff --git a/CLAUDE.md b/CLAUDE.md index 4b94f2c..68efadf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,8 +6,7 @@ Atlas is an EVM blockchain explorer (indexer + API + frontend) for ev-node based | Layer | Tech | |---|---| -| Indexer | Rust, tokio, sqlx, alloy, tokio-postgres (binary COPY) | -| API | Rust, Axum, sqlx, tower-http | +| Server | Rust, tokio, Axum, sqlx, alloy, tokio-postgres (binary COPY), tower-http | | Database | PostgreSQL (partitioned tables) | | Frontend | React, TypeScript, Vite, Tailwind CSS, Bun | | Deployment | Docker Compose, nginx (unprivileged, port 8080→80) | @@ -20,9 +19,13 @@ atlas/ │ ├── Cargo.toml # Workspace — all dep versions live here │ ├── crates/ │ │ ├── atlas-common/ # Shared types, DB pool, error handling, Pagination -│ │ ├── atlas-indexer/ # Block fetcher, batch writer, metadata fetcher -│ │ └── atlas-api/ # Axum REST API -│ └── migrations/ # sqlx migrations (run at startup by both crates) +│ │ └── atlas-server/ # Unified server: indexer + API in a single binary +│ │ └── src/ +│ │ ├── main.rs # Startup: migrations, pools, spawn indexer, serve API +│ │ ├── config.rs # Unified config from env vars +│ │ ├── indexer/ # Block fetcher, batch writer, metadata fetcher +│ │ └── api/ # Axum REST API + SSE handlers +│ └── migrations/ # sqlx migrations (run once at startup) ├── frontend/ │ ├── src/ │ │ ├── api/ # Typed API clients (axios) @@ -31,18 +34,24 @@ atlas/ │ │ ├── pages/ # One file per page/route │ │ └── types/ # Shared TypeScript types │ ├── Dockerfile # Multi-stage: oven/bun:1 → nginx-unprivileged:alpine -│ └── nginx.conf # SPA routing + /api/ reverse proxy to atlas-api:3000 +│ └── nginx.conf # SPA routing + /api/ reverse proxy to atlas-server:3000 ├── docker-compose.yml └── .env.example ``` ## Key Architectural Decisions +### Single binary +The indexer and API run as concurrent tokio tasks in a single `atlas-server` binary. The indexer pushes block events directly to SSE subscribers via an in-process `broadcast::Sender<()>`. If the indexer task fails, the API keeps running (graceful degradation); the indexer retries with exponential backoff. + ### Database connection pools -- **API pool**: 20 connections, `statement_timeout = '10s'` set via `after_connect` hook -- **Indexer pool**: 20 connections (configurable via `DB_MAX_CONNECTIONS`), same timeout +- **API pool**: 20 connections (configurable via `API_DB_MAX_CONNECTIONS`), `statement_timeout = '10s'` +- **Indexer pool**: 20 connections (configurable via `DB_MAX_CONNECTIONS`), same timeout — kept separate so API load can't starve the indexer - **Binary COPY client**: separate `tokio-postgres` direct connection (bypasses sqlx pool), conditional TLS based on `sslmode` in DATABASE_URL -- **Migrations**: run with a dedicated 1-connection pool with **no** statement_timeout (index builds can take longer than 10s) +- **Migrations**: run once with a dedicated 1-connection pool with **no** statement_timeout (index builds can take longer than 10s) + +### SSE live updates +The indexer publishes block updates through `broadcast::Sender<()>`. SSE handler (`GET /api/events`) subscribes to this broadcast channel and refreshes independently of the database write path. ### Pagination — blocks table The blocks table can have 80M+ rows. `OFFSET` on large pages causes 30s+ full index scans. Instead: @@ -57,29 +66,28 @@ let cursor = (total_count - 1) - (pagination.page.saturating_sub(1) as i64) * li ### Row count estimation For large tables (transactions, addresses), use `pg_class.reltuples` instead of `COUNT(*)`: ```rust -// handlers/mod.rs — get_table_count(pool, "table_name") +// handlers/mod.rs — get_table_count(pool) // Partition-aware: sums child reltuples, falls back to parent // For tables < 100k rows: falls back to exact COUNT(*) ``` ### HTTP timeout -`TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10))` wraps all routes — returns 408 if any handler exceeds 10s. +`TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(10))` wraps all routes except SSE — returns 408 if any handler exceeds 10s. ### AppState (API) ```rust pub struct AppState { - pub pool: PgPool, + pub pool: PgPool, // API pool only + pub block_events_tx: broadcast::Sender<()>, // shared with indexer pub rpc_url: String, pub solc_path: String, pub admin_api_key: Option, - pub chain_id: u64, // fetched from RPC once at startup via eth_chainId - pub chain_name: String, // from CHAIN_NAME env var, defaults to "Unknown" } ``` ### Frontend API client -- Base URL: `/api` (proxied by nginx to `atlas-api:3000`) -- `GET /api/status` → `{ block_height, indexed_at }` — single key-value lookup from `indexer_state`, sub-ms. This is the **only** chain status endpoint; there is no separate "full chain info" endpoint. Used by the navbar as a polling fallback when SSE is disconnected. +- 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. ## Important Conventions @@ -88,7 +96,7 @@ pub struct AppState { - **SQL**: never use `OFFSET` for large tables — use keyset/cursor pagination - **Migrations**: use `run_migrations(&database_url)` (not `&pool`) to get a timeout-free connection - **Frontend**: uses Bun (not npm/yarn). Lockfile is `bun.lock` (text, Bun ≥ 1.2). Build with `bunx vite build` (skips tsc type check). -- **Docker**: frontend image uses `nginxinc/nginx-unprivileged:alpine` (non-root, port 8080). API/indexer use `alpine` with `ca-certificates`. +- **Docker**: frontend image uses `nginxinc/nginx-unprivileged:alpine` (non-root, port 8080). Server uses `alpine` with `ca-certificates`. - **Tests**: add unit tests for new logic in a `#[cfg(test)] mod tests` block in the same file. Run with `cargo test --workspace`. - **Commits**: authored by the user only — no Claude co-author lines. @@ -99,12 +107,14 @@ Key vars (see `.env.example` for full list): | Var | Used by | Default | |---|---|---| | `DATABASE_URL` | all | required | -| `RPC_URL` | indexer, api | required | -| `CHAIN_NAME` | api | `"Unknown"` | -| `DB_MAX_CONNECTIONS` | indexer | `20` | +| `RPC_URL` | server | required | +| `DB_MAX_CONNECTIONS` | indexer pool | `20` | +| `API_DB_MAX_CONNECTIONS` | API pool | `20` | | `BATCH_SIZE` | indexer | `100` | | `FETCH_WORKERS` | indexer | `10` | -| `ADMIN_API_KEY` | api | none | +| `ADMIN_API_KEY` | API | none | +| `API_HOST` | API | `127.0.0.1` | +| `API_PORT` | API | `3000` | ## Running Locally @@ -112,8 +122,8 @@ Key vars (see `.env.example` for full list): # Start full stack docker compose up -d -# Rebuild a single service after code changes -docker compose build atlas-api && docker compose up -d atlas-api +# Rebuild after code changes +docker compose build atlas-server && docker compose up -d atlas-server # Backend only (no Docker) cd backend && cargo build --workspace @@ -121,7 +131,7 @@ cd backend && cargo build --workspace ## Common Gotchas -- `get_table_count(pool, table_name)` — pass the table name, it's not hardcoded anymore - `run_migrations` takes `&str` (database URL), not `&PgPool` - The blocks cursor uses `pagination.limit()` (clamped), not `pagination.offset()` — they diverge when client sends `limit > 100` - `bun.lock` not `bun.lockb` — Bun ≥ 1.2 uses text format +- SSE uses in-process broadcast, not PG NOTIFY — no PgListener needed diff --git a/Justfile b/Justfile index 53eb171..931b3da 100644 --- a/Justfile +++ b/Justfile @@ -26,11 +26,8 @@ backend-clippy: backend-test: cd backend && cargo test --workspace --all-targets -backend-api: - cd backend && cargo run --bin atlas-api - -backend-indexer: - cd backend && cargo run --bin atlas-indexer +backend-server: + cd backend && cargo run --bin atlas-server # Combined checks ci: backend-fmt backend-clippy backend-test frontend-install frontend-lint frontend-build diff --git a/README.md b/README.md index 092c42f..b5583c9 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,10 @@ docker-compose up -d postgres just frontend-install ``` -Start backend services (each in its own terminal): +Start the backend: ```bash -just backend-indexer -``` - -```bash -just backend-api +just backend-server ``` Start frontend: diff --git a/backend/Cargo.toml b/backend/Cargo.toml index fb250d6..cc9f43c 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -2,8 +2,7 @@ resolver = "2" members = [ "crates/atlas-common", - "crates/atlas-indexer", - "crates/atlas-api", + "crates/atlas-server", ] [workspace.package] diff --git a/backend/Dockerfile b/backend/Dockerfile index 07c84bb..8387d87 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -13,21 +13,17 @@ RUN cargo update wasip2 --precise 0.1.0 || true RUN cargo build --release -# Indexer image -FROM alpine:3.21 AS indexer +# Server image +FROM alpine:3.21 AS server RUN apk add --no-cache ca-certificates -COPY --from=builder /app/target/release/atlas-indexer /usr/local/bin/ +COPY --from=builder /app/target/release/atlas-server /usr/local/bin/ -CMD ["atlas-indexer"] - -# API image -FROM alpine:3.21 AS api - -RUN apk add --no-cache ca-certificates +EXPOSE 3000 +CMD ["atlas-server"] -COPY --from=builder /app/target/release/atlas-api /usr/local/bin/ +# Backward-compatible target names for CI jobs that still build the old images. +FROM server AS api -EXPOSE 3000 -CMD ["atlas-api"] +FROM server AS indexer diff --git a/backend/crates/atlas-api/src/handlers/auth.rs b/backend/crates/atlas-api/src/handlers/auth.rs deleted file mode 100644 index 4ba7a40..0000000 --- a/backend/crates/atlas-api/src/handlers/auth.rs +++ /dev/null @@ -1,23 +0,0 @@ -use crate::AppState; -use atlas_common::AtlasError; -use axum::http::HeaderMap; - -pub fn require_admin(headers: &HeaderMap, state: &AppState) -> Result<(), AtlasError> { - let configured = state - .admin_api_key - .as_deref() - .filter(|k| !k.is_empty()) - .ok_or_else(|| AtlasError::Unauthorized("Admin API is not enabled".to_string()))?; - - let provided = headers - .get("x-admin-api-key") - .and_then(|v| v.to_str().ok()) - .or_else(|| headers.get("x-api-key").and_then(|v| v.to_str().ok())); - - match provided { - Some(key) if key == configured => Ok(()), - _ => Err(AtlasError::Unauthorized( - "Missing or invalid admin API key".to_string(), - )), - } -} diff --git a/backend/crates/atlas-api/src/handlers/contracts.rs b/backend/crates/atlas-api/src/handlers/contracts.rs deleted file mode 100644 index a952810..0000000 --- a/backend/crates/atlas-api/src/handlers/contracts.rs +++ /dev/null @@ -1,605 +0,0 @@ -//! Contract verification handlers -//! -//! Provides endpoints for verifying smart contracts by compiling source code -//! and matching against deployed bytecode. - -use alloy::providers::{Provider, ProviderBuilder}; -use axum::{ - extract::{Path, State}, - Json, -}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use std::process::Stdio; -use std::sync::Arc; -use tokio::process::Command; - -use crate::error::ApiResult; -use crate::AppState; -use atlas_common::{ - AtlasError, ContractAbiResponse, ContractSourceResponse, VerifiedContract, - VerifyContractRequest, VerifyContractResponse, -}; - -/// Solc compiler output structure -#[derive(Debug, Deserialize)] -struct SolcOutput { - contracts: - Option>>, - errors: Option>, -} - -#[derive(Debug, Deserialize)] -struct SolcContract { - abi: Option, - evm: Option, -} - -#[derive(Debug, Deserialize)] -struct SolcEvm { - #[serde(rename = "deployedBytecode")] - deployed_bytecode: Option, -} - -#[derive(Debug, Deserialize)] -struct SolcBytecode { - object: String, -} - -#[derive(Debug, Deserialize)] -struct SolcError { - severity: String, - message: String, - #[serde(rename = "formattedMessage")] - formatted_message: Option, -} - -/// Standard JSON input format for solc -#[derive(Debug, Serialize)] -struct SolcStandardInput { - language: String, - sources: std::collections::HashMap, - settings: SolcSettings, -} - -#[derive(Debug, Serialize)] -struct SolcSource { - content: String, -} - -#[derive(Debug, Serialize)] -struct SolcSettings { - optimizer: SolcOptimizer, - #[serde(rename = "evmVersion", skip_serializing_if = "Option::is_none")] - evm_version: Option, - #[serde(rename = "outputSelection")] - output_selection: - std::collections::HashMap>>, -} - -#[derive(Debug, Serialize)] -struct SolcOptimizer { - enabled: bool, - runs: u32, -} - -/// POST /api/contracts/verify - Verify contract source code -pub async fn verify_contract( - State(state): State>, - Json(request): Json, -) -> ApiResult> { - let address = normalize_address(&request.address); - - // Validate address format - if !is_valid_address(&address) { - return Err(AtlasError::InvalidInput("Invalid contract address".to_string()).into()); - } - - // Check if already verified - let existing: Option<(String,)> = - sqlx::query_as("SELECT address FROM contract_abis WHERE LOWER(address) = LOWER($1)") - .bind(&address) - .fetch_optional(&state.pool) - .await?; - - if existing.is_some() { - return Ok(Json(VerifyContractResponse { - success: false, - address: address.clone(), - message: Some("Contract is already verified".to_string()), - abi: None, - })); - } - - // Fetch deployed bytecode from RPC - let deployed_bytecode = fetch_deployed_bytecode(&state.rpc_url, &address).await?; - - if deployed_bytecode.is_empty() || deployed_bytecode == "0x" { - return Err(AtlasError::Verification( - "No bytecode found at address. Is this a contract?".to_string(), - ) - .into()); - } - - // Compile the source code - let (abi, compiled_bytecode) = compile_contract(&request, &state.solc_path).await?; - - // Compare bytecodes (strip metadata hash) - let deployed_stripped = strip_metadata_hash(&deployed_bytecode); - let compiled_stripped = strip_metadata_hash(&compiled_bytecode); - - if !bytecodes_match( - &deployed_stripped, - &compiled_stripped, - &request.constructor_args, - ) { - return Err(AtlasError::BytecodeMismatch( - "Compiled bytecode does not match deployed bytecode. Check compiler settings." - .to_string(), - ) - .into()); - } - - // Parse constructor args - let constructor_args_bytes = request - .constructor_args - .as_ref() - .map(|args| hex::decode(args.trim_start_matches("0x"))) - .transpose() - .map_err(|_| AtlasError::InvalidInput("Invalid constructor arguments hex".to_string()))?; - - // Determine if multi-file - let is_multi_file = request.is_standard_json; - let source_files: Option = if is_multi_file { - // Parse the standard JSON to extract source files - serde_json::from_str(&request.source_code) - .ok() - .and_then(|v: serde_json::Value| v.get("sources").cloned()) - } else { - None - }; - - // Store verified contract - sqlx::query( - r#" - INSERT INTO contract_abis ( - address, abi, source_code, compiler_version, optimization_used, runs, - contract_name, constructor_args, evm_version, license_type, is_multi_file, source_files, verified_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NOW()) - "# - ) - .bind(&address) - .bind(&abi) - .bind(&request.source_code) - .bind(&request.compiler_version) - .bind(request.optimization_enabled) - .bind(request.optimization_runs as i32) - .bind(&request.contract_name) - .bind(&constructor_args_bytes) - .bind(&request.evm_version) - .bind(&request.license_type) - .bind(is_multi_file) - .bind(&source_files) - .execute(&state.pool) - .await?; - - tracing::info!(address = %address, "Contract verified successfully"); - - Ok(Json(VerifyContractResponse { - success: true, - address, - message: Some("Contract verified successfully".to_string()), - abi: Some(abi), - })) -} - -/// GET /api/contracts/:address/abi - Get verified contract ABI -pub async fn get_contract_abi( - State(state): State>, - Path(address): Path, -) -> ApiResult> { - let address = normalize_address(&address); - - let contract: Option<(String, serde_json::Value, Option, DateTime)> = sqlx::query_as( - "SELECT address, abi, contract_name, verified_at FROM contract_abis WHERE LOWER(address) = LOWER($1)" - ) - .bind(&address) - .fetch_optional(&state.pool) - .await?; - - match contract { - Some((addr, abi, contract_name, verified_at)) => Ok(Json(ContractAbiResponse { - address: addr, - abi, - contract_name, - verified_at, - })), - None => Err(AtlasError::NotFound(format!( - "No verified contract found at address {}", - address - )) - .into()), - } -} - -/// GET /api/contracts/:address/source - Get verified contract source code -pub async fn get_contract_source( - State(state): State>, - Path(address): Path, -) -> ApiResult> { - let address = normalize_address(&address); - - let contract: Option = sqlx::query_as( - r#" - SELECT address, abi, source_code, compiler_version, optimization_used, runs, verified_at, - contract_name, constructor_args, evm_version, license_type, is_multi_file, source_files - FROM contract_abis - WHERE LOWER(address) = LOWER($1) - "# - ) - .bind(&address) - .fetch_optional(&state.pool) - .await?; - - match contract { - Some(c) => { - let constructor_args_hex = c - .constructor_args - .as_ref() - .map(|bytes| format!("0x{}", hex::encode(bytes))); - - Ok(Json(ContractSourceResponse { - address: c.address, - contract_name: c.contract_name, - source_code: c.source_code.unwrap_or_default(), - compiler_version: c.compiler_version, - optimization_enabled: c.optimization_used.unwrap_or(false), - optimization_runs: c.runs.unwrap_or(200), - evm_version: c.evm_version, - license_type: c.license_type, - constructor_args: constructor_args_hex, - is_multi_file: c.is_multi_file, - source_files: c.source_files, - verified_at: c.verified_at, - })) - } - None => Err(AtlasError::NotFound(format!( - "No verified contract found at address {}", - address - )) - .into()), - } -} - -/// Fetch deployed bytecode from the RPC endpoint -async fn fetch_deployed_bytecode(rpc_url: &str, address: &str) -> Result { - let provider = ProviderBuilder::new().on_http( - rpc_url - .parse() - .map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?, - ); - - let addr: alloy::primitives::Address = address - .parse() - .map_err(|_| AtlasError::InvalidInput("Invalid address format".to_string()))?; - - let code = provider - .get_code_at(addr) - .await - .map_err(|e| AtlasError::Rpc(format!("Failed to fetch bytecode: {}", e)))?; - - Ok(format!("0x{}", hex::encode(code.as_ref()))) -} - -/// Compile contract using solc -async fn compile_contract( - request: &VerifyContractRequest, - solc_path: &str, -) -> Result<(serde_json::Value, String), AtlasError> { - // Build standard JSON input - let input = if request.is_standard_json { - // Use provided standard JSON directly, but ensure output selection is correct - let mut parsed: serde_json::Value = serde_json::from_str(&request.source_code) - .map_err(|e| AtlasError::InvalidInput(format!("Invalid standard JSON input: {}", e)))?; - - // Ensure we have the required output selections - if let Some(settings) = parsed.get_mut("settings") { - if let Some(obj) = settings.as_object_mut() { - let output_selection = serde_json::json!({ - "*": { - "*": ["abi", "evm.bytecode.object", "evm.deployedBytecode.object"] - } - }); - obj.insert("outputSelection".to_string(), output_selection); - } - } - - serde_json::to_string(&parsed) - .map_err(|e| AtlasError::Internal(format!("Failed to serialize JSON: {}", e)))? - } else { - // Build standard JSON input from single file - let mut sources = std::collections::HashMap::new(); - sources.insert( - format!( - "{}.sol", - request - .contract_name - .split(':') - .next() - .unwrap_or(&request.contract_name) - ), - SolcSource { - content: request.source_code.clone(), - }, - ); - - let mut output_selection = std::collections::HashMap::new(); - let mut file_selection = std::collections::HashMap::new(); - file_selection.insert( - "*".to_string(), - vec![ - "abi".to_string(), - "evm.bytecode.object".to_string(), - "evm.deployedBytecode.object".to_string(), - ], - ); - output_selection.insert("*".to_string(), file_selection); - - let input = SolcStandardInput { - language: "Solidity".to_string(), - sources, - settings: SolcSettings { - optimizer: SolcOptimizer { - enabled: request.optimization_enabled, - runs: request.optimization_runs, - }, - evm_version: request.evm_version.clone(), - output_selection, - }, - }; - - serde_json::to_string(&input) - .map_err(|e| AtlasError::Internal(format!("Failed to serialize input: {}", e)))? - }; - - // Determine solc binary path - support version-specific binaries - let version_clean = request - .compiler_version - .trim_start_matches('v') - .split('+') - .next() - .unwrap_or(&request.compiler_version); - - // Try version-specific binary first, then fall back to configured path - let solc_binary = format!("{}-{}", solc_path, version_clean); - let solc_to_use = if tokio::fs::metadata(&solc_binary).await.is_ok() { - solc_binary - } else { - solc_path.to_string() - }; - - // Run solc - let mut cmd = Command::new(&solc_to_use); - cmd.arg("--standard-json") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - let mut child = cmd.spawn().map_err(|e| { - AtlasError::Compilation(format!( - "Failed to spawn solc: {}. Is solc installed at '{}'?", - e, solc_to_use - )) - })?; - - // Write input to stdin - if let Some(mut stdin) = child.stdin.take() { - use tokio::io::AsyncWriteExt; - stdin.write_all(input.as_bytes()).await.map_err(|e| { - AtlasError::Compilation(format!("Failed to write to solc stdin: {}", e)) - })?; - } - - let output = child - .wait_with_output() - .await - .map_err(|e| AtlasError::Compilation(format!("Failed to wait for solc: {}", e)))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(AtlasError::Compilation(format!("solc failed: {}", stderr))); - } - - // Parse output - let solc_output: SolcOutput = serde_json::from_slice(&output.stdout) - .map_err(|e| AtlasError::Compilation(format!("Failed to parse solc output: {}", e)))?; - - // Check for errors - if let Some(errors) = &solc_output.errors { - let error_msgs: Vec<_> = errors - .iter() - .filter(|e| e.severity == "error") - .map(|e| e.formatted_message.as_ref().unwrap_or(&e.message).clone()) - .collect(); - - if !error_msgs.is_empty() { - return Err(AtlasError::Compilation(error_msgs.join("\n"))); - } - } - - // Find the contract - let contracts = solc_output - .contracts - .ok_or_else(|| AtlasError::Compilation("No contracts in output".to_string()))?; - - // Parse contract name - could be "ContractName" or "path/File.sol:ContractName" - let contract_name = request - .contract_name - .split(':') - .next_back() - .unwrap_or(&request.contract_name); - - // Find the contract in any file - let mut found_contract: Option<&SolcContract> = None; - let mut found_file: Option<&str> = None; - - for (file, file_contracts) in &contracts { - if let Some(contract) = file_contracts.get(contract_name) { - found_contract = Some(contract); - found_file = Some(file); - break; - } - } - - let contract = found_contract.ok_or_else(|| { - AtlasError::Compilation(format!( - "Contract '{}' not found in compilation output. Available contracts: {:?}", - contract_name, - contracts - .iter() - .flat_map(|(_, c)| c.keys()) - .collect::>() - )) - })?; - - let abi = contract - .abi - .clone() - .ok_or_else(|| AtlasError::Compilation("No ABI in contract output".to_string()))?; - - let evm = contract - .evm - .as_ref() - .ok_or_else(|| AtlasError::Compilation("No EVM output in contract".to_string()))?; - - let deployed_bytecode = evm - .deployed_bytecode - .as_ref() - .ok_or_else(|| AtlasError::Compilation("No deployed bytecode in output".to_string()))?; - - let bytecode = format!("0x{}", deployed_bytecode.object); - - tracing::debug!( - contract_name = %contract_name, - file = ?found_file, - bytecode_len = bytecode.len(), - "Compiled contract" - ); - - Ok((abi, bytecode)) -} - -/// Strip metadata hash from bytecode for comparison -/// Solidity appends CBOR-encoded metadata at the end of bytecode -fn strip_metadata_hash(bytecode: &str) -> String { - let bytecode = bytecode.trim_start_matches("0x"); - - // Metadata is typically the last 43-53 bytes depending on version - // Format: a2 64 'ipfs' 58 22 <32-byte-hash> 64 'solc' 43 - // We'll look for the 'a2 64' or 'a1 65' CBOR markers - - if bytecode.len() < 100 { - return bytecode.to_string(); - } - - // Try to find the CBOR metadata marker - // Common patterns: "a264" (2-item map with 4-char key) or "a265" (2-item map with 5-char key) - let patterns = ["a264697066735822", "a265627a7a7232"]; // "ipfs" and "bzzr2" markers - - for pattern in patterns { - if let Some(pos) = bytecode.rfind(pattern) { - // Verify this looks like metadata by checking position - // Metadata should be at the end, within ~100 bytes - if bytecode.len() - pos < 200 { - return bytecode[..pos].to_string(); - } - } - } - - // Fallback: strip last 86 characters (43 bytes = typical metadata length) - if bytecode.len() > 86 { - bytecode[..bytecode.len() - 86].to_string() - } else { - bytecode.to_string() - } -} - -/// Compare bytecodes, accounting for constructor args -fn bytecodes_match(deployed: &str, compiled: &str, _constructor_args: &Option) -> bool { - let deployed = deployed.to_lowercase(); - let compiled = compiled.to_lowercase(); - - // Direct match - if deployed == compiled { - return true; - } - - // If constructor args provided, the deployed code might have them appended - // to the creation bytecode, but runtime bytecode should match - // Since we're comparing deployed (runtime) bytecode, they should match directly - - // Allow for minor differences in metadata stripping - let min_len = deployed.len().min(compiled.len()); - - // If one is significantly shorter, likely an issue - if min_len < deployed.len().max(compiled.len()) * 90 / 100 { - return false; - } - - // Compare the common prefix - deployed[..min_len] == compiled[..min_len] -} - -fn normalize_address(address: &str) -> String { - let addr = address.trim().to_lowercase(); - if addr.starts_with("0x") { - addr - } else { - format!("0x{}", addr) - } -} - -fn is_valid_address(address: &str) -> bool { - let addr = address.trim_start_matches("0x"); - addr.len() == 40 && addr.chars().all(|c| c.is_ascii_hexdigit()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_strip_metadata_hash() { - // Bytecode with IPFS metadata - let bytecode = "608060405234801561001057600080fd5ba264697066735822122012345678901234567890123456789012345678901234567890123456789012345678901234"; - let stripped = strip_metadata_hash(bytecode); - assert!(stripped.len() < bytecode.len()); - assert!(!stripped.contains("a264697066735822")); - } - - #[test] - fn test_normalize_address() { - assert_eq!(normalize_address("0xABC123"), "0xabc123"); - assert_eq!(normalize_address("ABC123"), "0xabc123"); - assert_eq!(normalize_address(" 0xDEF456 "), "0xdef456"); - } - - #[test] - fn test_is_valid_address() { - assert!(is_valid_address( - "0x1234567890123456789012345678901234567890" - )); - assert!(is_valid_address("1234567890123456789012345678901234567890")); - assert!(!is_valid_address("0x12345")); // too short - assert!(!is_valid_address( - "0xGGGG567890123456789012345678901234567890" - )); // invalid hex - } - - #[test] - fn test_bytecodes_match() { - assert!(bytecodes_match("abcd1234", "abcd1234", &None)); - assert!(bytecodes_match("ABCD1234", "abcd1234", &None)); // case insensitive - assert!(!bytecodes_match("abcd1234", "efgh5678", &None)); - } -} diff --git a/backend/crates/atlas-api/src/handlers/labels.rs b/backend/crates/atlas-api/src/handlers/labels.rs deleted file mode 100644 index f27fbef..0000000 --- a/backend/crates/atlas-api/src/handlers/labels.rs +++ /dev/null @@ -1,290 +0,0 @@ -//! Address labels system -//! -//! Provides curated labels for known addresses (bridges, governance, etc.) - -use axum::{ - extract::{Path, Query, State}, - http::HeaderMap, - Json, -}; -use serde::Deserialize; -use std::sync::Arc; - -use crate::error::ApiResult; -use crate::handlers::auth::require_admin; -use crate::AppState; -use atlas_common::{AddressLabel, AddressLabelInput, AtlasError, PaginatedResponse, Pagination}; - -/// Query parameters for label filtering -#[derive(Debug, Deserialize)] -pub struct LabelQuery { - /// Filter by tag - pub tag: Option, - /// Search by name - pub search: Option, - #[serde(flatten)] - pub pagination: Pagination, -} - -/// GET /api/labels - List all address labels -pub async fn list_labels( - State(state): State>, - Query(query): Query, -) -> ApiResult>> { - let (total, labels) = if let Some(tag) = &query.tag { - let total: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM address_labels WHERE $1 = ANY(tags)") - .bind(tag) - .fetch_one(&state.pool) - .await?; - - let labels: Vec = sqlx::query_as( - "SELECT address, name, tags, created_at, updated_at - FROM address_labels - WHERE $1 = ANY(tags) - ORDER BY name ASC - LIMIT $2 OFFSET $3", - ) - .bind(tag) - .bind(query.pagination.limit()) - .bind(query.pagination.offset()) - .fetch_all(&state.pool) - .await?; - - (total.0, labels) - } else if let Some(search) = &query.search { - let search_pattern = format!("%{}%", search.to_lowercase()); - - let total: (i64,) = - sqlx::query_as("SELECT COUNT(*) FROM address_labels WHERE LOWER(name) LIKE $1") - .bind(&search_pattern) - .fetch_one(&state.pool) - .await?; - - let labels: Vec = sqlx::query_as( - "SELECT address, name, tags, created_at, updated_at - FROM address_labels - WHERE LOWER(name) LIKE $1 - ORDER BY name ASC - LIMIT $2 OFFSET $3", - ) - .bind(&search_pattern) - .bind(query.pagination.limit()) - .bind(query.pagination.offset()) - .fetch_all(&state.pool) - .await?; - - (total.0, labels) - } else { - let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM address_labels") - .fetch_one(&state.pool) - .await?; - - let labels: Vec = sqlx::query_as( - "SELECT address, name, tags, created_at, updated_at - FROM address_labels - ORDER BY name ASC - LIMIT $1 OFFSET $2", - ) - .bind(query.pagination.limit()) - .bind(query.pagination.offset()) - .fetch_all(&state.pool) - .await?; - - (total.0, labels) - }; - - Ok(Json(PaginatedResponse::new( - labels, - query.pagination.page, - query.pagination.limit, - total, - ))) -} - -/// GET /api/labels/:address - Get label for specific address -pub async fn get_label( - State(state): State>, - Path(address): Path, -) -> ApiResult> { - let address = normalize_address(&address); - - let label: AddressLabel = sqlx::query_as( - "SELECT address, name, tags, created_at, updated_at - FROM address_labels - WHERE LOWER(address) = LOWER($1)", - ) - .bind(&address) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AtlasError::NotFound(format!("Label for {} not found", address)))?; - - Ok(Json(label)) -} - -/// GET /api/labels/tags - Get all available tags -pub async fn list_tags(State(state): State>) -> ApiResult>> { - let tags: Vec = sqlx::query_as( - "SELECT unnest(tags) as tag, COUNT(*) as count - FROM address_labels - GROUP BY tag - ORDER BY count DESC, tag ASC", - ) - .fetch_all(&state.pool) - .await?; - - Ok(Json(tags)) -} - -/// Tag with count -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, sqlx::FromRow)] -pub struct TagCount { - pub tag: String, - pub count: i64, -} - -/// POST /api/labels - Create or update a label -pub async fn upsert_label( - State(state): State>, - headers: HeaderMap, - Json(input): Json, -) -> ApiResult> { - require_admin(&headers, &state)?; - - let address = normalize_address(&input.address); - - let label: AddressLabel = sqlx::query_as( - "INSERT INTO address_labels (address, name, tags, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW()) - ON CONFLICT (address) DO UPDATE SET - name = $2, - tags = $3, - updated_at = NOW() - RETURNING address, name, tags, created_at, updated_at", - ) - .bind(&address) - .bind(&input.name) - .bind(&input.tags) - .fetch_one(&state.pool) - .await?; - - Ok(Json(label)) -} - -/// DELETE /api/labels/:address - Delete a label -pub async fn delete_label( - State(state): State>, - headers: HeaderMap, - Path(address): Path, -) -> ApiResult> { - require_admin(&headers, &state)?; - - let address = normalize_address(&address); - - let result = sqlx::query("DELETE FROM address_labels WHERE LOWER(address) = LOWER($1)") - .bind(&address) - .execute(&state.pool) - .await?; - - if result.rows_affected() == 0 { - return Err(AtlasError::NotFound(format!("Label for {} not found", address)).into()); - } - - Ok(Json(())) -} - -/// Bulk import labels from JSON -#[derive(Debug, Deserialize)] -pub struct BulkLabelsInput { - pub labels: Vec, -} - -/// POST /api/labels/bulk - Bulk import labels -pub async fn bulk_import_labels( - State(state): State>, - headers: HeaderMap, - Json(input): Json, -) -> ApiResult> { - require_admin(&headers, &state)?; - - let mut imported = 0; - let mut errors = Vec::new(); - - for label in input.labels { - let address = normalize_address(&label.address); - - match sqlx::query( - "INSERT INTO address_labels (address, name, tags, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW()) - ON CONFLICT (address) DO UPDATE SET - name = $2, - tags = $3, - updated_at = NOW()", - ) - .bind(&address) - .bind(&label.name) - .bind(&label.tags) - .execute(&state.pool) - .await - { - Ok(_) => imported += 1, - Err(e) => errors.push(format!("{}: {}", address, e)), - } - } - - Ok(Json(BulkImportResult { imported, errors })) -} - -#[derive(Debug, serde::Serialize)] -pub struct BulkImportResult { - pub imported: usize, - pub errors: Vec, -} - -/// GET /api/addresses/:address with label enrichment -/// This is used to enrich address responses with labels -pub async fn get_address_with_label( - State(state): State>, - Path(address): Path, -) -> ApiResult> { - let address = normalize_address(&address); - - let addr: atlas_common::Address = sqlx::query_as( - "SELECT address, is_contract, first_seen_block, tx_count - FROM addresses - WHERE LOWER(address) = LOWER($1)", - ) - .bind(&address) - .fetch_optional(&state.pool) - .await? - .ok_or_else(|| AtlasError::NotFound(format!("Address {} not found", address)))?; - - let label: Option = sqlx::query_as( - "SELECT address, name, tags, created_at, updated_at - FROM address_labels - WHERE LOWER(address) = LOWER($1)", - ) - .bind(&address) - .fetch_optional(&state.pool) - .await?; - - Ok(Json(AddressWithLabel { - address: addr, - label, - })) -} - -#[derive(Debug, serde::Serialize)] -pub struct AddressWithLabel { - #[serde(flatten)] - pub address: atlas_common::Address, - pub label: Option, -} - -fn normalize_address(address: &str) -> String { - if address.starts_with("0x") { - address.to_lowercase() - } else { - format!("0x{}", address.to_lowercase()) - } -} diff --git a/backend/crates/atlas-common/src/types.rs b/backend/crates/atlas-common/src/types.rs index a3f9776..174fde2 100644 --- a/backend/crates/atlas-common/src/types.rs +++ b/backend/crates/atlas-common/src/types.rs @@ -163,29 +163,6 @@ pub struct EventSignature { pub abi: Option, } -// ===================== -// Address Label Types -// ===================== - -/// Address label as stored in the database -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct AddressLabel { - pub address: String, - pub name: String, - pub tags: Vec, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -/// Address label for creation/update (without timestamps) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AddressLabelInput { - pub address: String, - pub name: String, - #[serde(default)] - pub tags: Vec, -} - // ===================== // Proxy Contract Types // ===================== @@ -249,110 +226,6 @@ pub struct ContractAbi { pub verified_at: DateTime, } -// ===================== -// Contract Verification Types -// ===================== - -/// Extended verified contract data including all verification fields -#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] -pub struct VerifiedContract { - pub address: String, - pub abi: serde_json::Value, - pub source_code: Option, - pub compiler_version: Option, - pub optimization_used: Option, - pub runs: Option, - pub verified_at: DateTime, - pub contract_name: Option, - pub constructor_args: Option>, - pub evm_version: Option, - pub license_type: Option, - pub is_multi_file: bool, - pub source_files: Option, -} - -/// Request to verify a contract -#[derive(Debug, Clone, Deserialize)] -pub struct VerifyContractRequest { - /// Contract address to verify - pub address: String, - /// Solidity source code (single file) or standard JSON input - pub source_code: String, - /// Contract name (e.g., "MyContract" or "path/to/File.sol:ContractName") - pub contract_name: String, - /// Compiler version (e.g., "0.8.20", "v0.8.20+commit.a1b2c3d4") - pub compiler_version: String, - /// Whether optimization was enabled - #[serde(default)] - pub optimization_enabled: bool, - /// Number of optimization runs (default: 200) - #[serde(default = "default_optimization_runs")] - pub optimization_runs: u32, - /// Constructor arguments (hex encoded, without 0x prefix) - #[serde(default)] - pub constructor_args: Option, - /// EVM version (e.g., "paris", "shanghai") - #[serde(default)] - pub evm_version: Option, - /// License type (e.g., "MIT", "GPL-3.0") - #[serde(default)] - pub license_type: Option, - /// Whether source is standard JSON input format - #[serde(default)] - pub is_standard_json: bool, -} - -fn default_optimization_runs() -> u32 { - 200 -} - -/// Response from contract verification -#[derive(Debug, Clone, Serialize)] -pub struct VerifyContractResponse { - pub success: bool, - pub address: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub message: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub abi: Option, -} - -/// Verification error details -#[derive(Debug, Clone, Serialize)] -pub struct VerificationError { - pub code: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, -} - -/// Source code response -#[derive(Debug, Clone, Serialize)] -pub struct ContractSourceResponse { - pub address: String, - pub contract_name: Option, - pub source_code: String, - pub compiler_version: Option, - pub optimization_enabled: bool, - pub optimization_runs: i32, - pub evm_version: Option, - pub license_type: Option, - pub constructor_args: Option, - pub is_multi_file: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub source_files: Option, - pub verified_at: DateTime, -} - -/// ABI response -#[derive(Debug, Clone, Serialize)] -pub struct ContractAbiResponse { - pub address: String, - pub abi: serde_json::Value, - pub contract_name: Option, - pub verified_at: DateTime, -} - /// Pagination parameters #[derive(Debug, Clone, Deserialize)] pub struct Pagination { diff --git a/backend/crates/atlas-indexer/Cargo.toml b/backend/crates/atlas-indexer/Cargo.toml deleted file mode 100644 index 1ca4284..0000000 --- a/backend/crates/atlas-indexer/Cargo.toml +++ /dev/null @@ -1,32 +0,0 @@ -[package] -name = "atlas-indexer" -version.workspace = true -edition.workspace = true - -[[bin]] -name = "atlas-indexer" -path = "src/main.rs" - -[dependencies] -atlas-common = { workspace = true } -tokio = { workspace = true } -sqlx = { workspace = true } -alloy = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -reqwest = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } -dotenvy = { workspace = true } -bigdecimal = { workspace = true } -num-bigint = "0.4" -hex = { workspace = true } -chrono = { workspace = true } -async-channel = "2.3" -governor = "0.6" -tokio-postgres = { version = "0.7" } -tokio-postgres-rustls = "0.12" -rustls = "0.23" -webpki-roots = "0.26" diff --git a/backend/crates/atlas-indexer/src/main.rs b/backend/crates/atlas-indexer/src/main.rs deleted file mode 100644 index 62451e8..0000000 --- a/backend/crates/atlas-indexer/src/main.rs +++ /dev/null @@ -1,100 +0,0 @@ -use anyhow::Result; -use std::time::Duration; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -mod batch; -mod config; -mod copy; -mod fetcher; -mod indexer; -mod metadata; - -/// Retry delays for exponential backoff (in seconds) -const RETRY_DELAYS: &[u64] = &[5, 10, 20, 30, 60]; -const MAX_RETRY_DELAY: u64 = 60; - -#[tokio::main] -async fn main() -> Result<()> { - // Initialize tracing - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "atlas_indexer=info,sqlx=warn".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - tracing::info!("Starting Atlas Indexer"); - - // Load configuration - dotenvy::dotenv().ok(); - let config = config::Config::from_env()?; - - // Create database pool - let pool = - atlas_common::db::create_pool(&config.database_url, config.db_max_connections).await?; - - // Run migrations - tracing::info!("Running database migrations"); - atlas_common::db::run_migrations(&config.database_url).await?; - - // Start indexer - let indexer = indexer::Indexer::new(pool.clone(), config.clone()); - - // Start metadata fetcher in background - let metadata_pool = pool.clone(); - let metadata_config = config.clone(); - let metadata_handle = tokio::spawn(async move { - run_with_retry(|| async { - let fetcher = - metadata::MetadataFetcher::new(metadata_pool.clone(), metadata_config.clone())?; - fetcher.run().await - }) - .await - }); - - // Run indexer with retry on failure - run_with_retry(|| indexer.run()).await?; - - // Wait for metadata fetcher - metadata_handle.await??; - - Ok(()) -} - -/// Run an async function with exponential backoff retry -/// Note: Network errors are handled internally by the indexer with their own retry logic. -/// This outer retry is for catastrophic errors (DB failures, all RPC retries exhausted, etc.) -async fn run_with_retry(f: F) -> Result<()> -where - F: Fn() -> Fut, - Fut: std::future::Future>, -{ - let mut retry_count = 0; - - loop { - match f().await { - Ok(()) => { - // Success - reset retry count and continue - retry_count = 0; - } - Err(e) => { - // Get delay for this retry (cap at MAX_RETRY_DELAY) - let delay = RETRY_DELAYS - .get(retry_count) - .copied() - .unwrap_or(MAX_RETRY_DELAY); - - tracing::error!( - "Fatal error (internal retries exhausted): {}. Restarting in {}s (attempt {})...", - e, - delay, - retry_count + 1 - ); - - tokio::time::sleep(Duration::from_secs(delay)).await; - retry_count += 1; - } - } - } -} diff --git a/backend/crates/atlas-api/Cargo.toml b/backend/crates/atlas-server/Cargo.toml similarity index 76% rename from backend/crates/atlas-api/Cargo.toml rename to backend/crates/atlas-server/Cargo.toml index 2e37231..40f894a 100644 --- a/backend/crates/atlas-api/Cargo.toml +++ b/backend/crates/atlas-server/Cargo.toml @@ -1,10 +1,10 @@ [package] -name = "atlas-api" +name = "atlas-server" version.workspace = true edition.workspace = true [[bin]] -name = "atlas-api" +name = "atlas-server" path = "src/main.rs" [dependencies] @@ -16,6 +16,7 @@ sqlx = { workspace = true } alloy = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +reqwest = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } @@ -24,7 +25,14 @@ dotenvy = { workspace = true } bigdecimal = { workspace = true } hex = { workspace = true } chrono = { workspace = true } -reqwest = { workspace = true } tokio-stream = { workspace = true } futures = { workspace = true } async-stream = { workspace = true } +# Indexer-specific +num-bigint = "0.4" +async-channel = "2.3" +governor = "0.6" +tokio-postgres = { version = "0.7" } +tokio-postgres-rustls = "0.12" +rustls = "0.23" +webpki-roots = "0.26" diff --git a/backend/crates/atlas-api/src/error.rs b/backend/crates/atlas-server/src/api/error.rs similarity index 100% rename from backend/crates/atlas-api/src/error.rs rename to backend/crates/atlas-server/src/api/error.rs diff --git a/backend/crates/atlas-api/src/handlers/addresses.rs b/backend/crates/atlas-server/src/api/handlers/addresses.rs similarity index 99% rename from backend/crates/atlas-api/src/handlers/addresses.rs rename to backend/crates/atlas-server/src/api/handlers/addresses.rs index 05750db..3a9c018 100644 --- a/backend/crates/atlas-api/src/handlers/addresses.rs +++ b/backend/crates/atlas-server/src/api/handlers/addresses.rs @@ -5,8 +5,8 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; +use crate::api::error::ApiResult; +use crate::api::AppState; use atlas_common::{Address, AtlasError, NftToken, PaginatedResponse, Pagination, Transaction}; /// Merged address response that combines data from addresses, nft_contracts, and erc20_contracts tables diff --git a/backend/crates/atlas-api/src/handlers/blocks.rs b/backend/crates/atlas-server/src/api/handlers/blocks.rs similarity index 97% rename from backend/crates/atlas-api/src/handlers/blocks.rs rename to backend/crates/atlas-server/src/api/handlers/blocks.rs index d9a10b8..f4a08cb 100644 --- a/backend/crates/atlas-api/src/handlers/blocks.rs +++ b/backend/crates/atlas-server/src/api/handlers/blocks.rs @@ -4,8 +4,8 @@ use axum::{ }; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; +use crate::api::error::ApiResult; +use crate::api::AppState; use atlas_common::{AtlasError, Block, PaginatedResponse, Pagination, Transaction}; pub async fn list_blocks( diff --git a/backend/crates/atlas-api/src/handlers/etherscan.rs b/backend/crates/atlas-server/src/api/handlers/etherscan.rs similarity index 81% rename from backend/crates/atlas-api/src/handlers/etherscan.rs rename to backend/crates/atlas-server/src/api/handlers/etherscan.rs index dbbb551..5d60de8 100644 --- a/backend/crates/atlas-api/src/handlers/etherscan.rs +++ b/backend/crates/atlas-server/src/api/handlers/etherscan.rs @@ -5,17 +5,16 @@ use alloy::providers::{Provider, ProviderBuilder}; use axum::{ - extract::{Form, Query, State}, + extract::{Query, State}, Json, }; use bigdecimal::BigDecimal; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use crate::error::ApiResult; -use crate::handlers::contracts; -use crate::AppState; -use atlas_common::{AtlasError, ContractAbi, Transaction, VerifyContractRequest}; +use crate::api::error::ApiResult; +use crate::api::AppState; +use atlas_common::{AtlasError, ContractAbi, Transaction}; /// Etherscan API response wrapper #[derive(Debug, Serialize)] @@ -73,47 +72,6 @@ pub struct EtherscanQuery { pub _apikey: Option, } -/// Etherscan-compatible contract verification request (form data) -#[derive(Debug, Deserialize)] -pub struct EtherscanVerifyRequest { - pub module: String, - pub action: String, - /// Contract address to verify - #[serde(rename = "contractaddress")] - pub contract_address: String, - /// Solidity source code - #[serde(rename = "sourceCode")] - pub source_code: String, - /// Contract name (e.g., "contracts/MyContract.sol:MyContract") - #[serde(rename = "contractname")] - pub contract_name: String, - /// Compiler version (e.g., "v0.8.20+commit.a1b2c3d4") - #[serde(rename = "compilerversion")] - pub compiler_version: String, - /// Optimization used (0 or 1) - #[serde(rename = "optimizationUsed", default)] - pub optimization_used: String, - /// Number of optimization runs - #[serde(default = "default_runs_str")] - pub runs: String, - /// Constructor arguments (hex encoded) - #[serde(rename = "constructorArguements", default)] // Note: Etherscan typo is intentional - pub constructor_arguments: String, - /// EVM version - #[serde(rename = "evmversion", default)] - pub evm_version: String, - /// License type (1-14 mapped to SPDX identifiers) - #[serde(rename = "licenseType", default)] - pub license_type: String, - /// API key - #[serde(default, rename = "apikey")] - pub _apikey: String, -} - -fn default_runs_str() -> String { - "200".to_string() -} - /// Main Etherscan API router (GET requests) pub async fn etherscan_api( State(state): State>, @@ -132,117 +90,6 @@ pub async fn etherscan_api( } } -/// Etherscan API POST handler (for verification) -pub async fn etherscan_api_post( - State(state): State>, - Form(form): Form, -) -> ApiResult> { - if form.module != "contract" { - return Ok(Json(serde_json::to_value(EtherscanResponse::error( - format!( - "POST only supported for contract module, got: {}", - form.module - ), - serde_json::Value::Null, - ))?)); - } - - match form.action.as_str() { - "verifysourcecode" => verify_source_code_etherscan(state, form).await, - _ => Ok(Json(serde_json::to_value(EtherscanResponse::error( - format!("Unknown action: {}", form.action), - serde_json::Value::Null, - ))?)), - } -} - -/// Etherscan-compatible verifysourcecode implementation -async fn verify_source_code_etherscan( - state: Arc, - form: EtherscanVerifyRequest, -) -> ApiResult> { - // Detect if source is standard JSON input - let is_standard_json = form.source_code.trim().starts_with('{') - && form.source_code.contains("\"language\"") - && form.source_code.contains("\"sources\""); - - // Parse optimization setting - let optimization_enabled = form.optimization_used == "1"; - - // Parse runs - let optimization_runs: u32 = form.runs.parse().unwrap_or(200); - - // Map license type number to SPDX identifier - let license_type = map_license_type(&form.license_type); - - // Build the internal verification request - let request = VerifyContractRequest { - address: form.contract_address, - source_code: form.source_code, - contract_name: form.contract_name, - compiler_version: form.compiler_version, - optimization_enabled, - optimization_runs, - constructor_args: if form.constructor_arguments.is_empty() { - None - } else { - Some(form.constructor_arguments) - }, - evm_version: if form.evm_version.is_empty() { - None - } else { - Some(form.evm_version) - }, - license_type, - is_standard_json, - }; - - // Call the internal verification logic - match contracts::verify_contract(axum::extract::State(state), Json(request)).await { - Ok(Json(response)) => { - if response.success { - // Etherscan returns a GUID for async verification, we verify synchronously - // Return success with address as the "GUID" - Ok(Json(serde_json::to_value(EtherscanResponse::ok( - response.address, - ))?)) - } else { - Ok(Json(serde_json::to_value(EtherscanResponse::error( - response - .message - .unwrap_or_else(|| "Verification failed".to_string()), - serde_json::Value::Null, - ))?)) - } - } - Err(e) => Ok(Json(serde_json::to_value(EtherscanResponse::error( - e.to_string(), - serde_json::Value::Null, - ))?)), - } -} - -/// Map Etherscan license type numbers to SPDX identifiers -fn map_license_type(license_num: &str) -> Option { - match license_num { - "1" => Some("Unlicense".to_string()), - "2" => Some("MIT".to_string()), - "3" => Some("GPL-2.0".to_string()), - "4" => Some("GPL-3.0".to_string()), - "5" => Some("LGPL-2.1".to_string()), - "6" => Some("LGPL-3.0".to_string()), - "7" => Some("BSD-2-Clause".to_string()), - "8" => Some("BSD-3-Clause".to_string()), - "9" => Some("MPL-2.0".to_string()), - "10" => Some("OSL-3.0".to_string()), - "11" => Some("Apache-2.0".to_string()), - "12" => Some("AGPL-3.0".to_string()), - "13" => Some("BSL-1.1".to_string()), - "14" => Some("BUSL-1.1".to_string()), - _ => None, - } -} - /// Handle account module requests async fn handle_account_module( state: Arc, diff --git a/backend/crates/atlas-api/src/handlers/logs.rs b/backend/crates/atlas-server/src/api/handlers/logs.rs similarity index 65% rename from backend/crates/atlas-api/src/handlers/logs.rs rename to backend/crates/atlas-server/src/api/handlers/logs.rs index 482d0f4..4005896 100644 --- a/backend/crates/atlas-api/src/handlers/logs.rs +++ b/backend/crates/atlas-server/src/api/handlers/logs.rs @@ -5,9 +5,32 @@ use axum::{ use serde::Deserialize; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; -use atlas_common::{AtlasError, EventLog, PaginatedResponse, Pagination}; +use crate::api::error::ApiResult; +use crate::api::AppState; +use atlas_common::{EventLog, PaginatedResponse, Pagination}; + +/// Pagination for transaction log endpoints. +#[derive(Debug, Deserialize)] +pub struct TransactionLogsQuery { + #[serde(default = "default_page")] + pub page: u32, + #[serde(default = "default_limit")] + pub limit: u32, +} + +impl TransactionLogsQuery { + fn clamped_limit(&self) -> u32 { + self.limit.min(100) + } + + fn offset(&self) -> i64 { + (self.page.saturating_sub(1) as i64) * self.clamped_limit() as i64 + } + + fn limit(&self) -> i64 { + self.clamped_limit() as i64 + } +} /// Query parameters for log filtering #[derive(Debug, Deserialize)] @@ -19,24 +42,53 @@ pub struct LogsQuery { pub pagination: Pagination, } +impl LogsQuery { + fn clamped_limit(&self) -> u32 { + self.pagination.limit.min(100) + } + + fn offset(&self) -> i64 { + (self.pagination.page.saturating_sub(1) as i64) * self.clamped_limit() as i64 + } + + fn limit(&self) -> i64 { + self.clamped_limit() as i64 + } +} + /// GET /api/transactions/:hash/logs - Get all logs for a transaction pub async fn get_transaction_logs( State(state): State>, Path(hash): Path, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult>> { let hash = normalize_hash(&hash); + let total: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE LOWER(tx_hash) = LOWER($1)") + .bind(&hash) + .fetch_one(&state.pool) + .await?; + let logs: Vec = sqlx::query_as( "SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded FROM event_logs WHERE LOWER(tx_hash) = LOWER($1) - ORDER BY log_index ASC", + ORDER BY log_index ASC + LIMIT $2 OFFSET $3", ) .bind(&hash) + .bind(query.limit()) + .bind(query.offset()) .fetch_all(&state.pool) .await?; - Ok(Json(logs)) + Ok(Json(PaginatedResponse::new( + logs, + query.page, + query.clamped_limit(), + total.0, + ))) } /// GET /api/addresses/:address/logs - Get logs emitted by a contract @@ -66,8 +118,8 @@ pub async fn get_address_logs( ) .bind(&address) .bind(&topic0) - .bind(query.pagination.limit()) - .bind(query.pagination.offset()) + .bind(query.limit()) + .bind(query.offset()) .fetch_all(&state.pool) .await?; @@ -86,8 +138,8 @@ pub async fn get_address_logs( LIMIT $2 OFFSET $3", ) .bind(&address) - .bind(query.pagination.limit()) - .bind(query.pagination.offset()) + .bind(query.limit()) + .bind(query.offset()) .fetch_all(&state.pool) .await?; @@ -97,47 +149,11 @@ pub async fn get_address_logs( Ok(Json(PaginatedResponse::new( logs, query.pagination.page, - query.pagination.limit, + query.clamped_limit(), total, ))) } -/// GET /api/logs - Filter logs by topic0 (event signature) -pub async fn get_logs_by_topic( - State(state): State>, - Query(query): Query, -) -> ApiResult>> { - let topic0 = query.topic0.as_ref().ok_or_else(|| { - AtlasError::InvalidInput("topic0 query parameter is required".to_string()) - })?; - let topic0 = normalize_hash(topic0); - - let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE topic0 = $1") - .bind(&topic0) - .fetch_one(&state.pool) - .await?; - - let logs: Vec = sqlx::query_as( - "SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded - FROM event_logs - WHERE topic0 = $1 - ORDER BY block_number DESC, log_index DESC - LIMIT $2 OFFSET $3", - ) - .bind(&topic0) - .bind(query.pagination.limit()) - .bind(query.pagination.offset()) - .fetch_all(&state.pool) - .await?; - - Ok(Json(PaginatedResponse::new( - logs, - query.pagination.page, - query.pagination.limit, - total.0, - ))) -} - /// Enriched log with event name #[derive(Debug, Clone, serde::Serialize)] pub struct EnrichedEventLog { @@ -151,16 +167,26 @@ pub struct EnrichedEventLog { pub async fn get_transaction_logs_decoded( State(state): State>, Path(hash): Path, -) -> ApiResult>> { + Query(query): Query, +) -> ApiResult>> { let hash = normalize_hash(&hash); + let total: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE LOWER(tx_hash) = LOWER($1)") + .bind(&hash) + .fetch_one(&state.pool) + .await?; + let logs: Vec = sqlx::query_as( "SELECT id, tx_hash, log_index, address, topic0, topic1, topic2, topic3, data, block_number, decoded FROM event_logs WHERE LOWER(tx_hash) = LOWER($1) - ORDER BY log_index ASC", + ORDER BY log_index ASC + LIMIT $2 OFFSET $3", ) .bind(&hash) + .bind(query.limit()) + .bind(query.offset()) .fetch_all(&state.pool) .await?; @@ -195,7 +221,20 @@ pub async fn get_transaction_logs_decoded( }) .collect(); - Ok(Json(enriched)) + Ok(Json(PaginatedResponse::new( + enriched, + query.page, + query.clamped_limit(), + total.0, + ))) +} + +fn default_page() -> u32 { + 1 +} + +fn default_limit() -> u32 { + 20 } fn normalize_hash(hash: &str) -> String { @@ -213,3 +252,26 @@ fn normalize_address(address: &str) -> String { format!("0x{}", address.to_lowercase()) } } + +#[cfg(test)] +mod tests { + use super::TransactionLogsQuery; + use atlas_common::PaginatedResponse; + + #[test] + fn transaction_logs_query_clamps_limit_for_offset_and_metadata() { + let query = TransactionLogsQuery { + page: 2, + limit: 1000, + }; + + assert_eq!(query.clamped_limit(), 100); + assert_eq!(query.offset(), 100); + assert_eq!(query.limit(), 100); + + let response = + PaginatedResponse::new(Vec::<()>::new(), query.page, query.clamped_limit(), 250); + assert_eq!(response.limit, 100); + assert_eq!(response.total_pages, 3); + } +} diff --git a/backend/crates/atlas-api/src/handlers/mod.rs b/backend/crates/atlas-server/src/api/handlers/mod.rs similarity index 97% rename from backend/crates/atlas-api/src/handlers/mod.rs rename to backend/crates/atlas-server/src/api/handlers/mod.rs index 769a4e2..97f09d3 100644 --- a/backend/crates/atlas-api/src/handlers/mod.rs +++ b/backend/crates/atlas-server/src/api/handlers/mod.rs @@ -1,9 +1,6 @@ pub mod addresses; -pub mod auth; pub mod blocks; -pub mod contracts; pub mod etherscan; -pub mod labels; pub mod logs; pub mod nfts; pub mod proxy; diff --git a/backend/crates/atlas-api/src/handlers/nfts.rs b/backend/crates/atlas-server/src/api/handlers/nfts.rs similarity index 99% rename from backend/crates/atlas-api/src/handlers/nfts.rs rename to backend/crates/atlas-server/src/api/handlers/nfts.rs index 72cc19d..8f2cb70 100644 --- a/backend/crates/atlas-api/src/handlers/nfts.rs +++ b/backend/crates/atlas-server/src/api/handlers/nfts.rs @@ -6,8 +6,8 @@ use axum::{ use serde::Deserialize; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; +use crate::api::error::ApiResult; +use crate::api::AppState; use atlas_common::{AtlasError, NftContract, NftToken, NftTransfer, PaginatedResponse, Pagination}; /// NFT metadata JSON structure (ERC-721 standard) diff --git a/backend/crates/atlas-api/src/handlers/proxy.rs b/backend/crates/atlas-server/src/api/handlers/proxy.rs similarity index 56% rename from backend/crates/atlas-api/src/handlers/proxy.rs rename to backend/crates/atlas-server/src/api/handlers/proxy.rs index 6b4cfb2..b679c28 100644 --- a/backend/crates/atlas-api/src/handlers/proxy.rs +++ b/backend/crates/atlas-server/src/api/handlers/proxy.rs @@ -2,18 +2,15 @@ //! //! Detects and stores relationships between proxy contracts and their implementations. -use alloy::providers::Provider; use axum::{ extract::{Path, State}, - http::HeaderMap, Json, }; use std::sync::Arc; -use crate::error::ApiResult; -use crate::handlers::auth::require_admin; -use crate::AppState; -use atlas_common::{AtlasError, ContractAbi, ProxyContract}; +use crate::api::error::ApiResult; +use crate::api::AppState; +use atlas_common::{ContractAbi, ProxyContract}; /// GET /api/contracts/:address/proxy - Get proxy information for a contract pub async fn get_proxy_info( @@ -237,186 +234,6 @@ pub async fn list_proxies( ))) } -/// POST /api/contracts/:address/detect-proxy - Trigger proxy detection for a contract -pub async fn detect_proxy( - State(state): State>, - headers: HeaderMap, - Path(address): Path, -) -> ApiResult> { - require_admin(&headers, &state)?; - - let address = normalize_address(&address); - - // Get RPC provider - use alloy::providers::{Provider, ProviderBuilder}; - - let provider = ProviderBuilder::new().on_http( - state - .rpc_url - .parse() - .map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?, - ); - - let addr: alloy::primitives::Address = address - .parse() - .map_err(|_| AtlasError::InvalidInput("Invalid address".to_string()))?; - - // Check known proxy storage slots - let result = detect_proxy_impl(&provider, addr).await?; - - if let Some((impl_addr, proxy_type, admin_addr)) = result { - // Get current block - let current_block = provider - .get_block_number() - .await - .map_err(|e| AtlasError::Rpc(e.to_string()))?; - - // Store in database - sqlx::query( - "INSERT INTO proxy_contracts (proxy_address, implementation_address, proxy_type, admin_address, detected_at_block, last_checked_block, updated_at) - VALUES ($1, $2, $3, $4, $5, $5, NOW()) - ON CONFLICT (proxy_address) DO UPDATE SET - implementation_address = $2, - proxy_type = $3, - admin_address = $4, - last_checked_block = $5, - updated_at = NOW()", - ) - .bind(&address) - .bind(format!("{:?}", impl_addr)) - .bind(proxy_type.as_str()) - .bind(admin_addr.map(|a| format!("{:?}", a))) - .bind(current_block as i64) - .execute(&state.pool) - .await?; - - Ok(Json(ProxyDetectionResult { - is_proxy: true, - proxy_type: Some(proxy_type.to_string()), - implementation_address: Some(format!("{:?}", impl_addr)), - admin_address: admin_addr.map(|a| format!("{:?}", a)), - })) - } else { - Ok(Json(ProxyDetectionResult { - is_proxy: false, - proxy_type: None, - implementation_address: None, - admin_address: None, - })) - } -} - -/// Proxy detection result -#[derive(Debug, serde::Serialize)] -pub struct ProxyDetectionResult { - pub is_proxy: bool, - pub proxy_type: Option, - pub implementation_address: Option, - pub admin_address: Option, -} - -/// Known proxy storage slots -mod slots { - use alloy::primitives::B256; - - /// EIP-1967 Implementation slot - /// keccak256("eip1967.proxy.implementation") - 1 - pub const EIP1967_IMPL: B256 = B256::new([ - 0x36, 0x08, 0x94, 0xa1, 0x3b, 0xa1, 0xa3, 0x21, 0x06, 0x67, 0xc8, 0x28, 0x49, 0x2d, 0xb9, - 0x8d, 0xca, 0x3e, 0x20, 0x76, 0xcc, 0x37, 0x35, 0xa9, 0x20, 0xa3, 0xca, 0x50, 0x5d, 0x38, - 0x2b, 0xbc, - ]); - - /// EIP-1967 Admin slot - /// keccak256("eip1967.proxy.admin") - 1 - pub const EIP1967_ADMIN: B256 = B256::new([ - 0xb5, 0x31, 0x27, 0x68, 0x4a, 0x56, 0x8b, 0x31, 0x73, 0xae, 0x13, 0xb9, 0xf8, 0xa6, 0x01, - 0x6e, 0x24, 0x3e, 0x63, 0xb6, 0xe8, 0xee, 0x11, 0x78, 0xd6, 0xa7, 0x17, 0x85, 0x0b, 0x5d, - 0x61, 0x03, - ]); - - /// EIP-1822 (UUPS) Implementation slot - /// keccak256("PROXIABLE") - pub const EIP1822_IMPL: B256 = B256::new([ - 0xc5, 0xf1, 0x6f, 0x0f, 0xcc, 0x63, 0x9f, 0xa4, 0x8a, 0x69, 0x47, 0x83, 0x6d, 0x98, 0x50, - 0xf5, 0x04, 0x79, 0x85, 0x23, 0xbf, 0x8c, 0x9a, 0x3a, 0x87, 0xd5, 0x87, 0x6c, 0xf6, 0x22, - 0xbc, 0xf7, - ]); -} - -/// Detect proxy implementation from storage slots -async fn detect_proxy_impl( - provider: &alloy::providers::RootProvider< - alloy::transports::http::Http, - alloy::network::Ethereum, - >, - address: alloy::primitives::Address, -) -> Result< - Option<( - alloy::primitives::Address, - atlas_common::ProxyType, - Option, - )>, - AtlasError, -> { - use alloy::primitives::Address; - - // Check EIP-1967 implementation slot - let impl_slot = provider - .get_storage_at(address, slots::EIP1967_IMPL.into()) - .await - .map_err(|e| AtlasError::Rpc(e.to_string()))?; - - if !impl_slot.is_zero() { - // Convert U256 to bytes and extract address from last 20 bytes - let bytes = impl_slot.to_be_bytes::<32>(); - let impl_addr = Address::from_slice(&bytes[12..]); - if !impl_addr.is_zero() { - // Check for admin - let admin_addr = if let Ok(admin_slot) = provider - .get_storage_at(address, slots::EIP1967_ADMIN.into()) - .await - { - if !admin_slot.is_zero() { - let admin_bytes = admin_slot.to_be_bytes::<32>(); - let addr = Address::from_slice(&admin_bytes[12..]); - if !addr.is_zero() { - Some(addr) - } else { - None - } - } else { - None - } - } else { - None - }; - - return Ok(Some(( - impl_addr, - atlas_common::ProxyType::Eip1967, - admin_addr, - ))); - } - } - - // Check EIP-1822 (UUPS) slot - let uups_slot = provider - .get_storage_at(address, slots::EIP1822_IMPL.into()) - .await - .map_err(|e| AtlasError::Rpc(e.to_string()))?; - - if !uups_slot.is_zero() { - let bytes = uups_slot.to_be_bytes::<32>(); - let impl_addr = Address::from_slice(&bytes[12..]); - if !impl_addr.is_zero() { - return Ok(Some((impl_addr, atlas_common::ProxyType::Eip1822, None))); - } - } - - Ok(None) -} - fn normalize_address(address: &str) -> String { if address.starts_with("0x") { address.to_lowercase() diff --git a/backend/crates/atlas-api/src/handlers/search.rs b/backend/crates/atlas-server/src/api/handlers/search.rs similarity index 99% rename from backend/crates/atlas-api/src/handlers/search.rs rename to backend/crates/atlas-server/src/api/handlers/search.rs index 71ead3b..523bc59 100644 --- a/backend/crates/atlas-api/src/handlers/search.rs +++ b/backend/crates/atlas-server/src/api/handlers/search.rs @@ -5,8 +5,8 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; +use crate::api::error::ApiResult; +use crate::api::AppState; use atlas_common::{Address, Block, Erc20Contract, NftContract, Transaction}; #[derive(Deserialize)] diff --git a/backend/crates/atlas-api/src/handlers/sse.rs b/backend/crates/atlas-server/src/api/handlers/sse.rs similarity index 80% rename from backend/crates/atlas-api/src/handlers/sse.rs rename to backend/crates/atlas-server/src/api/handlers/sse.rs index 9e357b4..f1cddad 100644 --- a/backend/crates/atlas-api/src/handlers/sse.rs +++ b/backend/crates/atlas-server/src/api/handlers/sse.rs @@ -8,16 +8,14 @@ use std::convert::Infallible; use std::sync::Arc; use std::time::Duration; use tokio::sync::broadcast; -use tokio::time::sleep; -use crate::AppState; +use crate::api::AppState; use atlas_common::Block; -use sqlx::{postgres::PgListener, PgPool}; +use sqlx::PgPool; use tracing::warn; const BLOCK_COLUMNS: &str = "number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at"; -const BLOCK_EVENT_CHANNEL: &str = "atlas_new_blocks"; const FETCH_BATCH_SIZE: i64 = 256; #[derive(Serialize, Debug)] @@ -89,46 +87,6 @@ pub async fn block_events( ) } -pub async fn run_block_event_fanout( - database_url: String, - _pool: PgPool, - tx: broadcast::Sender<()>, -) { - loop { - let mut listener = match PgListener::connect(&database_url).await { - Ok(listener) => listener, - Err(e) => { - warn!(error = ?e, "sse: failed to connect Postgres listener"); - sleep(Duration::from_secs(1)).await; - continue; - } - }; - - if let Err(e) = listener.listen(BLOCK_EVENT_CHANNEL).await { - warn!(error = ?e, channel = BLOCK_EVENT_CHANNEL, "sse: failed to LISTEN for block notifications"); - sleep(Duration::from_secs(1)).await; - continue; - } - - // Wake all subscribers once after reconnect so they can requery the DB. - let _ = tx.send(()); - - loop { - match listener.recv().await { - Ok(_) => { - let _ = tx.send(()); - } - Err(e) => { - warn!(error = ?e, "sse: Postgres listener disconnected"); - break; - } - } - } - - sleep(Duration::from_secs(1)).await; - } -} - async fn fetch_latest_block(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as(&format!( "SELECT {} FROM blocks ORDER BY number DESC LIMIT 1", diff --git a/backend/crates/atlas-api/src/handlers/status.rs b/backend/crates/atlas-server/src/api/handlers/status.rs similarity index 92% rename from backend/crates/atlas-api/src/handlers/status.rs rename to backend/crates/atlas-server/src/api/handlers/status.rs index 3744536..f7c7d49 100644 --- a/backend/crates/atlas-api/src/handlers/status.rs +++ b/backend/crates/atlas-server/src/api/handlers/status.rs @@ -2,8 +2,8 @@ use axum::{extract::State, Json}; use serde::Serialize; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; +use crate::api::error::ApiResult; +use crate::api::AppState; #[derive(Serialize)] pub struct ChainStatus { diff --git a/backend/crates/atlas-api/src/handlers/tokens.rs b/backend/crates/atlas-server/src/api/handlers/tokens.rs similarity index 99% rename from backend/crates/atlas-api/src/handlers/tokens.rs rename to backend/crates/atlas-server/src/api/handlers/tokens.rs index 885ef63..885037d 100644 --- a/backend/crates/atlas-api/src/handlers/tokens.rs +++ b/backend/crates/atlas-server/src/api/handlers/tokens.rs @@ -4,8 +4,8 @@ use axum::{ }; use std::sync::Arc; -use crate::error::ApiResult; -use crate::AppState; +use crate::api::error::ApiResult; +use crate::api::AppState; use atlas_common::{ AtlasError, Erc20Balance, Erc20Contract, Erc20Holder, Erc20Transfer, PaginatedResponse, Pagination, diff --git a/backend/crates/atlas-api/src/handlers/transactions.rs b/backend/crates/atlas-server/src/api/handlers/transactions.rs similarity index 97% rename from backend/crates/atlas-api/src/handlers/transactions.rs rename to backend/crates/atlas-server/src/api/handlers/transactions.rs index 0bf0825..2145aea 100644 --- a/backend/crates/atlas-api/src/handlers/transactions.rs +++ b/backend/crates/atlas-server/src/api/handlers/transactions.rs @@ -4,9 +4,9 @@ use axum::{ }; use std::sync::Arc; -use crate::error::ApiResult; -use crate::handlers::get_table_count; -use crate::AppState; +use super::get_table_count; +use crate::api::error::ApiResult; +use crate::api::AppState; use atlas_common::{ AtlasError, Erc20Transfer, NftTransfer, PaginatedResponse, Pagination, Transaction, }; diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-server/src/api/mod.rs similarity index 58% rename from backend/crates/atlas-api/src/main.rs rename to backend/crates/atlas-server/src/api/mod.rs index 771ce32..9c0f63b 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-server/src/api/mod.rs @@ -1,8 +1,7 @@ -use anyhow::Result; -use axum::{ - routing::{delete, get, post}, - Router, -}; +pub mod error; +pub mod handlers; + +use axum::{routing::get, Router}; use sqlx::PgPool; use std::sync::Arc; use std::time::Duration; @@ -10,74 +9,20 @@ use tokio::sync::broadcast; use tower_http::cors::{Any, CorsLayer}; use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; - -mod error; -mod handlers; pub struct AppState { pub pool: PgPool, pub block_events_tx: broadcast::Sender<()>, pub rpc_url: String, - pub solc_path: String, - pub admin_api_key: Option, } -#[tokio::main] -async fn main() -> Result<()> { - // Initialize tracing - tracing_subscriber::registry() - .with( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| "atlas_api=info,tower_http=debug,sqlx=warn".into()), - ) - .with(tracing_subscriber::fmt::layer()) - .init(); - - tracing::info!("Starting Atlas API Server"); - - // Load configuration - dotenvy::dotenv().ok(); - let database_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); - 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 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"); - - // Create database pool - let pool = atlas_common::db::create_pool(&database_url, 20).await?; - - // Run migrations - tracing::info!("Running database migrations"); - atlas_common::db::run_migrations(&database_url).await?; - - let (block_events_tx, _) = broadcast::channel(1024); - - let state = Arc::new(AppState { - pool: pool.clone(), - block_events_tx: block_events_tx.clone(), - rpc_url, - solc_path, - admin_api_key, - }); - - tokio::spawn(handlers::sse::run_block_event_fanout( - database_url.clone(), - pool, - block_events_tx, - )); - +pub fn build_router(state: Arc) -> Router { // SSE route — excluded from TimeoutLayer so connections stay alive let sse_routes = Router::new() .route("/api/events", get(handlers::sse::block_events)) .with_state(state.clone()); - // Build router - let app = Router::new() + Router::new() // Blocks .route("/api/blocks", get(handlers::blocks::list_blocks)) .route("/api/blocks/{number}", get(handlers::blocks::get_block)) @@ -136,10 +81,6 @@ async fn main() -> Result<()> { "/api/addresses/{address}/logs", get(handlers::logs::get_address_logs), ) - .route( - "/api/addresses/{address}/label", - get(handlers::labels::get_address_with_label), - ) // NFTs .route( "/api/nfts/collections", @@ -176,21 +117,6 @@ async fn main() -> Result<()> { "/api/tokens/{address}/transfers", get(handlers::tokens::get_token_transfers), ) - // Event Logs - .route("/api/logs", get(handlers::logs::get_logs_by_topic)) - // Address Labels - .route("/api/labels", get(handlers::labels::list_labels)) - .route("/api/labels", post(handlers::labels::upsert_label)) - .route( - "/api/labels/bulk", - post(handlers::labels::bulk_import_labels), - ) - .route("/api/labels/tags", get(handlers::labels::list_tags)) - .route("/api/labels/{address}", get(handlers::labels::get_label)) - .route( - "/api/labels/{address}", - delete(handlers::labels::delete_label), - ) // Proxy Contracts .route("/api/proxies", get(handlers::proxy::list_proxies)) .route( @@ -201,26 +127,8 @@ async fn main() -> Result<()> { "/api/contracts/{address}/combined-abi", get(handlers::proxy::get_combined_abi), ) - .route( - "/api/contracts/{address}/detect-proxy", - post(handlers::proxy::detect_proxy), - ) - // Contract Verification - .route( - "/api/contracts/verify", - post(handlers::contracts::verify_contract), - ) - .route( - "/api/contracts/{address}/abi", - get(handlers::contracts::get_contract_abi), - ) - .route( - "/api/contracts/{address}/source", - get(handlers::contracts::get_contract_source), - ) // Etherscan-compatible API .route("/api", get(handlers::etherscan::etherscan_api)) - .route("/api", post(handlers::etherscan::etherscan_api_post)) // Search .route("/api/search", get(handlers::search::search)) // Status @@ -241,13 +149,5 @@ async fn main() -> Result<()> { .allow_methods(Any) .allow_headers(Any), ) - .layer(TraceLayer::new_for_http()); - - let addr = format!("{}:{}", host, port); - tracing::info!("Listening on {}", addr); - - let listener = tokio::net::TcpListener::bind(&addr).await?; - axum::serve(listener, app).await?; - - Ok(()) + .layer(TraceLayer::new_for_http()) } diff --git a/backend/crates/atlas-indexer/src/config.rs b/backend/crates/atlas-server/src/config.rs similarity index 76% rename from backend/crates/atlas-indexer/src/config.rs rename to backend/crates/atlas-server/src/config.rs index 806edd4..9be99d1 100644 --- a/backend/crates/atlas-indexer/src/config.rs +++ b/backend/crates/atlas-server/src/config.rs @@ -3,9 +3,17 @@ use std::env; #[derive(Debug, Clone)] pub struct Config { + // Shared pub database_url: String, - pub db_max_connections: u32, pub rpc_url: String, + + // Indexer pool + pub indexer_db_max_connections: u32, + + // API pool + pub api_db_max_connections: u32, + + // Indexer-specific pub rpc_requests_per_second: u32, pub start_block: u64, pub batch_size: u64, @@ -15,17 +23,27 @@ pub struct Config { pub metadata_retry_attempts: u32, pub fetch_workers: u32, pub rpc_batch_size: u32, + + // API-specific + pub api_host: String, + pub api_port: u16, } impl Config { pub fn from_env() -> Result { Ok(Self { database_url: env::var("DATABASE_URL").context("DATABASE_URL must be set")?, - db_max_connections: env::var("DB_MAX_CONNECTIONS") + rpc_url: env::var("RPC_URL").context("RPC_URL must be set")?, + + indexer_db_max_connections: env::var("DB_MAX_CONNECTIONS") .unwrap_or_else(|_| "20".to_string()) .parse() .context("Invalid DB_MAX_CONNECTIONS")?, - rpc_url: env::var("RPC_URL").context("RPC_URL must be set")?, + api_db_max_connections: env::var("API_DB_MAX_CONNECTIONS") + .unwrap_or_else(|_| "20".to_string()) + .parse() + .context("Invalid API_DB_MAX_CONNECTIONS")?, + rpc_requests_per_second: env::var("RPC_REQUESTS_PER_SECOND") .unwrap_or_else(|_| "100".to_string()) .parse() @@ -60,6 +78,12 @@ impl Config { .unwrap_or_else(|_| "20".to_string()) .parse() .context("Invalid RPC_BATCH_SIZE")?, + + api_host: env::var("API_HOST").unwrap_or_else(|_| "127.0.0.1".to_string()), + api_port: env::var("API_PORT") + .unwrap_or_else(|_| "3000".to_string()) + .parse() + .context("Invalid API_PORT")?, }) } } diff --git a/backend/crates/atlas-indexer/src/batch.rs b/backend/crates/atlas-server/src/indexer/batch.rs similarity index 100% rename from backend/crates/atlas-indexer/src/batch.rs rename to backend/crates/atlas-server/src/indexer/batch.rs diff --git a/backend/crates/atlas-indexer/src/copy.rs b/backend/crates/atlas-server/src/indexer/copy.rs similarity index 99% rename from backend/crates/atlas-indexer/src/copy.rs rename to backend/crates/atlas-server/src/indexer/copy.rs index eecec8c..0dfa7ed 100644 --- a/backend/crates/atlas-indexer/src/copy.rs +++ b/backend/crates/atlas-server/src/indexer/copy.rs @@ -6,7 +6,7 @@ use tokio_postgres::{ Transaction, }; -use crate::batch::BlockBatch; +use super::batch::BlockBatch; pub async fn copy_blocks(tx: &mut Transaction<'_>, batch: &BlockBatch) -> Result<()> { if batch.b_numbers.is_empty() { diff --git a/backend/crates/atlas-indexer/src/fetcher.rs b/backend/crates/atlas-server/src/indexer/fetcher.rs similarity index 100% rename from backend/crates/atlas-indexer/src/fetcher.rs rename to backend/crates/atlas-server/src/indexer/fetcher.rs diff --git a/backend/crates/atlas-indexer/src/indexer.rs b/backend/crates/atlas-server/src/indexer/indexer.rs similarity index 99% rename from backend/crates/atlas-indexer/src/indexer.rs rename to backend/crates/atlas-server/src/indexer/indexer.rs index 592db57..dcdb79d 100644 --- a/backend/crates/atlas-indexer/src/indexer.rs +++ b/backend/crates/atlas-server/src/indexer/indexer.rs @@ -10,21 +10,19 @@ use std::num::NonZeroU32; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; -use tokio::sync::mpsc; +use tokio::sync::{broadcast, mpsc}; use tokio_postgres::{types::ToSql, Client, NoTls}; use tokio_postgres_rustls::MakeRustlsConnect; -use crate::batch::{BlockBatch, NftTokenState}; -use crate::config::Config; -use crate::copy::{ +use super::batch::{BlockBatch, NftTokenState}; +use super::copy::{ copy_blocks, copy_erc20_transfers, copy_event_logs, copy_nft_transfers, copy_transactions, }; -use crate::fetcher::{ +use super::fetcher::{ fetch_blocks_batch, get_block_number_with_retry, FetchResult, FetchedBlock, SharedRateLimiter, WorkItem, }; - -const BLOCK_EVENT_CHANNEL: &str = "atlas_new_blocks"; +use crate::config::Config; /// Partition size: 10 million blocks per partition const PARTITION_SIZE: u64 = 10_000_000; @@ -40,15 +38,18 @@ pub struct Indexer { /// Tracks the maximum partition number that has been created /// Used to avoid checking pg_class on every batch current_max_partition: std::sync::atomic::AtomicU64, + /// Broadcast channel to notify SSE subscribers of new blocks + block_events_tx: broadcast::Sender<()>, } impl Indexer { - pub fn new(pool: PgPool, config: Config) -> Self { + pub fn new(pool: PgPool, config: Config, block_events_tx: broadcast::Sender<()>) -> Self { Self { pool, config, // Will be initialized on first run based on start block current_max_partition: std::sync::atomic::AtomicU64::new(0), + block_events_tx, } } @@ -785,15 +786,15 @@ impl Indexer { &[&last_value], ) .await?; - pg_tx - .execute( - "SELECT pg_notify($1, $2)", - &[&BLOCK_EVENT_CHANNEL, &last_value], - ) - .await?; } pg_tx.commit().await?; + + if update_watermark { + // Notify SSE subscribers only after the batch commit is visible. + let _ = self.block_events_tx.send(()); + } + Ok(()) } diff --git a/backend/crates/atlas-indexer/src/metadata.rs b/backend/crates/atlas-server/src/indexer/metadata.rs similarity index 100% rename from backend/crates/atlas-indexer/src/metadata.rs rename to backend/crates/atlas-server/src/indexer/metadata.rs diff --git a/backend/crates/atlas-server/src/indexer/mod.rs b/backend/crates/atlas-server/src/indexer/mod.rs new file mode 100644 index 0000000..c46af3c --- /dev/null +++ b/backend/crates/atlas-server/src/indexer/mod.rs @@ -0,0 +1,9 @@ +pub(crate) mod batch; +pub(crate) mod copy; +pub(crate) mod fetcher; +#[allow(clippy::module_inception)] +pub mod indexer; +pub mod metadata; + +pub use indexer::Indexer; +pub use metadata::MetadataFetcher; diff --git a/backend/crates/atlas-server/src/main.rs b/backend/crates/atlas-server/src/main.rs new file mode 100644 index 0000000..a80caed --- /dev/null +++ b/backend/crates/atlas-server/src/main.rs @@ -0,0 +1,209 @@ +use anyhow::Result; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::broadcast; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +mod api; +mod config; +mod indexer; + +/// Retry delays for exponential backoff (in seconds) +const RETRY_DELAYS: &[u64] = &[5, 10, 20, 30, 60]; +const MAX_RETRY_DELAY: u64 = 60; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "atlas_server=info,tower_http=debug,sqlx=warn".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + tracing::info!("Starting Atlas Server"); + + // Load configuration + dotenvy::dotenv().ok(); + let config = config::Config::from_env()?; + + // Run migrations once (dedicated pool, no statement_timeout) + tracing::info!("Running database migrations"); + atlas_common::db::run_migrations(&config.database_url).await?; + + // Create separate DB pools for indexer and API + let indexer_pool = + atlas_common::db::create_pool(&config.database_url, config.indexer_db_max_connections) + .await?; + let api_pool = + atlas_common::db::create_pool(&config.database_url, config.api_db_max_connections).await?; + + // Shared broadcast channel for SSE notifications + let (block_events_tx, _) = broadcast::channel(1024); + + // Build AppState for API + let state = Arc::new(api::AppState { + pool: api_pool, + block_events_tx: block_events_tx.clone(), + rpc_url: config.rpc_url.clone(), + }); + + // Spawn indexer task with retry logic + let indexer = indexer::Indexer::new(indexer_pool.clone(), config.clone(), block_events_tx); + tokio::spawn(async move { + if let Err(e) = run_with_retry(|| indexer.run()).await { + tracing::error!("Indexer terminated with error: {}", e); + } + }); + + // Spawn metadata fetcher in background + let metadata_pool = indexer_pool; + let metadata_config = config.clone(); + tokio::spawn(async move { + if let Err(e) = run_with_retry(|| async { + let fetcher = + indexer::MetadataFetcher::new(metadata_pool.clone(), metadata_config.clone())?; + fetcher.run().await + }) + .await + { + tracing::error!("Metadata fetcher terminated with error: {}", e); + } + }); + + // Build and serve API + let app = api::build_router(state); + let addr = format!("{}:{}", config.api_host, config.api_port); + tracing::info!("API listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(&addr).await?; + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to listen for SIGTERM"); + + wait_for_shutdown_signal( + async { + tokio::signal::ctrl_c() + .await + .expect("failed to listen for ctrl-c"); + }, + async move { + terminate.recv().await; + }, + ) + .await; + } + + #[cfg(not(unix))] + { + wait_for_shutdown_signal( + async { + tokio::signal::ctrl_c() + .await + .expect("failed to listen for ctrl-c"); + }, + std::future::pending::<()>(), + ) + .await; + } + + tracing::info!("Shutdown signal received, stopping..."); +} + +async fn wait_for_shutdown_signal(ctrl_c: CtrlC, terminate: Term) +where + CtrlC: std::future::Future, + Term: std::future::Future, +{ + tokio::select! { + _ = ctrl_c => {} + _ = terminate => {} + } +} + +/// Run an async function with exponential backoff retry +async fn run_with_retry(f: F) -> Result<()> +where + F: Fn() -> Fut, + Fut: std::future::Future>, +{ + let mut retry_count = 0; + + loop { + match f().await { + Ok(()) => { + retry_count = 0; + } + Err(e) => { + let delay = RETRY_DELAYS + .get(retry_count) + .copied() + .unwrap_or(MAX_RETRY_DELAY); + + tracing::error!( + "Fatal error (internal retries exhausted): {}. Restarting in {}s (attempt {})...", + e, + delay, + retry_count + 1 + ); + + tokio::time::sleep(Duration::from_secs(delay)).await; + retry_count += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::wait_for_shutdown_signal; + use tokio::sync::oneshot; + + #[tokio::test] + async fn wait_for_shutdown_signal_returns_on_ctrl_c_future() { + let (ctrl_tx, ctrl_rx) = oneshot::channel::<()>(); + let (_term_tx, term_rx) = oneshot::channel::<()>(); + + let shutdown = tokio::spawn(wait_for_shutdown_signal( + async move { + let _ = ctrl_rx.await; + }, + async move { + let _ = term_rx.await; + }, + )); + + ctrl_tx.send(()).unwrap(); + shutdown.await.unwrap(); + } + + #[tokio::test] + async fn wait_for_shutdown_signal_returns_on_terminate_future() { + let (_ctrl_tx, ctrl_rx) = oneshot::channel::<()>(); + let (term_tx, term_rx) = oneshot::channel::<()>(); + + let shutdown = tokio::spawn(wait_for_shutdown_signal( + async move { + let _ = ctrl_rx.await; + }, + async move { + let _ = term_rx.await; + }, + )); + + term_tx.send(()).unwrap(); + shutdown.await.unwrap(); + } +} diff --git a/backend/migrations/20240108000001_block_da_status.sql b/backend/migrations/20240108000001_block_da_status.sql new file mode 100644 index 0000000..df6bd3f --- /dev/null +++ b/backend/migrations/20240108000001_block_da_status.sql @@ -0,0 +1,21 @@ +-- Block DA (Data Availability) status for L2 chains using Celestia. +-- Only populated when EVNODE_URL is configured and the DA worker is running. +-- +-- The DA worker has two phases: +-- 1. Backfill: discovers blocks missing from this table, queries ev-node, and INSERTs. +-- Always inserts a row even when DA heights are 0 (not yet included on Celestia). +-- This marks the block as "checked" so backfill won't re-query it. +-- 2. Update pending: retries rows where header_da_height = 0 OR data_da_height = 0 +-- until real DA heights are returned by ev-node. + +CREATE TABLE IF NOT EXISTS block_da_status ( + block_number BIGINT PRIMARY KEY, + header_da_height BIGINT NOT NULL DEFAULT 0, + data_da_height BIGINT NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Partial index for the DA worker to efficiently find blocks still pending DA inclusion. +CREATE INDEX IF NOT EXISTS idx_block_da_status_pending + ON block_da_status (block_number) + WHERE header_da_height = 0 OR data_da_height = 0; diff --git a/docker-compose.yml b/docker-compose.yml index 0e206aa..bd600af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,11 +15,11 @@ services: timeout: 5s retries: 5 - atlas-indexer: + atlas-server: build: context: ./backend dockerfile: Dockerfile - target: indexer + target: server environment: DATABASE_URL: postgres://atlas:atlas@postgres/atlas RPC_URL: ${RPC_URL} @@ -30,23 +30,9 @@ services: FETCH_WORKERS: ${FETCH_WORKERS:-10} RPC_REQUESTS_PER_SECOND: ${RPC_REQUESTS_PER_SECOND:-100} RPC_BATCH_SIZE: ${RPC_BATCH_SIZE:-20} - RUST_LOG: atlas_indexer=info - depends_on: - postgres: - condition: service_healthy - restart: unless-stopped - - atlas-api: - build: - context: ./backend - dockerfile: Dockerfile - target: api - environment: - DATABASE_URL: postgres://atlas:atlas@postgres/atlas - RPC_URL: ${RPC_URL} API_HOST: 0.0.0.0 API_PORT: 3000 - RUST_LOG: atlas_api=info,tower_http=info + RUST_LOG: atlas_server=info,tower_http=info ports: - "3000:3000" depends_on: @@ -61,7 +47,7 @@ services: ports: - "80:8080" depends_on: - - atlas-api + - atlas-server restart: unless-stopped volumes: diff --git a/frontend/nginx.conf b/frontend/nginx.conf index d753b3b..c599131 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -11,7 +11,7 @@ server { # SSE endpoint — disable buffering so events stream through immediately location /api/events { - proxy_pass http://atlas-api:3000/api/events; + proxy_pass http://atlas-server:3000/api/events; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -24,7 +24,7 @@ server { # Proxy API requests to atlas-api service location /api/ { - proxy_pass http://atlas-api:3000/api/; + proxy_pass http://atlas-server:3000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;