diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebdb3b8..edcddd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,8 +11,8 @@ concurrency: cancel-in-progress: true jobs: - backend: - name: Backend (Rust) + backend-fmt: + name: Backend Format runs-on: ubuntu-latest defaults: run: @@ -24,19 +24,52 @@ jobs: - name: Setup Rust uses: dtolnay/rust-toolchain@stable with: - components: rustfmt, clippy + components: rustfmt + + - name: Format + run: cargo fmt --all --check + + backend-clippy: + name: Backend Clippy + 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: 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 + backend-test: + name: Backend Test + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + - name: Test run: cargo test --workspace --all-targets diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..e671c15 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,71 @@ +name: Docker Build + +on: + pull_request: + push: + branches: + - main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-indexer: + name: Indexer Docker (linux/amd64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build indexer image + uses: docker/build-push-action@v6 + with: + context: backend + target: indexer + platforms: linux/amd64 + cache-from: type=gha,scope=backend + cache-to: type=gha,scope=backend,mode=max + outputs: type=cacheonly + + build-api: + name: API Docker (linux/amd64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build api image + uses: docker/build-push-action@v6 + with: + context: backend + target: api + platforms: linux/amd64 + cache-from: type=gha,scope=backend + cache-to: type=gha,scope=backend,mode=max + outputs: type=cacheonly + + build-frontend: + name: Frontend Docker (linux/amd64) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build frontend image + uses: docker/build-push-action@v6 + with: + context: frontend + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + outputs: type=cacheonly diff --git a/CLAUDE.md b/CLAUDE.md index dc7a8d6..d35bb60 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ pub struct AppState { - **Migrations**: use `run_migrations(&database_url)` (not `&pool`) to get a timeout-free connection - **Frontend**: uses Bun (not npm/yarn). Lockfile is `bun.lock` (text, Bun ≥ 1.2). Build with `bunx vite build` (skips tsc type check). - **Docker**: frontend image uses `nginxinc/nginx-unprivileged:alpine` (non-root, port 8080). API/indexer use `alpine` with `ca-certificates`. +- **Tests**: add unit tests for new logic in a `#[cfg(test)] mod tests` block in the same file. Run with `cargo test --workspace`. - **Commits**: authored by the user only — no Claude co-author lines. ## Environment Variables diff --git a/backend/crates/atlas-indexer/src/batch.rs b/backend/crates/atlas-indexer/src/batch.rs index a3e8ffd..db699da 100644 --- a/backend/crates/atlas-indexer/src/batch.rs +++ b/backend/crates/atlas-indexer/src/batch.rs @@ -155,3 +155,189 @@ impl BlockBatch { entry.last_block = entry.last_block.max(block); } } + +#[cfg(test)] +mod tests { + use super::*; + use bigdecimal::BigDecimal; + + // --- touch_addr tests --- + + #[test] + fn touch_addr_first_insertion() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xabc".to_string(), 100, false, 1); + + let state = batch.addr_map.get("0xabc").unwrap(); + assert_eq!(state.first_seen_block, 100); + assert!(!state.is_contract); + assert_eq!(state.tx_count_delta, 1); + } + + #[test] + fn touch_addr_keeps_minimum_first_seen_block() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xabc".to_string(), 200, false, 0); + batch.touch_addr("0xabc".to_string(), 100, false, 0); + + assert_eq!(batch.addr_map["0xabc"].first_seen_block, 100); + } + + #[test] + fn touch_addr_is_contract_latches_true() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xabc".to_string(), 100, false, 0); + batch.touch_addr("0xabc".to_string(), 101, true, 0); + + assert!(batch.addr_map["0xabc"].is_contract); + } + + #[test] + fn touch_addr_accumulates_tx_count_delta() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xabc".to_string(), 100, false, 1); + batch.touch_addr("0xabc".to_string(), 101, false, 2); + batch.touch_addr("0xabc".to_string(), 102, false, 3); + + assert_eq!(batch.addr_map["0xabc"].tx_count_delta, 6); + } + + #[test] + fn touch_addr_deduplicates_different_addresses_separately() { + let mut batch = BlockBatch::new(); + batch.touch_addr("0xaaa".to_string(), 100, false, 1); + batch.touch_addr("0xbbb".to_string(), 200, true, 2); + + assert_eq!(batch.addr_map.len(), 2); + assert_eq!(batch.addr_map["0xaaa"].tx_count_delta, 1); + assert_eq!(batch.addr_map["0xbbb"].tx_count_delta, 2); + assert!(!batch.addr_map["0xaaa"].is_contract); + assert!(batch.addr_map["0xbbb"].is_contract); + } + + // --- apply_balance_delta tests --- + + #[test] + fn apply_balance_delta_first_insertion() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(100), + 50, + ); + + let entry = batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken".to_string())) + .unwrap(); + assert_eq!(entry.delta, BigDecimal::from(100)); + assert_eq!(entry.last_block, 50); + } + + #[test] + fn apply_balance_delta_accumulates_positive() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(100), + 50, + ); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(50), + 60, + ); + + let entry = batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken".to_string())) + .unwrap(); + assert_eq!(entry.delta, BigDecimal::from(150)); + assert_eq!(entry.last_block, 60); + } + + #[test] + fn apply_balance_delta_accumulates_negative() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(100), + 50, + ); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(-30), + 51, + ); + + let entry = batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken".to_string())) + .unwrap(); + assert_eq!(entry.delta, BigDecimal::from(70)); + } + + #[test] + fn apply_balance_delta_tracks_max_block() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(1), + 100, + ); + // Earlier block — last_block should stay at 100 + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken".to_string(), + BigDecimal::from(1), + 50, + ); + + let entry = batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken".to_string())) + .unwrap(); + assert_eq!(entry.last_block, 100); + } + + #[test] + fn apply_balance_delta_separate_contracts_are_independent() { + let mut batch = BlockBatch::new(); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken1".to_string(), + BigDecimal::from(100), + 50, + ); + batch.apply_balance_delta( + "0xaddr".to_string(), + "0xtoken2".to_string(), + BigDecimal::from(200), + 50, + ); + + assert_eq!(batch.balance_map.len(), 2); + assert_eq!( + batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken1".to_string())) + .unwrap() + .delta, + BigDecimal::from(100) + ); + assert_eq!( + batch + .balance_map + .get(&("0xaddr".to_string(), "0xtoken2".to_string())) + .unwrap() + .delta, + BigDecimal::from(200) + ); + } +} diff --git a/backend/crates/atlas-indexer/src/indexer.rs b/backend/crates/atlas-indexer/src/indexer.rs index f2c26c3..9994c1b 100644 --- a/backend/crates/atlas-indexer/src/indexer.rs +++ b/backend/crates/atlas-indexer/src/indexer.rs @@ -906,3 +906,265 @@ impl Indexer { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_fetched_block(number: u64) -> FetchedBlock { + FetchedBlock { + number, + block: alloy::rpc::types::Block::default(), + receipts: vec![], + } + } + + fn make_receipt(logs_json: serde_json::Value) -> alloy::rpc::types::TransactionReceipt { + let receipt_json = serde_json::json!({ + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "blockNumber": "0x1", + "from": "0x0000000000000000000000000000000000000001", + "to": "0x0000000000000000000000000000000000000002", + "cumulativeGasUsed": "0x5208", + "gasUsed": "0x5208", + "contractAddress": null, + "logs": logs_json, + "logsBloom": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000", + "type": "0x2", + "effectiveGasPrice": "0x1", + "status": "0x1" + }); + serde_json::from_value(receipt_json).expect("valid receipt JSON") + } + + #[test] + fn collect_empty_block_populates_block_fields() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + Indexer::collect_block( + &mut batch, + &known_erc20, + &known_nft, + empty_fetched_block(100), + ); + + assert_eq!(batch.b_numbers, vec![100i64]); + assert_eq!(batch.b_tx_counts, vec![0i32]); + assert!(batch.addr_map.is_empty()); + assert_eq!(batch.last_block, 100); + } + + #[test] + fn collect_multiple_blocks_accumulate_in_order() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, empty_fetched_block(1)); + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, empty_fetched_block(2)); + + assert_eq!(batch.b_numbers, vec![1i64, 2i64]); + assert_eq!(batch.last_block, 2); + } + + #[test] + fn collect_erc20_transfer_populates_transfer_and_balance_arrays() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // ERC-20 Transfer: 3 topics + 32 bytes data (value = 1000) + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + assert_eq!(batch.et_contracts.len(), 1); + assert_eq!(batch.et_froms.len(), 1); + assert_eq!(batch.et_tos.len(), 1); + assert_eq!(batch.et_values, vec!["1000".to_string()]); + + // New ERC-20 contract registered + assert_eq!(batch.ec_addresses.len(), 1); + assert_eq!(batch.new_erc20.len(), 1); + + // Two balance deltas: sender (negative) and receiver (positive) + assert_eq!(batch.balance_map.len(), 2); + + let contract = batch.ec_addresses[0].clone(); + let from = "0x1111111111111111111111111111111111111111"; + let to = "0x2222222222222222222222222222222222222222"; + + let sender_delta = &batch.balance_map[&(from.to_string(), contract.clone())]; + assert!(sender_delta.delta < 0); + + let receiver_delta = &batch.balance_map[&(to.to_string(), contract)]; + assert!(receiver_delta.delta > 0); + } + + #[test] + fn collect_erc20_mint_skips_zero_address_balance_delta() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // Mint: from = ZERO_ADDRESS → no balance delta for sender + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + // Only the receiver gets a balance delta; zero address is excluded + assert_eq!(batch.balance_map.len(), 1); + let contract = batch.ec_addresses[0].clone(); + let to = "0x2222222222222222222222222222222222222222"; + assert!(batch.balance_map.contains_key(&(to.to_string(), contract))); + } + + #[test] + fn collect_erc20_known_contract_not_added_to_ec_addresses() { + let mut batch = BlockBatch::new(); + let mut known_erc20 = HashSet::new(); + known_erc20.insert("0x3333333333333333333333333333333333333333".to_string()); + let known_nft = HashSet::new(); + + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x00000000000000000000000000000000000000000000000000000000000003e8", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + // Transfer is still recorded + assert_eq!(batch.et_contracts.len(), 1); + // But contract is NOT added again (already in known_erc20) + assert_eq!(batch.ec_addresses.len(), 0); + assert_eq!(batch.new_erc20.len(), 0); + } + + #[test] + fn collect_erc721_transfer_populates_nft_arrays() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // ERC-721 Transfer: 4 topics, token ID = 42, empty data + let logs = serde_json::json!([{ + "address": "0x4444444444444444444444444444444444444444", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222", + "0x000000000000000000000000000000000000000000000000000000000000002a" + ], + "data": "0x", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + assert_eq!(batch.nt_contracts.len(), 1); + assert_eq!(batch.nt_token_ids, vec!["42".to_string()]); + assert_eq!(batch.nt_froms.len(), 1); + assert_eq!(batch.nt_tos.len(), 1); + + // New NFT contract registered + assert_eq!(batch.nft_contract_addrs.len(), 1); + assert_eq!(batch.new_nft.len(), 1); + + // NFT token ownership tracked in nft_token_map + assert_eq!(batch.nft_token_map.len(), 1); + + // No ERC-20 data and no balance deltas + assert!(batch.et_contracts.is_empty()); + assert!(batch.balance_map.is_empty()); + } + + #[test] + fn collect_ambiguous_transfer_skipped_when_data_too_short() { + let mut batch = BlockBatch::new(); + let known_erc20 = HashSet::new(); + let known_nft = HashSet::new(); + + // 3 topics but only 2 bytes of data → neither ERC-20 nor ERC-721 + let logs = serde_json::json!([{ + "address": "0x3333333333333333333333333333333333333333", + "topics": [ + "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef", + "0x0000000000000000000000001111111111111111111111111111111111111111", + "0x0000000000000000000000002222222222222222222222222222222222222222" + ], + "data": "0x1234", + "blockNumber": "0x1", + "transactionHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "transactionIndex": "0x0", + "blockHash": "0x0000000000000000000000000000000000000000000000000000000000000001", + "logIndex": "0x0", + "removed": false + }]); + + let mut fb = empty_fetched_block(1); + fb.receipts = vec![make_receipt(logs)]; + Indexer::collect_block(&mut batch, &known_erc20, &known_nft, fb); + + assert!(batch.et_contracts.is_empty()); + assert!(batch.nt_contracts.is_empty()); + assert!(batch.ec_addresses.is_empty()); + assert!(batch.nft_contract_addrs.is_empty()); + assert!(batch.balance_map.is_empty()); + } +} diff --git a/backend/crates/atlas-indexer/src/metadata.rs b/backend/crates/atlas-indexer/src/metadata.rs index 3af8cb1..76e20ee 100644 --- a/backend/crates/atlas-indexer/src/metadata.rs +++ b/backend/crates/atlas-indexer/src/metadata.rs @@ -536,3 +536,51 @@ fn resolve_uri(uri: &str, ipfs_gateway: &str) -> String { uri.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + const GATEWAY: &str = "https://ipfs.io/ipfs/"; + + #[test] + fn resolve_ipfs_uri_prefixes_gateway() { + assert_eq!( + resolve_uri("ipfs://QmXxx123", GATEWAY), + "https://ipfs.io/ipfs/QmXxx123" + ); + } + + #[test] + fn resolve_ipfs_uri_with_path() { + assert_eq!( + resolve_uri("ipfs://QmXxx123/metadata/1.json", GATEWAY), + "https://ipfs.io/ipfs/QmXxx123/metadata/1.json" + ); + } + + #[test] + fn resolve_arweave_uri() { + assert_eq!( + resolve_uri("ar://txid123", GATEWAY), + "https://arweave.net/txid123" + ); + } + + #[test] + fn resolve_data_uri_is_unchanged() { + let data = "data:image/png;base64,abc123=="; + assert_eq!(resolve_uri(data, GATEWAY), data); + } + + #[test] + fn resolve_https_uri_is_unchanged() { + let url = "https://example.com/metadata/1.json"; + assert_eq!(resolve_uri(url, GATEWAY), url); + } + + #[test] + fn resolve_empty_string_is_unchanged() { + assert_eq!(resolve_uri("", GATEWAY), ""); + } +}