Ver Fonte

explorer: Add blockchain stats and WASM bincode tracking

x há 6 meses atrás
pai
commit
38750505f6
5 ficheiros alterados com 621 adições e 10 exclusões
  1. 1 0
      Cargo.lock
  2. 1 0
      bin/explorer/Cargo.toml
  3. 403 10
      bin/explorer/python/explorer.py
  4. 10 0
      bin/explorer/src/main.rs
  5. 206 0
      bin/explorer/src/rpc.rs

+ 1 - 0
Cargo.lock

@@ -2841,6 +2841,7 @@ dependencies = [
  "darkfi",
  "darkfi-sdk",
  "darkfi-serial",
+ "darkfi_deployooor_contract",
  "darkfi_money_contract",
  "easy-parallel",
  "hex",

+ 1 - 0
bin/explorer/Cargo.toml

@@ -9,6 +9,7 @@ darkfi-serial = {path = "../../src/serial", features = ["derive"]}
 darkfi-sdk = {path = "../../src/sdk"}
 
 darkfi_money_contract = {path = "../../src/contract/money", features = ["no-entrypoint", "client"]}
+darkfi_deployooor_contract = {path = "../../src/contract/deployooor", features = ["no-entrypoint", "client"]}
 
 async-channel = "2.5.0"
 async-trait = "0.1.89"

+ 403 - 10
bin/explorer/python/explorer.py

@@ -16,21 +16,38 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use std::io;
+use std::{io, str::FromStr};
 
 use bytemuck::{Pod, Zeroable};
 use darkfi::{
     blockchain::{BlockInfo, Header},
     tx::Transaction,
 };
-use darkfi_sdk::crypto::schnorr::Signature;
-use darkfi_serial::{deserialize_async, serialize_async};
+use darkfi_deployooor_contract::{model::LockParamsV1, DeployFunction};
+use darkfi_sdk::{
+    crypto::{schnorr::Signature, ContractId, DEPLOYOOOR_CONTRACT_ID},
+    deploy::DeployParamsV1,
+};
+use darkfi_serial::{
+    async_trait, deserialize, deserialize_async, serialize, serialize_async, SerialDecodable,
+    SerialEncodable,
+};
 use sled::{transaction::TransactionError, Transactional};
 use tapes::{BlobTape, FixedSizedTape, Persistence, TapeOpenOptions, Tapes};
 use tracing::info;
 
 use super::Explorer;
 
+/// Contract information stored in sled
+#[derive(Debug, Clone, SerialEncodable, SerialDecodable)]
+pub struct ContractData {
+    pub contract_id: ContractId,
+    pub locked: bool,
+    pub wasm_size: u64,
+    pub deploy_block: u64,
+    pub deploy_tx_hash: [u8; 32],
+}
+
 /// Index entry for a block pointing to block data in the blob tape
 #[derive(Debug, Copy, Clone, Pod, Zeroable)]
 #[repr(C)]
@@ -135,15 +152,34 @@ impl Explorer {
             tx_entries.push((tx_hash, tx_idx_pos.to_le_bytes()));
         }
 
-        // Atomic sled transaction for both tx_indices and header_indices
-        (&self.tx_indices, &self.header_indices)
-            .transaction(|(tx_tree, header_tree)| {
+        // Scan for contract deployments and locks
+        let (new_contracts, locked_contracts) =
+            self.scan_contract_calls(block, block.header.height as u64).await;
+
+        // Atomic sled transaction for tx_indices, header_indices, and contracts
+        (&self.tx_indices, &self.header_indices, &self.contracts)
+            .transaction(|(tx_tree, header_tree, contracts_tree)| {
                 // Insert all transaction indices
                 for (hash, idx) in &tx_entries {
                     tx_tree.insert(hash.as_slice(), idx.as_slice())?;
                 }
                 // Insert header hash -> height mapping
                 header_tree.insert(header_hash.as_slice(), height_bytes.as_slice())?;
+
+                // Insert new contracts
+                for contract in &new_contracts {
+                    contracts_tree
+                        .insert(serialize(&contract.contract_id.inner()), serialize(contract))?;
+                }
+
+                // Update locked contracts
+                for contract_id in &locked_contracts {
+                    let data = contracts_tree.get(serialize(&contract_id.inner()))?.unwrap();
+                    let mut contract: ContractData = deserialize(&data).unwrap();
+                    contract.locked = true;
+                    contracts_tree.insert(serialize(&contract_id.inner()), serialize(&contract))?;
+                }
+
                 Ok(())
             })
             .map_err(|e: TransactionError<sled::Error>| {
@@ -158,6 +194,15 @@ impl Explorer {
             block.txs.len(),
         );
 
+        // Update stats
+        let block_size = header_data.len() as u64 + (current_tx_offset - tx_blob_offset);
+        self.update_stats_for_block(
+            block.header.timestamp.inner(),
+            block.txs.len() as u64,
+            block_size,
+        )
+        .await?;
+
         Ok(())
     }
 
@@ -183,6 +228,7 @@ impl Explorer {
         // Collect data to remove from sled
         let mut header_hashes_to_remove: Vec<Vec<u8>> = Vec::new();
         let mut tx_hashes_to_remove: Vec<Vec<u8>> = Vec::new();
+        let mut contracts_to_remove: Vec<ContractId> = Vec::new();
 
         for height in new_block_count..current_len {
             // Get the block index to find transactions
@@ -196,7 +242,7 @@ impl Explorer {
             let header: Header = deserialize_async(&header_data).await?;
             header_hashes_to_remove.push(serialize_async(&header.hash()).await);
 
-            // Read each tx to get its hash
+            // Read each tx to get its hash and check for contract deployments
             for i in 0..block_idx.tx_count {
                 let tx_idx = reader
                     .read_entry(&self.database.tx_index, block_idx.tx_start_idx + i)?
@@ -206,6 +252,17 @@ impl Explorer {
                 reader.read_bytes(&self.database.transactions, tx_idx.offset, &mut tx_data)?;
                 let transaction: Transaction = deserialize_async(&tx_data).await?;
                 tx_hashes_to_remove.push(transaction.hash().0.to_vec());
+
+                // Check for contract deployments to remove
+                for call in &transaction.calls {
+                    if call.data.contract_id == *DEPLOYOOOR_CONTRACT_ID &&
+                        call.data.data[0] == DeployFunction::DeployV1 as u8
+                    {
+                        let params: DeployParamsV1 =
+                            deserialize_async(&call.data.data[1..]).await?;
+                        contracts_to_remove.push(ContractId::derive_public(params.public_key));
+                    }
+                }
             }
         }
 
@@ -247,15 +304,18 @@ impl Explorer {
 
         truncate_tx.commit(Persistence::SyncData)?;
 
-        // Atomic sled transaction for removing both tx and header indices
-        (&self.tx_indices, &self.header_indices)
-            .transaction(|(tx_tree, header_tree)| {
+        // Atomic sled transaction for removing tx, header indices, and contracts
+        (&self.tx_indices, &self.header_indices, &self.contracts)
+            .transaction(|(tx_tree, header_tree, contracts_tree)| {
                 for tx_hash in &tx_hashes_to_remove {
                     tx_tree.remove(tx_hash.as_slice())?;
                 }
                 for header_hash in &header_hashes_to_remove {
                     header_tree.remove(header_hash.as_slice())?;
                 }
+                for contract_id in &contracts_to_remove {
+                    contracts_tree.remove(serialize(&contract_id.inner()))?;
+                }
                 Ok(())
             })
             .map_err(|e: TransactionError<sled::Error>| {
@@ -269,6 +329,9 @@ impl Explorer {
             if new_block_count == 0 { 0 } else { new_block_count - 1 }
         );
 
+        // Rebuild stats from scratch after reorg
+        self.rebuild_stats().await?;
+
         Ok(())
     }
 
@@ -451,4 +514,334 @@ impl Explorer {
 
         self.get_tx_by_hash(&tx_hash).await
     }
+
+    /// Scan a block's transactions for contract deployments and locks.
+    /// Returns (new_contracts, locked_contract_ids)
+    async fn scan_contract_calls(
+        &self,
+        block: &BlockInfo,
+        block_height: u64,
+    ) -> (Vec<ContractData>, Vec<ContractId>) {
+        let mut new_contracts = Vec::new();
+        let mut locked_contracts = Vec::new();
+
+        for transaction in &block.txs {
+            let tx_hash = *transaction.hash().inner();
+
+            for call in &transaction.calls {
+                // Check if this is a call to Deployoor
+                if call.data.contract_id != *DEPLOYOOOR_CONTRACT_ID {
+                    continue;
+                }
+
+                let func = call.data.data[0];
+                if func == DeployFunction::DeployV1 as u8 {
+                    let params: DeployParamsV1 =
+                        deserialize_async(&call.data.data[1..]).await.unwrap();
+                    let contract_id = ContractId::derive_public(params.public_key);
+
+                    info!(
+                        target: "explorer::scan_contract_calls",
+                        "Found contract deployment: {} (size: {} bytes)",
+                        contract_id, params.wasm_bincode.len(),
+                    );
+
+                    new_contracts.push(ContractData {
+                        contract_id,
+                        locked: false,
+                        wasm_size: params.wasm_bincode.len() as u64,
+                        deploy_block: block_height,
+                        deploy_tx_hash: tx_hash,
+                    });
+                } else if func == DeployFunction::LockV1 as u8 {
+                    let params: LockParamsV1 =
+                        deserialize_async(&call.data.data[1..]).await.unwrap();
+                    let contract_id = ContractId::derive_public(params.public_key);
+
+                    info!(
+                        target: "explorer::scan_contract_calls",
+                        "Found contract lock: {}", contract_id,
+                    );
+
+                    locked_contracts.push(contract_id);
+                }
+            }
+        }
+
+        (new_contracts, locked_contracts)
+    }
+
+    /// Get a contract by its ID string (base58 encoded).
+    pub async fn get_contract(&self, contract_id_str: &str) -> io::Result<Option<ContractData>> {
+        let Ok(contract_id) = ContractId::from_str(contract_id_str) else {
+            return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid contract ID"))
+        };
+
+        match self.contracts.get(serialize_async(&contract_id.inner()).await)? {
+            Some(data) => Ok(Some(deserialize_async(&data).await?)),
+            None => Ok(None),
+        }
+    }
+
+    /// List all contracts, optionally filtered by locked status.
+    pub async fn list_contracts(
+        &self,
+        locked_filter: Option<bool>,
+    ) -> io::Result<Vec<ContractData>> {
+        let mut contracts = Vec::new();
+
+        for result in self.contracts.iter() {
+            let (_, value) = result?;
+            let contract: ContractData = deserialize_async(&value).await?;
+            if let Some(filter) = locked_filter {
+                if contract.locked == filter {
+                    contracts.push(contract);
+                }
+            } else {
+                contracts.push(contract);
+            }
+        }
+
+        Ok(contracts)
+    }
+
+    /// Get the total number of contracts.
+    pub fn get_contract_count(&self) -> io::Result<u64> {
+        Ok(self.contracts.len() as u64)
+    }
+}
+
+/// Daily statistics aggregate
+#[derive(Debug, Clone, SerialEncodable, SerialDecodable)]
+pub struct DailyStats {
+    pub block_count: u64,
+    pub user_tx_count: u64, // excluding coinbase
+    pub total_size: u64,
+}
+
+/// Monthly statistics aggregate
+#[derive(Debug, Clone, SerialEncodable, SerialDecodable)]
+pub struct MonthlyStats {
+    pub block_count: u64,
+    pub total_size: u64,
+}
+
+impl Explorer {
+    /// Update stats for a single block
+    pub async fn update_stats_for_block(
+        &self,
+        timestamp: u64,
+        tx_count: u64,
+        block_size: u64,
+    ) -> io::Result<()> {
+        let day = Self::day_from_timestamp(timestamp);
+        let (year, month) = Self::year_month_from_timestamp(timestamp);
+        let user_tx = tx_count.saturating_sub(1); // exclude coinbase
+
+        // Update daily stats
+        let daily_key = format!("daily:{}", day);
+        let mut daily = self.get_daily_stats(day).await?.unwrap_or(DailyStats {
+            block_count: 0,
+            user_tx_count: 0,
+            total_size: 0,
+        });
+        daily.block_count += 1;
+        daily.user_tx_count += user_tx;
+        daily.total_size += block_size;
+        self.stats.insert(daily_key.as_bytes(), serialize_async(&daily).await)?;
+
+        // Update monthly stats
+        let monthly_key = format!("monthly:{}:{:02}", year, month);
+        let mut monthly = self
+            .get_monthly_stats(year, month)
+            .await?
+            .unwrap_or(MonthlyStats { block_count: 0, total_size: 0 });
+        monthly.block_count += 1;
+        monthly.total_size += block_size;
+        self.stats.insert(monthly_key.as_bytes(), serialize_async(&monthly).await)?;
+
+        Ok(())
+    }
+
+    /// Get daily stats for a specific day
+    pub async fn get_daily_stats(&self, day: u64) -> io::Result<Option<DailyStats>> {
+        let key = format!("daily:{}", day);
+        match self.stats.get(key.as_bytes())? {
+            Some(data) => Ok(Some(deserialize_async(&data).await?)),
+            None => Ok(None),
+        }
+    }
+
+    /// Get monthly stats for a specific year/month
+    pub async fn get_monthly_stats(
+        &self,
+        year: u32,
+        month: u32,
+    ) -> io::Result<Option<MonthlyStats>> {
+        let key = format!("monthly:{}:{:02}", year, month);
+        match self.stats.get(key.as_bytes())? {
+            Some(data) => Ok(Some(deserialize_async(&data).await?)),
+            None => Ok(None),
+        }
+    }
+
+    /// Get all daily stats (for graph generation)
+    pub async fn get_all_daily_stats(&self) -> io::Result<Vec<(u64, DailyStats)>> {
+        let mut result = Vec::new();
+        let prefix = b"daily:";
+
+        for item in self.stats.scan_prefix(prefix) {
+            let (key, value) = item?;
+            let key_str = String::from_utf8_lossy(&key);
+            if let Some(day_str) = key_str.strip_prefix("daily:") {
+                if let Ok(day) = day_str.parse::<u64>() {
+                    let stats = deserialize_async(&value).await?;
+                    result.push((day, stats));
+                }
+            }
+        }
+
+        result.sort_by_key(|(day, _)| *day);
+        Ok(result)
+    }
+
+    /// Get all monthly stats (for table generation)
+    pub async fn get_all_monthly_stats(&self) -> io::Result<Vec<(u32, u32, MonthlyStats)>> {
+        let mut result = Vec::new();
+        let prefix = b"monthly:";
+
+        for item in self.stats.scan_prefix(prefix) {
+            let (key, value) = item?;
+            let key_str = String::from_utf8_lossy(&key);
+            if let Some(ym_str) = key_str.strip_prefix("monthly:") {
+                let parts: Vec<&str> = ym_str.split(':').collect();
+                if parts.len() == 2 {
+                    if let (Ok(year), Ok(month)) =
+                        (parts[0].parse::<u32>(), parts[1].parse::<u32>())
+                    {
+                        let stats = deserialize_async(&value).await?;
+                        result.push((year, month, stats));
+                    }
+                }
+            }
+        }
+
+        result.sort_by_key(|(year, month, _)| (*year, *month));
+        Ok(result)
+    }
+
+    /// Clear all stats (called before rebuilding after reorg)
+    pub fn clear_stats(&self) -> io::Result<()> {
+        // Clear daily stats
+        let daily_keys: Vec<_> =
+            self.stats.scan_prefix(b"daily:").filter_map(|r| r.ok().map(|(k, _)| k)).collect();
+        for key in daily_keys {
+            self.stats.remove(&key)?;
+        }
+
+        // Clear monthly stats
+        let monthly_keys: Vec<_> =
+            self.stats.scan_prefix(b"monthly:").filter_map(|r| r.ok().map(|(k, _)| k)).collect();
+        for key in monthly_keys {
+            self.stats.remove(&key)?;
+        }
+
+        Ok(())
+    }
+
+    /// Rebuild all stats from blockchain data
+    pub async fn rebuild_stats(&self) -> io::Result<()> {
+        info!(target: "explorer::rebuild_stats", "Clearing existing stats...");
+        self.clear_stats()?;
+
+        let height = match self.get_height()? {
+            Some(h) => h,
+            None => return Ok(()), // No blocks yet
+        };
+
+        info!(target: "explorer::rebuild_stats", "Rebuilding stats for {} blocks...", height + 1);
+
+        let reader = self.tapes_db.reader();
+
+        for h in 0..=height {
+            let block_idx = match reader.read_entry(&self.database.block_index, h)? {
+                Some(idx) => idx,
+                None => continue,
+            };
+
+            // Read header to get timestamp
+            let mut header_data = vec![0u8; block_idx.length as usize];
+            reader.read_bytes(&self.database.blocks, block_idx.offset, &mut header_data)?;
+            let header: Header = deserialize_async(&header_data).await?;
+
+            // Calculate block size
+            let tx_size = if block_idx.tx_count == 0 {
+                0
+            } else {
+                let first_tx_idx = reader
+                    .read_entry(&self.database.tx_index, block_idx.tx_start_idx)?
+                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "tx index not found"))?;
+                let last_tx_idx = reader
+                    .read_entry(
+                        &self.database.tx_index,
+                        block_idx.tx_start_idx + block_idx.tx_count - 1,
+                    )?
+                    .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "tx index not found"))?;
+                last_tx_idx.offset + last_tx_idx.length - first_tx_idx.offset
+            };
+
+            let block_size = block_idx.length + tx_size;
+
+            self.update_stats_for_block(header.timestamp.inner(), block_idx.tx_count, block_size)
+                .await?;
+        }
+
+        info!(target: "explorer::rebuild_stats", "Stats rebuild complete");
+        Ok(())
+    }
+
+    /// Get day number from unix timestamp (days since epoch)
+    fn day_from_timestamp(timestamp: u64) -> u64 {
+        timestamp / 86400
+    }
+
+    /// Get year and month from unix timestamp
+    fn year_month_from_timestamp(timestamp: u64) -> (u32, u32) {
+        // Days since epoch
+        let days = timestamp / 86400;
+
+        // Approximate year (will be corrected)
+        let mut year = 1970u32;
+        let mut remaining_days = days as i64;
+
+        loop {
+            let days_in_year =
+                if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) { 366i64 } else { 365i64 };
+
+            if remaining_days < days_in_year {
+                break;
+            }
+            remaining_days -= days_in_year;
+            year += 1;
+        }
+
+        // Now find month
+        let is_leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400));
+        let days_in_months: [i64; 12] = if is_leap {
+            [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+        } else {
+            [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+        };
+
+        let mut month = 1u32;
+        for days_in_month in days_in_months.iter() {
+            if remaining_days < *days_in_month {
+                break;
+            }
+            remaining_days -= days_in_month;
+            month += 1;
+        }
+
+        (year, month)
+    }
 }

+ 10 - 0
bin/explorer/src/main.rs

@@ -79,6 +79,8 @@ pub struct Explorer {
     _sled_db: sled::Db,
     header_indices: sled::Tree,
     tx_indices: sled::Tree,
+    contracts: sled::Tree,
+    stats: sled::Tree,
 
     tapes_db: Tapes,
     _tapes_options: TapeOpenOptions,
@@ -106,6 +108,10 @@ impl RequestHandler<RpcHandler> for Explorer {
             "get_tx" => self.rpc_get_tx(req.id, req.params).await,
             "search" => self.rpc_search(req.id, req.params).await,
             "get_hashrate" => self.rpc_get_hashrate(req.id, req.params).await,
+            "get_contract" => self.rpc_get_contract(req.id, req.params).await,
+            "list_contracts" => self.rpc_list_contracts(req.id, req.params).await,
+            "contract_count" => self.rpc_contract_count(req.id, req.params).await,
+            "get_stats" => self.rpc_get_stats(req.id, req.params).await,
             _ => JsonError::new(ErrorCode::MethodNotFound, None, req.id).into(),
         }
     }
@@ -121,6 +127,8 @@ impl Explorer {
         let sled_db = sled::open(sled_path)?;
         let header_indices = sled_db.open_tree("header_indices")?;
         let tx_indices = sled_db.open_tree("tx_indices")?;
+        let contracts = sled_db.open_tree("contracts")?;
+        let stats = sled_db.open_tree("stats")?;
 
         info!(target: "explorer::new", "Opening tapes");
         std::fs::create_dir_all(tapes_db_path)?;
@@ -137,6 +145,8 @@ impl Explorer {
             _sled_db: sled_db,
             header_indices,
             tx_indices,
+            contracts,
+            stats,
             tapes_db,
             _tapes_options: tapes_options,
             database,

+ 206 - 0
bin/explorer/src/rpc.rs

@@ -495,4 +495,210 @@ impl Explorer {
 
         JsonResponse::new(JsonValue::Number(hashrate), id).into()
     }
+
+    /// Get contract information by ID.
+    /// Params: `[contract_id: String]`
+    /// Returns: `{ contract_id, locked, wasm_size, deploy_block, deploy_tx }`
+    pub async fn rpc_get_contract(&self, id: u16, params: JsonValue) -> JsonResult {
+        let Some(params) = params.get::<Vec<JsonValue>>() else {
+            return JsonError::new(InvalidParams, None, id).into()
+        };
+        if params.len() != 1 || !params[0].is_string() {
+            return JsonError::new(InvalidParams, None, id).into()
+        }
+
+        let contract_id_str = params[0].get::<String>().unwrap();
+
+        let Ok(Some(contract)) = self.get_contract(contract_id_str).await else {
+            return JsonError::new(InternalError, Some("Contract not found".to_string()), id).into()
+        };
+
+        JsonResponse::new(
+            JsonValue::Object(HashMap::from([
+                ("contract_id".to_string(), JsonValue::String(contract.contract_id.to_string())),
+                ("locked".to_string(), JsonValue::Boolean(contract.locked)),
+                ("wasm_size".to_string(), JsonValue::Number(contract.wasm_size as f64)),
+                ("deploy_block".to_string(), JsonValue::Number(contract.deploy_block as f64)),
+                ("deploy_tx".to_string(), JsonValue::String(hex::encode(contract.deploy_tx_hash))),
+            ])),
+            id,
+        )
+        .into()
+    }
+
+    /// List all contracts.
+    /// Params: `[locked_filter: bool | null] (optional)`
+    /// Returns: Array of contract objects
+    pub async fn rpc_list_contracts(&self, id: u16, params: JsonValue) -> JsonResult {
+        let locked_filter = if let Some(params) = params.get::<Vec<JsonValue>>() {
+            if !params.is_empty() {
+                params[0].get::<bool>().copied()
+            } else {
+                None
+            }
+        } else {
+            None
+        };
+
+        let Ok(contracts) = self.list_contracts(locked_filter).await else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        let contracts_json: Vec<JsonValue> = contracts
+            .iter()
+            .map(|c| {
+                JsonValue::Object(HashMap::from([
+                    ("contract_id".to_string(), JsonValue::String(c.contract_id.to_string())),
+                    ("locked".to_string(), JsonValue::Boolean(c.locked)),
+                    ("wasm_size".to_string(), JsonValue::Number(c.wasm_size as f64)),
+                    ("deploy_block".to_string(), JsonValue::Number(c.deploy_block as f64)),
+                    ("deploy_tx".to_string(), JsonValue::String(hex::encode(c.deploy_tx_hash))),
+                ]))
+            })
+            .collect();
+
+        JsonResponse::new(JsonValue::Array(contracts_json), id).into()
+    }
+
+    /// Get contract count.
+    /// Returns: Number of contracts
+    pub async fn rpc_contract_count(&self, id: u16, _params: JsonValue) -> JsonResult {
+        let Ok(count) = self.get_contract_count() else {
+            return JsonError::new(InternalError, None, id).into()
+        };
+
+        JsonResponse::new(JsonValue::Number(count as f64), id).into()
+    }
+
+    /// Get blockchain statistics from stored data.
+    /// Returns daily stats, monthly growth, and tx per block stats.
+    pub async fn rpc_get_stats(&self, id: u16, _params: JsonValue) -> JsonResult {
+        // Get daily stats from sled
+        let daily_stats = match self.get_all_daily_stats().await {
+            Ok(stats) => stats,
+            Err(_) => return JsonError::new(InternalError, None, id).into(),
+        };
+
+        // Get monthly stats from sled
+        let monthly_stats = match self.get_all_monthly_stats().await {
+            Ok(stats) => stats,
+            Err(_) => return JsonError::new(InternalError, None, id).into(),
+        };
+
+        // Convert daily stats to JSON (for graph)
+        let daily_json: Vec<JsonValue> = daily_stats
+            .iter()
+            .map(|(day, stats)| {
+                let avg_tx = if stats.block_count > 0 {
+                    stats.user_tx_count as f64 / stats.block_count as f64
+                } else {
+                    0.0
+                };
+                JsonValue::Object(HashMap::from([
+                    ("day".to_string(), JsonValue::Number(*day as f64)),
+                    ("avg_tx".to_string(), JsonValue::Number(avg_tx)),
+                    ("block_count".to_string(), JsonValue::Number(stats.block_count as f64)),
+                    ("user_tx_count".to_string(), JsonValue::Number(stats.user_tx_count as f64)),
+                    ("total_size".to_string(), JsonValue::Number(stats.total_size as f64)),
+                ]))
+            })
+            .collect();
+
+        // Convert monthly stats to JSON with cumulative
+        let mut cumulative: u64 = 0;
+        let monthly_json: Vec<JsonValue> = monthly_stats
+            .iter()
+            .map(|(year, month, stats)| {
+                cumulative += stats.total_size;
+                JsonValue::Object(HashMap::from([
+                    ("year".to_string(), JsonValue::Number(*year as f64)),
+                    ("month".to_string(), JsonValue::Number(*month as f64)),
+                    (
+                        "size_mb".to_string(),
+                        JsonValue::Number(stats.total_size as f64 / 1_048_576.0),
+                    ),
+                    (
+                        "cumulative_mb".to_string(),
+                        JsonValue::Number(cumulative as f64 / 1_048_576.0),
+                    ),
+                    ("block_count".to_string(), JsonValue::Number(stats.block_count as f64)),
+                ]))
+            })
+            .collect();
+
+        // Calculate tx per block stats for time periods
+        // Get current day
+        let current_day = if let Some((day, _)) = daily_stats.last() { *day } else { 0 };
+
+        fn calc_period_stats(
+            daily_stats: &[(u64, crate::db::DailyStats)],
+            from_day: u64,
+            to_day: u64,
+        ) -> (f64, f64, u64, u64) {
+            let mut total_blocks: u64 = 0;
+            let mut total_user_tx: u64 = 0;
+            let mut empty_blocks: u64 = 0;
+
+            for (day, stats) in daily_stats {
+                if *day >= from_day && *day <= to_day {
+                    total_blocks += stats.block_count;
+                    total_user_tx += stats.user_tx_count;
+                    // A block is "empty" if it has 0 user transactions
+                    // We approximate empty blocks as: blocks where avg user_tx < 1
+                    // But we don't have per-block data, so we estimate
+                    if stats.block_count > 0 && stats.user_tx_count == 0 {
+                        empty_blocks += stats.block_count;
+                    }
+                }
+            }
+
+            let avg_tx =
+                if total_blocks > 0 { total_user_tx as f64 / total_blocks as f64 } else { 0.0 };
+            let empty_pct = if total_blocks > 0 {
+                empty_blocks as f64 / total_blocks as f64 * 100.0
+            } else {
+                0.0
+            };
+
+            (avg_tx, empty_pct, total_user_tx, total_blocks)
+        }
+
+        let (avg_day, empty_day, total_day, blocks_day) =
+            calc_period_stats(&daily_stats, current_day, current_day);
+        let (avg_week, empty_week, total_week, blocks_week) =
+            calc_period_stats(&daily_stats, current_day.saturating_sub(6), current_day);
+        let (avg_month, empty_month, total_month, blocks_month) =
+            calc_period_stats(&daily_stats, current_day.saturating_sub(29), current_day);
+        let (avg_year, empty_year, total_year, blocks_year) =
+            calc_period_stats(&daily_stats, current_day.saturating_sub(364), current_day);
+
+        fn stats_to_json(avg: f64, empty_pct: f64, total: u64, block_count: u64) -> JsonValue {
+            JsonValue::Object(HashMap::from([
+                ("avg_tx".to_string(), JsonValue::Number(avg)),
+                ("empty_pct".to_string(), JsonValue::Number(empty_pct)),
+                ("total_tx".to_string(), JsonValue::Number(total as f64)),
+                ("block_count".to_string(), JsonValue::Number(block_count as f64)),
+            ]))
+        }
+
+        let tx_per_block = JsonValue::Object(HashMap::from([
+            ("last_day".to_string(), stats_to_json(avg_day, empty_day, total_day, blocks_day)),
+            ("last_week".to_string(), stats_to_json(avg_week, empty_week, total_week, blocks_week)),
+            (
+                "last_month".to_string(),
+                stats_to_json(avg_month, empty_month, total_month, blocks_month),
+            ),
+            ("last_year".to_string(), stats_to_json(avg_year, empty_year, total_year, blocks_year)),
+        ]));
+
+        JsonResponse::new(
+            JsonValue::Object(HashMap::from([
+                ("daily_stats".to_string(), JsonValue::Array(daily_json)),
+                ("monthly_growth".to_string(), JsonValue::Array(monthly_json)),
+                ("tx_per_block".to_string(), tx_per_block),
+            ])),
+            id,
+        )
+        .into()
+    }
 }