-
Notifications
You must be signed in to change notification settings - Fork 0
chore: audit changes #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d5571c4
08d3c83
bc36a91
4e090dd
25074e5
6a207d5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| ``` | ||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Update API behavior docs to match current backend conventions. Two statements are currently out of sync with the project conventions:
📝 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 Also applies to: 82-84 🤖 Prompt for AI Agents |
||
| ### 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 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"] } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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/"
fiRepository: 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/nullRepository: evstack/atlas Length of output: 3134 Consider pinning Line 29 uses unconstrained serde 🤖 Prompt for AI Agents |
||
| 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" | ||
|
|
@@ -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" } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,6 +14,9 @@ pub enum AtlasError { | |
| #[error("Invalid input: {0}")] | ||
| InvalidInput(String), | ||
|
|
||
| #[error("Validation error: {0}")] | ||
| Validation(String), | ||
|
|
||
| #[error("Unauthorized: {0}")] | ||
| Unauthorized(String), | ||
|
|
||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Add a colocated unit test for the new Line 46 adds new status-code logic, but this file still lacks a ✅ 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: 🤖 Prompt for AI Agents |
||
| AtlasError::Database(_) | AtlasError::Internal(_) => 500, | ||
| AtlasError::Rpc(_) | AtlasError::MetadataFetch(_) => 502, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replace
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, " 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| pub fn limit(&self) -> i64 { | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language to the fenced code block.
Line 17 uses an unlabeled fenced block, which triggers markdown linting (
MD040).🧹 Suggested patch
🧰 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