From 20af5cd30d9cc447e3b7f1e9c02bce81bc2f2593 Mon Sep 17 00:00:00 2001 From: tac0turtle Date: Tue, 24 Feb 2026 11:44:25 +0100 Subject: [PATCH 1/5] ci and fixes --- .github/workflows/ci.yml | 61 +++++ Justfile | 36 +++ backend/crates/atlas-api/src/error.rs | 3 +- .../atlas-api/src/handlers/addresses.rs | 78 ++++-- .../crates/atlas-api/src/handlers/blocks.rs | 28 +- .../atlas-api/src/handlers/contracts.rs | 150 +++++++---- .../atlas-api/src/handlers/etherscan.rs | 218 +++++++++------ .../crates/atlas-api/src/handlers/labels.rs | 30 +-- backend/crates/atlas-api/src/handlers/logs.rs | 44 ++-- backend/crates/atlas-api/src/handlers/mod.rs | 31 +-- backend/crates/atlas-api/src/handlers/nfts.rs | 95 +++++-- .../crates/atlas-api/src/handlers/proxy.rs | 45 +++- .../crates/atlas-api/src/handlers/search.rs | 50 +++- .../crates/atlas-api/src/handlers/status.rs | 8 +- .../crates/atlas-api/src/handlers/tokens.rs | 42 ++- .../atlas-api/src/handlers/transactions.rs | 53 ++-- backend/crates/atlas-api/src/main.rs | 175 +++++++++--- backend/crates/atlas-common/src/lib.rs | 4 +- backend/crates/atlas-common/src/types.rs | 8 +- backend/crates/atlas-indexer/src/batch.rs | 36 ++- backend/crates/atlas-indexer/src/config.rs | 6 +- backend/crates/atlas-indexer/src/copy.rs | 2 +- backend/crates/atlas-indexer/src/fetcher.rs | 21 +- backend/crates/atlas-indexer/src/indexer.rs | 249 ++++++++++++------ backend/crates/atlas-indexer/src/main.rs | 17 +- backend/crates/atlas-indexer/src/metadata.rs | 118 ++++++--- frontend/src/App.tsx | 2 +- frontend/src/api/addresses.ts | 3 +- frontend/src/components/ImageIpfs.tsx | 2 - frontend/src/components/SmoothCounter.tsx | 3 - frontend/src/hooks/index.ts | 1 + frontend/src/pages/BlockDetailPage.tsx | 3 +- frontend/src/pages/BlocksPage.tsx | 1 + frontend/src/pages/TransactionDetailPage.tsx | 5 +- frontend/src/pages/TransactionsPage.tsx | 1 + 35 files changed, 1109 insertions(+), 520 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Justfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8cc1ec6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +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 diff --git a/Justfile b/Justfile new file mode 100644 index 0000000..6491cd5 --- /dev/null +++ b/Justfile @@ -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-lint frontend-build diff --git a/backend/crates/atlas-api/src/error.rs b/backend/crates/atlas-api/src/error.rs index 8405b13..4738342 100644 --- a/backend/crates/atlas-api/src/error.rs +++ b/backend/crates/atlas-api/src/error.rs @@ -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() })); diff --git a/backend/crates/atlas-api/src/handlers/addresses.rs b/backend/crates/atlas-api/src/handlers/addresses.rs index e2182e5..05750db 100644 --- a/backend/crates/atlas-api/src/handlers/addresses.rs +++ b/backend/crates/atlas-api/src/handlers/addresses.rs @@ -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)] @@ -59,8 +59,12 @@ pub struct AddressFilters { pub address_type: Option, } -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>, @@ -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() { @@ -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!( @@ -171,11 +178,11 @@ pub async fn list_addresses( base_query, where_clause, limit, offset ); - let addresses: Vec = sqlx::query_as(&query) - .fetch_all(&state.pool) - .await?; + let addresses: Vec = 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( @@ -188,7 +195,7 @@ pub async fn get_address( let base_addr: Option
= 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) @@ -198,7 +205,7 @@ pub async fn get_address( let nft_contract: Option = 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) @@ -208,7 +215,7 @@ pub async fn get_address( let erc20_contract: Option = 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) @@ -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, @@ -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()) + } } } @@ -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) @@ -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( @@ -345,12 +362,10 @@ pub async fn get_address_nfts( ) -> ApiResult>> { 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 = sqlx::query_as( "SELECT contract_address, token_id, owner, token_uri, metadata_fetched, metadata, image_url, name, last_transfer_block @@ -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 @@ -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 { diff --git a/backend/crates/atlas-api/src/handlers/blocks.rs b/backend/crates/atlas-api/src/handlers/blocks.rs index d8c6cff..7b13de9 100644 --- a/backend/crates/atlas-api/src/handlers/blocks.rs +++ b/backend/crates/atlas-api/src/handlers/blocks.rs @@ -4,9 +4,9 @@ use axum::{ }; use std::sync::Arc; -use atlas_common::{AtlasError, Block, Pagination, PaginatedResponse, Transaction}; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; +use atlas_common::{AtlasError, Block, PaginatedResponse, Pagination, Transaction}; pub async fn list_blocks( State(state): State>, @@ -30,7 +30,12 @@ pub async fn list_blocks( .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(blocks, pagination.page, pagination.limit, total_count))) + Ok(Json(PaginatedResponse::new( + blocks, + pagination.page, + pagination.limit, + total_count, + ))) } pub async fn get_block( @@ -55,12 +60,10 @@ pub async fn get_block_transactions( Path(number): Path, Query(pagination): Query, ) -> ApiResult>> { - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM transactions WHERE block_number = $1" - ) - .bind(number) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM transactions WHERE block_number = $1") + .bind(number) + .fetch_one(&state.pool) + .await?; let transactions: Vec = sqlx::query_as( "SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp @@ -75,5 +78,10 @@ pub async fn get_block_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, + ))) } diff --git a/backend/crates/atlas-api/src/handlers/contracts.rs b/backend/crates/atlas-api/src/handlers/contracts.rs index 38b4c5a..284ae20 100644 --- a/backend/crates/atlas-api/src/handlers/contracts.rs +++ b/backend/crates/atlas-api/src/handlers/contracts.rs @@ -14,17 +14,18 @@ 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, }; -use crate::AppState; -use crate::error::ApiResult; /// Solc compiler output structure #[derive(Debug, Deserialize)] struct SolcOutput { - contracts: Option>>, + contracts: + Option>>, errors: Option>, } @@ -73,7 +74,8 @@ struct SolcSettings { #[serde(rename = "evmVersion", skip_serializing_if = "Option::is_none")] evm_version: Option, #[serde(rename = "outputSelection")] - output_selection: std::collections::HashMap>>, + output_selection: + std::collections::HashMap>>, } #[derive(Debug, Serialize)] @@ -95,12 +97,11 @@ pub async fn verify_contract( } // 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?; + 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 { @@ -116,8 +117,9 @@ pub async fn verify_contract( if deployed_bytecode.is_empty() || deployed_bytecode == "0x" { return Err(AtlasError::Verification( - "No bytecode found at address. Is this a contract?".to_string() - ).into()); + "No bytecode found at address. Is this a contract?".to_string(), + ) + .into()); } // Compile the source code @@ -127,14 +129,22 @@ pub async fn verify_contract( 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) { + 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()); + "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() + 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()))?; @@ -143,7 +153,8 @@ pub async fn verify_contract( 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() + serde_json::from_str(&request.source_code) + .ok() .and_then(|v: serde_json::Value| v.get("sources").cloned()) } else { None @@ -198,18 +209,17 @@ pub async fn get_contract_abi( .await?; match contract { - Some((addr, abi, contract_name, verified_at)) => { - Ok(Json(ContractAbiResponse { - address: addr, - abi, - contract_name, - verified_at, - })) - } + 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()), + )) + .into()), } } @@ -234,7 +244,8 @@ pub async fn get_contract_source( match contract { Some(c) => { - let constructor_args_hex = c.constructor_args + let constructor_args_hex = c + .constructor_args .as_ref() .map(|bytes| format!("0x{}", hex::encode(bytes))); @@ -256,19 +267,26 @@ pub async fn get_contract_source( None => Err(AtlasError::NotFound(format!( "No verified contract found at address {}", address - )).into()), + )) + .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 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() + let addr: alloy::primitives::Address = address + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid address format".to_string()))?; - let code = provider.get_code_at(addr).await + 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()))) @@ -303,7 +321,14 @@ async fn compile_contract( // 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)), + format!( + "{}.sol", + request + .contract_name + .split(':') + .next() + .unwrap_or(&request.contract_name) + ), SolcSource { content: request.source_code.clone(), }, @@ -339,7 +364,8 @@ async fn compile_contract( }; // Determine solc binary path - support version-specific binaries - let version_clean = request.compiler_version + let version_clean = request + .compiler_version .trim_start_matches('v') .split('+') .next() @@ -360,17 +386,24 @@ async fn compile_contract( .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)))?; + 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)))?; + 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 + let output = child + .wait_with_output() + .await .map_err(|e| AtlasError::Compilation(format!("Failed to wait for solc: {}", e)))?; if !output.status.success() { @@ -384,7 +417,8 @@ async fn compile_contract( // Check for errors if let Some(errors) = &solc_output.errors { - let error_msgs: Vec<_> = errors.iter() + let error_msgs: Vec<_> = errors + .iter() .filter(|e| e.severity == "error") .map(|e| e.formatted_message.as_ref().unwrap_or(&e.message).clone()) .collect(); @@ -395,11 +429,13 @@ async fn compile_contract( } // Find the contract - let contracts = solc_output.contracts + 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 + let contract_name = request + .contract_name .split(':') .next_back() .unwrap_or(&request.contract_name); @@ -416,22 +452,30 @@ async fn compile_contract( } } - let contract = found_contract - .ok_or_else(|| AtlasError::Compilation(format!( + let contract = found_contract.ok_or_else(|| { + AtlasError::Compilation(format!( "Contract '{}' not found in compilation output. Available contracts: {:?}", contract_name, - contracts.iter() + contracts + .iter() .flat_map(|(_, c)| c.keys()) .collect::>() - )))?; + )) + })?; - let abi = contract.abi.clone() + let abi = contract + .abi + .clone() .ok_or_else(|| AtlasError::Compilation("No ABI in contract output".to_string()))?; - let evm = contract.evm.as_ref() + 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() + 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); @@ -543,10 +587,14 @@ mod tests { #[test] fn test_is_valid_address() { - assert!(is_valid_address("0x1234567890123456789012345678901234567890")); + assert!(is_valid_address( + "0x1234567890123456789012345678901234567890" + )); assert!(is_valid_address("1234567890123456789012345678901234567890")); assert!(!is_valid_address("0x12345")); // too short - assert!(!is_valid_address("0xGGGG567890123456789012345678901234567890")); // invalid hex + assert!(!is_valid_address( + "0xGGGG567890123456789012345678901234567890" + )); // invalid hex } #[test] diff --git a/backend/crates/atlas-api/src/handlers/etherscan.rs b/backend/crates/atlas-api/src/handlers/etherscan.rs index f5ca9cd..f81740f 100644 --- a/backend/crates/atlas-api/src/handlers/etherscan.rs +++ b/backend/crates/atlas-api/src/handlers/etherscan.rs @@ -12,10 +12,10 @@ use bigdecimal::BigDecimal; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use atlas_common::{AtlasError, ContractAbi, Transaction, VerifyContractRequest}; -use crate::AppState; use crate::error::ApiResult; use crate::handlers::contracts; +use crate::AppState; +use atlas_common::{AtlasError, ContractAbi, Transaction, VerifyContractRequest}; /// Etherscan API response wrapper #[derive(Debug, Serialize)] @@ -136,7 +136,10 @@ pub async fn etherscan_api_post( ) -> ApiResult> { if form.module != "contract" { return Ok(Json(serde_json::to_value(EtherscanResponse::error( - format!("POST only supported for contract module, got: {}", form.module), + format!( + "POST only supported for contract module, got: {}", + form.module + ), serde_json::Value::Null, ))?)); } @@ -192,30 +195,27 @@ async fn verify_source_code_etherscan( }; // Call the internal verification logic - match contracts::verify_contract( - axum::extract::State(state), - Json(request), - ).await { + 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 + response.address, ))?)) } else { Ok(Json(serde_json::to_value(EtherscanResponse::error( - response.message.unwrap_or_else(|| "Verification failed".to_string()), + 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, - ))?)) - } + Err(e) => Ok(Json(serde_json::to_value(EtherscanResponse::error( + e.to_string(), + serde_json::Value::Null, + ))?)), } } @@ -307,39 +307,57 @@ async fn handle_proxy_module( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let provider = ProviderBuilder::new() - .on_http(state.rpc_url.parse().map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?); + let provider = ProviderBuilder::new().on_http( + state + .rpc_url + .parse() + .map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?, + ); match query.action.as_str() { "eth_blockNumber" => { - let block_number = provider.get_block_number().await + let block_number = provider + .get_block_number() + .await .map_err(|e| AtlasError::Rpc(e.to_string()))?; - Ok(Json(serde_json::to_value(EtherscanResponse::ok( - format!("0x{:x}", block_number), - ))?)) + Ok(Json(serde_json::to_value(EtherscanResponse::ok(format!( + "0x{:x}", + block_number + )))?)) } "eth_getBlockByNumber" => { - let block_no = query.blockno.as_ref() + let block_no = query + .blockno + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("blockno required".to_string()))?; let block_num: u64 = if let Some(stripped) = block_no.strip_prefix("0x") { u64::from_str_radix(stripped, 16) .map_err(|_| AtlasError::InvalidInput("Invalid block number".to_string()))? } else { - block_no.parse() + block_no + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid block number".to_string()))? }; let block = provider - .get_block_by_number(alloy::rpc::types::BlockNumberOrTag::Number(block_num), alloy::rpc::types::BlockTransactionsKind::Full) + .get_block_by_number( + alloy::rpc::types::BlockNumberOrTag::Number(block_num), + alloy::rpc::types::BlockTransactionsKind::Full, + ) .await .map_err(|e| AtlasError::Rpc(e.to_string()))?; Ok(Json(serde_json::to_value(EtherscanResponse::ok(block))?)) } "eth_getTransactionByHash" => { - let hash = query.txhash.as_ref() + let hash = query + .txhash + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("txhash required".to_string()))?; - let hash_bytes: alloy::primitives::B256 = hash.parse() + let hash_bytes: alloy::primitives::B256 = hash + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid transaction hash".to_string()))?; - let tx = provider.get_transaction_by_hash(hash_bytes).await + let tx = provider + .get_transaction_by_hash(hash_bytes) + .await .map_err(|e| AtlasError::Rpc(e.to_string()))?; Ok(Json(serde_json::to_value(EtherscanResponse::ok(tx))?)) } @@ -358,18 +376,27 @@ async fn get_balance( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let address = query.address.as_ref() + let address = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; let address = normalize_address(address); // Get balance from RPC - let provider = ProviderBuilder::new() - .on_http(state.rpc_url.parse().map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?); + 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() + let addr: alloy::primitives::Address = address + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid address".to_string()))?; - let balance = provider.get_balance(addr).await + let balance = provider + .get_balance(addr) + .await .map_err(|e| AtlasError::Rpc(e.to_string()))?; Ok(Json(serde_json::to_value(EtherscanResponse::ok( @@ -381,21 +408,30 @@ async fn get_balance_multi( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let addresses_str = query.address.as_ref() + let addresses_str = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; - let provider = ProviderBuilder::new() - .on_http(state.rpc_url.parse().map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?); + let provider = ProviderBuilder::new().on_http( + state + .rpc_url + .parse() + .map_err(|e| AtlasError::Config(format!("Invalid RPC URL: {}", e)))?, + ); let addresses: Vec<&str> = addresses_str.split(',').collect(); let mut results = Vec::new(); for addr_str in addresses { let addr_str = normalize_address(addr_str.trim()); - let addr: alloy::primitives::Address = addr_str.parse() + let addr: alloy::primitives::Address = addr_str + .parse() .map_err(|_| AtlasError::InvalidInput(format!("Invalid address: {}", addr_str)))?; - let balance = provider.get_balance(addr).await + let balance = provider + .get_balance(addr) + .await .map_err(|e| AtlasError::Rpc(e.to_string()))?; results.push(serde_json::json!({ @@ -435,7 +471,9 @@ async fn get_tx_list( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let address = query.address.as_ref() + let address = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; let address = normalize_address(address); @@ -474,7 +512,7 @@ async fn get_tx_list( block_number: tx.block_number.to_string(), time_stamp: tx.timestamp.to_string(), hash: tx.hash, - nonce: "0".to_string(), // Not stored + nonce: "0".to_string(), // Not stored block_hash: "".to_string(), // Would need join transaction_index: tx.block_index.to_string(), from: tx.from_address, @@ -501,9 +539,10 @@ async fn get_internal_tx_list( _query: EtherscanQuery, ) -> ApiResult> { // Internal transactions require trace support - return empty for now - Ok(Json(serde_json::to_value(EtherscanResponse::ok( - Vec::::new(), - ))?)) + Ok(Json(serde_json::to_value(EtherscanResponse::ok(Vec::< + serde_json::Value, + >::new( + )))?)) } /// Token transfer in Etherscan format @@ -553,7 +592,9 @@ async fn get_token_tx_list( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let address = query.address.as_ref() + let address = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; let address = normalize_address(address); @@ -615,9 +656,13 @@ async fn get_token_balance( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let address = query.address.as_ref() + let address = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; - let contract_address = query.contractaddress.as_ref() + let contract_address = query + .contractaddress + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("contractaddress required".to_string()))?; let address = normalize_address(address); @@ -636,7 +681,9 @@ async fn get_token_balance( .map(|(b,)| b.to_string()) .unwrap_or_else(|| "0".to_string()); - Ok(Json(serde_json::to_value(EtherscanResponse::ok(balance_str))?)) + Ok(Json(serde_json::to_value(EtherscanResponse::ok( + balance_str, + ))?)) } // ===================== @@ -647,7 +694,9 @@ async fn get_contract_abi( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let address = query.address.as_ref() + let address = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; let address = normalize_address(address); @@ -698,7 +747,9 @@ async fn get_source_code( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let address = query.address.as_ref() + let address = query + .address + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("address required".to_string()))?; let address = normalize_address(address); @@ -722,24 +773,34 @@ async fn get_source_code( match contract { Some(c) => { - let abi_str = serde_json::to_string(&c.abi) - .map_err(|e| AtlasError::Internal(e.to_string()))?; + let abi_str = + serde_json::to_string(&c.abi).map_err(|e| AtlasError::Internal(e.to_string()))?; let result = SourceCodeResult { source_code: c.source_code.unwrap_or_default(), abi: abi_str, contract_name: "".to_string(), // Not stored compiler_version: c.compiler_version.unwrap_or_default(), - optimization_used: if c.optimization_used.unwrap_or(false) { "1" } else { "0" }.to_string(), + optimization_used: if c.optimization_used.unwrap_or(false) { + "1" + } else { + "0" + } + .to_string(), runs: c.runs.unwrap_or(200).to_string(), constructor_arguments: "".to_string(), evm_version: "".to_string(), library: "".to_string(), license_type: "".to_string(), proxy: if proxy.is_some() { "1" } else { "0" }.to_string(), - implementation: proxy.as_ref().map(|(_, impl_addr)| impl_addr.clone()).unwrap_or_default(), + implementation: proxy + .as_ref() + .map(|(_, impl_addr)| impl_addr.clone()) + .unwrap_or_default(), swarm_source: "".to_string(), }; - Ok(Json(serde_json::to_value(EtherscanResponse::ok(vec![result]))?)) + Ok(Json(serde_json::to_value(EtherscanResponse::ok(vec![ + result, + ]))?)) } None => Ok(Json(serde_json::to_value(EtherscanResponse::error( "Contract source code not verified", @@ -756,16 +817,17 @@ async fn get_tx_receipt_status( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let txhash = query.txhash.as_ref() + let txhash = query + .txhash + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("txhash required".to_string()))?; let txhash = normalize_hash(txhash); - let status: Option<(bool,)> = sqlx::query_as( - "SELECT status FROM transactions WHERE LOWER(hash) = LOWER($1)", - ) - .bind(&txhash) - .fetch_optional(&state.pool) - .await?; + let status: Option<(bool,)> = + sqlx::query_as("SELECT status FROM transactions WHERE LOWER(hash) = LOWER($1)") + .bind(&txhash) + .fetch_optional(&state.pool) + .await?; match status { Some((success,)) => Ok(Json(serde_json::to_value(EtherscanResponse::ok( @@ -786,29 +848,33 @@ async fn get_block_reward( state: Arc, query: EtherscanQuery, ) -> ApiResult> { - let blockno = query.blockno.as_ref() + let blockno = query + .blockno + .as_ref() .ok_or_else(|| AtlasError::InvalidInput("blockno required".to_string()))?; - let block_number: i64 = blockno.parse() + let block_number: i64 = blockno + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid block number".to_string()))?; - let block: Option<(i64, String, i64)> = sqlx::query_as( - "SELECT number, hash, timestamp FROM blocks WHERE number = $1", - ) - .bind(block_number) - .fetch_optional(&state.pool) - .await?; + let block: Option<(i64, String, i64)> = + sqlx::query_as("SELECT number, hash, timestamp FROM blocks WHERE number = $1") + .bind(block_number) + .fetch_optional(&state.pool) + .await?; match block { Some((number, _hash, timestamp)) => { // L2s typically don't have block rewards in the traditional sense - Ok(Json(serde_json::to_value(EtherscanResponse::ok(serde_json::json!({ - "blockNumber": number.to_string(), - "timeStamp": timestamp.to_string(), - "blockMiner": "", // L2 doesn't have miners - "blockReward": "0", - "uncles": [], - "uncleInclusionReward": "0" - })))?)) + Ok(Json(serde_json::to_value(EtherscanResponse::ok( + serde_json::json!({ + "blockNumber": number.to_string(), + "timeStamp": timestamp.to_string(), + "blockMiner": "", // L2 doesn't have miners + "blockReward": "0", + "uncles": [], + "uncleInclusionReward": "0" + }), + ))?)) } None => Ok(Json(serde_json::to_value(EtherscanResponse::error( "Block not found", diff --git a/backend/crates/atlas-api/src/handlers/labels.rs b/backend/crates/atlas-api/src/handlers/labels.rs index 3294fdf..57de634 100644 --- a/backend/crates/atlas-api/src/handlers/labels.rs +++ b/backend/crates/atlas-api/src/handlers/labels.rs @@ -9,9 +9,9 @@ use axum::{ use serde::Deserialize; use std::sync::Arc; -use atlas_common::{AddressLabel, AddressLabelInput, AtlasError, Pagination, PaginatedResponse}; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; +use atlas_common::{AddressLabel, AddressLabelInput, AtlasError, PaginatedResponse, Pagination}; /// Query parameters for label filtering #[derive(Debug, Deserialize)] @@ -30,12 +30,11 @@ pub async fn list_labels( 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 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 @@ -54,12 +53,11 @@ pub async fn list_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 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 @@ -123,9 +121,7 @@ pub async fn get_label( } /// GET /api/labels/tags - Get all available tags -pub async fn list_tags( - State(state): State>, -) -> ApiResult>> { +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 diff --git a/backend/crates/atlas-api/src/handlers/logs.rs b/backend/crates/atlas-api/src/handlers/logs.rs index c0a1240..482d0f4 100644 --- a/backend/crates/atlas-api/src/handlers/logs.rs +++ b/backend/crates/atlas-api/src/handlers/logs.rs @@ -5,9 +5,9 @@ use axum::{ use serde::Deserialize; use std::sync::Arc; -use atlas_common::{AtlasError, EventLog, Pagination, PaginatedResponse}; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; +use atlas_common::{AtlasError, EventLog, PaginatedResponse, Pagination}; /// Query parameters for log filtering #[derive(Debug, Deserialize)] @@ -50,13 +50,12 @@ pub async fn get_address_logs( let (total, logs) = if let Some(topic0) = &query.topic0 { let topic0 = normalize_hash(topic0); - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM event_logs WHERE address = $1 AND topic0 = $2", - ) - .bind(&address) - .bind(&topic0) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE address = $1 AND topic0 = $2") + .bind(&address) + .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 @@ -74,12 +73,10 @@ pub async fn get_address_logs( (total.0, logs) } else { - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM event_logs WHERE address = $1", - ) - .bind(&address) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM event_logs WHERE address = $1") + .bind(&address) + .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 @@ -110,18 +107,15 @@ 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 = 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 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 diff --git a/backend/crates/atlas-api/src/handlers/mod.rs b/backend/crates/atlas-api/src/handlers/mod.rs index 1cd17f8..7b67dee 100644 --- a/backend/crates/atlas-api/src/handlers/mod.rs +++ b/backend/crates/atlas-api/src/handlers/mod.rs @@ -1,15 +1,15 @@ -pub mod blocks; -pub mod transactions; pub mod addresses; -pub mod nfts; -pub mod search; -pub mod tokens; -pub mod logs; +pub mod blocks; +pub mod contracts; pub mod etherscan; pub mod labels; +pub mod logs; +pub mod nfts; pub mod proxy; -pub mod contracts; +pub mod search; pub mod status; +pub mod tokens; +pub mod transactions; use sqlx::PgPool; @@ -29,7 +29,7 @@ pub async fn get_table_count(pool: &PgPool, table_name: &str) -> Result Result,) = sqlx::query_as( - "SELECT reltuples::float8 FROM pg_class WHERE relname = $1" - ) - .bind(table_name) - .fetch_one(pool) - .await?; + let parent: (Option,) = + sqlx::query_as("SELECT reltuples::float8 FROM pg_class WHERE relname = $1") + .bind(table_name) + .fetch_one(pool) + .await?; parent.0.unwrap_or(0.0) as i64 }; @@ -61,8 +60,6 @@ pub async fn get_table_count(pool: &PgPool, table_name: &str) -> Result Result { - let count: (i64,) = sqlx::query_as(query) - .fetch_one(pool) - .await?; + let count: (i64,) = sqlx::query_as(query).fetch_one(pool).await?; Ok(count.0) } diff --git a/backend/crates/atlas-api/src/handlers/nfts.rs b/backend/crates/atlas-api/src/handlers/nfts.rs index 1499a86..81a3078 100644 --- a/backend/crates/atlas-api/src/handlers/nfts.rs +++ b/backend/crates/atlas-api/src/handlers/nfts.rs @@ -1,14 +1,14 @@ +use alloy::primitives::{Address, U256}; use axum::{ extract::{Path, Query, State}, Json, }; -use std::sync::Arc; -use alloy::primitives::{Address, U256}; use serde::Deserialize; +use std::sync::Arc; -use atlas_common::{AtlasError, NftContract, NftToken, NftTransfer, Pagination, PaginatedResponse}; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; +use atlas_common::{AtlasError, NftContract, NftToken, NftTransfer, PaginatedResponse, Pagination}; /// NFT metadata JSON structure (ERC-721 standard) #[derive(Debug, Deserialize, serde::Serialize)] @@ -32,14 +32,19 @@ pub async fn list_collections( "SELECT address, name, symbol, total_supply, first_seen_block FROM nft_contracts ORDER BY first_seen_block DESC - LIMIT $1 OFFSET $2" + LIMIT $1 OFFSET $2", ) .bind(pagination.limit()) .bind(pagination.offset()) .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(collections, pagination.page, pagination.limit, total.0))) + Ok(Json(PaginatedResponse::new( + collections, + pagination.page, + pagination.limit, + total.0, + ))) } pub async fn get_collection( @@ -51,7 +56,7 @@ pub async fn get_collection( let mut collection: NftContract = 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) @@ -63,7 +68,7 @@ pub async fn get_collection( if let Ok((name, symbol)) = fetch_collection_metadata(&state.rpc_url, &address).await { // Update the database sqlx::query( - "UPDATE nft_contracts SET name = $1, symbol = $2 WHERE LOWER(address) = LOWER($3)" + "UPDATE nft_contracts SET name = $1, symbol = $2 WHERE LOWER(address) = LOWER($3)", ) .bind(&name) .bind(&symbol) @@ -80,14 +85,19 @@ pub async fn get_collection( } /// Fetch NFT collection name and symbol from contract -async fn fetch_collection_metadata(rpc_url: &str, contract_address: &str) -> Result<(Option, Option), AtlasError> { +async fn fetch_collection_metadata( + rpc_url: &str, + contract_address: &str, +) -> Result<(Option, Option), AtlasError> { use alloy::providers::{Provider, ProviderBuilder}; use alloy::rpc::types::TransactionRequest; - let contract: Address = contract_address.parse() + let contract: Address = contract_address + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid contract address".to_string()))?; - let url: reqwest::Url = rpc_url.parse() + let url: reqwest::Url = rpc_url + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid RPC URL".to_string()))?; let provider = ProviderBuilder::new().on_http(url); @@ -96,7 +106,11 @@ async fn fetch_collection_metadata(rpc_url: &str, contract_address: &str) -> Res let tx = TransactionRequest::default() .to(contract) .input(alloy::primitives::Bytes::from(vec![0x06, 0xfd, 0xde, 0x03]).into()); - provider.call(&tx).await.ok().and_then(|r| decode_abi_string(&r)) + provider + .call(&tx) + .await + .ok() + .and_then(|r| decode_abi_string(&r)) }; // symbol() selector = 0x95d89b41 @@ -104,7 +118,11 @@ async fn fetch_collection_metadata(rpc_url: &str, contract_address: &str) -> Res let tx = TransactionRequest::default() .to(contract) .input(alloy::primitives::Bytes::from(vec![0x95, 0xd8, 0x9b, 0x41]).into()); - provider.call(&tx).await.ok().and_then(|r| decode_abi_string(&r)) + provider + .call(&tx) + .await + .ok() + .and_then(|r| decode_abi_string(&r)) }; Ok((name, symbol)) @@ -117,12 +135,11 @@ pub async fn list_collection_tokens( ) -> ApiResult>> { let address = normalize_address(&address); - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM nft_tokens WHERE LOWER(contract_address) = LOWER($1)" - ) - .bind(&address) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM nft_tokens WHERE LOWER(contract_address) = LOWER($1)") + .bind(&address) + .fetch_one(&state.pool) + .await?; let tokens: Vec = sqlx::query_as( "SELECT contract_address, token_id, owner, token_uri, metadata_fetched, metadata, image_url, name, last_transfer_block @@ -137,7 +154,12 @@ pub async fn list_collection_tokens( .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, + ))) } pub async fn get_token( @@ -174,7 +196,8 @@ async fn fetch_and_store_metadata( token_id: &str, ) -> Result { // Parse contract address and token ID - let contract_addr: Address = contract_address.parse() + let contract_addr: Address = contract_address + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid contract address".to_string()))?; let token_id_u256 = U256::from_str_radix(token_id, 10) .map_err(|_| AtlasError::InvalidInput("Invalid token ID".to_string()))?; @@ -188,7 +211,11 @@ async fn fetch_and_store_metadata( Ok(metadata) => { let image = metadata.image.as_ref().map(|img| resolve_ipfs_url(img)); let name = metadata.name.clone(); - (Some(serde_json::to_value(&metadata).unwrap_or_default()), image, name) + ( + Some(serde_json::to_value(&metadata).unwrap_or_default()), + image, + name, + ) } Err(_) => (None, None, None), } @@ -204,7 +231,7 @@ async fn fetch_and_store_metadata( metadata = $2, image_url = $3, name = $4 - WHERE LOWER(contract_address) = LOWER($5) AND token_id = $6::numeric" + WHERE LOWER(contract_address) = LOWER($5) AND token_id = $6::numeric", ) .bind(&token_uri) .bind(&metadata_json) @@ -283,12 +310,14 @@ async fn fetch_metadata_from_uri(uri: &str) -> Result { .build() .map_err(|e| AtlasError::MetadataFetch(e.to_string()))?; - let response = client.get(&url) + let response = client + .get(&url) .send() .await .map_err(|e| AtlasError::MetadataFetch(format!("Failed to fetch metadata: {}", e)))?; - let metadata: NftMetadata = response.json() + let metadata: NftMetadata = response + .json() .await .map_err(|e| AtlasError::MetadataFetch(format!("Failed to parse metadata: {}", e)))?; @@ -317,7 +346,7 @@ pub async fn get_collection_transfers( let address = normalize_address(&address); let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM nft_transfers WHERE LOWER(contract_address) = LOWER($1)" + "SELECT COUNT(*) FROM nft_transfers WHERE LOWER(contract_address) = LOWER($1)", ) .bind(&address) .fetch_one(&state.pool) @@ -336,7 +365,12 @@ pub async fn get_collection_transfers( .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(transfers, pagination.page, pagination.limit, total.0))) + Ok(Json(PaginatedResponse::new( + transfers, + pagination.page, + pagination.limit, + total.0, + ))) } /// GET /api/nfts/collections/{address}/tokens/{token_id}/transfers - Get transfers for a specific token @@ -369,7 +403,12 @@ pub async fn get_token_transfers( .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(transfers, pagination.page, pagination.limit, total.0))) + Ok(Json(PaginatedResponse::new( + transfers, + pagination.page, + pagination.limit, + total.0, + ))) } fn normalize_address(address: &str) -> String { diff --git a/backend/crates/atlas-api/src/handlers/proxy.rs b/backend/crates/atlas-api/src/handlers/proxy.rs index 3d512c0..7f96049 100644 --- a/backend/crates/atlas-api/src/handlers/proxy.rs +++ b/backend/crates/atlas-api/src/handlers/proxy.rs @@ -9,9 +9,9 @@ use axum::{ }; use std::sync::Arc; -use atlas_common::{AtlasError, ContractAbi, ProxyContract}; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; +use atlas_common::{AtlasError, ContractAbi, ProxyContract}; /// GET /api/contracts/:address/proxy - Get proxy information for a contract pub async fn get_proxy_info( @@ -245,10 +245,15 @@ pub async fn detect_proxy( // 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 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() + let addr: alloy::primitives::Address = address + .parse() .map_err(|_| AtlasError::InvalidInput("Invalid address".to_string()))?; // Check known proxy storage slots @@ -256,7 +261,9 @@ pub async fn detect_proxy( if let Some((impl_addr, proxy_type, admin_addr)) = result { // Get current block - let current_block = provider.get_block_number().await + let current_block = provider + .get_block_number() + .await .map_err(|e| AtlasError::Rpc(e.to_string()))?; // Store in database @@ -334,9 +341,19 @@ mod slots { /// Detect proxy implementation from storage slots async fn detect_proxy_impl( - provider: &alloy::providers::RootProvider, alloy::network::Ethereum>, + provider: &alloy::providers::RootProvider< + alloy::transports::http::Http, + alloy::network::Ethereum, + >, address: alloy::primitives::Address, -) -> Result)>, AtlasError> { +) -> Result< + Option<( + alloy::primitives::Address, + atlas_common::ProxyType, + Option, + )>, + AtlasError, +> { use alloy::primitives::Address; // Check EIP-1967 implementation slot @@ -358,7 +375,11 @@ async fn detect_proxy_impl( 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 } + if !addr.is_zero() { + Some(addr) + } else { + None + } } else { None } @@ -366,7 +387,11 @@ async fn detect_proxy_impl( None }; - return Ok(Some((impl_addr, atlas_common::ProxyType::Eip1967, admin_addr))); + return Ok(Some(( + impl_addr, + atlas_common::ProxyType::Eip1967, + admin_addr, + ))); } } diff --git a/backend/crates/atlas-api/src/handlers/search.rs b/backend/crates/atlas-api/src/handlers/search.rs index 5a492ea..71ead3b 100644 --- a/backend/crates/atlas-api/src/handlers/search.rs +++ b/backend/crates/atlas-api/src/handlers/search.rs @@ -5,9 +5,9 @@ use axum::{ use serde::{Deserialize, Serialize}; use std::sync::Arc; -use atlas_common::{Block, Transaction, Address, NftContract, Erc20Contract}; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; +use atlas_common::{Address, Block, Erc20Contract, NftContract, Transaction}; #[derive(Deserialize)] pub struct SearchQuery { @@ -43,7 +43,10 @@ pub async fn search( let mut results = Vec::new(); if query.is_empty() { - return Ok(Json(SearchResponse { results, query: query.to_string() })); + return Ok(Json(SearchResponse { + results, + query: query.to_string(), + })); } // Detect query type and run appropriate searches in parallel @@ -104,15 +107,21 @@ pub async fn search( } } - Ok(Json(SearchResponse { results, query: query.to_string() })) + Ok(Json(SearchResponse { + results, + query: query.to_string(), + })) } -async fn search_address(state: &AppState, address: &str) -> Result, atlas_common::AtlasError> { +async fn search_address( + state: &AppState, + address: &str, +) -> Result, atlas_common::AtlasError> { // Address is already lowercased by caller sqlx::query_as( "SELECT address, is_contract, first_seen_block, tx_count FROM addresses - WHERE address = $1" + WHERE address = $1", ) .bind(address) .fetch_optional(&state.pool) @@ -120,7 +129,10 @@ async fn search_address(state: &AppState, address: &str) -> Result Result, atlas_common::AtlasError> { +async fn search_transaction( + state: &AppState, + hash: &str, +) -> Result, atlas_common::AtlasError> { // Use tx_hash_lookup table for O(1) lookup, then fetch full tx with partition key sqlx::query_as( "SELECT t.hash, t.block_number, t.block_index, t.from_address, t.to_address, t.value, t.gas_price, t.gas_used, t.input_data, t.status, t.contract_created, t.timestamp @@ -134,7 +146,10 @@ async fn search_transaction(state: &AppState, hash: &str) -> Result Result, atlas_common::AtlasError> { +async fn search_block_by_hash( + state: &AppState, + hash: &str, +) -> Result, atlas_common::AtlasError> { // Hash is already lowercased by caller sqlx::query_as( "SELECT number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at @@ -147,7 +162,10 @@ async fn search_block_by_hash(state: &AppState, hash: &str) -> Result Result, atlas_common::AtlasError> { +async fn search_block_by_number( + state: &AppState, + number: i64, +) -> Result, atlas_common::AtlasError> { sqlx::query_as( "SELECT number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at FROM blocks @@ -159,14 +177,17 @@ async fn search_block_by_number(state: &AppState, number: i64) -> Result Result, atlas_common::AtlasError> { +async fn search_nft_collections( + state: &AppState, + query: &str, +) -> Result, atlas_common::AtlasError> { let pattern = format!("%{}%", query); sqlx::query_as( "SELECT address, name, symbol, total_supply, first_seen_block FROM nft_contracts WHERE name ILIKE $1 OR symbol ILIKE $1 ORDER BY total_supply DESC NULLS LAST - LIMIT 10" + LIMIT 10", ) .bind(&pattern) .fetch_all(&state.pool) @@ -174,14 +195,17 @@ async fn search_nft_collections(state: &AppState, query: &str) -> Result Result, atlas_common::AtlasError> { +async fn search_erc20_tokens( + state: &AppState, + query: &str, +) -> Result, atlas_common::AtlasError> { let pattern = format!("%{}%", query); sqlx::query_as( "SELECT address, name, symbol, decimals, total_supply, first_seen_block FROM erc20_contracts WHERE name ILIKE $1 OR symbol ILIKE $1 ORDER BY first_seen_block DESC - LIMIT 10" + LIMIT 10", ) .bind(&pattern) .fetch_all(&state.pool) diff --git a/backend/crates/atlas-api/src/handlers/status.rs b/backend/crates/atlas-api/src/handlers/status.rs index 6fca3b8..3744536 100644 --- a/backend/crates/atlas-api/src/handlers/status.rs +++ b/backend/crates/atlas-api/src/handlers/status.rs @@ -2,8 +2,8 @@ use axum::{extract::State, Json}; use serde::Serialize; use std::sync::Arc; -use crate::AppState; use crate::error::ApiResult; +use crate::AppState; #[derive(Serialize)] pub struct ChainStatus { @@ -13,11 +13,9 @@ pub struct ChainStatus { /// GET /api/status - Lightweight endpoint for current chain status /// Returns in <1ms, optimized for frequent polling -pub async fn get_status( - State(state): State>, -) -> ApiResult> { +pub async fn get_status(State(state): State>) -> ApiResult> { let result: (String, chrono::DateTime) = sqlx::query_as( - "SELECT value, updated_at FROM indexer_state WHERE key = 'last_indexed_block'" + "SELECT value, updated_at FROM indexer_state WHERE key = 'last_indexed_block'", ) .fetch_one(&state.pool) .await?; diff --git a/backend/crates/atlas-api/src/handlers/tokens.rs b/backend/crates/atlas-api/src/handlers/tokens.rs index 8fad558..fa96b2c 100644 --- a/backend/crates/atlas-api/src/handlers/tokens.rs +++ b/backend/crates/atlas-api/src/handlers/tokens.rs @@ -4,12 +4,12 @@ use axum::{ }; use std::sync::Arc; +use crate::error::ApiResult; +use crate::AppState; use atlas_common::{ - AtlasError, Erc20Balance, Erc20Contract, Erc20Holder, Erc20Transfer, Pagination, - PaginatedResponse, + AtlasError, Erc20Balance, Erc20Contract, Erc20Holder, Erc20Transfer, PaginatedResponse, + Pagination, }; -use crate::AppState; -use crate::error::ApiResult; /// GET /api/tokens - List all ERC-20 tokens pub async fn list_tokens( @@ -72,12 +72,11 @@ pub async fn get_token( .fetch_one(&state.pool) .await?; - let transfer_count: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM erc20_transfers WHERE contract_address = $1", - ) - .bind(&address) - .fetch_one(&state.pool) - .await?; + let transfer_count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM erc20_transfers WHERE contract_address = $1") + .bind(&address) + .fetch_one(&state.pool) + .await?; // Compute total_supply from balances if not set if contract.total_supply.is_none() { @@ -107,13 +106,11 @@ pub async fn get_token_holders( let address = normalize_address(&address); // Verify token exists - let _: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM erc20_contracts WHERE address = $1", - ) - .bind(&address) - .fetch_one(&state.pool) - .await - .map_err(|_| AtlasError::NotFound(format!("Token {} not found", address)))?; + let _: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM erc20_contracts WHERE address = $1") + .bind(&address) + .fetch_one(&state.pool) + .await + .map_err(|_| AtlasError::NotFound(format!("Token {} not found", address)))?; let total: (i64,) = sqlx::query_as( "SELECT COUNT(*) FROM erc20_balances WHERE contract_address = $1 AND balance > 0", @@ -199,12 +196,11 @@ pub async fn get_token_transfers( ) -> ApiResult>> { let address = normalize_address(&address); - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM erc20_transfers WHERE contract_address = $1", - ) - .bind(&address) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM erc20_transfers WHERE contract_address = $1") + .bind(&address) + .fetch_one(&state.pool) + .await?; let transfers: Vec = sqlx::query_as( "SELECT id, tx_hash, log_index, contract_address, from_address, to_address, value, block_number, timestamp diff --git a/backend/crates/atlas-api/src/handlers/transactions.rs b/backend/crates/atlas-api/src/handlers/transactions.rs index e4d798d..dd6907b 100644 --- a/backend/crates/atlas-api/src/handlers/transactions.rs +++ b/backend/crates/atlas-api/src/handlers/transactions.rs @@ -4,10 +4,12 @@ use axum::{ }; use std::sync::Arc; -use atlas_common::{AtlasError, Erc20Transfer, NftTransfer, Pagination, PaginatedResponse, Transaction}; -use crate::AppState; use crate::error::ApiResult; use crate::handlers::get_table_count; +use crate::AppState; +use atlas_common::{ + AtlasError, Erc20Transfer, NftTransfer, PaginatedResponse, Pagination, Transaction, +}; pub async fn list_transactions( State(state): State>, @@ -27,7 +29,12 @@ pub async fn list_transactions( .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(transactions, pagination.page, pagination.limit, total))) + Ok(Json(PaginatedResponse::new( + transactions, + pagination.page, + pagination.limit, + total, + ))) } pub async fn get_transaction( @@ -35,7 +42,11 @@ pub async fn get_transaction( Path(hash): Path, ) -> ApiResult> { // Normalize hash format - let hash = if hash.starts_with("0x") { hash } else { format!("0x{}", hash) }; + let hash = if hash.starts_with("0x") { + hash + } else { + format!("0x{}", hash) + }; let transaction: Transaction = sqlx::query_as( "SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp @@ -58,12 +69,10 @@ pub async fn get_transaction_erc20_transfers( ) -> ApiResult>> { let hash = normalize_hash(&hash); - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM erc20_transfers WHERE tx_hash = $1" - ) - .bind(&hash) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM erc20_transfers WHERE tx_hash = $1") + .bind(&hash) + .fetch_one(&state.pool) + .await?; let transfers: Vec = sqlx::query_as( "SELECT id, tx_hash, log_index, contract_address, from_address, to_address, value, block_number, timestamp @@ -78,7 +87,12 @@ pub async fn get_transaction_erc20_transfers( .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(transfers, pagination.page, pagination.limit, total.0))) + Ok(Json(PaginatedResponse::new( + transfers, + pagination.page, + pagination.limit, + total.0, + ))) } /// GET /api/transactions/{hash}/nft-transfers - Get all NFT transfers in a transaction @@ -89,12 +103,10 @@ pub async fn get_transaction_nft_transfers( ) -> ApiResult>> { let hash = normalize_hash(&hash); - let total: (i64,) = sqlx::query_as( - "SELECT COUNT(*) FROM nft_transfers WHERE tx_hash = $1" - ) - .bind(&hash) - .fetch_one(&state.pool) - .await?; + let total: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM nft_transfers WHERE tx_hash = $1") + .bind(&hash) + .fetch_one(&state.pool) + .await?; let transfers: Vec = sqlx::query_as( "SELECT id, tx_hash, log_index, contract_address, token_id, from_address, to_address, block_number, timestamp @@ -109,7 +121,12 @@ pub async fn get_transaction_nft_transfers( .fetch_all(&state.pool) .await?; - Ok(Json(PaginatedResponse::new(transfers, pagination.page, pagination.limit, total.0))) + Ok(Json(PaginatedResponse::new( + transfers, + pagination.page, + pagination.limit, + total.0, + ))) } fn normalize_hash(hash: &str) -> String { diff --git a/backend/crates/atlas-api/src/main.rs b/backend/crates/atlas-api/src/main.rs index a237fcf..63b18d4 100644 --- a/backend/crates/atlas-api/src/main.rs +++ b/backend/crates/atlas-api/src/main.rs @@ -1,16 +1,16 @@ use anyhow::Result; use axum::{ - routing::{get, post, delete}, + routing::{delete, get, post}, Router, }; use sqlx::PgPool; use std::sync::Arc; -use tower_http::cors::{CorsLayer, Any}; +use tower_http::cors::{Any, CorsLayer}; use tower_http::trace::TraceLayer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -mod handlers; mod error; +mod handlers; pub struct AppState { pub pool: PgPool, @@ -22,8 +22,10 @@ pub struct AppState { 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::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "atlas_api=info,tower_http=debug,sqlx=warn".into()), + ) .with(tracing_subscriber::fmt::layer()) .init(); @@ -47,60 +49,154 @@ async fn main() -> Result<()> { tracing::info!("Running database migrations"); atlas_common::db::run_migrations(&pool).await?; - let state = Arc::new(AppState { pool, rpc_url, solc_path }); + let state = Arc::new(AppState { + pool, + rpc_url, + solc_path, + }); // Build router let app = Router::new() // Blocks .route("/api/blocks", get(handlers::blocks::list_blocks)) .route("/api/blocks/{number}", get(handlers::blocks::get_block)) - .route("/api/blocks/{number}/transactions", get(handlers::blocks::get_block_transactions)) + .route( + "/api/blocks/{number}/transactions", + get(handlers::blocks::get_block_transactions), + ) // Transactions - .route("/api/transactions", get(handlers::transactions::list_transactions)) - .route("/api/transactions/{hash}", get(handlers::transactions::get_transaction)) - .route("/api/transactions/{hash}/logs", get(handlers::logs::get_transaction_logs)) - .route("/api/transactions/{hash}/logs/decoded", get(handlers::logs::get_transaction_logs_decoded)) - .route("/api/transactions/{hash}/erc20-transfers", get(handlers::transactions::get_transaction_erc20_transfers)) - .route("/api/transactions/{hash}/nft-transfers", get(handlers::transactions::get_transaction_nft_transfers)) + .route( + "/api/transactions", + get(handlers::transactions::list_transactions), + ) + .route( + "/api/transactions/{hash}", + get(handlers::transactions::get_transaction), + ) + .route( + "/api/transactions/{hash}/logs", + get(handlers::logs::get_transaction_logs), + ) + .route( + "/api/transactions/{hash}/logs/decoded", + get(handlers::logs::get_transaction_logs_decoded), + ) + .route( + "/api/transactions/{hash}/erc20-transfers", + get(handlers::transactions::get_transaction_erc20_transfers), + ) + .route( + "/api/transactions/{hash}/nft-transfers", + get(handlers::transactions::get_transaction_nft_transfers), + ) // Addresses .route("/api/addresses", get(handlers::addresses::list_addresses)) - .route("/api/addresses/{address}", get(handlers::addresses::get_address)) - .route("/api/addresses/{address}/transactions", get(handlers::addresses::get_address_transactions)) - .route("/api/addresses/{address}/transfers", get(handlers::addresses::get_address_transfers)) - .route("/api/addresses/{address}/nfts", get(handlers::addresses::get_address_nfts)) - .route("/api/addresses/{address}/tokens", get(handlers::tokens::get_address_tokens)) - .route("/api/addresses/{address}/logs", get(handlers::logs::get_address_logs)) - .route("/api/addresses/{address}/label", get(handlers::labels::get_address_with_label)) + .route( + "/api/addresses/{address}", + get(handlers::addresses::get_address), + ) + .route( + "/api/addresses/{address}/transactions", + get(handlers::addresses::get_address_transactions), + ) + .route( + "/api/addresses/{address}/transfers", + get(handlers::addresses::get_address_transfers), + ) + .route( + "/api/addresses/{address}/nfts", + get(handlers::addresses::get_address_nfts), + ) + .route( + "/api/addresses/{address}/tokens", + get(handlers::tokens::get_address_tokens), + ) + .route( + "/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", get(handlers::nfts::list_collections)) - .route("/api/nfts/collections/{address}", get(handlers::nfts::get_collection)) - .route("/api/nfts/collections/{address}/tokens", get(handlers::nfts::list_collection_tokens)) - .route("/api/nfts/collections/{address}/transfers", get(handlers::nfts::get_collection_transfers)) - .route("/api/nfts/collections/{address}/tokens/{token_id}", get(handlers::nfts::get_token)) - .route("/api/nfts/collections/{address}/tokens/{token_id}/transfers", get(handlers::nfts::get_token_transfers)) + .route( + "/api/nfts/collections", + get(handlers::nfts::list_collections), + ) + .route( + "/api/nfts/collections/{address}", + get(handlers::nfts::get_collection), + ) + .route( + "/api/nfts/collections/{address}/tokens", + get(handlers::nfts::list_collection_tokens), + ) + .route( + "/api/nfts/collections/{address}/transfers", + get(handlers::nfts::get_collection_transfers), + ) + .route( + "/api/nfts/collections/{address}/tokens/{token_id}", + get(handlers::nfts::get_token), + ) + .route( + "/api/nfts/collections/{address}/tokens/{token_id}/transfers", + get(handlers::nfts::get_token_transfers), + ) // ERC-20 Tokens .route("/api/tokens", get(handlers::tokens::list_tokens)) .route("/api/tokens/{address}", get(handlers::tokens::get_token)) - .route("/api/tokens/{address}/holders", get(handlers::tokens::get_token_holders)) - .route("/api/tokens/{address}/transfers", get(handlers::tokens::get_token_transfers)) + .route( + "/api/tokens/{address}/holders", + get(handlers::tokens::get_token_holders), + ) + .route( + "/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/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)) + .route( + "/api/labels/{address}", + delete(handlers::labels::delete_label), + ) // Proxy Contracts .route("/api/proxies", get(handlers::proxy::list_proxies)) - .route("/api/contracts/{address}/proxy", get(handlers::proxy::get_proxy_info)) - .route("/api/contracts/{address}/combined-abi", get(handlers::proxy::get_combined_abi)) - .route("/api/contracts/{address}/detect-proxy", post(handlers::proxy::detect_proxy)) + .route( + "/api/contracts/{address}/proxy", + get(handlers::proxy::get_proxy_info), + ) + .route( + "/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)) + .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)) @@ -110,7 +206,12 @@ async fn main() -> Result<()> { .route("/api/status", get(handlers::status::get_status)) // Health .route("/health", get(|| async { "OK" })) - .layer(CorsLayer::new().allow_origin(Any).allow_methods(Any).allow_headers(Any)) + .layer( + CorsLayer::new() + .allow_origin(Any) + .allow_methods(Any) + .allow_headers(Any), + ) .layer(TraceLayer::new_for_http()) .with_state(state); diff --git a/backend/crates/atlas-common/src/lib.rs b/backend/crates/atlas-common/src/lib.rs index a2ac90b..87c6bd6 100644 --- a/backend/crates/atlas-common/src/lib.rs +++ b/backend/crates/atlas-common/src/lib.rs @@ -1,6 +1,6 @@ -pub mod types; pub mod db; pub mod error; +pub mod types; -pub use types::*; pub use error::AtlasError; +pub use types::*; diff --git a/backend/crates/atlas-common/src/types.rs b/backend/crates/atlas-common/src/types.rs index 41b2b65..a3f9776 100644 --- a/backend/crates/atlas-common/src/types.rs +++ b/backend/crates/atlas-common/src/types.rs @@ -362,8 +362,12 @@ pub struct Pagination { pub limit: u32, } -fn default_page() -> u32 { 1 } -fn default_limit() -> u32 { 20 } +fn default_page() -> u32 { + 1 +} +fn default_limit() -> u32 { + 20 +} impl Pagination { pub fn offset(&self) -> i64 { diff --git a/backend/crates/atlas-indexer/src/batch.rs b/backend/crates/atlas-indexer/src/batch.rs index 127c661..a3e8ffd 100644 --- a/backend/crates/atlas-indexer/src/batch.rs +++ b/backend/crates/atlas-indexer/src/batch.rs @@ -41,8 +41,8 @@ pub(crate) struct BlockBatch { pub(crate) t_block_indices: Vec, pub(crate) t_froms: Vec, pub(crate) t_tos: Vec>, - pub(crate) t_values: Vec, // BigDecimal as string → cast to numeric in SQL - pub(crate) t_gas_prices: Vec, // BigDecimal as string → cast to numeric in SQL + pub(crate) t_values: Vec, // BigDecimal as string → cast to numeric in SQL + pub(crate) t_gas_prices: Vec, // BigDecimal as string → cast to numeric in SQL pub(crate) t_gas_used: Vec, pub(crate) t_input_data: Vec>, pub(crate) t_statuses: Vec, @@ -75,7 +75,7 @@ pub(crate) struct BlockBatch { pub(crate) nt_tx_hashes: Vec, pub(crate) nt_log_indices: Vec, pub(crate) nt_contracts: Vec, - pub(crate) nt_token_ids: Vec, // BigDecimal as string + pub(crate) nt_token_ids: Vec, // BigDecimal as string pub(crate) nt_froms: Vec, pub(crate) nt_tos: Vec, pub(crate) nt_block_numbers: Vec, @@ -94,7 +94,7 @@ pub(crate) struct BlockBatch { pub(crate) et_contracts: Vec, pub(crate) et_froms: Vec, pub(crate) et_tos: Vec, - pub(crate) et_values: Vec, // BigDecimal as string + pub(crate) et_values: Vec, // BigDecimal as string pub(crate) et_block_numbers: Vec, pub(crate) et_timestamps: Vec, @@ -118,7 +118,13 @@ impl BlockBatch { /// Upsert an address into the in-memory deduplication map. /// tx_count_delta is added to whatever was already accumulated for this address. - pub(crate) fn touch_addr(&mut self, address: String, block_num: i64, is_contract: bool, tx_count_delta: i64) { + pub(crate) fn touch_addr( + &mut self, + address: String, + block_num: i64, + is_contract: bool, + tx_count_delta: i64, + ) { let entry = self.addr_map.entry(address).or_insert(AddrState { first_seen_block: block_num, is_contract: false, @@ -131,13 +137,21 @@ impl BlockBatch { /// Add a balance delta for (address, contract). /// Multiple transfers in the same batch are aggregated into one row. - pub(crate) fn apply_balance_delta(&mut self, address: String, contract: String, delta: BigDecimal, block: i64) { - let entry = self.balance_map.entry((address, contract)).or_insert(BalanceDelta { - delta: BigDecimal::from(0), - last_block: block, - }); + pub(crate) fn apply_balance_delta( + &mut self, + address: String, + contract: String, + delta: BigDecimal, + block: i64, + ) { + let entry = self + .balance_map + .entry((address, contract)) + .or_insert(BalanceDelta { + delta: BigDecimal::from(0), + last_block: block, + }); entry.delta += delta; entry.last_block = entry.last_block.max(block); } } - diff --git a/backend/crates/atlas-indexer/src/config.rs b/backend/crates/atlas-indexer/src/config.rs index 3740c36..806edd4 100644 --- a/backend/crates/atlas-indexer/src/config.rs +++ b/backend/crates/atlas-indexer/src/config.rs @@ -20,14 +20,12 @@ pub struct Config { impl Config { pub fn from_env() -> Result { Ok(Self { - database_url: env::var("DATABASE_URL") - .context("DATABASE_URL must be set")?, + database_url: env::var("DATABASE_URL").context("DATABASE_URL must be set")?, 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")?, + rpc_url: env::var("RPC_URL").context("RPC_URL must be set")?, rpc_requests_per_second: env::var("RPC_REQUESTS_PER_SECOND") .unwrap_or_else(|_| "100".to_string()) .parse() diff --git a/backend/crates/atlas-indexer/src/copy.rs b/backend/crates/atlas-indexer/src/copy.rs index ec8e71e..eecec8c 100644 --- a/backend/crates/atlas-indexer/src/copy.rs +++ b/backend/crates/atlas-indexer/src/copy.rs @@ -2,7 +2,7 @@ use anyhow::Result; use tokio::pin; use tokio_postgres::{ binary_copy::BinaryCopyInWriter, - types::{Type, ToSql}, + types::{ToSql, Type}, Transaction, }; diff --git a/backend/crates/atlas-indexer/src/fetcher.rs b/backend/crates/atlas-indexer/src/fetcher.rs index 9a60578..139e113 100644 --- a/backend/crates/atlas-indexer/src/fetcher.rs +++ b/backend/crates/atlas-indexer/src/fetcher.rs @@ -20,7 +20,13 @@ pub(crate) struct WorkItem { } pub(crate) type HttpProvider = RootProvider, Ethereum>; -pub(crate) type SharedRateLimiter = Arc>; +pub(crate) type SharedRateLimiter = Arc< + RateLimiter< + governor::state::NotKeyed, + governor::state::InMemoryState, + governor::clock::DefaultClock, + >, +>; /// Result of fetching a block from RPC pub(crate) enum FetchResult { @@ -42,7 +48,11 @@ pub(crate) async fn fetch_blocks_batch( count: usize, rate_limiter: &SharedRateLimiter, ) -> Vec { - tracing::debug!("Fetching batch: blocks {} to {}", start_block, start_block + count as u64 - 1); + tracing::debug!( + "Fetching batch: blocks {} to {}", + start_block, + start_block + count as u64 - 1 + ); // Wait for rate limiter - we're making 2*count RPC calls in one HTTP request for _ in 0..(count * 2) { @@ -78,12 +88,7 @@ pub(crate) async fn fetch_blocks_batch( for attempt in 0..RPC_MAX_RETRIES { // Send request - let response = match client - .post(rpc_url) - .json(&batch_request) - .send() - .await - { + let response = match client.post(rpc_url).json(&batch_request).send().await { Ok(resp) => resp, Err(e) => { let delay = RPC_RETRY_DELAYS diff --git a/backend/crates/atlas-indexer/src/indexer.rs b/backend/crates/atlas-indexer/src/indexer.rs index ad5b858..b07aa96 100644 --- a/backend/crates/atlas-indexer/src/indexer.rs +++ b/backend/crates/atlas-indexer/src/indexer.rs @@ -16,15 +16,19 @@ use tokio_postgres_rustls::MakeRustlsConnect; use crate::batch::{BlockBatch, NftTokenState}; use crate::config::Config; -use crate::copy::{copy_blocks, copy_erc20_transfers, copy_event_logs, copy_nft_transfers, copy_transactions}; -use crate::fetcher::{fetch_blocks_batch, get_block_number_with_retry, FetchResult, FetchedBlock, SharedRateLimiter, WorkItem}; +use crate::copy::{ + copy_blocks, copy_erc20_transfers, copy_event_logs, copy_nft_transfers, copy_transactions, +}; +use crate::fetcher::{ + fetch_blocks_batch, get_block_number_with_retry, FetchResult, FetchedBlock, SharedRateLimiter, + WorkItem, +}; /// Partition size: 10 million blocks per partition const PARTITION_SIZE: u64 = 10_000_000; /// ERC-20/721 Transfer event signature: Transfer(address,address,uint256) -const TRANSFER_TOPIC: &str = - "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; +const TRANSFER_TOPIC: &str = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"; const ZERO_ADDRESS: &str = "0x0000000000000000000000000000000000000000"; @@ -87,7 +91,8 @@ impl Indexer { let mut copy_client = Self::connect_copy_client(&self.config.database_url).await?; // Create rate limiter for RPC requests - let rps = NonZeroU32::new(self.config.rpc_requests_per_second).unwrap_or(NonZeroU32::new(100).unwrap()); + let rps = NonZeroU32::new(self.config.rpc_requests_per_second) + .unwrap_or(NonZeroU32::new(100).unwrap()); let rate_limiter: SharedRateLimiter = Arc::new(RateLimiter::direct(Quota::per_second(rps))); tracing::info!("Rate limiting RPC requests to {} req/sec", rps); @@ -109,13 +114,18 @@ impl Indexer { let num_workers = self.config.fetch_workers as usize; let rpc_batch_size = self.config.rpc_batch_size as usize; - tracing::info!("Starting {} fetch workers with {} blocks per RPC batch", num_workers, rpc_batch_size); + tracing::info!( + "Starting {} fetch workers with {} blocks per RPC batch", + num_workers, + rpc_batch_size + ); // Channels for work distribution and results // work_tx: send WorkItems (block ranges) to fetch workers // result_tx: workers send fetched blocks back to main loop let (work_tx, work_rx) = async_channel::bounded::(num_workers * 2); - let (result_tx, mut result_rx) = mpsc::channel::(num_workers * rpc_batch_size * 2); + let (result_tx, mut result_rx) = + mpsc::channel::(num_workers * rpc_batch_size * 2); // Create HTTP client for batch requests let http_client = reqwest::Client::new(); @@ -142,7 +152,8 @@ impl Indexer { work_item.start_block, work_item.count, &limiter, - ).await; + ) + .await; // Send all results back for result in results { @@ -189,7 +200,12 @@ impl Indexer { // Calculate batch end let end_block = (current_block + self.config.batch_size - 1).min(head); let batch_size = (end_block - current_block + 1) as usize; - tracing::debug!("Fetching batch: {} to {} ({} blocks)", current_block, end_block, batch_size); + tracing::debug!( + "Fetching batch: {} to {} ({} blocks)", + current_block, + end_block, + batch_size + ); // Ensure partitions exist for this batch range self.ensure_partitions_exist(end_block).await?; @@ -210,7 +226,11 @@ impl Indexer { } block += count as u64; } - tracing::debug!("Sent {} blocks to workers in batches of {}", batch_size, blocks_per_batch); + tracing::debug!( + "Sent {} blocks to workers in batches of {}", + batch_size, + blocks_per_batch + ); }); // Collect results with reorder buffer, accumulating into a single @@ -269,39 +289,51 @@ impl Indexer { // Retry failed blocks if any if !failed_blocks.is_empty() { let block_nums: Vec = failed_blocks.iter().map(|(n, _)| *n).collect(); - tracing::warn!("Retrying {} failed blocks: {:?}", failed_blocks.len(), block_nums); + tracing::warn!( + "Retrying {} failed blocks: {:?}", + failed_blocks.len(), + block_nums + ); // Retry up to 3 times with increasing delay for attempt in 1..=3 { - if failed_blocks.is_empty() { break; } + if failed_blocks.is_empty() { + break; + } let delay = Duration::from_secs(attempt * 2); // 2s, 4s, 6s - tracing::info!("Retry attempt {} for {} blocks (waiting {:?})", - attempt, failed_blocks.len(), delay); + tracing::info!( + "Retry attempt {} for {} blocks (waiting {:?})", + attempt, + failed_blocks.len(), + delay + ); tokio::time::sleep(delay).await; let mut still_failed = Vec::new(); for (block_num, last_error) in failed_blocks { // Fetch single block - let results = fetch_blocks_batch( - &http_client, - &rpc_url, - block_num, - 1, - &rate_limiter, - ).await; + let results = + fetch_blocks_batch(&http_client, &rpc_url, block_num, 1, &rate_limiter) + .await; match results.into_iter().next() { Some(FetchResult::Success(fetched)) => { // Write retried block immediately let mut mini_batch = BlockBatch::new(); - Self::collect_block(&mut mini_batch, &known_erc20, &known_nft, fetched); + Self::collect_block( + &mut mini_batch, + &known_erc20, + &known_nft, + fetched, + ); let new_erc20 = std::mem::take(&mut mini_batch.new_erc20); let new_nft = std::mem::take(&mut mini_batch.new_nft); // Don't update the watermark — the main batch already wrote // a higher last_indexed_block; overwriting it with this // block's lower number would cause a regression on restart. - self.write_batch(&mut copy_client, mini_batch, false).await?; + self.write_batch(&mut copy_client, mini_batch, false) + .await?; known_erc20.extend(new_erc20); known_nft.extend(new_nft); tracing::info!("Block {} retry succeeded", block_num); @@ -369,7 +401,12 @@ impl Indexer { // Accumulates all block data into the batch for later bulk insert. // ----------------------------------------------------------------------- - fn collect_block(batch: &mut BlockBatch, known_erc20: &HashSet, known_nft: &HashSet, fetched: FetchedBlock) { + fn collect_block( + batch: &mut BlockBatch, + known_erc20: &HashSet, + known_nft: &HashSet, + fetched: FetchedBlock, + ) { use alloy::consensus::Transaction as TxTrait; let block = fetched.block; @@ -378,7 +415,8 @@ impl Indexer { // Build a receipt map keyed by tx hash for O(1) lookup. // This lets us merge receipt data (status, gas_used, contract_created) // directly into the transaction row, eliminating the UPDATE after INSERT. - let receipt_map: HashMap = fetched.receipts + let receipt_map: HashMap = fetched + .receipts .iter() .map(|r| (format!("{:?}", r.transaction_hash), r)) .collect(); @@ -387,7 +425,9 @@ impl Indexer { let tx_count = block.transactions.len() as i32; batch.b_numbers.push(block_num as i64); batch.b_hashes.push(format!("{:?}", block.header.hash)); - batch.b_parent_hashes.push(format!("{:?}", block.header.parent_hash)); + batch + .b_parent_hashes + .push(format!("{:?}", block.header.parent_hash)); batch.b_timestamps.push(block.header.timestamp as i64); batch.b_gas_used.push(block.header.gas_used as i64); batch.b_gas_limits.push(block.header.gas_limit as i64); @@ -401,7 +441,8 @@ impl Indexer { let from_str = format!("{:?}", transaction.from); let to_opt = inner.to().map(|a| format!("{:?}", a)); let value_str = inner.value().to_string(); - let gas_price_str = transaction.effective_gas_price + let gas_price_str = transaction + .effective_gas_price .map(|gp| gp.to_string()) .unwrap_or_else(|| "0".to_string()); let input = inner.input().to_vec(); @@ -409,11 +450,13 @@ impl Indexer { // Merge receipt data — no separate UPDATE needed let (status, gas_used, contract_created) = receipt_map .get(&tx_hash_str) - .map(|r| ( - r.inner.status(), - r.gas_used as i64, - r.contract_address.map(|a| format!("{:?}", a)), - )) + .map(|r| { + ( + r.inner.status(), + r.gas_used as i64, + r.contract_address.map(|a| format!("{:?}", a)), + ) + }) .unwrap_or((false, 0, None)); batch.t_hashes.push(tx_hash_str.clone()); @@ -454,13 +497,23 @@ impl Indexer { }; let emitter = format!("{:?}", log.address()); - batch.el_tx_hashes.push(log.transaction_hash.map(|h| format!("{:?}", h)).unwrap_or_default()); + batch.el_tx_hashes.push( + log.transaction_hash + .map(|h| format!("{:?}", h)) + .unwrap_or_default(), + ); batch.el_log_indices.push(log.log_index.unwrap_or(0) as i32); batch.el_addresses.push(emitter.clone()); batch.el_topic0s.push(topic0.clone()); - batch.el_topic1s.push(topics.get(1).map(|t| format!("{:?}", t))); - batch.el_topic2s.push(topics.get(2).map(|t| format!("{:?}", t))); - batch.el_topic3s.push(topics.get(3).map(|t| format!("{:?}", t))); + batch + .el_topic1s + .push(topics.get(1).map(|t| format!("{:?}", t))); + batch + .el_topic2s + .push(topics.get(2).map(|t| format!("{:?}", t))); + batch + .el_topic3s + .push(topics.get(3).map(|t| format!("{:?}", t))); batch.el_datas.push(log.data().data.to_vec()); batch.el_block_numbers.push(block_num as i64); @@ -479,13 +532,18 @@ impl Indexer { let to = format!("0x{}", hex::encode(&topics[2].as_slice()[12..])); let token_id_str = U256::from_be_slice(topics[3].as_slice()).to_string(); - if !known_nft.contains(&contract) && batch.new_nft.insert(contract.clone()) { + if !known_nft.contains(&contract) && batch.new_nft.insert(contract.clone()) + { batch.nft_contract_addrs.push(contract.clone()); batch.nft_contract_first_seen.push(block_num as i64); batch.touch_addr(contract.clone(), block_num as i64, true, 0); } - batch.nt_tx_hashes.push(log.transaction_hash.map(|h| format!("{:?}", h)).unwrap_or_default()); + batch.nt_tx_hashes.push( + log.transaction_hash + .map(|h| format!("{:?}", h)) + .unwrap_or_default(), + ); batch.nt_log_indices.push(log.log_index.unwrap_or(0) as i32); batch.nt_contracts.push(contract.clone()); batch.nt_token_ids.push(token_id_str.clone()); @@ -497,7 +555,10 @@ impl Indexer { // Keep only the latest state per token (last transfer wins) batch.nft_token_map.insert( (contract, token_id_str), - NftTokenState { owner: to, last_transfer_block: block_num as i64 }, + NftTokenState { + owner: to, + last_transfer_block: block_num as i64, + }, ); } // ERC-20: Transfer(address indexed from, address indexed to, uint256 value) @@ -506,18 +567,25 @@ impl Indexer { let from = format!("0x{}", hex::encode(&topics[1].as_slice()[12..])); let to = format!("0x{}", hex::encode(&topics[2].as_slice()[12..])); let value = BigDecimal::from_str( - &U256::from_be_slice(&log.data().data[..32]).to_string() - ).unwrap_or_default(); + &U256::from_be_slice(&log.data().data[..32]).to_string(), + ) + .unwrap_or_default(); // Register new contract without blocking RPC calls — // the metadata fetcher will fill in name/symbol/decimals. - if !known_erc20.contains(&contract) && batch.new_erc20.insert(contract.clone()) { + if !known_erc20.contains(&contract) + && batch.new_erc20.insert(contract.clone()) + { batch.ec_addresses.push(contract.clone()); batch.ec_first_seen_blocks.push(block_num as i64); batch.touch_addr(contract.clone(), block_num as i64, true, 0); } - batch.et_tx_hashes.push(log.transaction_hash.map(|h| format!("{:?}", h)).unwrap_or_default()); + batch.et_tx_hashes.push( + log.transaction_hash + .map(|h| format!("{:?}", h)) + .unwrap_or_default(), + ); batch.et_log_indices.push(log.log_index.unwrap_or(0) as i32); batch.et_contracts.push(contract.clone()); batch.et_froms.push(from.clone()); @@ -530,10 +598,20 @@ impl Indexer { // for the same (address, contract) pair are summed in Rust, // so we only need one DB upsert per unique pair. if from != ZERO_ADDRESS { - batch.apply_balance_delta(from, contract.clone(), -value.clone(), block_num as i64); + batch.apply_balance_delta( + from, + contract.clone(), + -value.clone(), + block_num as i64, + ); } if to != ZERO_ADDRESS { - batch.apply_balance_delta(to, contract.clone(), value, block_num as i64); + batch.apply_balance_delta( + to, + contract.clone(), + value, + block_num as i64, + ); } } _ => {} @@ -549,7 +627,12 @@ impl Indexer { // For a batch of N blocks this is ~11 round-trips regardless of N. // ----------------------------------------------------------------------- - async fn write_batch(&self, copy_client: &mut Client, batch: BlockBatch, update_watermark: bool) -> Result<()> { + async fn write_batch( + &self, + copy_client: &mut Client, + batch: BlockBatch, + update_watermark: bool, + ) -> Result<()> { if batch.b_numbers.is_empty() { return Ok(()); } @@ -578,13 +661,14 @@ impl Indexer { if !tl_hashes.is_empty() { let params: [&(dyn ToSql + Sync); 2] = [&tl_hashes, &tl_block_numbers]; - pg_tx.execute( - "INSERT INTO tx_hash_lookup (hash, block_number) + pg_tx + .execute( + "INSERT INTO tx_hash_lookup (hash, block_number) SELECT * FROM unnest($1::text[], $2::bigint[]) AS t(hash, block_number) ON CONFLICT (hash) DO NOTHING", - ¶ms, - ) - .await?; + ¶ms, + ) + .await?; } if !addr_map.is_empty() { @@ -599,7 +683,8 @@ impl Indexer { a_tx_counts.push(state.tx_count_delta); } - let params: [&(dyn ToSql + Sync); 4] = [&a_addrs, &a_contracts, &a_first_seen, &a_tx_counts]; + let params: [&(dyn ToSql + Sync); 4] = + [&a_addrs, &a_contracts, &a_first_seen, &a_tx_counts]; pg_tx.execute( "INSERT INTO addresses (address, is_contract, first_seen_block, tx_count) SELECT * FROM unnest($1::text[], $2::bool[], $3::bigint[], $4::bigint[]) @@ -615,13 +700,14 @@ impl Indexer { if !nft_contract_addrs.is_empty() { let params: [&(dyn ToSql + Sync); 2] = [&nft_contract_addrs, &nft_contract_first_seen]; - pg_tx.execute( - "INSERT INTO nft_contracts (address, first_seen_block) + pg_tx + .execute( + "INSERT INTO nft_contracts (address, first_seen_block) SELECT * FROM unnest($1::text[], $2::bigint[]) AS t(address, first_seen_block) ON CONFLICT (address) DO NOTHING", - ¶ms, - ) - .await?; + ¶ms, + ) + .await?; } if !nft_token_map.is_empty() { @@ -636,7 +722,8 @@ impl Indexer { tok_last_blocks.push(state.last_transfer_block); } - let params: [&(dyn ToSql + Sync); 4] = [&tok_contracts, &tok_ids, &tok_owners, &tok_last_blocks]; + let params: [&(dyn ToSql + Sync); 4] = + [&tok_contracts, &tok_ids, &tok_owners, &tok_last_blocks]; pg_tx.execute( "INSERT INTO nft_tokens (contract_address, token_id, owner, metadata_fetched, last_transfer_block) SELECT contract_address, token_id::numeric, owner, false, last_transfer_block @@ -656,14 +743,15 @@ impl Indexer { if !ec_addresses.is_empty() { let params: [&(dyn ToSql + Sync); 2] = [&ec_addresses, &ec_first_seen_blocks]; - pg_tx.execute( - "INSERT INTO erc20_contracts (address, decimals, first_seen_block) + pg_tx + .execute( + "INSERT INTO erc20_contracts (address, decimals, first_seen_block) SELECT address, 18, first_seen_block FROM unnest($1::text[], $2::bigint[]) AS t(address, first_seen_block) ON CONFLICT (address) DO NOTHING", - ¶ms, - ) - .await?; + ¶ms, + ) + .await?; } if !balance_map.is_empty() { @@ -679,7 +767,8 @@ impl Indexer { } let bal_delta_strs: Vec = bal_deltas.iter().map(|d| d.to_string()).collect(); - let params: [&(dyn ToSql + Sync); 4] = [&bal_addrs, &bal_contracts, &bal_delta_strs, &bal_blocks]; + let params: [&(dyn ToSql + Sync); 4] = + [&bal_addrs, &bal_contracts, &bal_delta_strs, &bal_blocks]; pg_tx.execute( "INSERT INTO erc20_balances (address, contract_address, balance, last_updated_block) SELECT address, contract_address, balance::numeric, last_updated_block @@ -695,13 +784,14 @@ impl Indexer { if update_watermark { let last_value = last_block.to_string(); - pg_tx.execute( - "INSERT INTO indexer_state (key, value, updated_at) + pg_tx + .execute( + "INSERT INTO indexer_state (key, value, updated_at) VALUES ('last_indexed_block', $1, NOW()) ON CONFLICT (key) DO UPDATE SET value = $1, updated_at = NOW()", - &[&last_value], - ) - .await?; + &[&last_value], + ) + .await?; } pg_tx.commit().await?; @@ -727,11 +817,10 @@ impl Indexer { } async fn get_start_block(&self) -> Result { - let result: Option<(String,)> = sqlx::query_as( - "SELECT value FROM indexer_state WHERE key = 'last_indexed_block'" - ) - .fetch_optional(&self.pool) - .await?; + let result: Option<(String,)> = + sqlx::query_as("SELECT value FROM indexer_state WHERE key = 'last_indexed_block'") + .fetch_optional(&self.pool) + .await?; if let Some((value,)) = result { let last_block: u64 = value.parse()?; @@ -758,14 +847,15 @@ impl Indexer { // First run - check what partitions exist let existing: Option<(i64,)> = sqlx::query_as( "SELECT MAX(CAST(SUBSTRING(relname FROM 'blocks_p(\\d+)') AS BIGINT)) - FROM pg_class WHERE relname ~ '^blocks_p\\d+$'" + FROM pg_class WHERE relname ~ '^blocks_p\\d+$'", ) .fetch_optional(&self.pool) .await?; match existing { Some((max,)) => { - self.current_max_partition.store(max as u64, Ordering::Relaxed); + self.current_max_partition + .store(max as u64, Ordering::Relaxed); if partition_num <= max as u64 { return Ok(()); } @@ -804,14 +894,13 @@ impl Indexer { table, p, table, partition_start, partition_end ); - sqlx::query(&create_sql) - .execute(&self.pool) - .await?; + sqlx::query(&create_sql).execute(&self.pool).await?; } } // Update our tracked max - self.current_max_partition.store(partition_num, Ordering::Relaxed); + self.current_max_partition + .store(partition_num, Ordering::Relaxed); tracing::info!("Partitions up to p{} ready", partition_num); Ok(()) } diff --git a/backend/crates/atlas-indexer/src/main.rs b/backend/crates/atlas-indexer/src/main.rs index 2be5d52..331f047 100644 --- a/backend/crates/atlas-indexer/src/main.rs +++ b/backend/crates/atlas-indexer/src/main.rs @@ -3,8 +3,8 @@ use std::time::Duration; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod batch; -mod copy; mod config; +mod copy; mod fetcher; mod indexer; mod metadata; @@ -17,8 +17,10 @@ const MAX_RETRY_DELAY: u64 = 60; 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::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "atlas_indexer=info,sqlx=warn".into()), + ) .with(tracing_subscriber::fmt::layer()) .init(); @@ -29,7 +31,8 @@ async fn main() -> Result<()> { let config = config::Config::from_env()?; // Create database pool - let pool = atlas_common::db::create_pool(&config.database_url, config.db_max_connections).await?; + let pool = + atlas_common::db::create_pool(&config.database_url, config.db_max_connections).await?; // Run migrations tracing::info!("Running database migrations"); @@ -43,9 +46,11 @@ async fn main() -> Result<()> { 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())?; + let fetcher = + metadata::MetadataFetcher::new(metadata_pool.clone(), metadata_config.clone())?; fetcher.run().await - }).await + }) + .await }); // Run indexer with retry on failure diff --git a/backend/crates/atlas-indexer/src/metadata.rs b/backend/crates/atlas-indexer/src/metadata.rs index ea32aba..4111961 100644 --- a/backend/crates/atlas-indexer/src/metadata.rs +++ b/backend/crates/atlas-indexer/src/metadata.rs @@ -52,11 +52,19 @@ impl MetadataFetcher { let provider = Arc::new(ProviderBuilder::new().on_http(config.rpc_url.parse()?)); - Ok(Self { pool, config, client, provider }) + Ok(Self { + pool, + config, + client, + provider, + }) } pub async fn run(&self) -> Result<()> { - tracing::info!("Starting metadata fetcher with {} workers", self.config.metadata_fetch_workers); + tracing::info!( + "Starting metadata fetcher with {} workers", + self.config.metadata_fetch_workers + ); loop { let mut did_work = false; @@ -80,7 +88,7 @@ impl MetadataFetcher { /// Fetch metadata for NFT contracts (name, symbol, totalSupply) async fn fetch_nft_contract_metadata(&self) -> Result { let contracts: Vec<(String,)> = sqlx::query_as( - "SELECT address FROM nft_contracts WHERE metadata_fetched = false LIMIT $1" + "SELECT address FROM nft_contracts WHERE metadata_fetched = false LIMIT $1", ) .bind(self.config.metadata_fetch_workers as i32 * 5) .fetch_all(&self.pool) @@ -99,12 +107,18 @@ impl MetadataFetcher { handles.push(tokio::spawn(async move { if let Err(e) = fetch_nft_contract_metadata(&pool, &provider, &address).await { - tracing::debug!("Failed to fetch NFT contract metadata for {}: {}", address, e); + tracing::debug!( + "Failed to fetch NFT contract metadata for {}: {}", + address, + e + ); // Mark as fetched to avoid infinite retries - let _ = sqlx::query("UPDATE nft_contracts SET metadata_fetched = true WHERE address = $1") - .bind(&address) - .execute(&pool) - .await; + let _ = sqlx::query( + "UPDATE nft_contracts SET metadata_fetched = true WHERE address = $1", + ) + .bind(&address) + .execute(&pool) + .await; } })); @@ -125,7 +139,7 @@ impl MetadataFetcher { /// Fetch metadata for ERC-20 contracts (name, symbol, decimals, totalSupply) async fn fetch_erc20_contract_metadata(&self) -> Result { let contracts: Vec<(String,)> = sqlx::query_as( - "SELECT address FROM erc20_contracts WHERE metadata_fetched = false LIMIT $1" + "SELECT address FROM erc20_contracts WHERE metadata_fetched = false LIMIT $1", ) .bind(self.config.metadata_fetch_workers as i32 * 5) .fetch_all(&self.pool) @@ -144,12 +158,18 @@ impl MetadataFetcher { handles.push(tokio::spawn(async move { if let Err(e) = fetch_erc20_contract_metadata(&pool, &provider, &address).await { - tracing::debug!("Failed to fetch ERC-20 contract metadata for {}: {}", address, e); + tracing::debug!( + "Failed to fetch ERC-20 contract metadata for {}: {}", + address, + e + ); // Mark as fetched to avoid infinite retries - let _ = sqlx::query("UPDATE erc20_contracts SET metadata_fetched = true WHERE address = $1") - .bind(&address) - .execute(&pool) - .await; + let _ = sqlx::query( + "UPDATE erc20_contracts SET metadata_fetched = true WHERE address = $1", + ) + .bind(&address) + .execute(&pool) + .await; } })); @@ -173,7 +193,7 @@ impl MetadataFetcher { "SELECT contract_address, token_id::text, token_uri FROM nft_tokens WHERE metadata_fetched = false - LIMIT $1" + LIMIT $1", ) .bind(self.config.metadata_fetch_workers as i32 * 10) .fetch_all(&self.pool) @@ -196,10 +216,16 @@ impl MetadataFetcher { handles.push(tokio::spawn(async move { // Errors are logged inside fetch_and_store_token_metadata at debug level let _ = fetch_and_store_token_metadata( - &pool, &client, &provider, &ipfs_gateway, - &contract_address, &token_id, token_uri.as_deref(), - retry_attempts - ).await; + &pool, + &client, + &provider, + &ipfs_gateway, + &contract_address, + &token_id, + token_uri.as_deref(), + retry_attempts, + ) + .await; })); if handles.len() >= self.config.metadata_fetch_workers as usize { @@ -233,7 +259,12 @@ async fn fetch_nft_contract_metadata( let symbol = contract.symbol().call().await.ok().map(|r| r._0); // Fetch totalSupply (optional - ERC-721 doesn't require it) - let total_supply = contract.totalSupply().call().await.ok().map(|r| r._0.try_into().unwrap_or(0i64)); + let total_supply = contract + .totalSupply() + .call() + .await + .ok() + .map(|r| r._0.try_into().unwrap_or(0i64)); sqlx::query( "UPDATE nft_contracts SET @@ -241,7 +272,7 @@ async fn fetch_nft_contract_metadata( symbol = COALESCE($3, symbol), total_supply = COALESCE($4, total_supply), metadata_fetched = true - WHERE address = $1" + WHERE address = $1", ) .bind(contract_address) .bind(name) @@ -273,9 +304,12 @@ async fn fetch_erc20_contract_metadata( let decimals = contract.decimals().call().await.ok().map(|r| r._0 as i16); // Fetch totalSupply - let total_supply = contract.totalSupply().call().await.ok().map(|r| { - BigDecimal::from_str(&r._0.to_string()).unwrap_or_default() - }); + let total_supply = contract + .totalSupply() + .call() + .await + .ok() + .map(|r| BigDecimal::from_str(&r._0.to_string()).unwrap_or_default()); sqlx::query( "UPDATE erc20_contracts SET @@ -284,7 +318,7 @@ async fn fetch_erc20_contract_metadata( decimals = COALESCE($4, decimals), total_supply = COALESCE($5, total_supply), metadata_fetched = true - WHERE address = $1" + WHERE address = $1", ) .bind(contract_address) .bind(name) @@ -318,7 +352,7 @@ async fn fetch_and_store_token_metadata( // Store the URI in the database for future reference sqlx::query( "UPDATE nft_tokens SET token_uri = $3 - WHERE contract_address = $1 AND token_id = $2::numeric" + WHERE contract_address = $1 AND token_id = $2::numeric", ) .bind(contract_address) .bind(token_id) @@ -328,11 +362,16 @@ async fn fetch_and_store_token_metadata( uri } Err(e) => { - tracing::debug!("Failed to fetch tokenURI for {}:{}: {}", contract_address, token_id, e); + tracing::debug!( + "Failed to fetch tokenURI for {}:{}: {}", + contract_address, + token_id, + e + ); // Mark as fetched to avoid retrying forever sqlx::query( "UPDATE nft_tokens SET metadata_fetched = true - WHERE contract_address = $1 AND token_id = $2::numeric" + WHERE contract_address = $1 AND token_id = $2::numeric", ) .bind(contract_address) .bind(token_id) @@ -348,7 +387,7 @@ async fn fetch_and_store_token_metadata( if uri.is_empty() { sqlx::query( "UPDATE nft_tokens SET metadata_fetched = true - WHERE contract_address = $1 AND token_id = $2::numeric" + WHERE contract_address = $1 AND token_id = $2::numeric", ) .bind(contract_address) .bind(token_id) @@ -379,7 +418,7 @@ async fn fetch_and_store_token_metadata( "UPDATE nft_tokens SET metadata_fetched = true, image_url = $3 - WHERE contract_address = $1 AND token_id = $2::numeric" + WHERE contract_address = $1 AND token_id = $2::numeric", ) .bind(contract_address) .bind(token_id) @@ -389,7 +428,9 @@ async fn fetch_and_store_token_metadata( tracing::debug!( "NFT {}:{} has direct image URI ({})", - contract_address, token_id, content_type + contract_address, + token_id, + content_type ); return Ok(()); } @@ -399,7 +440,8 @@ async fn fetch_and_store_token_metadata( Ok(metadata) => { // Extract common fields let name = metadata.get("name").and_then(|v| v.as_str()); - let image = metadata.get("image") + let image = metadata + .get("image") .or_else(|| metadata.get("image_url")) .and_then(|v| v.as_str()); @@ -412,7 +454,7 @@ async fn fetch_and_store_token_metadata( metadata = $3, name = $4, image_url = $5 - WHERE contract_address = $1 AND token_id = $2::numeric" + WHERE contract_address = $1 AND token_id = $2::numeric", ) .bind(contract_address) .bind(token_id) @@ -446,7 +488,7 @@ async fn fetch_and_store_token_metadata( // Mark as fetched even on failure (to avoid infinite retries) sqlx::query( "UPDATE nft_tokens SET metadata_fetched = true - WHERE contract_address = $1 AND token_id = $2::numeric" + WHERE contract_address = $1 AND token_id = $2::numeric", ) .bind(contract_address) .bind(token_id) @@ -456,10 +498,14 @@ async fn fetch_and_store_token_metadata( // Log at debug level since this is often expected (non-standard NFTs) tracing::debug!( "Failed to fetch metadata for {}:{}: {}", - contract_address, token_id, last_error.as_deref().unwrap_or("Unknown error") + contract_address, + token_id, + last_error.as_deref().unwrap_or("Unknown error") ); - Err(anyhow::anyhow!(last_error.unwrap_or_else(|| "Unknown error".to_string()))) + Err(anyhow::anyhow!( + last_error.unwrap_or_else(|| "Unknown error".to_string()) + )) } /// Call tokenURI on an NFT contract diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f96dfaa..d6e3115 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { Layout } from './components'; import { BlocksPage, diff --git a/frontend/src/api/addresses.ts b/frontend/src/api/addresses.ts index 2e933ba..9802bf7 100644 --- a/frontend/src/api/addresses.ts +++ b/frontend/src/api/addresses.ts @@ -29,8 +29,7 @@ export async function getEthBalance(address: string): Promise { params: { module: 'account', action: 'balance', address }, }); // Returns Wei as string - // @ts-expect-error dynamic shape compatible - return (response.data as any).result ?? '0'; + return response.data.result ?? '0'; } // New: Address transfers (ERC-20 + NFT) diff --git a/frontend/src/components/ImageIpfs.tsx b/frontend/src/components/ImageIpfs.tsx index aa387f4..f52d5f0 100644 --- a/frontend/src/components/ImageIpfs.tsx +++ b/frontend/src/components/ImageIpfs.tsx @@ -36,7 +36,6 @@ export default function ImageIpfs({ srcUrl, gateways = DEFAULT_GATEWAYS, alt = ' if (!srcUrl) return null; return ( - // eslint-disable-next-line jsx-a11y/alt-text 
     />
   );
 }
