Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Atlas — Codex Context

Atlas is an EVM blockchain explorer (indexer + API + frontend) for ev-node based chains.

## Tech Stack

| Layer | Tech |
|---|---|
| Indexer | Rust, tokio, sqlx, alloy, tokio-postgres (binary COPY) |
| API | Rust, Axum, sqlx, tower-http |
| Database | PostgreSQL (partitioned tables) |
| Frontend | React, TypeScript, Vite, Tailwind CSS, Bun |
| Deployment | Docker Compose, nginx (unprivileged, port 8080→80) |

## Repository Layout

```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language to the fenced code block.

Line 17 uses an unlabeled fenced block, which triggers markdown linting (MD040).

🧹 Suggested patch
-```
+```text
 atlas/
 ├── backend/
 ...
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 17-17: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` at line 17, Update the unlabeled fenced code block in AGENTS.md
(the block starting at the example directory tree) to include a language
identifier (e.g., add "text" after the opening triple backticks) so the block is
labeled and satisfies markdown lint rule MD040; locate the fenced block in
AGENTS.md and change the opening fence from ``` to ```text.

atlas/
├── backend/
│ ├── 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)
├── frontend/
│ ├── src/
│ │ ├── api/ # Typed API clients (axios)
│ │ ├── components/ # Shared UI components
│ │ ├── hooks/ # React hooks (useBlocks, useLatestBlockHeight, …)
│ │ ├── 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
├── docker-compose.yml
└── .env.example
```

## Key Architectural Decisions

### 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
- **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)

### Pagination — blocks table
The blocks table can have 80M+ rows. `OFFSET` on large pages causes 30s+ full index scans. Instead:
```rust
// cursor = max_block - (page - 1) * limit — uses clamped limit(), not raw offset()
let limit = pagination.limit(); // clamped to 100
let cursor = (total_count - 1) - (pagination.page.saturating_sub(1) as i64) * limit;
// Query: WHERE number <= $cursor ORDER BY number DESC LIMIT $1
```
`total_count` comes from `MAX(number) + 1` (O(1), not COUNT(*)).

### 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")
// 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.

Comment on lines +66 to +67
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Update API behavior docs to match current backend conventions.

Two statements are currently out of sync with the project conventions:

  • Line 66 should call out that SSE routes are excluded from the 10s timeout wrapper.
  • Line 82-83 should document /api/status as the polling fallback payload { block_height, indexed_at }, with /api/events as live updates.
📝 Suggested doc patch
-`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 non-SSE routes — returns 408 if any handler exceeds 10s.

 ### Frontend API client
 - Base URL: `/api` (proxied by nginx to `atlas-api:3000`)
-- Fast polling endpoint: `GET /api/height` → `{ block_height, indexed_at }` — used by navbar every 2s
-- Chain status: `GET /api/status` → full chain info, fetched once on page load
+- Live updates: `GET /api/events` (SSE)
+- Polling fallback: `GET /api/status` → `{ block_height, indexed_at }`

Based on learnings: "Use GET /api/status endpoint for navbar polling fallback …" and "Use GET /api/events endpoint for SSE live updates …", plus "Wrap all HTTP routes except SSE with TimeoutLayer...".

