diff --git a/backend/crates/atlas-common/src/types.rs b/backend/crates/atlas-common/src/types.rs index 6edd8a9..8a3f139 100644 --- a/backend/crates/atlas-common/src/types.rs +++ b/backend/crates/atlas-common/src/types.rs @@ -12,6 +12,7 @@ pub struct Block { pub timestamp: i64, pub gas_used: i64, pub gas_limit: i64, + pub base_fee_per_gas: Option, pub transaction_count: i32, pub indexed_at: DateTime, } @@ -240,7 +241,7 @@ pub struct ContractAbi { /// SQL column list for the `blocks` table, matching the field order in [`Block`]. pub const BLOCK_COLUMNS: &str = - "number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at"; + "number, hash, parent_hash, timestamp, gas_used, gas_limit, base_fee_per_gas::text AS base_fee_per_gas, transaction_count, indexed_at"; /// Pagination parameters #[derive(Debug, Clone, Deserialize)] diff --git a/backend/crates/atlas-server/src/api/handlers/blocks.rs b/backend/crates/atlas-server/src/api/handlers/blocks.rs index 67106f0..cb1d9b0 100644 --- a/backend/crates/atlas-server/src/api/handlers/blocks.rs +++ b/backend/crates/atlas-server/src/api/handlers/blocks.rs @@ -7,7 +7,9 @@ use std::sync::Arc; use crate::api::error::ApiResult; use crate::api::AppState; -use atlas_common::{AtlasError, Block, BlockDaStatus, PaginatedResponse, Pagination, Transaction}; +use atlas_common::{ + AtlasError, Block, BlockDaStatus, PaginatedResponse, Pagination, Transaction, BLOCK_COLUMNS, +}; /// Block response with optional DA status. /// DA fields are always present in the JSON (null when no data), @@ -36,13 +38,10 @@ pub async fn list_blocks( let limit = pagination.limit(); let cursor = (total_count - 1) - (pagination.page.saturating_sub(1) as i64) * limit; - let blocks: Vec = sqlx::query_as( - "SELECT number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at - FROM blocks - WHERE number <= $2 - ORDER BY number DESC - LIMIT $1", - ) + let blocks: Vec = sqlx::query_as(&format!( + "SELECT {} FROM blocks WHERE number <= $2 ORDER BY number DESC LIMIT $1", + BLOCK_COLUMNS + )) .bind(limit) .bind(cursor) .fetch_all(&state.pool) @@ -82,11 +81,10 @@ pub async fn get_block( State(state): State>, Path(number): Path, ) -> ApiResult> { - let block: Block = sqlx::query_as( - "SELECT number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at - FROM blocks - WHERE number = $1", - ) + let block: Block = sqlx::query_as(&format!( + "SELECT {} FROM blocks WHERE number = $1", + BLOCK_COLUMNS + )) .bind(number) .fetch_optional(&state.pool) .await? diff --git a/backend/crates/atlas-server/src/api/handlers/search.rs b/backend/crates/atlas-server/src/api/handlers/search.rs index 523bc59..87a4d6a 100644 --- a/backend/crates/atlas-server/src/api/handlers/search.rs +++ b/backend/crates/atlas-server/src/api/handlers/search.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use crate::api::error::ApiResult; use crate::api::AppState; -use atlas_common::{Address, Block, Erc20Contract, NftContract, Transaction}; +use atlas_common::{Address, Block, Erc20Contract, NftContract, Transaction, BLOCK_COLUMNS}; #[derive(Deserialize)] pub struct SearchQuery { @@ -151,11 +151,10 @@ async fn search_block_by_hash( 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 - FROM blocks - WHERE hash = $1" - ) + sqlx::query_as(&format!( + "SELECT {} FROM blocks WHERE hash = $1", + BLOCK_COLUMNS + )) .bind(hash) .fetch_optional(&state.pool) .await @@ -166,11 +165,10 @@ 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 - WHERE number = $1" - ) + sqlx::query_as(&format!( + "SELECT {} FROM blocks WHERE number = $1", + BLOCK_COLUMNS + )) .bind(number) .fetch_optional(&state.pool) .await diff --git a/backend/crates/atlas-server/src/api/handlers/sse.rs b/backend/crates/atlas-server/src/api/handlers/sse.rs index 6e8ddf2..7e1faf8 100644 --- a/backend/crates/atlas-server/src/api/handlers/sse.rs +++ b/backend/crates/atlas-server/src/api/handlers/sse.rs @@ -207,6 +207,7 @@ mod tests { timestamp: 1_700_000_000 + number, gas_used: 21_000, gas_limit: 30_000_000, + base_fee_per_gas: Some("1000000000".to_string()), transaction_count: 1, indexed_at: Utc::now(), } diff --git a/backend/crates/atlas-server/src/api/handlers/stats.rs b/backend/crates/atlas-server/src/api/handlers/stats.rs index 5947df5..bf44246 100644 --- a/backend/crates/atlas-server/src/api/handlers/stats.rs +++ b/backend/crates/atlas-server/src/api/handlers/stats.rs @@ -77,7 +77,14 @@ pub struct DailyTxPoint { #[derive(Serialize)] pub struct GasPricePoint { pub bucket: String, - pub avg_gas_price: f64, + pub avg_gas_price: Option, +} + +fn resolve_avg_gas_price( + tx_avg_gas_price: Option, + block_avg_base_fee_per_gas: Option, +) -> Option { + tx_avg_gas_price.or(block_avg_base_fee_per_gas) } /// GET /api/stats/blocks-chart?window=1h|6h|24h|7d @@ -175,7 +182,7 @@ pub async fn get_daily_txs( /// GET /api/stats/gas-price?window=1h|6h|24h|7d /// /// Returns average gas price (in wei) per bucket over the given window. -/// Anchored to the latest indexed transaction timestamp (not NOW()). +/// Anchored to the latest indexed block timestamp (not NOW()). pub async fn get_gas_price_chart( State(state): State>, Query(params): Query, @@ -183,7 +190,7 @@ pub async fn get_gas_price_chart( let window = params.window; let bucket_secs = window.bucket_secs(); - let rows: Vec<(chrono::DateTime, Option)> = sqlx::query_as( + let rows: Vec<(chrono::DateTime, Option, Option)> = sqlx::query_as( r#" WITH latest AS (SELECT MAX(timestamp) AS max_ts FROM blocks), bounds AS ( @@ -192,7 +199,7 @@ pub async fn get_gas_price_chart( max_ts AS end_ts FROM latest ), - agg AS ( + tx_agg AS ( SELECT (b.start_ts + (((transactions.timestamp - b.start_ts) / $1) * $1))::bigint AS bucket_ts, AVG(gas_price::float8) AS avg_gas_price @@ -202,13 +209,26 @@ pub async fn get_gas_price_chart( AND transactions.timestamp <= b.end_ts AND gas_price > 0 GROUP BY 1 + ), + block_agg AS ( + SELECT + (b.start_ts + (((blocks.timestamp - b.start_ts) / $1) * $1))::bigint AS bucket_ts, + AVG(base_fee_per_gas::float8) AS avg_base_fee_per_gas + FROM blocks + CROSS JOIN bounds b + WHERE blocks.timestamp >= b.start_ts + AND blocks.timestamp <= b.end_ts + AND base_fee_per_gas IS NOT NULL + GROUP BY 1 ) SELECT to_timestamp(gs::float8) AS bucket, - a.avg_gas_price - FROM bounds b - CROSS JOIN generate_series(b.start_ts, b.end_ts - $1, $1::bigint) AS gs - LEFT JOIN agg a ON a.bucket_ts = gs + t.avg_gas_price, + ba.avg_base_fee_per_gas + FROM bounds bo + CROSS JOIN generate_series(bo.start_ts, bo.end_ts - $1, $1::bigint) AS gs + LEFT JOIN tx_agg t ON t.bucket_ts = gs + LEFT JOIN block_agg ba ON ba.bucket_ts = gs ORDER BY gs ASC "#, ) @@ -219,10 +239,12 @@ pub async fn get_gas_price_chart( let points = rows .into_iter() - .map(|(bucket, avg_gas_price)| GasPricePoint { - bucket: bucket.to_rfc3339(), - avg_gas_price: avg_gas_price.unwrap_or(0.0), - }) + .map( + |(bucket, tx_avg_gas_price, block_avg_base_fee_per_gas)| GasPricePoint { + bucket: bucket.to_rfc3339(), + avg_gas_price: resolve_avg_gas_price(tx_avg_gas_price, block_avg_base_fee_per_gas), + }, + ) .collect(); Ok(Json(points)) @@ -263,4 +285,19 @@ mod tests { assert_eq!(Window::SevenDays.duration_secs(), 7 * 24 * 3_600); assert_eq!(Window::SevenDays.bucket_secs(), 43_200); } + + #[test] + fn resolve_avg_gas_price_prefers_transaction_average() { + assert_eq!(resolve_avg_gas_price(Some(42.0), Some(7.0)), Some(42.0)); + } + + #[test] + fn resolve_avg_gas_price_falls_back_to_base_fee() { + assert_eq!(resolve_avg_gas_price(None, Some(7.0)), Some(7.0)); + } + + #[test] + fn resolve_avg_gas_price_returns_none_when_bucket_is_empty() { + assert_eq!(resolve_avg_gas_price(None, None), None); + } } diff --git a/backend/crates/atlas-server/src/api/handlers/status.rs b/backend/crates/atlas-server/src/api/handlers/status.rs index 72707e6..e19156a 100644 --- a/backend/crates/atlas-server/src/api/handlers/status.rs +++ b/backend/crates/atlas-server/src/api/handlers/status.rs @@ -99,6 +99,7 @@ mod tests { timestamp: 1_700_000_000 + number, gas_used: 21_000, gas_limit: 30_000_000, + base_fee_per_gas: Some("1000000000".to_string()), transaction_count: 1, indexed_at: Utc::now(), } diff --git a/backend/crates/atlas-server/src/head.rs b/backend/crates/atlas-server/src/head.rs index 13f273e..50fd5ff 100644 --- a/backend/crates/atlas-server/src/head.rs +++ b/backend/crates/atlas-server/src/head.rs @@ -131,6 +131,7 @@ mod tests { timestamp: 1_700_000_000 + number, gas_used: 21_000, gas_limit: 30_000_000, + base_fee_per_gas: Some("1000000000".to_string()), transaction_count: 1, indexed_at: Utc.timestamp_opt(1_700_000_000 + number, 0).unwrap(), } diff --git a/backend/crates/atlas-server/src/indexer/batch.rs b/backend/crates/atlas-server/src/indexer/batch.rs index 713501e..786a4b2 100644 --- a/backend/crates/atlas-server/src/indexer/batch.rs +++ b/backend/crates/atlas-server/src/indexer/batch.rs @@ -36,6 +36,7 @@ pub(crate) struct BlockBatch { pub(crate) b_timestamps: Vec, pub(crate) b_gas_used: Vec, pub(crate) b_gas_limits: Vec, + pub(crate) b_base_fee_per_gas: Vec>, pub(crate) b_tx_counts: Vec, // transactions (receipt data merged in at collection time) @@ -177,6 +178,7 @@ impl BlockBatch { debug_assert_eq!(self.b_numbers.len(), self.b_timestamps.len()); debug_assert_eq!(self.b_numbers.len(), self.b_gas_used.len()); debug_assert_eq!(self.b_numbers.len(), self.b_gas_limits.len()); + debug_assert_eq!(self.b_numbers.len(), self.b_base_fee_per_gas.len()); debug_assert_eq!(self.b_numbers.len(), self.b_tx_counts.len()); (0..self.b_numbers.len()) @@ -187,6 +189,7 @@ impl BlockBatch { timestamp: self.b_timestamps[i], gas_used: self.b_gas_used[i], gas_limit: self.b_gas_limits[i], + base_fee_per_gas: self.b_base_fee_per_gas[i].clone(), transaction_count: self.b_tx_counts[i], indexed_at, }) @@ -294,6 +297,9 @@ mod tests { batch.b_timestamps.push(1_700_000_042); batch.b_gas_used.push(21_000); batch.b_gas_limits.push(30_000_000); + batch + .b_base_fee_per_gas + .push(Some("1000000000".to_string())); batch.b_tx_counts.push(3); let indexed_at = Utc.timestamp_opt(1_700_000_100, 0).unwrap(); @@ -306,6 +312,7 @@ mod tests { assert_eq!(blocks[0].timestamp, 1_700_000_042); assert_eq!(blocks[0].gas_used, 21_000); assert_eq!(blocks[0].gas_limit, 30_000_000); + assert_eq!(blocks[0].base_fee_per_gas.as_deref(), Some("1000000000")); assert_eq!(blocks[0].transaction_count, 3); assert_eq!(blocks[0].indexed_at, indexed_at); } diff --git a/backend/crates/atlas-server/src/indexer/copy.rs b/backend/crates/atlas-server/src/indexer/copy.rs index 1c9dff3..0b83924 100644 --- a/backend/crates/atlas-server/src/indexer/copy.rs +++ b/backend/crates/atlas-server/src/indexer/copy.rs @@ -26,6 +26,7 @@ pub async fn copy_blocks( timestamp BIGINT, gas_used BIGINT, gas_limit BIGINT, + base_fee_per_gas TEXT, transaction_count INT, indexed_at TIMESTAMPTZ ) ON COMMIT DELETE ROWS; @@ -35,7 +36,7 @@ pub async fn copy_blocks( let sink = tx .copy_in( - "COPY tmp_blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) FROM STDIN BINARY", + "COPY tmp_blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, base_fee_per_gas, transaction_count, indexed_at) FROM STDIN BINARY", ) .await?; let writer = BinaryCopyInWriter::new( @@ -47,6 +48,7 @@ pub async fn copy_blocks( Type::INT8, Type::INT8, Type::INT8, + Type::TEXT, Type::INT4, Type::TIMESTAMPTZ, ], @@ -54,13 +56,14 @@ pub async fn copy_blocks( pin!(writer); for i in 0..batch.b_numbers.len() { - let row: [&(dyn ToSql + Sync); 8] = [ + let row: [&(dyn ToSql + Sync); 9] = [ &batch.b_numbers[i], &batch.b_hashes[i], &batch.b_parent_hashes[i], &batch.b_timestamps[i], &batch.b_gas_used[i], &batch.b_gas_limits[i], + &batch.b_base_fee_per_gas[i], &batch.b_tx_counts[i], &indexed_at, ]; @@ -70,8 +73,8 @@ pub async fn copy_blocks( writer.finish().await?; tx.execute( - "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at) - SELECT number, hash, parent_hash, timestamp, gas_used, gas_limit, transaction_count, indexed_at + "INSERT INTO blocks (number, hash, parent_hash, timestamp, gas_used, gas_limit, base_fee_per_gas, transaction_count, indexed_at) + SELECT number, hash, parent_hash, timestamp, gas_used, gas_limit, base_fee_per_gas::numeric, transaction_count, indexed_at FROM tmp_blocks ON CONFLICT (number) DO UPDATE SET hash = EXCLUDED.hash, @@ -79,6 +82,7 @@ pub async fn copy_blocks( timestamp = EXCLUDED.timestamp, gas_used = EXCLUDED.gas_used, gas_limit = EXCLUDED.gas_limit, + base_fee_per_gas = EXCLUDED.base_fee_per_gas, transaction_count = EXCLUDED.transaction_count, indexed_at = EXCLUDED.indexed_at", &[], diff --git a/backend/crates/atlas-server/src/indexer/indexer.rs b/backend/crates/atlas-server/src/indexer/indexer.rs index 5656e48..5452fdb 100644 --- a/backend/crates/atlas-server/src/indexer/indexer.rs +++ b/backend/crates/atlas-server/src/indexer/indexer.rs @@ -501,7 +501,7 @@ impl Indexer { known_nft: &HashSet, fetched: FetchedBlock, ) { - use alloy::consensus::Transaction as TxTrait; + use alloy::consensus::{BlockHeader, Transaction as TxTrait}; let block = fetched.block; let block_num = fetched.number; @@ -525,6 +525,12 @@ impl Indexer { 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); + batch.b_base_fee_per_gas.push( + block + .header + .base_fee_per_gas() + .map(|base_fee| base_fee.to_string()), + ); batch.b_tx_counts.push(tx_count); // --- Transactions --- diff --git a/backend/migrations/20260407000002_add_blocks_base_fee_per_gas.sql b/backend/migrations/20260407000002_add_blocks_base_fee_per_gas.sql new file mode 100644 index 0000000..2a0be69 --- /dev/null +++ b/backend/migrations/20260407000002_add_blocks_base_fee_per_gas.sql @@ -0,0 +1,2 @@ +ALTER TABLE blocks +ADD COLUMN IF NOT EXISTS base_fee_per_gas NUMERIC(78, 0); diff --git a/docker-compose.yml b/docker-compose.yml index 4ce3d37..859fb56 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,9 +33,6 @@ services: DA_RPC_REQUESTS_PER_SECOND: ${DA_RPC_REQUESTS_PER_SECOND:-50} DA_WORKER_CONCURRENCY: ${DA_WORKER_CONCURRENCY:-50} FAUCET_ENABLED: ${FAUCET_ENABLED:-false} - FAUCET_PRIVATE_KEY: ${FAUCET_PRIVATE_KEY:-} - FAUCET_AMOUNT: ${FAUCET_AMOUNT:-} - FAUCET_COOLDOWN_MINUTES: ${FAUCET_COOLDOWN_MINUTES:-} CHAIN_NAME: ${CHAIN_NAME:-Unknown} CHAIN_LOGO_URL: ${CHAIN_LOGO_URL:-} ACCENT_COLOR: ${ACCENT_COLOR:-} diff --git a/frontend/src/api/chartData.ts b/frontend/src/api/chartData.ts index 724d5b3..4c7ac73 100644 --- a/frontend/src/api/chartData.ts +++ b/frontend/src/api/chartData.ts @@ -15,7 +15,7 @@ export interface DailyTxPoint { export interface GasPricePoint { bucket: string; // ISO timestamp - avg_gas_price: number; // wei + avg_gas_price: number | null; // wei } export function getBlocksChart(window: ChartWindow): Promise { diff --git a/frontend/src/pages/StatusPage.tsx b/frontend/src/pages/StatusPage.tsx index cae482e..99e8ec5 100644 --- a/frontend/src/pages/StatusPage.tsx +++ b/frontend/src/pages/StatusPage.tsx @@ -232,7 +232,7 @@ export default function StatusPage() { contentStyle={{ background: CHART_TOOLTIP_BG, border: `1px solid ${CHART_GRID}`, borderRadius: 8 }} labelStyle={{ color: CHART_AXIS_TEXT }} itemStyle={{ color: CHART_TOOLTIP_TEXT }} - formatter={(v: unknown) => [formatGwei(v as number), 'Avg Gas Price']} + formatter={(v: unknown) => [formatGwei(v as number | null), 'Avg Gas Price']} labelFormatter={(v) => formatBucketTooltip(v, window)} /> = 1_000) return `${(gwei / 1_000).toFixed(1)}K gwei`; if (gwei >= 1) return `${gwei.toFixed(2)} gwei`; diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 73f560a..91fc875 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -6,6 +6,7 @@ export interface Block { timestamp: number; gas_used: number; gas_limit: number; + base_fee_per_gas?: string | null; transaction_count: number; indexed_at: string; da_status?: BlockDaStatus | null; @@ -122,6 +123,7 @@ export interface BlockSearchResult extends SearchResult { timestamp: number; gas_used: number; gas_limit: number; + base_fee_per_gas?: string | null; transaction_count: number; indexed_at: string; }