-
diff --git a/frontend/src/components/SmoothCounter.tsx b/frontend/src/components/SmoothCounter.tsx
index 2a87bf0..bfdb6d4 100644
--- a/frontend/src/components/SmoothCounter.tsx
+++ b/frontend/src/components/SmoothCounter.tsx
@@ -1,5 +1,3 @@
-import React from ); } - diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 1122557..f5bc2c2 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -6,5 +6,6 @@ export * from './useNFTs'; export * from './useTokens'; export * from './useLogs'; export * from './useTransfers'; +export * from './useProxies'; export { default as useEthBalance } from './useEthBalance'; export { default as useEthPrice } from './useEthPrice'; diff --git a/frontend/src/pages/BlockDetailPage.tsx b/frontend/src/pages/BlockDetailPage.tsx index bc89fc7..57ed126 100644 --- a/frontend/src/pages/BlockDetailPage.tsx +++ b/frontend/src/pages/BlockDetailPage.tsx @@ -3,6 +3,7 @@ import { useBlock, useBlockTransactions } from '../hooks'; import { CopyButton, Loading, AddressLink, TxHashLink, StatusBadge } from '../components'; import { formatNumber, formatTimestamp, formatGas, truncateHash, formatTimeAgo, formatEther } from '../utils'; import { useState } from 'react'; +import type { ReactNode } from 'react'; export default function BlockDetailPage() { const { number } = useParams<{ number: string }>(); @@ -11,7 +12,7 @@ export default function BlockDetailPage() { const [txPage, setTxPage] = useState(1); const { transactions, pagination, loading } = useBlockTransactions(blockNumber, { page: txPage, limit: 20 }); - type DetailRow = { label: string; value: JSX.Element | string; stacked?: boolean }; + type DetailRow = { label: string; value: ReactNode; stacked?: boolean }; const details: DetailRow[] = block ? [ { label: 'Block Height', value: formatNumber(block.number) }, { label: 'Timestamp', value: formatTimestamp(block.timestamp) }, diff --git a/frontend/src/pages/BlocksPage.tsx b/frontend/src/pages/BlocksPage.tsx index 84ca18b..a0c6f8d 100644 --- a/frontend/src/pages/BlocksPage.tsx +++ b/frontend/src/pages/BlocksPage.tsx @@ -123,6 +123,7 @@ export default function BlocksPage() {

Blocks

+