Also applies to: 82-84

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@AGENTS.md` around lines 66 - 67, Update the API docs to reflect current
backend behavior: clarify that
TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT,
Duration::from_secs(10)) wraps all HTTP routes except SSE streams (exclude SSE
routes from the 10s timeout), and change the description for the polling vs live
endpoints to state that GET /api/status is the polling fallback returning the
payload { block_height, indexed_at } while GET /api/events is the SSE endpoint
for live updates; update the two doc locations that mention the timeout and the
endpoints accordingly.

### AppState (API)
```rust
pub struct AppState {
pub pool: PgPool,
pub rpc_url: String,
pub solc_path: String,
pub admin_api_key: Option<String>,
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`)
- Fast polling endpoint: `GET /api/height` → `{ block_height, indexed_at }` — used by navbar every 2s
- Chain status: `GET /api/status` → full chain info, fetched once on page load

## Important Conventions

- **Rust**: idiomatic — use `.min()`, `.max()`, `|=`, `+=` over manual if/assign
- **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`.
- **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 Codex co-author lines.

## Environment Variables

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` |
| `BATCH_SIZE` | indexer | `100` |
| `FETCH_WORKERS` | indexer | `10` |
| `ADMIN_API_KEY` | api | none |

## Running Locally

```bash
# 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

# Backend only (no Docker)
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
89 changes: 75 additions & 14 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,92 @@ set shell := ["bash", "-cu"]
default:
@just --list

# Frontend
frontend-install:
cd frontend && bun install --frozen-lockfile
# Run all quality checks (format, lint, typecheck, test, build)
[group('quality')]
quality: check test build

frontend-dev:
cd frontend && bun run dev
# Fast static checks only (no compilation, no tests)
[group('quality')]
check: fmt lint

frontend-lint:
cd frontend && bun run lint
# All tests
[group('quality')]
test: backend-test

frontend-build:
cd frontend && bun run build
# All builds (proves compilation + artifacts)
[group('quality')]
build: backend-build frontend-build

# Full CI pipeline
[group('quality')]
ci: quality
Comment thread
coderabbitai[bot] marked this conversation as resolved.

# Backend
backend-fmt:
# Check Rust formatting
[group('backend')]
fmt:
cd backend && cargo fmt --all --check

# Fix Rust formatting in-place
[group('backend')]
fmt-fix:
cd backend && cargo fmt --all

# Run clippy
[group('backend')]
lint: backend-clippy

[group('backend')]
backend-clippy:
cd backend && cargo clippy --workspace --all-targets -- -D warnings

[group('backend')]
backend-test:
cd backend && cargo test --workspace --all-targets

backend-server:
[group('backend')]
backend-build:
cd backend && cargo build --workspace

[group('backend')]
backend-run:
cd backend && cargo run --bin atlas-server

# Combined checks
ci: backend-fmt backend-clippy backend-test frontend-install frontend-lint frontend-build
[group('frontend')]
frontend-install:
cd frontend && bun install --frozen-lockfile

[group('frontend')]
frontend-dev:
cd frontend && bun run dev

[group('frontend')]
frontend-lint: frontend-install
cd frontend && bun run lint

[group('frontend')]
frontend-typecheck: frontend-install
cd frontend && bunx tsc -b --noEmit

[group('frontend')]
frontend-build: frontend-install
cd frontend && bun run build

[group('docker')]
docker-up:
docker compose up -d

[group('docker')]
docker-build:
docker compose build

[group('docker')]
docker-down:
docker compose down

[group('docker')]
docker-logs service="atlas-server":
docker compose logs -f {{service}}

[group('docker')]
docker-rebuild service="atlas-server":
docker compose build {{service}} && docker compose up -d {{service}}
9 changes: 3 additions & 6 deletions backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ tower-http = { version = "0.6", features = ["cors", "trace", "timeout"] }
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "json", "bigdecimal", "chrono"] }

# Ethereum
alloy = { version = "0.9", features = ["full", "provider-http", "rpc-types-eth"] }
alloy = { version = "1", features = ["full", "provider-http", "rpc-types-eth"] }

