Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: CI

on:
pull_request:
push:
branches:
- main

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
backend:
name: Backend (Rust)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy

- name: Cache Cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: backend

- name: Format
run: cargo fmt --all --check

- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings

- name: Test
run: cargo test --workspace --all-targets

frontend:
name: Frontend (Bun)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install
run: bun install --frozen-lockfile

- name: Lint
run: bun run lint

- name: Build
run: bun run build
36 changes: 36 additions & 0 deletions Justfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
set shell := ["bash", "-cu"]

default:
@just --list

# Frontend
frontend-install:
cd frontend && bun install --frozen-lockfile

frontend-dev:
cd frontend && bun run dev

frontend-lint:
cd frontend && bun run lint

frontend-build:
cd frontend && bun run build

# Backend
backend-fmt:
cd backend && cargo fmt --all --check

backend-clippy:
cd backend && cargo clippy --workspace --all-targets -- -D warnings

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

# Combined checks
ci: backend-fmt backend-clippy backend-test frontend-install frontend-lint frontend-build
46 changes: 27 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,59 @@ A lightweight Ethereum L2 blockchain explorer.

### Prerequisites

- `just` 1.0+
- Docker and Docker Compose
- Bun 1.0+ (for frontend development)
- Rust 1.75+ (for backend development)
- Bun 1.0+
- Rust 1.75+

### Running with Docker

```bash
cp .env.example .env
# Edit .env with your RPC endpoint

docker-compose up -d
```

Access the explorer at http://localhost:3000

### Local Development

**Backend:**
```bash
cp .env.example .env
docker-compose up -d postgres
just frontend-install
```

Start backend services (each in its own terminal):

```bash
cd backend
just backend-indexer
```

# Start PostgreSQL
docker-compose up -d postgres
```bash
just backend-api
```

# Set environment
export DATABASE_URL=postgres://atlas:atlas@localhost/atlas
export RPC_URL=https://your-l2-rpc.example.com
Start frontend:

# Run services
cargo run --bin atlas-indexer
cargo run --bin atlas-api # in another terminal
```bash
just frontend-dev
```

**Frontend:**
### Useful Commands

```bash
cd frontend
bun install
bun run dev
just --list
just frontend-lint
just frontend-build
just backend-fmt
just backend-clippy
just backend-test
just ci
```

## Configuration

Copy `.env.example` to `.env` and set your RPC endpoint. Available options:
Copy `.env.example` to `.env` and set `RPC_URL`. Common options:

| Variable | Description | Default |
|----------|-------------|---------|
Expand Down
3 changes: 2 additions & 1 deletion backend/crates/atlas-api/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ impl Deref for ApiError {

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = StatusCode::from_u16(self.0.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let status =
StatusCode::from_u16(self.0.status_code()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
let body = Json(json!({
"error": self.0.to_string()
}));
Expand Down
78 changes: 50 additions & 28 deletions backend/crates/atlas-api/src/handlers/addresses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ use axum::{
use serde::{Deserialize, Serialize};
use std::sync::Arc;

use atlas_common::{Address, AtlasError, NftToken, Pagination, PaginatedResponse, Transaction};
use crate::AppState;
use crate::error::ApiResult;
use crate::AppState;
use atlas_common::{Address, AtlasError, NftToken, PaginatedResponse, Pagination, Transaction};

/// Merged address response that combines data from addresses, nft_contracts, and erc20_contracts tables
#[derive(Debug, Clone, Serialize)]
Expand Down Expand Up @@ -59,8 +59,12 @@ pub struct AddressFilters {
pub address_type: Option<String>,
}

fn default_page() -> u32 { 1 }
fn default_limit() -> u32 { 20 }
fn default_page() -> u32 {
1
}
fn default_limit() -> u32 {
20
}

pub async fn list_addresses(
State(state): State<Arc<AppState>>,
Expand Down Expand Up @@ -144,7 +148,9 @@ pub async fn list_addresses(
"eoa" | "contract" | "erc20" | "nft" => Some(address_type.to_lowercase()),
_ => None,
};
if let Some(at) = at { conditions.push(format!("address_type = '{}'", at)); }
if let Some(at) = at {
conditions.push(format!("address_type = '{}'", at));
}
}

let where_clause = if conditions.is_empty() {
Expand All @@ -156,12 +162,13 @@ pub async fn list_addresses(
// Count total
let count_query = format!(
"{} {} ",
base_query.replace("SELECT * FROM all_addresses", "SELECT COUNT(*) FROM all_addresses"),
base_query.replace(
"SELECT * FROM all_addresses",
"SELECT COUNT(*) FROM all_addresses"
),
where_clause
);
let total: (i64,) = sqlx::query_as(&count_query)
.fetch_one(&state.pool)
.await?;
let total: (i64,) = sqlx::query_as(&count_query).fetch_one(&state.pool).await?;

// Fetch addresses sorted by tx_count (most active first), then by first_seen_block
let query = format!(
Expand All @@ -171,11 +178,11 @@ pub async fn list_addresses(
base_query, where_clause, limit, offset
);

let addresses: Vec<AddressListItem> = sqlx::query_as(&query)
.fetch_all(&state.pool)
.await?;
let addresses: Vec<AddressListItem> = sqlx::query_as(&query).fetch_all(&state.pool).await?;

Ok(Json(PaginatedResponse::new(addresses, page, limit, total.0)))
Ok(Json(PaginatedResponse::new(
addresses, page, limit, total.0,
)))
}

pub async fn get_address(
Expand All @@ -188,7 +195,7 @@ pub async fn get_address(
let base_addr: Option<Address> = sqlx::query_as(
"SELECT address, is_contract, first_seen_block, tx_count
FROM addresses
WHERE LOWER(address) = LOWER($1)"
WHERE LOWER(address) = LOWER($1)",
)
.bind(&address)
.fetch_optional(&state.pool)
Expand All @@ -198,7 +205,7 @@ pub async fn get_address(
let nft_contract: Option<NftContractRow> = sqlx::query_as(
"SELECT address, name, symbol, total_supply, first_seen_block
FROM nft_contracts
WHERE LOWER(address) = LOWER($1)"
WHERE LOWER(address) = LOWER($1)",
)
.bind(&address)
.fetch_optional(&state.pool)
Expand All @@ -208,7 +215,7 @@ pub async fn get_address(
let erc20_contract: Option<Erc20ContractRow> = sqlx::query_as(
"SELECT address, name, symbol, decimals, total_supply, first_seen_block
FROM erc20_contracts
WHERE LOWER(address) = LOWER($1)"
WHERE LOWER(address) = LOWER($1)",
)
.bind(&address)
.fetch_optional(&state.pool)
Expand Down Expand Up @@ -274,7 +281,10 @@ pub async fn get_address(
// Edge case: found in both NFT and ERC-20 (shouldn't happen, prefer ERC-20)
(base, _, Some(erc20)) => Ok(Json(AddressDetailResponse {
address: erc20.address.clone(),
first_seen_block: base.as_ref().map(|b| b.first_seen_block).unwrap_or(erc20.first_seen_block),
first_seen_block: base
.as_ref()
.map(|b| b.first_seen_block)
.unwrap_or(erc20.first_seen_block),
tx_count: base.as_ref().map(|b| b.tx_count).unwrap_or(0),
address_type: "erc20".to_string(),
name: erc20.name,
Expand All @@ -283,7 +293,9 @@ pub async fn get_address(
total_supply: erc20.total_supply.map(|s| s.to_string()),
})),
// Not found anywhere
(None, None, None) => Err(AtlasError::NotFound(format!("Address {} not found", address)).into()),
(None, None, None) => {
Err(AtlasError::NotFound(format!("Address {} not found", address)).into())
}
}
}

Expand Down Expand Up @@ -316,7 +328,7 @@ pub async fn get_address_transactions(
let address = normalize_address(&address);

let total: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM transactions WHERE from_address = $1 OR to_address = $1"
"SELECT COUNT(*) FROM transactions WHERE from_address = $1 OR to_address = $1",
)
.bind(&address)
.fetch_one(&state.pool)
Expand All @@ -335,7 +347,12 @@ pub async fn get_address_transactions(
.fetch_all(&state.pool)
.await?;

Ok(Json(PaginatedResponse::new(transactions, pagination.page, pagination.limit, total.0)))
Ok(Json(PaginatedResponse::new(
transactions,
pagination.page,
pagination.limit,
total.0,
)))
}

pub async fn get_address_nfts(
Expand All @@ -345,12 +362,10 @@ pub async fn get_address_nfts(
) -> ApiResult<Json<PaginatedResponse<NftToken>>> {
let address = normalize_address(&address);

let total: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM nft_tokens WHERE owner = $1"
)
.bind(&address)
.fetch_one(&state.pool)
.await?;
let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM nft_tokens WHERE owner = $1")
.bind(&address)
.fetch_one(&state.pool)
.await?;

let tokens: Vec<NftToken> = sqlx::query_as(
"SELECT contract_address, token_id, owner, token_uri, metadata_fetched, metadata, image_url, name, last_transfer_block
Expand All @@ -365,7 +380,12 @@ pub async fn get_address_nfts(
.fetch_all(&state.pool)
.await?;

Ok(Json(PaginatedResponse::new(tokens, pagination.page, pagination.limit, total.0)))
Ok(Json(PaginatedResponse::new(
tokens,
pagination.page,
pagination.limit,
total.0,
)))
}

/// Unified transfer type combining ERC-20 and NFT transfers
Expand Down Expand Up @@ -561,7 +581,9 @@ pub async fn get_address_transfers(
})
.collect();

Ok(Json(PaginatedResponse::new(transfers, page, limit, total.0)))
Ok(Json(PaginatedResponse::new(
transfers, page, limit, total.0,
)))
}

fn normalize_address(address: &str) -> String {
Expand Down
Loading