# Serialization
serde = { version = ">=1.0.171, <1.0.220", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether rust-version is declared in workspace/package manifests
rg -n '^\s*rust-version\s*=' --glob '**/Cargo.toml'

# Confirm serde constraint in this workspace manifest
rg -n '^\s*serde\s*=' backend/Cargo.toml

# Inspect currently locked serde version (if lockfile exists)
if [ -f backend/Cargo.lock ]; then
  rg -n -C2 '^name = "serde"$|^version = "' backend/Cargo.lock
elif [ -f Cargo.lock ]; then
  rg -n -C2 '^name = "serde"$|^version = "' Cargo.lock
else
  echo "No Cargo.lock found at repo root or backend/"
fi

Repository: evstack/atlas

Length of output: 39


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# List repository structure to understand layout
echo "=== Repository root files ==="
ls -la | head -20

# Check if backend directory exists
if [ -d "backend" ]; then
  echo -e "\n=== Backend directory contents ==="
  ls -la backend/ | head -20
fi

# Try to find all Cargo.toml files
echo -e "\n=== Cargo.toml files ==="
find . -maxdepth 3 -name "Cargo.toml" -type f 2>/dev/null | head -20

# If backend/Cargo.toml exists, inspect it
if [ -f "backend/Cargo.toml" ]; then
  echo -e "\n=== backend/Cargo.toml content ==="
  cat -n backend/Cargo.toml | head -50
fi

# Check for Cargo.lock files
echo -e "\n=== Cargo.lock files ==="
find . -maxdepth 2 -name "Cargo.lock" -type f 2>/dev/null

Repository: evstack/atlas

Length of output: 3134


Consider pinning rust-version or tightening serde constraints for MSRV stability.

Line 29 uses unconstrained serde 1.0 without a declared workspace rust-version. Patch updates within the 1.0.x range could introduce higher MSRV requirements. Add rust-version to [workspace.package] or narrow serde to a specific patch version.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/Cargo.toml` at line 29, The Cargo.toml entry for serde currently uses
a floating "1.0" which can bump MSRV requirements; either declare a workspace
rust-version in [workspace.package] (e.g., add rust-version = "1.xx") or tighten
the serde dependency to a fixed patch (e.g., serde = "1.0.Y") to guarantee MSRV
stability—update the manifest accordingly and ensure the serde line and/or
[workspace.package] rust-version are changed so CI and downstream consumers have
a stable minimum Rust version.

serde_json = "1.0"

# HTTP client
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
reqwest = { version = "0.13", features = ["json", "rustls"], default-features = false }

# Error handling
thiserror = "2.0"
Expand All @@ -53,8 +53,5 @@ bigdecimal = { version = "0.4", features = ["serde"] }
hex = "0.4"
chrono = { version = "0.4", features = ["serde"] }

# Pin home to avoid Rust 1.88 requirement
home = "=0.5.9"

# Internal crates
atlas-common = { path = "crates/atlas-common" }
3 changes: 3 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ RUN apk add --no-cache ca-certificates

COPY --from=builder /app/target/release/atlas-server /usr/local/bin/

RUN addgroup -S atlas && adduser -S atlas -G atlas
USER atlas

EXPOSE 3000
CMD ["atlas-server"]

Expand Down
5 changes: 4 additions & 1 deletion backend/crates/atlas-common/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ pub enum AtlasError {
#[error("Invalid input: {0}")]
InvalidInput(String),

#[error("Validation error: {0}")]
Validation(String),

#[error("Unauthorized: {0}")]
Unauthorized(String),

Expand All @@ -40,7 +43,7 @@ impl AtlasError {
pub fn status_code(&self) -> u16 {
match self {
AtlasError::NotFound(_) => 404,
AtlasError::InvalidInput(_) => 400,
AtlasError::InvalidInput(_) | AtlasError::Validation(_) => 400,
AtlasError::Unauthorized(_) => 401,
Comment on lines +46 to 47
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Add a colocated unit test for the new Validation mapping.

Line 46 adds new status-code logic, but this file still lacks a #[cfg(test)] assertion for AtlasError::Validation(_) -> 400.

✅ Suggested test addition
 impl AtlasError {
     pub fn status_code(&self) -> u16 {
         match self {
             AtlasError::NotFound(_) => 404,
             AtlasError::InvalidInput(_) | AtlasError::Validation(_) => 400,
@@
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use super::AtlasError;
+
+    #[test]
+    fn validation_maps_to_400() {
+        let err = AtlasError::Validation("bad field".to_string());
+        assert_eq!(err.status_code(), 400);
+    }
+}

As per coding guidelines: backend/**/*.rs: "Add unit tests for new logic in a #[cfg(test)] mod tests block in the same file, and run with cargo test --workspace."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/crates/atlas-common/src/error.rs` around lines 46 - 47, Add a
colocated unit test in a #[cfg(test)] mod tests block that asserts
AtlasError::Validation(...) maps to HTTP 400; specifically, construct an
AtlasError::Validation variant and call the status-code mapping (the
function/method containing the match with AtlasError::InvalidInput/Validation ->
400) and assert it equals 400. Place this test in the same file alongside other
tests and run with cargo test --workspace.

AtlasError::Database(_) | AtlasError::Internal(_) => 500,
AtlasError::Rpc(_) | AtlasError::MetadataFetch(_) => 502,
Expand Down
2 changes: 1 addition & 1 deletion backend/crates/atlas-common/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,7 @@ fn default_limit() -> u32 {

impl Pagination {
pub fn offset(&self) -> i64 {
((self.page.saturating_sub(1)) * self.limit) as i64
(self.page.saturating_sub(1) as i64) * self.limit()
}
Comment on lines 250 to 252
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Replace offset() with cursor/keyset pagination helper.

Pagination::offset() still encodes offset-based pagination (Line 247), which violates repo pagination rules for large tables and promotes slow scan patterns in downstream queries. Please switch this shared API to cursor/keyset semantics.

Suggested refactor direction
 impl Pagination {
-    pub fn offset(&self) -> i64 {
-        (self.page.saturating_sub(1) as i64) * self.limit()
-    }
+    /// Cursor for descending keyset pagination.
+    /// Query shape at call sites:
+    /// `WHERE number <= $cursor ORDER BY number DESC LIMIT $1`
+    pub fn cursor(&self, total_count: i64) -> i64 {
+        total_count
+            .saturating_sub(1)
+            .saturating_sub((self.page.saturating_sub(1) as i64) * self.limit())
+    }
 
     pub fn limit(&self) -> i64 {
         self.limit.min(100) as i64
     }
 }

As per coding guidelines, "{backend/migrations/*.sql,backend/**/*.rs}: Never use OFFSET for pagination on large tables — use keyset/cursor pagination instead".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn offset(&self) -> i64 {
((self.page.saturating_sub(1)) * self.limit) as i64
(self.page.saturating_sub(1) as i64) * self.limit()
}
/// Cursor for descending keyset pagination.
/// Query shape at call sites:
/// `WHERE number <= $cursor ORDER BY number DESC LIMIT $1`
pub fn cursor(&self, total_count: i64) -> i64 {
total_count
.saturating_sub(1)
.saturating_sub((self.page.saturating_sub(1) as i64) * self.limit())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/crates/atlas-common/src/types.rs` around lines 246 - 248,
Pagination::offset currently exposes offset-based pagination; replace it with a
keyset/cursor helper. Remove or deprecate the offset(&self) method on the
Pagination type and add a cursor helper (e.g., Pagination::cursor_after or
Pagination::keyset) that returns an opaque cursor (Option<String> or
Option<CursorStruct>) encoding the last-seen key(s) needed for keyset queries
(for example primary key and tie-breaker like created_at), and implement a
complementary helper (e.g., Pagination::apply_to_query or
Pagination::bind_keyset) to convert that cursor into query parameters for WHERE
... > (key) ORDER BY key LIMIT. Update any call sites that used offset() to
consume the new cursor API. Ensure the cursor is stable/opaque (base64 or
similar) and include reference to Pagination::offset, Pagination::cursor_after
(or chosen names), and Pagination::apply_to_query when making these
replacements.


pub fn limit(&self) -> i64 {
Expand Down
42 changes: 39 additions & 3 deletions backend/crates/atlas-server/src/api/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,47 @@ impl Deref for ApiError {

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
use atlas_common::AtlasError;

let status =
StatusCode::from_u16(self.0.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = Json(json!({
"error": self.0.to_string()
}));

// Determine the client-facing message based on error type.
// Internal details are logged server-side to avoid leaking stack traces or
// database internals to callers.
let client_message = match &self.0 {
// Safe to surface: meaningful to the caller
AtlasError::NotFound(msg) => msg.clone(),
AtlasError::InvalidInput(msg) => msg.clone(),
AtlasError::Validation(msg) => msg.clone(),
AtlasError::Unauthorized(msg) => msg.clone(),
AtlasError::Verification(msg) => msg.clone(),
AtlasError::BytecodeMismatch(msg) => msg.clone(),
AtlasError::Compilation(msg) => msg.clone(),
// Opaque: log full detail, return generic message
AtlasError::Database(inner) => {
tracing::error!(error = %inner, "Database error");
"Internal server error".to_string()
}
AtlasError::Internal(inner) => {
tracing::error!(error = %inner, "Internal error");
"Internal server error".to_string()
}
AtlasError::Config(inner) => {
tracing::error!(error = %inner, "Configuration error");
"Internal server error".to_string()
}
AtlasError::Rpc(inner) => {
tracing::error!(error = %inner, "RPC error");
"Service unavailable".to_string()
}
AtlasError::MetadataFetch(inner) => {
tracing::error!(error = %inner, "Metadata fetch error");
"Service unavailable".to_string()
}
};

let body = Json(json!({ "error": client_message }));
(status, body).into_response()
}
}
Expand Down
Loading
Loading